Skip to content

fix(storage)!: a record write claims or copies only a file its caller may read - #25951

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-n8sx5h
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-n8sx5h

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #25692

Clause-②: no (narrowing)
Metadata-protocol: none

What changes

ADR-0104 D3 (wave 2) gives each sys_file at most one owning field slot. A record write that references a file id either claims an unclaimed file for the writing slot, or, when another slot owns the file, copies its bytes into a fresh sys_file the writing slot owns (copy-on-claim). Neither step asked whether the write's caller may read the file. The claim path now asks the download door's own verdict, under the write's caller context, and claims or copies only on an allow.

  • One judgement, two callers. The door's verdict body moves out of buildFileReadAuthorizer into judgeFileRead: the uploader, a reader of the record whose field owns the file (readableFieldOwners, honouring fileAccessDelegate), a member of the organization for a file no record owns (files: an authorization-adjacent gap on a file-serving door (detail withheld pending measurement) #25693), a reader of any sys_attachment parent for an attachments-scope file. The download door calls it with no change in behaviour. The claim path calls it through buildFileReferenceReadAuthorizer, which also admits a public_read file and asks nothing on a kernel with no auth service, where the door binds no gate either. One file has one access rule, decided in one place.
  • The quiet answer, after fix(rest)!: an anonymous public-form submission carries no file #25682. A file the caller may not read is answered exactly as the organization-scoped lookup answers a file it cannot see: the id is kept as written, and nothing is claimed or copied. This follows the precedent fix(rest)!: an anonymous public-form submission carries no file #25682 set for the cross-organization half of the same lifecycle. There is no new error code or status, and the write cannot tell "may not read" from "does not exist".
  • Ordering. Constraint verdicts no longer run ahead of the read check.
  • A write's own copy. A copy the write itself minted is claimed by that same write without a second question. The mint is remembered per write (keyed on the write's dispatch scope). When that record is missing, the write fails safe: the copy stays unclaimed and is never claimed for the wrong caller.
  • Outage. An authorization-store outage (AuthzStoreUnavailableError) fails the write with its declared envelope rather than reading as either answer. Any other throw from the judgement is a deny.
  • Unchanged. System writes and context-less programmatic writes carry no caller to ask, and reference files as before.

callerContext (attachment-access hooks) is exported so the lifecycle reads a write's caller the same way, with operation-private keys dropped. The system-context page (content/docs/permissions/system-context.mdx) gains row 47f for the claim path's system-write exemption, which check-system-context-census requires.

BREAKING, shipped as patch

The changeset carries Before / Now / Fix and the ADR-0087 marker not-required (no-migration-prescription): no spec key, stored row or exported declaration moves. A write that referenced a file its caller may not read now keeps the id unowned. To fix, reference the file as a caller who may read it, or upload it again. A server-side process that copies files between records on purpose runs as a system write.

Tests

  • file-reference-lifecycle.test.ts: 15 new cases against a stand-in verdict. They cover refusal on insert and update for owned files (no copy) and unclaimed files (no claim, a tombstone not revived), per-id judging of a multiple-value field, the constraint ordering with its control, the verdict being asked with the caller context (operation-private keys dropped), outage propagation (the record is not written), and system, context-less and unbound writes unchanged. Controls show an admitted caller still copies and claims.
  • file-reference-read-verdict.integration.test.ts (new): 11 cases on a real ObjectQL and SqlDriver with StorageServicePlugin booted. For each file, the download door's answer to the writer and the claim path's outcome for the same writer agree. A refused file gets 403 and is kept; a served file gets 200 and is copied or claimed. The cases span the field-owned, delegate, attachments, uploader, public_read and unclaimed arms, plus outage and other-fault pins and a system write.
  • Package suite: 65 files, 1179 tests pass. tsc -p tsconfig.test.json compiles both files.

Ablations

Every negative pin was put back to the refused behaviour and shown red, then restored. Each leg ran through scripts/ablation-replace.mjs: the anchor hit 1 → 0, the blob changed, the restored blob equals HEAD, and git diff HEAD was empty.

Leg Mutation Red
A the lookup ignores the verdict 15: every unit refusal, ordering, caller-context and outage pin, and every integration refusal and fault pin. Every integration allow control stays green.
B the lifecycle swallows a verdict throw 2: the unit and integration outage pins
C the plugin's binding admits everyone 5: the integration refusal and fault pins
D the plugin's binding stops re-raising an outage 1: the integration outage envelope pin
E a write's own copy is asked again 1: the unit control "claimed without a second question"
F a system write is asked 1: the unit system-write pin

Gates

All on head 4f3c21e5. The service-storage closure was built first, then the whole repo (pnpm build, 119/119 tasks).

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 102 gates. All 102 exited 0, and --ran reconciles them: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint (repo-wide eslint . --no-inline-config): exit 0.
  • check:commit-card-trailers --range origin/main..HEAD: all 6 commits carry the model-free trailer pair.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test, no shard, no path, under the verify lock): 295 of 299 files passed and 3 were skipped; 2522 of 2541 tests passed and 18 were skipped. The one failure was packaged-flow-write-door-parity.dogfood.test.ts, "a flow no package ships is still created, updated and removed through the same door", which hit its 5 s timeout while the 102 gates ran on the same 4 cores. Re-run alone under the lock: 5/5 passed. The test touches no file field.

Acceptance notes


Generated by Claude Code

… may read

The file-reference claim path asks the download door's own read judgement,
under the write's caller context, before it copies or claims a referenced
file. A file the caller may not read is answered as one the
organization-scoped lookup cannot see: the value is kept as written, nothing
is claimed or copied, and the field's declared constraints are not judged
against it. System and context-less writes are unchanged.

The door's judgement moves into one function both the door and the
lifecycle call, so the two cannot diverge.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DTRymn12to8YSGMcTzS7h
A write's own copy is claimed without a second question; the verdict is asked
of the source file only.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DTRymn12to8YSGMcTzS7h
…per file

Through a real engine with the storage plugin booted as it ships, each file
is asked of the download door and of a record write by the same caller; the
write takes the file exactly when the door serves it. An authorization-store
outage fails the write with its declared envelope; any other fault fails
closed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DTRymn12to8YSGMcTzS7h
…ence

Code comments, one test name and the changeset now say only that
constraint verdicts no longer run ahead of the read check.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DTRymn12to8YSGMcTzS7h
…tion

The file-reference claim path reads the hook session's system flag to skip
the read verdict for a system write; the system-context page now anchors it.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 19 documentable anchor(s).

16 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 2a728ba2acf52c13d1918c2afca7b79bf391e1f5.

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

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 — 10 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 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2a728ba2acf52c13d1918c2afca7b79bf391e1f5

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4f3c21e550894267d48aff9ac2985567ea2b3c18
Local-runs: none
Scope: packages/services/service-storage/src/file-reference-lifecycle.ts, packages/services/service-storage/src/file-reference-lifecycle.test.ts, packages/services/service-storage/src/file-reference-read-verdict.integration.test.ts, packages/services/service-storage/src/storage-service-plugin.ts, packages/services/service-storage/src/attachment-access-hooks.ts, content/docs/permissions/system-context.mdx + packages/services/service-storage/src/storage-routes.ts (the download door's arms before the verdict and its outage relay), packages/services/service-storage/src/index.ts (the published barrel), packages/objectql/src/engine.ts (buildSession, the insert and by-id update hook contexts and their dispatch.scope), packages/core/src/security/authz-store-unavailable.ts (the declared envelope and brand), packages/core/src/security/operation-private-keys.ts (withoutOperationPrivateKeys), docs/adr/0104-field-runtime-value-shape-contract.md (D3 and the 2026-07-27 ownership addendum), scripts/check-system-context-census.mjs (row 47f's anchor rule), the #25682 lifecycle hunk as it stands on main (systemReadContext, the quiet answer's precedent), and .changeset/25692-file-reference-claim-read-verdict.md (read for ②). Read-only throughout: the net diff against main at the merge base cb0a2fcf, the card and its comments, and the head's check-runs.

Face: narrowing (Clause-②: no (narrowing) on the PR body and the changeset). Review at the PR's requested effort, security card, disclosure discipline in force: mechanism and verdict only.

① Derived judgments

  1. callerContext exported from attachment-access-hooks.ts — right. The barrel (index.ts) does not re-export it and the package publishes "." alone, so the export is package-internal and moves nothing on the published surface. The lifecycle reads a write's caller through the same function the attach gates use: the operation context from the write's options with the operation-private keys dropped by withoutOperationPrivateKeys from core (the keys belong to the object being written, and the question is about a file's owning record), else the hook session's userId / organizationId / positions. One reading for both modules, as the claim amendment asked.

  2. judgeFileRead moved out of buildFileReadAuthorizer — right, no behaviour change at the door. The extracted body is the removed body line for line (53 non-blank lines, equal under whitespace normalisation): the uploader arm, the field-owned arm through readableFieldOwners (which honours fileAccessDelegate and fails closed), the files: an authorization-adjacent gap on a file-serving door (detail withheld pending measurement) #25693 organization arm for an unowned file through isWithinCallerOrganization, and the attachments arm over the parent records. The door keeps its own request-to-context composition, its unauthenticated answer for a caller with no user, and its unchanged catch (the authorization-store brand and the API-key lifecycle brand re-raised, every other throw a deny).

  3. The claim path's new deny — right, and it is the door's rule. buildFileReferenceReadAuthorizer mirrors the door's arms before the judgement, in the door's order: a kernel with no auth service asks nothing (where the door binds no gate), a public_read file is admitted, a caller with no user is refused, then judgeFileRead under the write's caller context. findReferenceableFile is the one lookup both halves of the path use (copy-on-claim before the write, the claim after it), so the two cannot disagree; it keeps the fix(rest)!: an anonymous public-form submission carries no file #25682 organization-scoped lookup first and asks the verdict only when the write has a caller and the plugin bound one. The quiet answer is the fix(rest)!: an anonymous public-form submission carries no file #25682 answer exactly: the value is kept as written, nothing is claimed or copied, no new code or status, and the tombstone-revival branch sits behind the same lookup. Each id of a multiple field is judged on its own. The declared-constraint check runs after the read check, as the PR body states in one sentence, and the control pins that an admitted caller is still held to the declaration.

  4. The two exemptions — right. referencingCallerOf returns no caller for !ctx.session and for session.isSystem. In the engine, buildSession returns undefined when the operation carries no context (a bare kernel, a seed, a provenance-only flow run) and sets isSystem only when the context carries it, so the two exemptions are exactly the context-less programmatic write and the system write that census row 47f names, and nothing else. A flow run is either isSystem or carries the acting user, so it lands on one of the two declared sides.

  5. The outage envelope — right in the phase that decides the write. The plugin binding re-raises AuthzStoreUnavailableError by brand and answers every other throw as a refusal; the lifecycle does not catch it in copy-on-claim, so the before-hook throw fails the write before the driver runs, and the integration pin reads code and status off the error rather than writing digits. Precision note, not a defect: the claim in the after phase sits inside the module's existing warn-and-continue net for bookkeeping, so an outage arriving between the two phases of one write is answered there in the closed direction (the file stays unclaimed, a warning is logged) rather than by a thrown envelope. The same file was judged by the same binding in the before phase of that write.

  6. The fail-safe for a write's own copy — right. The mint record is keyed on dispatch.scope, falling back to the hook context itself. In the engine the single-record insert builds one row context and reuses it for afterInsert with one insertScope shared across both phases, and the by-id update binds hookContext.dispatch.scope once for both phases, so the record is found on every engine path. Without it the copy is judged as any unclaimed file and a refusal leaves it unclaimed and retained, the closed direction. The map is weak, so an entry lives as long as its write.

  7. The published surface otherwise — right. installFileReferenceHooks (on the barrel) gains an optional trailing parameter; nothing exported is removed or narrowed. The named type of that parameter, FileReferenceReadAuthorizer, is exported from the module but not from the barrel; harmless for a caller, noted for completeness.

  8. System-context census row 47f — right. Anchored on file-reference-lifecycle.ts#referencingCallerOf, the symbol that reads isSystem; the row's Get and Lose columns state what the code does. The Lint · docs & code guards check-run on this head is green.

② Semver level

patch on @objectstack/service-storage, with the BREAKING banner, Before / Now / Fix, the ADR-0087 marker not-required (no-migration-prescription), Clause-②: no (narrowing) and Metadata-protocol: none in both the PR body and the changeset. This matches what the diff publishes: no spec key, stored row, or exported declaration moves; one optional parameter is added. Per the Post-Task Checklist, a non-protocol breaking change ships patch and keeps its banner and disposition. The narrowing holds in both directions: the accept set shrinks only (a record write no longer copies or claims a file its caller may not read), and nothing refused becomes accepted, because the only open arms of the new binding (no auth service, public_read) are arms the door already leaves open. One consequence inside the declared narrowing: a non-system write whose context carries an organization or a service actor but no user is now refused as the door refuses a caller with no user, and the changeset's Fix line (reference as a caller who may read, or run a deliberate server-side copy as a system write) covers it. The Check Changeset check-run on this head is green.

③ Boundary flags

  • Dev open_questions: empty; nothing to answer.
  • Dev finding, three acceptance-note candidates handed to the seat thread off GitHub: not on GitHub and not readable here; they remain the seat's to dispose of and are not covered by this record.
  • Dev finding and PR acceptance note, anonymous write doors: an authorization-adjacent gap when a guest-writable object declares a file field (detail withheld pending measurement) #25694 to be re-read against this head before dispatch: concur. That card concerns a different door class, and the binding here now refuses a caller with no user, which bears on that card's first measurement.
  • PR acceptance note, the ADR-0104 D3 text amended in a separate Tier H docs PR: right that this PR carries no Tier H path. No such docs PR is open at the time of this review. The ADR's D3 Authorization bullet names the download check; the rule this PR implements tightens within the addendum's stated promise (one file, one owning record, one access rule) and reverses nothing, so Prime Directive 13 is met for this landing; the amendment stays owed and stays the maintainer's. Escalated to the seat as an open follow-up.
  • Dogfood: the one failure named in the PR body is a 5 s timeout under a concurrent gate sweep on an unrelated flow test, green alone; the head's Dogfood Regression Gate check-run is green. Accepted.
  • Check-runs on this head at the time of writing: 32 completed green, 0 red, 5 Test Core shards still in progress. The required Test Core context is therefore not yet green; this record judges the contract, and the queue reads the checks.
  • One boundary observation on an adjacent, pre-existing path (not introduced by this diff, not reached through it, closed direction preserved) was escalated to the seat off GitHub under the card's disclosure rule. It does not block.

Ablation legs A to F in the PR body name the pins each leg reddens and the controls that stay green; the counts are consistent with the 15 unit and 11 integration cases read above.

Implemented-by: claude/project-thread-n8sx5h
Reviewed-by: session_01TFnDdepdwyDJeM2kJ4GtMy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 (#20966) · seat session session_01TFnDdepdwyDJeM2kJ4GtMy · 2026-10-26T05:29Z · head 4f3c21e550894267d48aff9ac2985567ea2b3c18 · card #25692 (claim 6407719048, amendment 6407756179, report 6408697605)

  1. Shape: 7 files, +1025/−82, service-storage (file-reference-lifecycle.ts, storage-service-plugin.ts, attachment-access-hooks.ts and two test files), one changeset, and row 47f of content/docs/permissions/system-context.mdx. The declaration is Clause-②: no (narrowing), shipped patch with the BREAKING banner and its ADR-0087 disposition. The narrowing face owes a CONTRACT_REVIEW_TIER record, and it is on this head: PASS 6408818225.
  2. Premise, measured: the claim path now asks the download door's own verdict (judgeFileRead, moved out of buildFileReadAuthorizer) under the write's caller context before it claims or copies a file. The contract review read the move as equal line for line, read the door as unchanged, and read the quiet answer as the fix(rest)!: an anonymous public-form submission carries no file #25682 precedent. The dev's ablations A–F each turned red and restored clean.
  3. Gates: check-runs on this head: 47 total, 38 success, 9 skipped, 0 failed, 0 in progress; mergeable clean. Locally the dev reconciled 102 of 102 derived gates and pnpm lint exited 0. One dogfood timeout under 4-core contention passed 5/5 when rerun alone, and it touches no file field. Accepted.
  4. Accepted deviations: the system-context census row 47f is outside the claim's file surface. check-system-context-census requires it, and it is a prose face, checked here against the diff's referencingCallerOf exemptions (system writes, context-less writes). An earlier commit message on the branch states the change in the PR body's register. History is not rewritten.
  5. Out-of-scope findings: a hand-written docs drift on an adjacent page, left by an earlier change, gets a docs-only carrier from this seat. The ADR-0104 D3 amendment follows as its own Tier H docs PR after this one merges. Acceptance notes beyond these are held by the seat off GitHub.
  6. Landing: Tier S (no governed path in the file list), not over 3000 lines, with the contract-tier PASS on this head. It lands once the maintainer's landing word for this seat thread is on record. Until then it stays draft and out of the queue.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 05:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 05:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 3761710 Oct 26, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-n8sx5h branch October 26, 2026 06:40
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

files: an authorization gap in the file-reference lifecycle, within one organization (detail withheld)

2 participants