feat(service-messaging): sys_inbox_message carries actor_id, travelling end to end from emit() - #17886
Conversation
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…ng end to end from emit() The actor stopped at sys_notification.actor_id, an object the default permission sets grant members no read on, so an inbox row could not answer 'did I cause this?'. Widen the channel seam, snapshot the actor onto the delivery row beside the rendered content, read it back in the dispatcher, and materialize it on the row. Digest rows keep it null by construction. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 43bec809e8dbb0c6c32a5901b90aaabc1f3091df && git checkout 43bec809e8dbb0c6c32a5901b90aaabc1f3091df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e0e4a5669a90555a4fc411587c056f1c9272af09 76be9861fb8544283d077049a5d809b975d0555d && git checkout -B drift-repro e0e4a5669a90555a4fc411587c056f1c9272af09 && git merge --no-ff 76be9861fb8544283d077049a5d809b975d0555d
node scripts/docs-audit/affected-docs.mjs --json e0e4a5669a90555a4fc411587c056f1c9272af09
|
…translating actor_id The three translated-locale leaves for the new field are hand-written (AGENTS.md: translated-locale VALUES are hand-written; adding or dropping KEYS is drift, and the keys came from the writer). Re-running the writer then drops their stale-fill provenance entries, which is the machine-checkable signal that they are translations rather than source fills. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewPR #17886 · card #16974 · head Clause-② review, in-seat at the default judgement tier plus gates — the prescribed form for a non- ① Derived judgments — every accept-set / public-surface change, named and judged
Measured on this head, ⛔ not taken from the report:
② Semver
③ Boundary flags — each answered
Independence pairImplemented-by:
Verdict: PASS on
|
Contract-review carriers CLEARED — provenance
⇒ Flipping to ready and arming auto-merge. ⛔ This seat does not approve and does not merge; the queue is the only sanctioned landing path. Tracked to MERGED, ⛔ not to 「enqueued」. Generated by Claude Code |
Fixes #16974
Implements the director seat's ruling (
#issuecomment-5643392717, decision batch #119 item 5, maintainer verbatim 「同意」 to1A·2A·3A·4D·5A): option A — widen the channel seam and snapshot the actor onto the delivery row.The path
An inbox row could not answer "did I cause this?". The actor stopped one layer upstream on
sys_notification.actor_id, and the shipped default permission sets grant a member no read onsys_notification— so the value sat behind an FK hop into an object the reader cannot open. The actor now travels, one leg per seam, with no new read anywhere:channel.tsNotification.actorId?: string— the per-recipient unit every channel implementation consumesmessaging-service.tsemit()'s inline literal projectsEmitInput.actorIdonto itmessaging-service.tsenqueueDeliveriessnapshotsactorIdintodeliveryPayload, beside the rendered contentoutbox.tsDeliveryPayload.actorId?: stringdeclared, rather than left to that type's index signaturedispatcher.tsprocessRowreadsp.actorIdback off the snapshotinbox-channel.tsactor_id: n.actorId ?? nullon the rowobjects/inbox-message.object.tsactor_id,Field.lookup('sys_user'), same semantics assys_notification.actor_idItem 2 — digest rows keep
actor_idnull by construction.processDigestGroupsets no actor: a collapsed group has no single actor, so "you caused this" must not be asserted over a message that also carries other people's events. Stated in the object's own description text and pinned with a same-tick control (below).Item 3 —
plugin-security/src/objects/default-permission-sets.tsis NOT touched, and that is the finding, not an omission. Re-measured on this branch's base: the file declares zero field-level permissions (grep -c 'fieldPermissions'= 0,grep -cE '^\s*fields:'= 0), and field permissions are a subtract-only mask —FieldMasker.maskResultsreturns results untouched when the permission map is empty, andhiddenFieldsis derived only from entries whosereadableisfalse. The object-levelsys_inbox_message: { allowRead: true, … }entry, narrowed to the recipient by thesys_inbox_message_selfRLS policy, therefore already carries the new column. Item 3 is a no-op; editing the file would have been a speculative touch.Item 4 —
Clause-②: yes. Two widenings, both intended and both declared: theNotificationseam type gains a member, and the storedsys_notification_delivery.payloadJSON column gains a key. The changeset is aminoron@objectstack/service-messaging. ⛔ Item 5 (objectui#7011's consumer follow-up) is another repo's card and is untouched here.Tests
New pins, all in files that already existed (no new engine double, no ledger row — see report):
inbox-channel.test.ts— the channel's own leg (actor on the row;null, asserted within, when absent), plus one whole-path run: realMessagingService+MemoryNotificationOutbox+ realNotificationDispatcher+ real inbox channel,emit({ actorId: 'user_9' })→ delivery-row snapshot →tick()→sys_inbox_message.actor_id === 'user_9', and an assertion that nofindOneagainstsys_notificationhappened. Four green legs do not prove a connected path.digest.test.ts— the read-back and the digest absence in one test with its own control: in the same tick the immediate row's actor survives the outbox round trip (user_9) while the collapsed group'sactorIdisundefined. An absence is evidence only when the same run shows the presence. Plus: a non-stringactorIdon a stored payload does not reach the typed seam.messaging-service.test.ts— the P0 projection (same actor reaches every registered channel) and the P1 snapshot, each with its actor-absent twin.inbox-channel.test.ts's existing whole-rowtoEqualkey-set pin updated withactor_id: null— it is designed to go red on a key-set change and it did.Ablation — every negative pin gets one
Both legs ran from the committed tree, each under a
trap '<restore>' EXIT INT TERMwith absolute paths; restoration is proven by an emptygit diff HEADand agit hash-objectmatch against theHEADblob, not by an exit code. Before each run: the anchor text was counted on disk before and after the mutation, andgit diff --numstatconfirmed the edit landed.These tests exercise SOURCE, not
dist. Same-package relative specifiers (./dispatcher.js) resolve tosrc/*.tsunder vitest, and this package'svitest.config.tsaliases only@objectstack/core. The proof is the result itself: a source-only mutation with no rebuild turned the tests red, which adist-resolved subject could not do.Leg 1 — delete the dispatcher's read-back (
actorId: typeof p.actorId === 'string' ? p.actorId : undefined, one line removed; anchor count 1 → 0,numstat0 1):⭐ Worth recording: the digest-absence assertion stayed green under this mutation. An absence assertion cannot distinguish "deliberately not set" from "the plumbing is broken", which is exactly why it ships with a same-tick presence control rather than alone.
Leg 2 — let the digest group claim the first row's actor (one line inserted into
processDigestGroup; marker count 0 → 1,numstat1 0):Restore, both legs:
git diff HEADempty,git hash-object083c99761dda01cba6b2ec059f3a3902e3ef95a3== theHEADblob.Local verification
pnpm --filter @objectstack/service-messaging testTest Files 41 passed (41)·Tests 441 passed (441)· lockVERDICT command-exit 0pnpm --filter @objectstack/service-messaging typecheckVERDICT command-exit 0pnpm --workspace-concurrency=2 --filter '...@objectstack/service-messaging' buildScope: 28 of 81 workspace projects, lockVERDICT command-exit 0— downstream consumers (prefix form), the direction a contract widening lands innode scripts/check-i18n-bundles.mjs --writeservice-messaging's four bundles movedThe rebuilt
dist/index.d.tscarries the widened seam (readonly actorId?: stringat bothNotificationand the delivery payload), so the consumer build above read the new declaration and not a cached one.zh-CN/ja-JP/es-ESvalues for the new field are hand-written, matching the wording already used forsys_notification.actor_idinplatform-objects; the generator's merge pass fills a gap with the English source, which is a fill, not a translation.All of it re-run at the final head
76be9861f, after the last commit.Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 63 families from the 15 changed paths; all 63 were run with$?captured before any pipe, and--ran(with:: exit <code>on every line) reconciles:63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, a DERIVED zero. Three answered exit 3 = PREREQUISITE NOT MET first (check:i18n,check:dual-build-cjs-loads,check:type-check-debt); each was re-run to a real verdict after building the closure it names, and ⛔ none is reported as a pass on its exit 3.Repo-wide scans (
pnpm lintand the wide-population families) are CI's run, not this card's.🤖 Generated with Claude Code
https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Generated by Claude Code