Uh oh!
There was an error while loading. Please reload this page.
feat(messaging): scope the plugin-facing inbox writes to the authenticated caller - #11450
Conversation
…ed caller Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…ng inbox writes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 1 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 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 aba6e35027edd1ab459e853045a14e28f4d7b228 && git checkout aba6e35027edd1ab459e853045a14e28f4d7b228
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 14cfc007a72f334892d7a68968dfa40cd1ed0bfe 1ce71df0e5d7783bb4d7b34ff9eb4e904c22c9f6 && git checkout -B drift-repro 14cfc007a72f334892d7a68968dfa40cd1ed0bfe && git merge --no-ff 1ce71df0e5d7783bb4d7b34ff9eb4e904c22c9f6
node scripts/docs-audit/affected-docs.mjs --json 14cfc007a72f334892d7a68968dfa40cd1ed0bfe
|
Uh oh!
There was an error while loading. Please reload this page.
Part of #10753
What this is
MessagingServicegains a plugin-facing inbox write door scoped to the authenticated caller:Ruled 2026-08-23 (
5386675655), maintainer, verbatim and untranslated: 「10950 不考虑存量,其他接受你的建议」 — recorded as Q1 = A1 · Q2 = (a) hard recall · Q3 = implementation-first, with two constraints the same ruling attaches:Everything here lands in
service-messaging(Q3, implementation-first). Nopackages/specedit, no new column, no object declaration moved.The measured BEFORE — where this card's severity actually lives
markRead(userId, ids)is the REST door's contract method (INotificationService.markRead?), and on that path the parameter is trustworthy:runtime/src/domains/notifications.ts:198binds it tocontext.executionContext.userIdafter answering 401 for a request that has none.The same service is also a kernel service —
ctx.registerService('messaging', service),messaging-service-plugin.ts:162— and the kernel hands every plugin one sharedPluginContext(core/src/kernel.ts:65,:91) whosegetServicecarries no caller identity. For an in-process caller that first parameter is therefore a free string:Today any plugin can mark any user's inbox messages read — unconstrained and undeclared. And the receipt write lands context-lessly on
sys_notification_receipt, which ismanagedBy: 'engine-owned'withapiMethods: ['get','list'](ADR-0103), so no engine permission check sees it either.So this PR is both an API widening (new plugin-facing methods) and the first tightening of in-process power on this path. That fact was not in front of the maintainer when Option A was first framed.
Why the new door is a tightening and not just a convenience
The pair takes no target user at all. The recipient is derived from
caller.userId, so "mark someone else's inbox read" has no spelling on this surface — unrepresentable rather than discouraged. It fits the case the card was filed for exactly: the approver who clears a request is the recipient whose badge is stuck, and the close-out hook runs inside that approver's own request.userIdis read, and nothing that merely resembles one:userIdattributedUserIdactorsvc:<name>), not asys_userid —svc:flowhas no inboxisSystem: true, no usermarkRead(userId, ids)A tolerant
userId ?? attributedUserId ?? actorchain is exactly the consumer-side widening contract-first exists to refuse: it reads as working and silently clears the wrong person's badge.Refusals throw
InboxCallerErrorcarrying the ADR-0112 envelope pair a boundary reads —status: 401+ the registeredcode: 'UNAUTHENTICATED'— so an HTTP surface answers 401 rather than the 500 a bareErrordemotes to.UNAUTHENTICATEDand notPERMISSION_DENIEDis the axis: there is no second identity to disagree with, so there is no forbidden-target case.Order is pinned, not left to reading: the refusal is evaluated before the empty-
idsand no-data-engine short-circuits, both of which return{ success: true, readCount: 0 }. Reaching one of those with no authenticated caller would report success for a write that was never authorized — the silent-success shape this door exists to replace.Honest about what it is NOT
A discipline boundary, not a security boundary, and that is stated in the module header rather than left to be discovered. An in-process plugin already holds the data engine and can write
sys_notification_receiptdirectly; nothing at this layer stops trusted code that means to. AndmarkRead(userId, ids)stays reachable — it is the publishedINotificationServicecontract the REST door needs, and this lane has zero ownership ofpackages/spec. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the wrong one now fails loudly at the call site. TheINotificationServicedeclaration of the new pair is the follow-up inside the contract-review chain.Verification
Everything below was run against the final commit
1ce71df0e5— the same tree the gate union was derived from, with a clean working tree. Union derived bynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackwith no hand-supplied paths (the script takes its own change set from the merge base, three-dot; the run confirmed 5 committed paths / 0 working-tree / 0 untracked).check:i18nfirst answeredPREREQUISITE NOT MET — the workspace CLI is not built, which is not measured, never a pass; the CLI was built and it re-ran to its own verdict:check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).pnpm --filter @objectstack/service-messaging typecheck(tsc --noEmit) — clean.pnpm --filter @objectstack/service-messaging test— 267 passed, 26 files, including 8 new cases pinning the door.One declared narrowing:
check:type-check-debt --re-measurewas not run — it refuses without the whole workspace closure built, and a repo-wide scan is CI's run. It is named here only by the kind "adds or edits a test file", and the three things that make this a measurement rather than a gap: (1) its population is its own ledger, whose singleservice-messagingentry ispackages/services/service-messaging/scripts(thei18n-extract.config.ts); (2) none of this PR's 5 changed paths is under it; (3) the appended test code is inside the package's own tsc program —tsconfig.jsonisinclude: ["src"]with no test exclusion — and that program is green above. Its structural half,check:type-check-coverage, ran and passed.Ablation — the prediction was written down before the run
Predicted, in writing, before mutating anything: replacing the strict derivation with the forbidden fallback chain (
caller?.userId ?? caller?.attributedUserId) turns the suite RED on exactly one test, named in advance — "refuses a context carrying only attributedUserId — attribution never becomes authorization" — with the absent /{}/ blank /actor/isSystemrefusals staying green, because the ablated chain never reaches those fields.Observed:
Tests 1 failed | 266 passed (267), and the one failure is that test, by name. Direction RED as predicted.grep -F, plus a reverse control taken before the mutation: injected literal0 → 1, deleted literal1 → 0.git hash-object:59883355018fe7b5fcbad947a3edaa0efb78786cbefore and after.trap … EXIT INT TERMcarried the restore../inbox-caller.jsrelatively, so vitest resolvessrc. Adist-resolved subject would have stayed green — and a green under ablation would have been not measured, never a pass.Documentation
Checked by reading the prose, not by trusting
docs-drift's green (which cannot see prose or table claims). Every page mentioningmarkRead/sys_inbox_message/sys_notification_receipt/service-messagingwas read:content/docs/api/client-sdk.mdx(client SDK, REST path),content/docs/kernel/services-checklist.mdx:395(the three REST routes — all three keep their signatures),content/docs/concepts/north-star.mdx:124, and the package roster incontent/docs/plugins/packages.mdx:240. None enumerates who may mark read, and none is falsified by this change, so there is nothing to repair in-PR. The package ships no README.recall()— the ruling's fork clause is triggered. NOT implemented here.Q2 ruled (a) hard recall: delete the notification's
sys_inbox_messagerows and cancel its pending/unclaimed inbox deliveries. Writing that contract before its implementation — as instructed — is what surfaced that it cannot be built under this ruling's constraints. Two independent blockers, both measured:1. Authorization has no expressible predicate.
recallis an emitter-side verb — "retract what I sent, from everyone's inbox" — and emitter-scoped and authenticated-scoped are different axes. No emitter provenance exists on any row (sys_notificationcarriestopic/payload/severity/dedup_key/source_object/source_id/actor_id/created_at;actor_idis the user who caused the event, not the emitting plugin;sys_inbox_messagehas none;EmitInputhas no such field; the sharedPluginContextmints no caller identity). On the authenticated axis the only expressible predicate is "the caller is a recipient" — which yields a recipient-scoped retract, not a hard recall across all recipients. Shipping that under the namerecallwould be the weaker approximation the dispatch forbids, so it is not shipped.2. The mechanism has no safe expression either — and this one is independent of the column.
INotificationOutboxdeclares exactlyenqueue | claim | ack | list | claimDigest(outbox.ts:115-129). There is no cancel. Worse than absent:ack(id, { success: false, suppressed: true })would appear to work — neitherMemoryNotificationOutbox.acknorSqlNotificationOutbox.ackchecks that the row is claimed, so both would flip apendingrow tosuppressed. It races the dispatcher (the row can be claimed between thelistand theack, sent, and acked back tosuccess) and it incrementsattemptson a delivery that was never attempted. A real cancellation needsINotificationOutboxwidened plus both implementations — a separate surface decision.⇒ Reported, not invented, and not approximated. This is the PM seat's call, not a re-ruling request.
Generated by Claude Code