Uh oh!
There was an error while loading. Please reload this page.
fix(messaging): stamp organization_id on flow-produced notifications and markRead receipts - #11698
Conversation
…and markRead receipts `sys_inbox_message`, `sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` were measured at 100% `organization_id = NULL` on a live install, while `sys_approval_request` in the same database carried an organization on every row. Ruled a gap, not a design choice. The chain below the messaging ingress was already threaded end to end — every writer reads `EmitInput.organizationId`. The break was at the origin: the `notify` flow node never passed it, and its local structural mirror of `emit()` did not declare the field, so it could not have. The node now threads the organization from the run's acting context (`AutomationContext.tenantId`), the same source `plugin-audit`'s `collab.mention` producer already uses. A second producer of the same table is fixed alongside: the `read` receipt `markRead` inserts named no organization at all, and now carries the organization of the `sys_notification` row it is about. No fallback limb in either producer, by design: a run with no organization in scope still emits and still writes, and the node warns audibly instead of guessing. A wrong organization_id is worse than a null — a null is visibly missing, a wrong value is silently authoritative. Forward-stamping only; no backfill and no migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 2 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 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 55b9768761110aed245e9366e777c165c5e7b822 && git checkout 55b9768761110aed245e9366e777c165c5e7b822
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c6a62d01cd0d97a0bf0456aee59317330db0b3b6 8321b6a82930f4eaa0631c941a7143f1fa4141c2 && git checkout -B drift-repro c6a62d01cd0d97a0bf0456aee59317330db0b3b6 && git merge --no-ff 8321b6a82930f4eaa0631c941a7143f1fa4141c2
node scripts/docs-audit/affected-docs.mjs --json c6a62d01cd0d97a0bf0456aee59317330db0b3b6
|
os-sam
commented
Aug 24, 2026
PM: docs-drift advisory dispositioned — no action
The advisory lists 23 hand-written pages (truncated above 15 rows). Aimed at the two things this diff could actually falsify: ① Does any doc assert these tables carry no organization, or are not org-scoped? Positive control first: 7 non-release pages name ② Does any doc describe the ⛔ The 4 release-owned pages were read-only and untouched.
|
Uh oh!
There was an error while loading. Please reload this page.
Part of #11303
Stamps
organization_idon the notification family at the two producers thatwere not supplying it. Scope is narrower than the card — see What this PR
does not cover below; the card should stay open.
Ruling
Maintainer, 2026-08-24, comment 5393621706, verbatim:
A gap, not a design choice, and the producers are the fix site.
What the census found
The chain below the messaging ingress was already threaded end to end.
MessagingService.writeEventstampssys_notification, the inbox channelstamps
sys_inbox_messageand itsdeliveredreceipt, and the outbox carriesthe value onto the
sys_notification_deliveryrow. Every one of them readsEmitInput.organizationId.The break was at the origin. The
notifyflow node — the dominant producerof all four tables — never passed
organizationId, and its local structuralmirror of
emit()did not even declare the field, so the value could not havebeen passed. One missing argument, four tables at 100% null. The other emit
caller in the tree,
plugin-audit'scollab.mentionproducer, already passesit.
A second producer of the same table was found alongside it:
MessagingService.markReadinserts thereadreceipt — written when a userreads a notification whose delivered receipt never landed — and that insert
named no organization at all. One table, two producers; a per-producer suite
that enumerated only the first would have gone green with this one still
emitting nulls.
The change
notify-node.tsthreads the organization from the run's own acting context(
AutomationContext.tenantId) — the same sourceplugin-audit's produceruses, so the two notification producers agree about whose organization a
notification carries.
messaging-service.tsstamps themarkReadreceipt from thesys_notificationrow the receipt is about — the subject record's ownorganization, which is the platform's standing answer for a platform row
([finding] API keys carry no organization — under the isolated posture a minted key reads no org data at all (no leak, but the key surface is inert) #8287).
No fallback limb in either producer, deliberately. Not "the current
organization", not the install's first organization, not the recipient's first
membership. A wrong
organization_idis worse than a null: a null is visiblymissing, while a wrong value is silently authoritative to every report, export
and cleanup script that filters by organization. Where no organization is in
scope, none is written.
Fail-loud, not fail-guess, and not fail-closed. The node warns audibly,
naming the topic and the consequence, rather than refusing. Refusing would break
the two deployments that legitimately have no organization to thread — a
single-posture install, and every stack before its first organization exists.That trade-off has its own pin (PIN C below).
Forward-stamping only. No backfill, no migration, and no old row is
opportunistically stamped.
What this PR does NOT cover — why the card stays open
sys_email— blocked on a fence, not attempted. Threading anorganization to the
sys_emailwriter requires wideningSendEmailInput,which lives in
packages/spec/src/contracts/email-service.ts. This cardcarries zero
packages/specownership, so it stops here and reportsrather than guessing at a public contract.
plugin-emailcontains noorganization handling of any kind today.
sys_audit_log/sys_activity— the premise does not hold as written.All three writers (the CRUD writer, the auth-event writer, the read-audit
writer) already stamp
organization_id, through the shared platform-roworganization resolver (Audit rows are stamped from the ACTOR's active organization in preference to the record's own — and the record-side fallback cannot see
sys_api_key.active_organization_id#8707 / PromoteresolveRecordOrganizationFieldto the shared platform-row resolver (approvals + automation runs), per the ruled cloud#1395 Option A #10101). Their residual nulls are writes whereno organization was resolvable at all, which is exactly the case the ruling
forbids filling by fabrication. No change was needed and none was made.
Because of (2) this PR touches no
sys_audit_logdeclaration or writer, soit does not overlap #11676.
Verification
All measurements below were taken on
8321b6a8, a clean tree at the finalcommit.
Test-first. Every pin was written and run red on an otherwise-unmodified
tree, with its failure signature predicted in writing beforehand; no ablation
and no restore was involved, so no restore could silently fail. Observed red,
matching the predictions:
expected undefined to be 'org_pin_alpha'expected [ 'sys_notification:NULL', …(2) ] to deeply equal [ …(3) ]expected undefined to be 'org_pin_alpha'expected undefined to be 'org_pin_beta'stack with no organization in scope still delivers and still writes its rows.
PIN B asserts an identity list (
object:organization_idper row in writeorder), not a count, so an offsetting error cannot hold it green.
Suites (
8321b6a8):@objectstack/service-messaging—Test Files 27 passed (27),Tests 276 passed (276)@objectstack/service-automation—Test Files 87 passed (87),Tests 1036 passed (1036)@objectstack/service-messagingtypecheck— cleanGates, derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon the clean tree at the final commit (no path arguments), every exit code
captured before any pipe. All green; each gate's own verdict line:
check-engine-double-contract: OK — 398 pinned, 133 in the DEBT ledger, 2 exempt.✓ where-matcher conformance holds: 293 matcher(s) discovered … none new./baseline key set verified against 8bcd054: no files added.✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new/baseline key set verified against 8bcd054: no files added.check-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger, 1 exempt.check-nul-bytes: OK (scanned 6524 text file(s) … no raw ASCII control bytes).check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).—services/service-messaging in sync (4 bundle(s))check:changeset-gate-self-tests,check:cross-package-test-inputs,check:objectui-changeset,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-plugin-teardown-shape,check-affected-docs,release-rehearsal-clone --self-test.Neither ratchet moved: both report
no files addedagainst8bcd054.Declared narrowing.
check:type-check-debtwas not run locally — it demandsa full workspace build (
turbo run buildover every package) before it willmeasure, and refuses outright otherwise. A refusal is NOT MEASURED rather than a
pass, so it is named here as not run rather than reported green; CI owns that
run. Repo-wide
pnpm lintis likewise CI's run.The two new test doubles declare no
update()/delete()verb, so they add noengine-double pin and leave the coverage ledger untouched.
Clause ② assessment: still NO. The change stamps a column on write. It
alters no accept/reject behaviour at any write door, widens no public surface,
and touches no
packages/specpath — which is precisely why thesys_emailhalf stopped at the fence instead of proceeding.
Generated by Claude Code
Generated by Claude Code