Skip to content

fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in - #22045

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21908-by-id-producers-opt-in
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21908-by-id-producers-opt-in

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #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 #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 fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in #22025's insert pins and fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user #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

claude added 5 commits October 7, 2026 01:52
…thods and the HTTP outbox's redeliver take the explicit system opt-in

StorageMetadataStore getFile, updateFile, deleteFile, getSession,
updateSession and deleteSession, and SqlHttpOutbox.redeliver, now pass
the explicit system opt-in on their data-engine calls instead of no
principal. The door-derived organization stays the driver-level scope on
update and delete, redeliver keeps its threaded tenant on every call and
states its tenant audit armed, and the by-id updates send the patch alone
so the provisioned columns stay the platform's.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…stem opt-in and the tenant scope beside it

The storage store's by-id reads carry the opt-in and no tenant; its by-id
writes carry the opt-in beside the door's organization and send the patch
alone; through the real engine over a real SQL driver a door tenant still
cannot update or delete another organization's row. The HTTP outbox's
redeliver carries the opt-in on both reads and the reset write, keeps the
caller's tenant on every bag, states its tenant audit armed, and still
cannot reach a foreign row.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…eliver take the explicit system opt-in

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
… with the engine's dispatch predicates

The two wrappers that record the opt-in on the real engine path now route
each verb through the engine's own dispatch predicate, and the engine-double
ledger counts the storage file's second double.

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…enant audit armed

Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 7, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-messaging, @objectstack/service-storage, touching 15 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/automation/approvals.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/automation/webhooks.mdx (via /api/v1/webhooks/redeliver (route, a path literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/kernel/contracts/storage-service.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/permissions/attachments-access.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile), sys_upload_session (literal, a string literal in deleteSession; a string literal in getSession; a string literal in updateSession))
  • content/docs/protocol/objectql/types.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/ui/translations.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile), sys_upload_session (literal, a string literal in deleteSession; a string literal in getSession; a string literal in updateSession))

⛔ 8 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/releases/index.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/releases/v15.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile), sys_upload_session (literal, a string literal in deleteSession; a string literal in getSession; a string literal in updateSession))
  • content/docs/releases/v16.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile), sys_upload_session (literal, a string literal in deleteSession; a string literal in getSession; a string literal in updateSession))
  • content/docs/releases/v17/17-0.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/releases/v17/17-2.mdx (via MessagingService (symbol, a top-level class), SqlHttpOutbox (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))
  • content/docs/releases/v17/index.mdx (via sys_file (literal, a string literal in deleteFile; a string literal in getFile; a string literal in updateFile))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 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 — 9 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 8caa131e52717ef35fe71742d758f10b8f2b77ee → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dd8d8486d7ef675818fa638d3d8edc42b2345802 — the merge of head 2665532dc973b8e827b468e1faadfa2c6bbe1214 into base 8caa131e52717ef35fe71742d758f10b8f2b77ee, 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 dd8d8486d7ef675818fa638d3d8edc42b2345802 && git checkout dd8d8486d7ef675818fa638d3d8edc42b2345802
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8caa131e52717ef35fe71742d758f10b8f2b77ee 2665532dc973b8e827b468e1faadfa2c6bbe1214 && git checkout -B drift-repro 8caa131e52717ef35fe71742d758f10b8f2b77ee && git merge --no-ff 2665532dc973b8e827b468e1faadfa2c6bbe1214

node scripts/docs-audit/affected-docs.mjs --json 8caa131e52717ef35fe71742d758f10b8f2b77ee

⚠️ 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 8caa131e52717ef35fe71742d758f10b8f2b77ee → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 03:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 03:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit a7a48b7 Oct 7, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21908-by-id-producers-opt-in branch October 7, 2026 03:52
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