Repository navigation
fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in - #22025
Conversation
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…elete doubles Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…or inside its callback Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 93638c07b785ff6a76638eab4ea952e6c0fcc873 && git checkout 93638c07b785ff6a76638eab4ea952e6c0fcc873
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1fb274e61cfff12ede54963d779acdfd079be318 e2aa5ecaa0548e791ba50de3b9aec6838eaf241c && git checkout -B drift-repro 1fb274e61cfff12ede54963d779acdfd079be318 && git merge --no-ff e2aa5ecaa0548e791ba50de3b9aec6838eaf241c
node scripts/docs-audit/affected-docs.mjs --json 1fb274e61cfff12ede54963d779acdfd079be318
|
Part of #21908
Clause-②: no
Stage 1 of the closure of the security middleware's principal-less hand-off (ADR-0096 E1 / D5). Every producer this stage names now passes the explicit system opt-in that already exists (
isSystem: true) on its data-engine calls. The deny itself is stage 2 and is not in this PR, so #21908 remains open. ⛔ Noplugin-security,packages/spec,packages/qaordocs/adrfile is in the diff, and there is no new elevation API.What moved
Positions are at the base
9c3bec0f4d. "Gate fired" means one of the six gates the middleware still runs before its hand-off (package-managed, system-row, curated-capability, audience-anchor, ADR-0103 engine-owned, ADR-0090 D12 delegated-admin) threw on the producer's calls today. Probe counts are principal-less records at the hand-off, before → after the change; each "after" call arrived as anisSystemcall instead.service-messagingmessaging-service.ts:536,:639MessagingService.listInboxwindow read andcountUnreadTotalmessaging-service.ts:671readReceiptStatesmessaging-service.ts:858unreadNotificationIds(mark-all-read)messaging-service.ts:881upsertReadReceipt: receipt read, update, insert (mark-read)messaging-service.ts:953notificationOrganizationservice-storagemetadata-store.ts:319StorageMetadataStore.createFile(sys_fileinsert)metadata-store.ts:438StorageMetadataStore.createSession(sys_upload_sessioninsert)service-messagingrecipient-resolver.ts:180RecipientResolver.resolveOwnerOfrecipient-resolver.ts:150,:164resolveRole,resolveTeamsql-outbox.ts:89,:217,:352SqlNotificationOutbox.enqueue,ack,listsql-http-outbox.ts:129/:158(sharedinsert,:162),:352,:413,:430SqlHttpOutbox.enqueue/recordUndeliverable,ack,ackById,listemail-channel.ts:186,sms-channel.ts:135resolveRecipientreads (first read and the address-only retry)messaging-service.ts:1296findEventByDedupKey(the emit dedup lookup)template-renderer.ts:113NotificationTemplateStore.loadservice-settingssettings-service-plugin.ts:396sys_secretstore:insert,get,updateupdatehas no caller; unit-pinned)settings-service.ts:2635SettingsService.readStoredHandlemetadata-protocolsys-metadata-repository.ts:520,:1185,:1300,:1377SysMetadataRepository.getByHash,list,history,replayFromHistoryStatic half. All six gates act on writes only (
insert/update/delete/transfer/restore/purge), each on a closed set of objects:sys_permission_set,sys_position,sys_capability,sys_position_permission_set, the RBAC link tables andsys_member. The engine-owned guard refuses only a write whose context carries a user id, which none of these calls carried before or carries now. No moved call writes any object in those sets; the reads (includingresolveRole'ssys_memberread) are outside every gate by verb.Measured half. A local, uncommitted instrument sat in
plugin-security's engine middleware. For each principal-less, non-system context it recorded the producer frame (the first frame inside these four packages), any gate that threw, and the outcome shape after the hand-off. For eachisSystemcall from these packages it dry-ran the six gates with the flag cleared, then recorded the outcome shape. Two runs, before and after the change:Results: 0 gate throws before, and 0 gates would fire on any moved call after. Principal-less records attributed to these packages went 64 → 1 in the harness (the 1 is the harness's own deliberate context-less comparison write) and 178 → 129 in the dogfood subset. Of the 129 that remain, 128 are the rows below that this stage does not move; the other is the
createSessioninsert, which moved after that run. Outcome shapes per call are equal before and after, except where the Q2 change adds theowner_of:delivery. The instrument was reverted (security-plugin.tsblob5b4ab28045afequals HEAD),plugin-securitywas rebuilt, andablation-dist-preflight --absentpasses. The positive control was the marker present in 2 built files while the instrument was live.The rulings, and how each one holds
where: { user_id }. The receipt a mark-read inserts is stamped with thatuser_id. The receipt it updates is addressed by the id that auser_id-keyed read of the same call returned, and by nothing else.notificationOrganizationreads onesys_notificationevent row, which names no recipient, by id, projectingidandorganization_id, and uses it only as the stamp on the caller's own receipt.createFilestampsowner_idwith the user the door resolved from the session. Both inserts still hand the acting organization to the driver astenantId. Under the opt-in the organizations plugin's fill-only stamp stands down, so this channel is now the only stamper, and a pin holds it there.resolveOwnerOftakes the same opt-in its siblingresolveEmailcarries. It projectsidplus the owner fields, and only the owner id leaves the resolver. Measured on a booted showcase stack, for anowner_of:audience on aprivateobject: on9c3bec0f4d, 0 recipients, andemit()delivered 0. On this change, 1 recipient (the record's owner), andemit()delivered 1. Nothing the read returns appears in whatemit()answers.Other engine behaviour keyed on the flag (checked per moved call)
isSystemwrite. None of the moved writes supplies a non-readonly reference field: the receipt, delivery,sys_file,sys_upload_sessionandsys_secretcolumns these writes fill are text, number or json. So there is no skipped refusal to restore.sys_secretstore'supdate. It writesciphertext, which is declared read-only. Without a context the strip dropped it, and under the opt-in the value is written. This was measured on the real engine in the harness. That is what the member promises ("replace an existing secret row"), and no caller in this repository reaches it.sys_object here. The only business-object read isresolveOwnerOf, which is the ruled Q2 change.Found by this build, not moved (listed for the seat)
service-storageStorageMetadataStore:getFile(:342),updateFile(:368),deleteFile(:403),getSession(:469),updateSession(:496),deleteSession(:529). They are principal-less today (dogfood subset: 83sys_filereads, 39 updates, 3 session calls; 0 gate throws) and reached from the upload and download doors. The Q1 ruling namescreateFileonly. These read and write by id with no user term, so the ruling's user-id pin has nothing to hold, and their posture is the same class of decision. The deny needs a route for them.service-messagingSqlHttpOutbox.redeliver(:456): principal-less (its tenant rides the driver bag, not a context) and request-reachable. Its own documentation keeps the outbox opt-ins off it. Same class of decision.metadata-protocolprotocol.tspromoteDraftForPublishstill readssys_metadataprincipal-less (3 dogfood records). That file is another lane's, so it is listed only.Pins and ablations
service-messagingsystem-context.pin.test.ts(it extends the double security(service-settings, service-messaging, service-datasource, plugin-webhooks): plumbing reads and writes reach the engine with no principal and no system opt-in — the services-lane producers of #21908's closure #21913 pinned):isSystem, every user-row read is keyed on the user id, the insert is stamped with it, and the update addresses only a returned id.emit()carrying none of the record's values.service-storage:{ tenantId, isSystem: true }, and{ isSystem: true }alone where the caller has no organization (no organization is invented).owner_iddoes not reach the insert.tenantId.service-settingssettings-system-context.pin.test.ts: the store's four verbs, and a rotation whose write, update and post-update verification read all carryisSystem.metadata-protocolprotocol.platform-store-system-opt-in.test.ts:getByHash,list,historyand thewatchreplay.Each ablation was run through
scripts/ablation-replace.mjs, which confirms the anchor hit and the mutation landed, runs the suite, and restores with the blob equal to HEAD and an emptygit diff HEAD. The pins import their subjects fromsrc, so no build sat in between. All went red:listInboxloses itsuser_idtermisSystem: falseresolveOwnerOf's opt-in dropped (context: undefined)resolveOwnerOfalso returns a record valuefalsesys_secretstore's insert loses its opt-inreadStoredHandle's opt-in droppedisSystemowner_idfrom the bodygetByHash's opt-in droppedfalsefalseTests
Gates, at
e2aa5ecaa0(this PR's head):dispatch-gates --commands --repo objectstack-ai/objectstackderives 106 commands, and the dispatch-time list's 53 are among them. All 106 ran and exited 0.--ranreports "106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED (a DERIVED zero — all 106 recorded an exit code and none of them is 3)".db67da722d),check:where-matcherwent red on the new pin's matcher, because it refused a combinator outside its callback. That is fixed here.check:skill-examplesandcheck:dual-build-cjs-loadsfirst refused withPREREQUISITE NOT MET: thespecdist was stale after the merge, and eight unrelated packages had no dist. They were built, and both re-ran green.Package suites, local:
service-messaging48 files / 523 tests,service-storage42 / 652,service-settings36 / 628,metadata-protocol218 files + 3 skipped / 28,043 tests passed.typecheckexits 0 for all four (service-storageincludes itscheck:test-typecheck).Dogfood, at
db67da722dand before the change. The 16 files areapproval-notification-body,schedule-acting-organization,schedule-sweep-organization-scope,webhook-materialization,platform-app-object-entry-views,attachments-permission-matrix,field-file-collection,file-field-constraint-refusal,sys-file-metadata-write-refusal,parent-derived-write-refusal-not-visible,predicate-write-unreadable-not-matched,route-ledger-live-mount-parity,write-door-unreadable-is-not-found,settings-config-change-audit,flow-credential-channelandobject-designer-field-reorder. They ran 16 passed, 185 tests passed and 1 skipped, both before and after.Lint, as a proven narrowing (CI runs the full
pnpm lint). The population is read fromeslint.config.mjs(**/*.{ts,…}plus thepackages/**objects).--no-inline-config --format jsonover the 20 changed.tsfiles gives 20 files, 0 errors and 0 warnings. The config enables no type-aware linting, so this diff cannot move a verdict on any untouched file.Acceptance notes
createSessionmoved withcreateFile. Row 16's census text names both inserts, and the existing pin holds the two inserts' contexts equal. The upload-session row has no user column. It is bound to the caller's file byfile_id, and that file carriesowner_id.check:tenant-audit-censusneededdocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdand the generated block ofcontent/docs/permissions/tenant-audit-census.mdxregenerated, and the page's hand-written figures follow the census: threading 164 → 171, unreadable 60 → 54, decidably elevated 121 → 123.check:system-context-census --fixmoved theisSystemcensus page's declaration count 26 → 27, for the internal helper's return type.scripts/engine-double-contract.pinned.jsongained two rows (--write) for the settings pin's newupdate/deletedoubles.privateobject, 0 recipients on the base and 1 on this change". No package in this surface composesplugin-sharingwithservice-messaging, andservice-messagingdeliberately carries no plugin dependency. The committed pins hold the opt-in, the projection and the id-only result. A composed pin belongs where both plugins compose.main. That commit editsmetadata-protocolprotocol.tsonly, and no file here.Generated by Claude Code