Skip to content

fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in - #22025

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21908-principal-less-producers-final
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21908-principal-less-producers-final

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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. ⛔ No plugin-security, packages/spec, packages/qa or docs/adr file 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 an isSystem call instead.

Row Position Function Gate fired Moved Evidence (harness · dogfood subset)
15 (Q1) service-messaging messaging-service.ts:536, :639 MessagingService.listInbox window read and countUnreadTotal no yes 5 → 0 · 4 → 0
15 (Q1) messaging-service.ts:671 readReceiptStates no yes 5 → 0 · 4 → 0
Q1 messaging-service.ts:858 unreadNotificationIds (mark-all-read) no yes 1 → 0 · —
Q1 messaging-service.ts:881 upsertReadReceipt: receipt read, update, insert (mark-read) no yes 6 → 0 · —
Q1 messaging-service.ts:953 notificationOrganization no yes 1 → 0 · —
16 (Q1) service-storage metadata-store.ts:319 StorageMetadataStore.createFile (sys_file insert) no yes 2 → 0 · 41 → 0
16 (Q1) metadata-store.ts:438 StorageMetadataStore.createSession (sys_upload_session insert) no yes — · 1 measured today, 0 gate throws
Q2 service-messaging recipient-resolver.ts:180 RecipientResolver.resolveOwnerOf no yes 3 → 0 · —
24+ recipient-resolver.ts:150, :164 resolveRole, resolveTeam no yes 3 → 0 · —
24+ sql-outbox.ts:89, :217, :352 SqlNotificationOutbox.enqueue, ack, list no yes 7 → 0 · —
24+ sql-http-outbox.ts:129/:158 (shared insert, :162), :352, :413, :430 SqlHttpOutbox.enqueue / recordUndeliverable, ack, ackById, list no yes 13 → 0 · —
24+ email-channel.ts:186, sms-channel.ts:135 the channels' resolveRecipient reads (first read and the address-only retry) no yes 2 → 0 · —
24+ messaging-service.ts:1296 findEventByDedupKey (the emit dedup lookup) no yes 2 → 0 · —
24+ template-renderer.ts:113 NotificationTemplateStore.load no yes 3 → 0 · —
24+ service-settings settings-service-plugin.ts:396 the sys_secret store: insert, get, update no yes 6 → 0 · — (update has no caller; unit-pinned)
24+ settings-service.ts:2635 SettingsService.readStoredHandle no yes 1 → 0 · —
23 metadata-protocol sys-metadata-repository.ts:520, :1185, :1300, :1377 SysMetadataRepository.getByHash, list, history, replayFromHistory no yes 4 → 0 · —

Static 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 and sys_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 (including resolveRole's sys_member read) 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 each isSystem call 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:

  • a scratch harness on a booted showcase stack (messaging and storage plugins composed) that calls every producer above directly, plus the upload door;
  • the 16 dogfood files that exercise messaging, uploads and settings (listed under Tests).

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 createSession insert, which moved after that run. Outcome shapes per call are equal before and after, except where the Q2 change adds the owner_of: delivery. The instrument was reverted (security-plugin.ts blob 5b4ab28045af equals HEAD), plugin-security was rebuilt, and ablation-dist-preflight --absent passes. The positive control was the marker present in 2 built files while the instrument was live.

The rulings, and how each one holds

  • Q1 → A. The inbox read-state producers and the two upload inserts take the opt-in inside their owning service and keep the door-derived scope. Every read of a user's inbox or receipt rows is keyed where: { user_id }. The receipt a mark-read inserts is stamped with that user_id. The receipt it updates is addressed by the id that a user_id-keyed read of the same call returned, and by nothing else. notificationOrganization reads one sys_notification event row, which names no recipient, by id, projecting id and organization_id, and uses it only as the stamp on the caller's own receipt. createFile stamps owner_id with the user the door resolved from the session. Both inserts still hand the acting organization to the driver as tenantId. 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.
  • Q2 → 甲. resolveOwnerOf takes the same opt-in its sibling resolveEmail carries. It projects id plus the owner fields, and only the owner id leaves the resolver. Measured on a booted showcase stack, for an owner_of: audience on a private object: on 9c3bec0f4d, 0 recipients, and emit() delivered 0. On this change, 1 recipient (the record's owner), and emit() delivered 1. Nothing the read returns appears in what emit() answers.
  • Rows 23 and 24 onward. Moved after measuring that no gate fires, per the standing rule.

Other engine behaviour keyed on the flag (checked per moved call)

  • Referential-integrity check. It is skipped for an isSystem write. None of the moved writes supplies a non-readonly reference field: the receipt, delivery, sys_file, sys_upload_session and sys_secret columns these writes fill are text, number or json. So there is no skipped refusal to restore.
  • Read-only strip on update. None of the moved updates (the receipt update, both acks) writes a read-only field. The exception is the sys_secret store's update. It writes ciphertext, 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.
  • Sharing read filter. It abstains for every sys_ object here. The only business-object read is resolveOwnerOf, which is the ruled Q2 change.
  • Read-audit, attachment and comment hooks, approvals locks, the tenant wall and the owner stamp. None of these applies to these objects, or each skips a context with no user id exactly as it skips a system one.
  • Tenant-audit warning. This is a diagnostic only. The census page moved and was regenerated.

Found by this build, not moved (listed for the seat)

  • service-storage StorageMetadataStore: getFile (:342), updateFile (:368), deleteFile (:403), getSession (:469), updateSession (:496), deleteSession (:529). They are principal-less today (dogfood subset: 83 sys_file reads, 39 updates, 3 session calls; 0 gate throws) and reached from the upload and download doors. The Q1 ruling names createFile only. 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-messaging SqlHttpOutbox.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.
  • Outside the declared surface: metadata-protocol protocol.ts promoteDraftForPublish still reads sys_metadata principal-less (3 dogfood records). That file is another lane's, so it is listed only.

Pins and ablations

  • service-messaging system-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):
    • Q1: every call carries 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.
    • ⛔ negative: no call made for one user reads, writes or addresses another user's rows (two users' rows seeded).
    • Q2: the opt-in, the exact projection, the owner id alone back, and emit() carrying none of the record's values.
    • Rows 24+: role and team reads, the dedup lookup, both outboxes' enqueue (including the lost-race read-back) / ack (both arities) / list, the email and SMS reads with the retry, and the template read.
  • service-storage:
    • The stamping pins now expect { tenantId, isSystem: true }, and { isSystem: true } alone where the caller has no organization (no organization is invented).
    • ⛔ negative: a body naming another owner_id does not reach the insert.
    • Engine leg: under the opt-in both inserts still reach the driver with tenantId.
  • service-settings settings-system-context.pin.test.ts: the store's four verbs, and a rotation whose write, update and post-update verification read all carry isSystem.
  • metadata-protocol protocol.platform-store-system-opt-in.test.ts: getByHash, list, history and the watch replay.

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 empty git diff HEAD. The pins import their subjects from src, so no build sat in between. All went red:

Leg Mutation Red
A1 listInbox loses its user_id term 2 of 12, including the negative pin ("find on sys_inbox_message: expected 'u_b' to be 'u_a'")
A2 the inbox opt-in constant set to isSystem: false 1 of 12
A3 resolveOwnerOf's opt-in dropped (context: undefined) 1 of 12. The first attempt was a no-op the tool refused, because the replacement was a prefix of the anchor. It was re-run.
A4 resolveOwnerOf also returns a record value 2 of 12
A5 the outbox opt-in constant set to false 2 of 12
A6 the sys_secret store's insert loses its opt-in 2 of 4
A7 readStoredHandle's opt-in dropped 1 of 4
A8 the storage inserts lose isSystem 9 of 12
A9 the upload door takes owner_id from the body 1 of 12 (the negative pin)
A10 getByHash's opt-in dropped 1 of 4
A11 the fan-out opt-in constant set to false 5 of 12
A12 the dispatcher opt-in constant set to false 4 of 12

Tests

  • Gates, at e2aa5ecaa0 (this PR's head): dispatch-gates --commands --repo objectstack-ai/objectstack derives 106 commands, and the dispatch-time list's 53 are among them. All 106 ran and exited 0. --ran reports "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)".

    • On the previous head (db67da722d), check:where-matcher went red on the new pin's matcher, because it refused a combinator outside its callback. That is fixed here.
    • check:skill-examples and check:dual-build-cjs-loads first refused with PREREQUISITE NOT MET: the spec dist was stale after the merge, and eight unrelated packages had no dist. They were built, and both re-ran green.
  • Package suites, local: service-messaging 48 files / 523 tests, service-storage 42 / 652, service-settings 36 / 628, metadata-protocol 218 files + 3 skipped / 28,043 tests passed. typecheck exits 0 for all four (service-storage includes its check:test-typecheck).

  • Dogfood, at db67da722d and before the change. The 16 files are approval-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-channel and object-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 from eslint.config.mjs (**/*.{ts,…} plus the packages/** objects). --no-inline-config --format json over the 20 changed .ts files 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

  • createSession moved with createFile. 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 by file_id, and that file carries owner_id.
  • Gate-required artifacts outside the code surface. check:tenant-audit-census needed docs/audits/2026-08-tenant-audit-write-call-sites.counts.md and the generated block of content/docs/permissions/tenant-audit-census.mdx regenerated, and the page's hand-written figures follow the census: threading 164 → 171, unreadable 60 → 54, decidably elevated 121 → 123. check:system-context-census --fix moved the isSystem census page's declaration count 26 → 27, for the internal helper's return type. scripts/engine-double-contract.pinned.json gained two rows (--write) for the settings pin's new update / delete doubles.
  • The composed Q2 reading is a measurement, not a committed pin. The reading is "on a private object, 0 recipients on the base and 1 on this change". No package in this surface composes plugin-sharing with service-messaging, and service-messaging deliberately 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.
  • One read-state stamp is held for the seat at class level. It is pre-existing and unchanged by this move.
  • At this head the branch is one commit behind main. That commit edits metadata-protocol protocol.ts only, and no file here.

Generated by Claude Code

@github-actions github-actions Bot added the size/l label Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/service-messaging, @objectstack/service-settings, @objectstack/service-storage, touching 35 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-messaging/src/fan-out-system-context.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1fb274e61cfff12ede54963d779acdfd079be318.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-messaging/src/fan-out-system-context.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 38 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 23 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 1fb274e61cfff12ede54963d779acdfd079be318 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 93638c07b785ff6a76638eab4ea952e6c0fcc873 — the merge of head e2aa5ecaa0548e791ba50de3b9aec6838eaf241c into base 1fb274e61cfff12ede54963d779acdfd079be318, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1fb274e61cfff12ede54963d779acdfd079be318 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 18:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 18:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit b88c356 Oct 6, 2026
41 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21908-principal-less-producers-final branch October 6, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants