Repository navigation
fix(service-automation,service-messaging,plugin-approvals,objectql)!: a notification takes the organization of what it is about, and is refused where no owner is derivable - #23340
Conversation
Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 126f017fe019eb7feff9ea9eb1e02c981307a721 && git checkout 126f017fe019eb7feff9ea9eb1e02c981307a721
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 430e5f5ad128792a5f0e4acfbbd14ab79cd317e8 238d07b36509b71682396b2fcc0451d6206899c9 && git checkout -B drift-repro 430e5f5ad128792a5f0e4acfbbd14ab79cd317e8 && git merge --no-ff 238d07b36509b71682396b2fcc0451d6206899c9
node scripts/docs-audit/affected-docs.mjs --json 430e5f5ad128792a5f0e4acfbbd14ab79cd317e8
|
Contract reviewServed-tier: Inputs, and nothing else: card #23284 (body and every comment: the triage ruling 6148637195, the claim 6154283897, the os-dev-report 6155779257, the claim amendment 6155816752); PR #23340 (body, file list, the net diff of the head against its merge base ① Derived judgments
② Semver level
③ Boundary flags
The diff's semantics pass on every item above. The verdict is FAIL on the required check: Implemented-by: VERDICT: FAIL Generated by Claude Code |
Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review after the FAIL record ① Derived judgments
② Semver level
③ Boundary flags
The diff's semantics pass on every item above, the declaration matches what the diff publishes, and every check on this head is green. The PR touches no governed surface, is 816 changed lines, is not a fork, and is still draft; landing is the owning seat's. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23284
Clause-②: no (narrowing) — rows gain the organization they should already carry. The narrowing, which the seat chose where triage left it open: an emit about no record, from a context with no organization, gets the owner ADR-0131 D9 derives for a system write (the sole organization), and where D9 derives none it is refused loudly instead of stored with NULL.
What this changes
A notification now carries the organization of what it is about, at every producer, and a notification no organization owns is refused instead of stored with
organization_idNULL.notifynode (service-automation). A run that carries an organization emits exactly as before. A run that carries none (a schedule tick, a record change written by a system context) takes the organization of the record the notification is about: the declared click-through record (sourceObject+sourceId), else the run's trigger record. The value is read from the STORED row by id under a system read, through the shared wall-column resolver (createRecordWallOrganizationResolver). It is never read from the record snapshot the run carries. The[notify] no organization in scopeWARN is removed, together with the NULL row it predicted. When the engine refuses the emit, the step fails asnotify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: ....plugin-approvals).notify()takes a REQUIREDorganizationIdkey, so no call site can leave it out. All 13 call sites pass the request's organization:requestOrgwhile the request opens, otherwise the stored row'sorganization_id. Before this change no approval notification carried one, including a user-drivenapproval.requested.sys_notification(objectql). It is admitted to the design:isSystem写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 platform-object tenancy ledger astenant-scoped. An emit that names no organization therefore gets ADR-0131 D9: the sole organization undersingle, andERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED(500) where none is derivable. This is the engine's own path, with no notify-specific copy of the rule.service-messaging).writeEventreturns the organization the stored event row carries.emit()stamps that organization on the delivery, the inbox row and the receipt when the producer passed none. Otherwise the inline fan-out would write an owned event over org-less inbox and receipt rows.Measured on a booted composition (before → after)
Harness:
bootStack(@objectstack/verify, sqlite-wasm). It runs the productionsinglecomposition with its Default Organization, plus record-change triggers and the approvals plugin, with reliable delivery ON (outbox + dispatcher), the HotCRM shape. The six emits:sourceObject/sourceId);case_sla_monitorshape);task_urgent_alertshape);The schedule-tick context is the one
ScheduleTriggerbuilds for an organization-less flow (event: 'schedule', notenantId), handed toautomation.execute. Before =cc14657ede; after = this branch's code, built.single, one organization (HotCRM): NULLorganization_idrows / totalsys_notificationapproval.requestedincluded)sys_notification_deliverysys_inbox_messagesys_notification_receipt[notify] no organization in scopeWARN lines (both topologies)This matches the card's reading: NULL event rows, but inbox rows that carry the organization.
single, two organizations (D9 derives none):sys_notificationNULLapproval.requestedcarries org B (the request's)Node 'n' failed: notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: Insert on 'sys_notification' was REFUSED: ... holds 2 organizations ...; nothing writtenThe PM's hypotheses, measured
context.tenantIdonly. The record it is about is available in two places: the node's ownsource(what the inventory anchors on) and the run's trigger record (AutomationContext.object+record). Both HotCRM notify nodes declare NOsourceObject/sourceId.case_sla_monitornotifies throughactionUrlonly, so from the platform's side its breach alert is about no record. The D9 arm, not the record anchor, is what gives it the Default Organization on HotCRM.organization_id: nullfromwriteEventis NOT treated as carried (carriesOrganization(null)is false). D9 never saw the write becausesys_notificationwasunclassifiedin the design:isSystem写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 ledger and so outsideresolveSystemInsertOrganization.sys_notification_deliveryIS admitted, which is why on HotCRM the delivery row, and the inbox row and receipt the dispatcher threads from it, carried the organization while the event row did not. Measured: before, one org, 0 NULL deliveries / 5 NULL events.approval.requestedemit is inopenNodeRequest. The helper never passed an organization at any of its 13 call sites.toStrictEqual, key for key) and issues no read, even when the click-through record lives in another organization (PIN I).NodeExecutionResult.codeis not set. That field is reserved for codes an executor classifies itself, andservice-automationcannot import the engine's constant without a runtime dependency onobjectql. The code reaches the step's error and the run'serrorinstead.MessagingService.emit()producer gets the engine's rejection.isolated/group) was measured at the engine level (classification pins). The open harness cannot boot a wall without the enterprise organizations plugin.Pins
objectqltenancy-by-object-classification.test.ts: the admitted list now includessys_notification. For it:single+ one org derives;single+ two orgs,isolatedandgrouprefuse with{ code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', status: 500 }and write nothing; a row carrying its organization is written as it came.service-messagingnotification-organization-follows-event.integration.test.ts, on a real ObjectQL over better-sqlite3:single+ one org lands the sole organization on the event, inbox and receipt rows (inline) and on the delivery row (outbox);service-automationnotify-organization-stamp.integration.test.ts:emit()with no key and no read;plugin-approvalsapproval-notification-body.integration.test.ts: the battery that drives all 12 notifying call sites now opens its deals in two organizations. Every emit'sorganizationIdmust equal the stored request row's.Negative-pin ablations (committed first, trap-restored, blob-verified)
Both subjects are imported by relative path from
src/(./notify-node.js,./approval-service.js), so nodist/sits on the resolution path. Each mutation went throughscripts/ablation-replace.mjs(anchor x1 → x0, blob changed), with atraprestore on EXIT/INT/TERM. Each restore was proven by the blob matching HEAD and an emptygit diff HEAD.1. Record anchor removed (
organizationId = undefined && await organizationOfRecordAbout(...)), headb389aa57dd,notify-node.tsblob2f4ea29ce0→37320cd67f→ restored2f4ea29ce0:2. Request-organization carry removed (the
organizationIdspread innotify()deleted), headb389aa57dd,approval-service.tsblob5d330baff3→edd34821d2→ restored5d330baff3:Direction: red in both, as predicted.
Tests
d564b21d57(the two test files changed after it were re-run atb389aa57dd, 4/4 and 4/4 green):plugin-approvals: 74 files / 1052 tests green;service-messaging: 50 / 568 green;service-automation: 185 / 2455 green;objectql: 413 / 8066 green.b389aa57ddthe claim "green for all four packages" did NOT hold forservice-automation: an orphanedrecordingLoggerhelper innotify-organization-stamp.integration.test.tsfailedTS6133, which the contract review6156230712caught. At238d07b365the CI step "Type check workspace packages" was run verbatim (pnpm exec turbo run typecheckover the 45 affected packages: 111 of 111 tasks, exit 0), andpnpm --filter @objectstack/service-automation typecheckexits 0.b389aa57dd:node scripts/pm/dispatch-gates.mjs --commandsderived 70 commands from this diff (9 paths vs merge basecc14657ede). All 70 ran and each exited 0.--ranreconciled 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded. This includescheck:platform-object-tenancy-census,check:tenant-audit-census,check:system-context-census,check:adr-0087-registration(exemptionno-migration-prescriptionaccepted),check:query-options-erasure(test surface back at 236),check:slot-lookup,check:dual-build-cjs-loads(after building the 8 unrelated packages it found withoutdist/) andcheck-engine-split-ratio(after deepening the shallow clone as it prescribed).eslint --no-inline-configsemantics (ESLint API,allowInlineConfig: false) over the 8 changed source files atb389aa57dd: 8 linted, 0 ignored by the config's ownignores, 0 errors, 0 warnings. Invariance:eslint.config.mjssets noparserOptions.projectand registers no typed rule, so no lint verdict is type-aware and this diff cannot move the verdict of any untouched file. The repo-widepnpm lintis CI's.Patch round 1 (at
238d07b365; contract review FAIL6156230712, the dev's report6156779695)TypeScript Type Check): the orphanedrecordingLoggerhelper is deleted. PIN D asserts the step failure and the engine code inrun.error, not a log line, so no assertion moved.@objectstack/plugin-approvalsis nowminor, like the other three packages. The optionalorganizationIdonApprovalMessagingSurface.emitis reachable through the exportedApprovalServiceOptions.messagingand the publicApprovalService.attachMessaging. The changeset says so, where it used to say "not exported by name".238d07b365: 70 derived and 70 run, each exit 0.check:adr-0087-registrationpasses:[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). Suites:service-automation2455 passed,plugin-approvals1052 passed.Acceptance notes
isSystem写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 ledger grows by one object. The pinned list says growing it is a maintainer decision. The admission cites the maintainer's 2026-08-24 ruling that names the table (「11303 sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」), ADR-0131 D9 (column means in scope), and this PR's writers that fill the column. Onlysys_notificationis admitted. The inbox row and receipt follow the event row throughemit(), sosys_inbox_message/sys_notification_receiptstay unclassified. That keeps a read receipt for a legacy NULL notification on a walled install writable until the v18 ceremony attributes the residue.packages/objectql/src/tenancy/platform-object-tenancy.tsand its test (the engine path the seat's decision names). The engine.ts and config-parse files held by open PRs are untouched.attributionfate in the v18 ownership inventory.case_sla_monitorandtask_urgent_alertdeclare nosourceObject/sourceId. On HotCRM's one-organization install they now get the Default Organization (D9, and the trigger record). On an install holding several organizations,case_sla_monitor's alert would be refused until it names the case it is about.singleinstall holding two organizations, an organization-less schedule run's ownsys_automation_runhistory row is refused. The engine logs this at ERROR (run-history persist failed ...), before and after this change. It is the run recorder's existing loud behaviour, in the engine file held by an open PR.Generated by Claude Code