Skip to content

feat(service-messaging): plugin-facing listInboxAsCaller scoped to the authenticated caller - #11536

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-11452-listinbox-as-caller
Aug 24, 2026
Merged

feat(service-messaging): plugin-facing listInboxAsCaller scoped to the authenticated caller#11536
os-sam merged 1 commit into
mainfrom
claude/issue-11452-listinbox-as-caller

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#11452

What

MessagingService gains the plugin-facing inbox read door scoped to the authenticated caller — listInboxAsCaller(caller, opts) — mirroring the merged write-side counterpart PR #11450 (card #10753):

  • The recipient is derived from caller.userId via the sameresolveInboxRecipient (packages/services/service-messaging/src/inbox-caller.ts) the write door uses — one refusal vocabulary, not two.
  • No authenticated user ⇒ InboxCallerError carrying the ADR-0112 envelope pair a boundary reads — status: 401, registered code: 'UNAUTHENTICATED'. attributedUserId (attribution only, ADR-0118 D2), actor (a service-principal label) and isSystem are refused rather than promoted.
  • The refusal is evaluated beforelistInbox's no-data-engine / no-user short-circuit, which answers a well-formed empty { notifications: [], unreadCount: 0 } — the read-side analog of the silent success the write door replaced.
  • resolveInboxRecipient gains an optional third parameter naming the target-user door its refusal prescribes (listInbox(userId, opts) for this verb). It defaults to the write door's existing text, so the Console 顶栏铃铛未读角标在审批处理完成后不更新 #10753 call sites keep their refusal bytes unchanged.

Additive only: listInbox(userId, opts) stays byte-for-byte as the published INotificationService contract the REST door (runtime/src/domains/notifications.ts) binds to. No packages/spec/** edits were needed — the new method lives on the service class, exactly as the write door does.

What it is, honestly

Same caveat as the write door, deliberately carried forward: this is a discipline boundary, not a security boundary — an in-process plugin already holds the data engine and can read sys_inbox_message rows directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site.

Tests

New #11452 block in messaging-service.test.ts (7 tests): own-inbox-only scoping (the other user's rendered content absent from the answer in any field), options (read/type/limit) forwarded unchanged (answer-equality against listInbox on the same engine), and the refusal matrix — absent/empty/blank context, attributedUserId-only, actor label, isSystem, refusal-before-short-circuit, and the refusal prescribing the read contract door rather than markRead(userId, ids).

Ablation (fix reverted to a naive caller?.userId ?? '' delegation, mutation confirmed on disk by anchored grep in both directions): exactly the 5 refusal tests go red, every failure being the predicted expected InboxCallerError, but the call resolved; restore verified byte-identical via git hash-object. The suite resolves the subject through relative src imports, so no dist rebuild leg applies.

Verification (all at 90b3528079, this PR's head)

  • @objectstack/service-messaging: pnpm testTest Files 26 passed (26) / Tests 274 passed (274); pnpm typecheck clean.
  • Derived gate union (node scripts/pm/dispatch-gates.mjs, derivation stamped at 90b3528079): check:changeset-gate-self-tests, 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-ci-filter-parity, check-empty-changeset, check-plugin-teardown-shape, docs-audit/check-affected-docs, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:cross-package-test-inputs, check:where-matcher — all exit 0 (exits captured before any pipe).
  • check:type-check-debt (full closure built first): check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 293.3s, 1897 raw tsc error(s) total, none above its recorded number.
  • check:i18n: check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
  • check:nul-bytes: OK (scanned 6456 text file(s) …).
  • Targeted eslint --no-inline-config on the three touched TS files: exit 0 (early signal only; the repo-wide sweep is CI's run).

Changeset: .changeset/messaging-inbox-read-authenticated-caller.md (minor, @objectstack/service-messaging; additive, not breaking, so no ADR-0087 disposition marker is required).


Generated by Claude Code

…e authenticated caller
The read-side sibling of the #10753 write door: listInbox(userId, opts) is
the REST door's contract method, but for an in-process caller its userId is
a free string. listInboxAsCaller derives the recipient from the caller's
ExecutionContext.userId via the shared resolveInboxRecipient, refusing with
InboxCallerError (401 UNAUTHENTICATED) before listInbox's empty-envelope
short-circuit. resolveInboxRecipient gains an optional parameter naming the
target-user door its refusal prescribes (default: the write door's existing
text, byte-unchanged).
Part of #11452
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c96cbfc48dd5c46861134832e1d0cbc622205b13packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6deb0214229aecb3879e59f44d2fdfec77c507a8 — the merge of head 90b3528079d2fa5a1e94b220282616e807effc5e into base c96cbfc48dd5c46861134832e1d0cbc622205b13, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6deb0214229aecb3879e59f44d2fdfec77c507a8 && git checkout 6deb0214229aecb3879e59f44d2fdfec77c507a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c96cbfc48dd5c46861134832e1d0cbc622205b13 90b3528079d2fa5a1e94b220282616e807effc5e && git checkout -B drift-repro c96cbfc48dd5c46861134832e1d0cbc622205b13 && git merge --no-ff 90b3528079d2fa5a1e94b220282616e807effc5e
node scripts/docs-audit/affected-docs.mjs --json c96cbfc48dd5c46861134832e1d0cbc622205b13

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Seat review (domain:services) — verified independently, no conflict with the dev's report

This card was dispatched at CONTRACT_REVIEW_TIER, so per the maintainer's standing ruling (2026-08-23) a passing same-tier review needs no separate re-review round. My findings below agree with the dev's report on every point, and on one point are stronger than it claimed.

listInbox is untouched — proven structurally, not accepted as "byte-for-byte"

That claim is the fence protecting the REST door (runtime/src/domains/notifications.ts binds to it), so I checked it rather than reading it:

  • git diff from the merge base shows zero removed lines anywhere in messaging-service.ts — the file is insertion-only
  • both hunks are pure inserts: @@ -368,0 +369,15 @@ and @@ -417,0 +433,33 @@
  • old line :416 is listInbox's closing brace and :417 is blank, so hunk 2 lands between methods; hunk 1 lands before listInbox begins

⇒ Not merely unmodified — structurally incapable of having been modified. Stronger than the PR body asserts.

The shared-file change preserves #10753's refusal bytes — readable from the diff

resolveInboxRecipient gains a third parameter. The PR says existing call sites keep their refusal bytes unchanged; that is verifiable rather than trusted:

- + `a system/background sweep that must name one uses markRead(userId, ids).`,
+ + `a system/background sweep that must name one uses ${targetUserDoor}.`,

with targetUserDoor = 'markRead(userId, ids)' as the default. A two-argument call therefore interpolates the identical string. Every other change in that file is docblock. No logic moved.

Fences

Verified from the merge base: 4 files, zero packages/spec/** — so no contract half to split, and the dispatch's stop-and-report condition correctly never fired. The new method lives on the service class exactly as the write door does.

The ablation was done properly

Worth naming because it is the half that usually gets skipped: the fix was reverted to a naive caller?.userId ?? '' delegation, the mutation was confirmed on disk by anchored grep in both directions (injected-count 1, removed-anchor-count 0), exactly the 5 refusal tests went red, every failure matching the predicted signature expected InboxCallerError, but the call resolved, and the restore was verified byte-identical via git hash-object. The dist leg was addressed rather than ignored — the suite resolves the subject through relative src imports, so no rebuild applies.

NOT MEASURED, disclosed by name

The dev recorded that two verify-lock exit 99 queue-timeouts and one 10-minute-cap SIGTERM (exit 143, full-closure build killed at 66/68 tasks) were treated as NOT MEASURED and re-run — never read as results. That is the correct handling of #11363 contention and of the cross-agent kill incident, and it is recorded here so the distinction survives.

Also correctly declared unmeasured: the repo-wide ESLint sweep (targeted narrowing on the 3 touched files declared instead) and consumer packages' suites beyond the derived gate union. CI closes both.

The claim is correctly sized

The PR carries the write door's caveat forward rather than inflating it: this is a discipline boundary, not a security boundary — an in-process plugin already holds the data engine and can read sys_inbox_message directly. What changes is that the correct pattern is the only one the plugin-facing surface expresses. On a card labelled security, keeping the claim no larger than the change is the right call.

Landing

Clause-② no: no packages/spec/** path, and the change is additive plus a tightening (a new refusal path), never a widening. Inside seat discretion.

Flipping ready and arming auto-merge once CI is green — merge queue only, never bypassed. CI was mid-run at review time with nothing red.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review August 24, 2026 02:55
@os-sam
os-sam added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 3b5f036Aug 24, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-11452-listinbox-as-caller branch August 24, 2026 03:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MessagingService.listInbox has the same free-userId shape markRead just lost — any in-process plugin can read any user's inbox

2 participants

@os-sam@claude