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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…otification delivered to that user (objectstack-ai#22037)

Fixes objectstack-ai#22026
Clause-②: no (narrowing)

A read receipt belongs to a recipient: ADR-0030 keys
`sys_notification_receipt` by recipient. `MessagingService.markRead` is
the method behind the notifications mark-read door and behind
`markReadAsCaller`. It used to insert a `read` receipt for any
notification id it was handed, stamped with that notification's
organization, and never checked that the caller was a recipient. It now
inserts one only for a notification that was delivered to that user.
This follows triage's direction (`6022270298`) and the seat verdict on
the measure-first round (`6023979476`, option A). Rows already written
are not cleaned up; the verdict ruled that out.

## What changed

Positions are at base `b88c356413`, in
`packages/services/service-messaging/src/messaging-service.ts`.

- **`upsertReadReceipt`** (near `:888`) keeps its first step: it flips a
receipt keyed `(notification_id, user_id, channel 'inbox')` to `read` in
place. That read is also the receipt half of the delivery check. The
receipt key is the `delivered` receipt's key, so when the read finds
nothing, there is no delivered receipt either.
- **When nothing is found,** the new private `inboxMessageNames` checks
for the inbox half. It reads the caller's own `sys_inbox_message` for
that notification: `where: { user_id, notification_id }`, `fields:
['id']`, under `INBOX_SYSTEM_CONTEXT`. An inbox message is enough on its
own, because the inbox channel's `delivered` receipt is best-effort:
`writeDeliveredReceipt` logs a failure and moves on, and the inbox row
stays.
- **If neither half holds,** `upsertReadReceipt` returns 0. No row is
written, `readCount` does not count the id, and
`notificationOrganization` is not called. That organization read now
happens only on the delivered path.
- **If the inbox read throws,** the error reaches `markRead`'s existing
per-id catch. It is logged at warn, the id counts 0, and no row is
written. A check should fail in that direction.
- **`markAllRead` is unchanged.** Its ids come from
`unreadNotificationIds`, which reads the user's own inbox rows that
carry a `notification_id`. So every id it passes already has an inbox
message for that user. A pin below covers it.
- **Doc comments.** The `markRead` docblock now says an undelivered id
writes nothing. The `inbox-system-context.ts` docblock lists the new
read among the calls that context carries; that edit is comment-only, in
the same package.
- **Unchanged:** the response shape `{ success, readCount }`. There is
no spec, permission-set or `plugin-security` change, and nothing changes
who may read what.

## Reachability, measured before the build

Triage asked for this measurement first. It was taken on base
`b88c356413` with a local engine-level probe: the real `SecurityPlugin`,
`ObjectQL` and `SqlDriver`, the shipped permission sets, and the
`isolated`, `group` and `single` postures. The probe was deleted and
never committed. Classes and roles only:

- **No reader in the stamped organization** reads a non-recipient's
receipt row, by list or by id. That covers the member, the viewer, the
org admin, and the platform admin with that organization active. The
`_self` policy binds every authenticated principal on this object, and
the object grants no superuser bypass.
- **The stamp grants nobody a read.** Its only effect on reads: the
writer cannot see their own wrongly stamped row on the generic data
door. That row is the defect's own output, and this change stops
producing it.
- **The inbox's own reads** run under their user-keyed context, and the
stamp does not reach them.

Report: `6023938722`. Seat verdict: `6023979476`.

## Pins and ablation

The pins are in `messaging-service.test.ts`, block `[objectstack-ai#22026]`. They use
the file's existing stateful `inboxEngine` double, observed through
spies, so no new double was added.

- **The recipient half:** with an inbox message and no receipt, the
receipt persists, is stamped with the notification's organization, and
counts 1.
- ⛔ **The non-recipient half,** for the same notification: the call
returns `{ success: true, readCount: 0 }`. The receipt table stays
empty, nothing is inserted, the event's organization is not read, and
the recipient's state does not move.
- ⛔ The same holds through `markReadAsCaller`.
- A mixed call counts only the delivered id and writes only its receipt.
- **The receipt half:** a delivered receipt with no inbox message is
still flipped in place, as before.
- The delivery check reads only the caller's own inbox: keyed on
`user_id`, run with `{ isSystem: true }`, and projecting `id` alone.
- **An inbox row with no `notification_id`:** a call naming its row id
writes nothing, and the row still lists as unread.
- **`markAllRead`:** only delivered ids are swept, by construction. The
test counts 2 and finds receipts for that user only.

**Fixture triage:** in `read-receipt-organization.test.ts` (pins E and
E2), the reader had no delivery evidence. The fixture now gives them
their inbox message, which is exactly the case the insert path exists
for. The assertions are unchanged.

**Ablation.** Run on the committed state `7eb0b82008` with
`scripts/ablation-replace.mjs` in wrap mode: delete the delivery-check
statement, run the block, restore.
- The anchor count went 1 to 0, and the blob went `448159c99cee` to
`ac84bbb73a43`.
- 5 of the 8 pins turned red. The non-recipient half read `expected {
success: true, readCount: 1 } to deeply equal { success: true,
readCount: +0 }`.
- The 3 that stayed green are the recipient half, the receipt half and
`markAllRead`, which do not depend on the check.
- After the restore, the blob matched HEAD `448159c99cee` and `git diff
HEAD` was empty.

objectstack-ai#22025's pins (`system-context.pin.test.ts`) are green and unchanged.

## Tests

Head `f2d085db22` unless a line says otherwise. That commit merges
`origin/main` `2301e17eaf`, a CI-script commit that touches none of
these files.

- **Builds.** `pnpm --filter '@objectstack/service-messaging^...' build`
exited 0. `pnpm --filter @objectstack/service-messaging build` exited 0,
with `check-dts-emitted: 2/2`.
- **Package suite.** `pnpm --filter @objectstack/service-messaging
test`: 48 files and 531 tests passed. `typecheck` exited 0, and `tsc
--listFiles` shows its program includes all 48 test files, the two
edited ones among them.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands`, run on
this change with no paths, derives 65 commands: the dispatch's 51 plus
14 from the changeset and test-file families, none dropped. All 65 exit
0. The `--ran` reconciliation over the recorded exit codes reads "65
derived, 65 run, 0 NOT-MEASURED, 0 UNRUN".
- **Two gates needed a build first.** In the first run, at `7eb0b82008`,
`check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT
MET: no build output). After a turbo build of the workspace (72 tasks,
71 cached), both exited 0, and they exit 0 at the merge head too.
- **Verdict lines.**
  - `check-nul-bytes: OK`, no raw ASCII control bytes.
- `check-adr-0087-registration` printed its no-migration-prescription
exemption notice.
- `check-empty-changeset`: "No empty-frontmatter changeset introduced by
this diff".
  - `check-system-context-census: OK`.
- `check-changeset-no-major` reads its level axis from the PR payload,
so that half is answered in CI.
- **Dogfood, at `7eb0b82008`** (the merge touched no package). These
suites reach service-messaging through `dist/`, which was checked to
contain the change: `approval-notification-body.dogfood.test.ts`,
`schedule-acting-organization.dogfood.test.ts`,
`schedule-sweep-organization-scope.dogfood.test.ts` and
`platform-app-object-entry-views.test.ts`. 4 files and 84 tests passed.
- **Runtime, through the real HTTP mark-read door, at `7eb0b82008`.**
`notifications.hono.integration.test.ts` delivers through the real
pipeline, marks one notification read (`readCount: 1`), then marks all
read. Together with `notification-schema-conformance.test.ts` and
`domains/notifications-query-validation.test.ts`: 3 files and 54 tests
passed.
- **Left to CI:** repo-wide lint, the whole-workspace type-check lanes,
the Test Core shards and the full dogfood shards.

## Acceptance notes

- **Comment drift in `plugin-security`, carrier: none.** In
`default-permission-sets.ts`, the comment above `sys_inbox_message_self`
says neither inbox object declares `organization_id`, so Layer 0 is
inert on them. In fact the registry injects `organization_id` into
`sys_notification_receipt` (it is engine-owned, not better-auth), and
the tenant wall and the driver's native tenant scope do act on it, only
ever narrowing. This is comment drift, not a behaviour defect. That file
is not edited here.
- **Rows written by the old path stay.** No non-system reader can read
them, and they expire under the object's declared 90-day transient
lifecycle. If the same user marks the same id read again, that existing
row is flipped, as before: the flip path is unchanged by ruling.
- **Inbox rows without a `notification_id`.** objectstack-ai#6448, which asked about
such rows, ended in the not-planned state. `listInbox` looks up read
state only through a row's `notification_id`, so a row without one
always lists as unread. Nothing regresses for those rows.
- **The changeset** is `minor` with a BREAKING banner (an accept-set
narrowing, under the launch-window convention) and carries an ADR-0087
`not-required (no-migration-prescription)` disposition.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…thods and the HTTP outbox's redeliver take the explicit system opt-in (objectstack-ai#22045)

Part of objectstack-ai#21908
Clause-②: no

Phase two, stage 2a of the principal-less hand-off closure (ADR-0096 E1
/ D5), per the maintainer's ruling on the card (letter A). Seven more
producers of a principal-less, non-system data context now take the
explicit system opt-in (`isSystem: true`) inside their owning service.
The deny itself lands last and is not in this PR, so objectstack-ai#21908 stays open.
⛔ No `plugin-security`, `packages/spec`, `metadata-protocol` or
`docs/adr` file is in the diff, there is no new elevation API, and no
door's authorization changed.

## What moved

Positions are at the base `9a0401fdd3`. "Gate fired" means one of the
six gates the security middleware still runs before its hand-off
(package-managed, system-row, curated-capability, audience-anchor,
ADR-0103 engine-owned, ADR-0090 D12 delegated-admin) fires on the
producer's calls. Counts are principal-less calls at the hand-off,
before and after the change; every "after" call arrived as an `isSystem`
call.

| Position | Function | Doors | Door authorizes (where) | Gate fired |
Moved | Evidence (harness · dogfood subset) |
| :-- | :-- | :-- | :-- | :-- | :-- | :-- |
| `service-storage` `metadata-store.ts:373` |
`StorageMetadataStore.getFile` | upload commit
(`storage-routes.ts:453`), both download doors (`:799`, `:846`), and the
chunked completion through `updateFile` | yes. Commit and chunked
completion: session authentication (`requireUploadSession`, `:458`,
`:684`) before the read. Downloads: `authorizeDownload` (`:808`, `:855`)
after the read and before anything is disclosed, because its verdict
reads the row | no | yes | 5 → 0 · 111 → 0 |
| `metadata-store.ts:399` | `StorageMetadataStore.updateFile` | upload
commit (`:472`), chunked completion (`:720`) | yes. Session
authentication before the write; the session's organization scopes the
write | no | yes | 2 → 0 · 46 → 0 |
| `metadata-store.ts:434` | `StorageMetadataStore.deleteFile` | none. No
production caller in this repository; the class is exported | not
applicable, no door | no | yes | 1 → 0 (direct call) · not reached |
| `metadata-store.ts:502` | `StorageMetadataStore.getSession` | chunk
upload (`:593`), chunked completion (`:681`), progress (`:751`) | yes.
Session authentication before the read; at the chunk door the
resume-token check (`:617`) follows the read, because it reads the row's
token, and precedes every write | no | yes | 8 → 0 · 2 → 0 |
| `metadata-store.ts:529` | `StorageMetadataStore.updateSession` | chunk
upload (`:658`, expiry via `:623`), chunked completion (`:705`, `:725`,
expiry via `:694`, failure stamp via `:732`), progress (expiry via
`:770`) | yes. Session authentication before every write; the chunk door
also checks the resume token first | no | yes | 4 → 0 · 1 → 0 |
| `metadata-store.ts:562` | `StorageMetadataStore.deleteSession` | none.
No production caller in this repository | not applicable, no door | no |
yes | 1 → 0 (direct call) · not reached |
| `service-messaging` `sql-http-outbox.ts:468` |
`SqlHttpOutbox.redeliver` (two reads, one reset write) | `POST
/api/v1/webhooks/redeliver` (`plugin-webhooks`
`webhook-outbox-plugin.ts:397`) through `MessagingService.redeliverHttp`
| yes. An authenticated session or `401` before the call; the session's
active organization is the tenant; the producer's veto runs before the
write | no | yes | 3 → 0 · not reached |

Read scope, asked for by the claim: `getFile` and `getSession` read by
id with no organization scope before the move (no context at all, so no
`tenantId` reached the driver, and the middleware handed the call
through before any row or tenant filter) and after it (the middleware
short-circuits before the same filters, and still no `tenantId` reaches
the driver).

## What the opt-in would also have changed, and what holds it

The opt-in skips more than the six gates. Measured on the real engine,
per call:

- **The update-side `readonly` strip.** `updateFile` and `updateSession`
used to send the whole row read back, merged with the patch. Under the
strip, the engine took `organization_id`, `created_at`, `updated_at`,
`created_by` and `updated_by` out of that row on every call. Under the
opt-in the strip does not run, so the full row, `organization_id`
included and read without tenant scope, would have been written back.
**Fix inside the store:** the two updates now send the caller's patch
alone (`changedColumns`). Measured on SQLite through the real engine: on
the base the strip took those five columns; on the change it takes none,
and the stored rows are equal on every non-timestamp column, with
`updated_at` still stamped by the platform.
- **The tenant-audit fill-in.** For an `isSystem` write,
`ObjectQL.buildDriverOptions` fills in `bypassTenantAudit: true` when
the object is outside the platform tenancy inventory. `sys_file` and
`sys_upload_session` are in it as tenant-scoped, so nothing changes for
them (pinned). `sys_http_delivery` is not, so the opt-in would have
silenced the audit for a redelivery from a caller with no organization,
which is the one line `RedeliverOptions` keeps. **Fix inside the
outbox:** the reset write states `bypassTenantAudit: false`. The engine
never overwrites an explicit value, and the driver still audits.
- **Unchanged, measured or read:** row and field security, the masker
and the Layer 0 tenant wall are skipped by the hand-off and by the
opt-in alike. Read auditing records neither (no user id). No lookup
field exists on the three objects, so the referential-integrity skip
does not apply. Redelivery's bulk data event publishes without an
organization in both cases.

## Pins

- **Store, `tenant-audit-update-delete-half-repairs.test.ts`.**
- Every by-id read carries `{ context: { isSystem: true } }` and no
tenant.
- Every by-id write carries the opt-in beside the door's tenant, or the
opt-in alone with no tenant invented. The route-level pins are updated
to match.
- ⛔ An update payload never carries the provisioned columns of the row
read back.
  - The opt-in leaves these writes' tenant audit armed.
- ⛔ Negative, through the real `ObjectQL` over a real `SqlDriver` on
SQLite in the isolated posture: under the opt-in, a door tenant's
`updateFile`, `updateSession`, `deleteFile` and `deleteSession` on a row
stamped for another organization are refused
(`StorageMetadataStoreError` wrapping `RECORD_NOT_FOUND`) and leave the
row untouched. The still-works half shows the caller's own row and an
organization-less row are reached.
- **Outbox, `system-context.pin.test.ts`.** Both reads and the reset
write carry the opt-in. The caller's `tenantId` stays on every bag. The
reset states `bypassTenantAudit: false`, and a caller with no tenant
gets none invented.
- **Outbox, `delivery-update-tenant-audit.integration.test.ts`.**
- ⛔ Negative, on the real driver: a foreign row stays
`RESOURCE_NOT_FOUND` with zero writes under the opt-in, and the caller's
own row resets.
- The two existing audit assertions now read `false`, the value the
driver receives, where they read "absent".
- **The stage-1 pins stay green.** These are objectstack-ai#22025's insert pins and
objectstack-ai#22037's inbox pins, inside the full package suites.

**Ablations**, run at `0f6df0f306`. Each mutation went through
`scripts/ablation-replace.mjs`: the anchor hit, the blob changed, and
the restore was proved by blob equal to `HEAD` and an empty `git diff
HEAD`. The pins import from `src`, so no `dist` leg applies.

| # | Mutation | Result |
| :-- | :-- | :-- |
| A1 | by-id writes keep the opt-in but drop the door tenant | 13 of 28
red; the negative pins read "promise resolved … instead of rejecting"
(the foreign row was reached) |
| A3 | `updateFile` sends the merged row again | 1 of 28 red; the
payload carried `organization_id` and `created_by` |
| A4 | `redeliver`'s deciding read drops `tenantId` | 5 of 30 red; the
negative pin got `DELIVERY_NOT_ELIGIBLE` where it expects
`RESOURCE_NOT_FOUND` |
| A5 | the reset write's `bypassTenantAudit: false` removed | 4 of 21
red; the driver received `true` ("expected true to be false"), and the
tenant-less redelivery went silent |

## Measurement

The measurement used a local instrument in `plugin-security`'s
middleware, never committed. Its instrument file was reverted, and blob
`5b4ab28045af` equals `HEAD`. `plugin-security` was rebuilt, and
`ablation-dist-preflight --absent` passes; its positive control found
the marker in 2 built files while it was live.

For each call from the seven producers, the instrument recorded the
producer frame, whether the call was principal-less, and a dry run of
the six gates with the system flag cleared. Two runs used it:

- A scratch harness, deleted and not in the diff. It drove every door on
a booted stack: upload commit, both downloads, the chunk upload,
progress, chunked completion, progress on an expired session, and `POST
/api/v1/webhooks/redeliver`. It called `deleteFile` and `deleteSession`
directly.
- The 11-file dogfood subset named below.

Every door answered the same on the base and on the change (200, or 302
for the redirect). There were 0 gate firings before and after.

## Acceptance notes

- **Premise correction.** `updateFile` and `updateSession` already
accepted the optional organization context (`metadata-store.ts:399`,
`:529`), as `deleteFile` and `deleteSession` did. Only `getFile` and
`getSession` take none, and they still take none: the reads carry the
opt-in without a tenant. No public signature changed.
- **Door ordering, reported for the seat.** At both download doors the
read precedes `authorizeDownload`. At the chunk door the read precedes
the resume-token check. Each check reads the row it needs, and each
precedes any disclosure and any write. The read's reach is the same
before and after (unscoped by principal and by organization).
- **Doors without a door-level binding.** The chunked completion and
progress doors authorize by session authentication and check no resume
token. The commit door checks no ownership of the file id. The move
leaves all three unchanged, and owner checks are the question the ruling
sent to its own card (option C). Reported to the seat at class level.
- **`deleteFile` and `deleteSession` have no caller in this
repository.** They were moved as the ruling wires all seven. Their door
check is vacuous.
- **Files beyond the two named files, all in the same packages.**
- `outbox-dispatcher-scope.ts` declares `REDELIVER_SYSTEM_CONTEXT`
(module-internal) with its own warrant, and amends the two docblocks
that kept the outbox opt-ins off `redeliver`.
  - `messaging-service.ts` gets one sentence made true.
- `scripts/engine-double-contract.pinned.json` counts the storage test
file's second double, which `--write` grew.
- `plugin-audit`'s `audit-writers.test.ts` fixture prose still describes
`updateSession` writing the merged full record. That is test residue in
another package and is left alone.

## Verification (head `2665532dc9`)

Each command ran in the foreground of this worktree, and its exit code
was captured before any pipe.

- **Gates.** Running `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `2665532dc9`, with no paths,
derived 73 commands. The dispatch-time 51 are a subset. All 73 ran and
all exited 0. The reconciliation (`--ran`) printed: "73 derived
famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all
73 recorded an exit code and none of them is 3)".
- On an earlier pass at `6f366adcb1`, `check:engine-double-contract`
exited 1. It had counted the new recording wrapper as a fake engine. The
wrapper now routes each verb through the engine's dispatch predicates,
and `--write` grew the ledger.
- `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 8
packages outside the dogfood closure had no `dist/`). They were built,
and it then exited 0.
- **Suites at `2665532dc9`.** `@objectstack/service-storage`: 42 files,
659 tests passed. `@objectstack/service-messaging`: 48 files, 534
passed. `typecheck` passed for both, the storage test layer's
`check:test-typecheck` included. `--listFiles` confirms both programs
compile the edited test files. `@objectstack/plugin-webhooks`, which
mounts the redeliver door, passed at `0f6df0f306`: 15 files, 165 tests.
- **Dogfood.** The 11 files below passed, 100 passed and 1 skipped, on
both the base and the change. They ran against the change's built
`dist/`; the source is unchanged since `230ed9ea15`. The files:
`attachments-permission-matrix`, `attachments-public-read-acl`,
`attachments-unscoped-delete-gate`, `field-file-collection`,
`file-field-constraint-refusal`, `sys-file-metadata-write-refusal`,
`predicate-write-unreadable-not-matched`,
`write-door-unreadable-is-not-found`, `storage-growth`,
`temporal-storage-e2e`, `webhook-materialization`. None of these files
reaches the redeliver door; the scratch harness covered it.
- **Lint, a proven narrowing; CI runs the full `pnpm lint`.** The
population was read from `eslint.config.mjs`: the
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block plus the `packages/**`
blocks. `eslint --no-inline-config --format json` over the 7 changed
`.ts` files reported 7 files, 0 errors and 0 warnings at `c4f10218af`.
The config enables no type-aware linting (no `parserOptions.project` and
no typed rules), so no untouched file's verdict can move.

## Changeset

`.changeset/21908-by-id-producers-opt-in.md` grades `patch` for
`@objectstack/service-storage` and `@objectstack/service-messaging`. It
names the opt-in, the patch-only update payload and the held tenant
audit. No exported type or entry symbol changed: the new constants and
the store helper are module-internal.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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