Skip to content

fix(storage)!: an anonymous write carries no file, at every anonymous door - #26036

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

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #25694

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

What this changes

Ruling C on #25572 ("an anonymous submission carries no file", landed at the public form door by #25682) now holds at every anonymous write door of ADR-0138 D2, as the maintainer chose for this card (「立卡,先实测再修」).

  • The rule, in one place (@objectstack/service-storage, the file-reference lifecycle). A record write whose caller has no user may not put a value in a file field (FILE_REFERENCE_TYPES). It is refused in the before-write hook, before the driver runs and before any file is looked up, so a refused write leaves nothing behind.
  • The answer is the form door's. 400 VALIDATION_FAILED, one fields[] entry per refused field with the ADR-0114 field code invalid_value and the field's label, built with the same validationFailure and judged with the same emptiness rule (isEmptyForRequired). No error code is added.
  • Unchanged. An empty or absent file field is written as before, so an anonymous update may still leave a file field out or clear it under the grants a deployment binds. Signed-in callers, system writes and context-less programmatic writes are not judged. "Has no user" is the caller the files: an authorization gap in the file-reference lifecycle, within one organization (detail withheld) #25692 read verdict already asks about, so the two rules read one caller.
  • Per door class. Classes 2, 3 and 6 are reads. Class 1 keeps its own judgement at the door (fix(rest)!: an anonymous public-form submission carries no file #25682). Class 4 is closed by this change. Class 5 was measured closed on main before this change, and the rule above holds for it as well. The measurements are with the server seat.

Why the lifecycle and not each door: one guard closes the whole class, including a door added later, and it sits on the path the claim file surface names. The class-4 door itself (@objectstack/runtime) is not edited.

Declarations

  • Clause-②: no (narrowing), measured on this diff: an anonymous write that carried a file value is refused. Nothing refused becomes accepted. The claim wrote Clause-②: no and named this arm as expected.
  • Metadata-protocol: none: nothing a metadata author writes changes.
  • One patch changeset on @objectstack/service-storage, fix(storage)!:, with the BREAKING section and the ADR-0087 disposition not-required (no-migration-prescription).

Three existing fixtures changed, with their subjects kept

sys-file-copy-backfill-organization-stamping.test.ts (three cases) and backfill-sys-file-public-scope.test.ts drove a copy-on-claim write with a caller that carried an organization and no user. Their subject is organization stamping and scope on the copy, not the caller, and a user-less caller is now the anonymous case. Each now names a user. Nothing they assert changed.

Tests

All on head b2f90ab2b5.

  • pnpm --filter @objectstack/service-storage test: Test Files 65 passed (65), Tests 1197 passed (1197). typecheck (including check:test-typecheck): exit 0.
  • Red first: the pins were committed ahead of the fix (cb5bb350, be589179) and failed on the unchanged lifecycle; the fix is 1171d6c2. The ablation below is the same proof on the final tree.
  • file-reference-lifecycle.test.ts, new describe, 11 cases: refusal on insert for four value shapes, refusal on update, several fields named in one envelope by label or name, and five controls (empty or absent values, an anonymous update that leaves out or clears a file field, a signed-in caller, a system and a context-less write, an object with no file field). Each refusal asserts code and the fields[] entry, and that nothing was written, no file was looked up and no bytes moved.
  • file-reference-read-verdict.integration.test.ts, new describe, 7 cases on a real ObjectQL over SqlDriver with StorageServicePlugin booted: insert and update refused for the guest envelope across the file arms the read verdict distinguishes, nothing written and no ownership moved, plus a guest write with no file and a signed-in control.
  • eslint --no-inline-config with the root config over packages/services/service-storage/src: exit 0. The repo-wide pnpm lint is CI's run.
  • Full dogfood battery (owed for the (narrowing) arm), pnpm --filter @objectstack/dogfood test under the verify lock, no shard, no path, on b2f90ab2b5: Test Files 295 passed, 1 failed, 3 skipped (299); Tests 2522 passed, 1 failed, 18 skipped (2541). The one failure is authz-probe-blind-spot.test.ts, the packages/runtime/src/domains/i18n.ts control count (expects 3 handleI18nRequest, finds 4). It is red on main independently of this diff: neither that test nor that file differs between this branch's base 05f7758f and current main 39a78b15, and this diff touches neither package.

Proof

Through node scripts/ablation-replace.mjs (anchor 1 to 0, blob changed, restored blob equals HEAD, git diff HEAD empty). With the anonymous-caller test replaced by an always-pass, 12 pins redden: the 6 unit refusal pins, the 5 integration refusal pins, and the integration signed-in control, which follows from the guest pins having left the file claimed. The 5 unit controls and the guest no-file control stay green. Both suites import the lifecycle through relative src paths, so no dist/ is involved.

Gates

  • Derived on b2f90ab2b5 by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; the change set is from the merge base): 71 commands, each exit code captured before any pipe. 70 exited 0 in sequence. pnpm check:dual-build-cjs-loads first exited 3 (seven packages outside the dogfood closure had no dist/, nothing measured), and after building those seven its rerun exited 0. --ran: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.
  • CI check-runs had not concluded when this PR opened; convergence is read by the seat.

Acceptance notes

  • The rule lives where the file-reference hooks are installed, so it binds wherever the storage service is composed (it is on the always-on slate serve mounts). A kernel composed without the storage service has no platform files for an anonymous write to carry.
  • The class-4 refusal message is inline English beside the form door's, from the same pattern. No catalog key is added.

Generated by Claude Code

Red on main: the lifecycle claims, copies or keeps a value an anonymous
caller wrote into a file field.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VG2Ug3VQqDPcepHcmoD9m1
Ruling C held at every anonymous write door, not only the public form
door: a write whose caller has no user may not put a value in a file
field. It is refused before the driver runs with the form door's
envelope (400 VALIDATION_FAILED, invalid_value per field). Empty and
absent file fields, signed-in callers, system writes and context-less
programmatic writes are unchanged.

Three existing fixtures gave a signed-in subject a user-less caller
for convenience; they now name a user, their subjects unchanged.

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

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via invalid_value (literal, a string literal in assertAnonymousWriteCarriesNoFile))
  • content/docs/data-modeling/field-types.mdx (via invalid_value (literal, a string literal in assertAnonymousWriteCarriesNoFile))
  • content/docs/data-modeling/validation-rules.mdx (via invalid_value (literal, a string literal in assertAnonymousWriteCarriesNoFile))
What this run could not see
  • 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 431f065177de0cfc9d682b3b18692132390e0c57 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 431f065177de0cfc9d682b3b18692132390e0c57

⚠️ 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 431f065177de0cfc9d682b3b18692132390e0c57 → 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: b2f90ab2b51dfdf731c496a2bb6f0702ff98024e
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/backfill-sys-file-public-scope.test.ts, packages/services/service-storage/src/sys-file-copy-backfill-organization-stamping.test.ts + .changeset/25694-anonymous-write-carries-no-file.md, packages/objectql/src/engine.ts (buildSession, the beforeInsert / beforeUpdate dispatch sites incl. insertMany per row), packages/services/service-storage/src/attachment-access-hooks.ts (callerContext), packages/core/src/utils/required-emptiness.ts (isEmptyForRequired), packages/types/src/validation-failure.ts, packages/types/src/thrown-http-error.ts (resolveThrownHttpError), packages/runtime/src/endpoint-executor.ts, packages/rest/src/form-routes.ts (the class-1 judgement from #25682), packages/core/src/security/assemble-execution-context.ts (assembleExecutionContextOrGuest), packages/core/src/security/anonymous-deny.ts, packages/services/service-automation/src/runtime-identity.ts (resolveRunDataContext), packages/services/service-storage/src/backfill-file-references.ts, packages/services/service-storage/src/backfill-sys-file-public-scope.ts, packages/services/service-storage/src/metadata-store.ts, docs/adr/0138 (D2, D9), docs/adr/0112, docs/adr/0114

① Derived judgments

Accept-set change (narrowing only). The one behavioural edit is a module-private predicate run first in both before-write hooks the storage lifecycle already registers (beforeInsert, beforeUpdate), ahead of the #7102 predicate refusal and ahead of copy-on-claim. A record write is refused when, and only when, all of: the object declares at least one FILE_REFERENCE_TYPES field and sys_file is registered (activeFileFields); the write has a caller (referencingCallerOf: a hook session exists and is not isSystem); that caller carries no userId; and the payload sets a file field to a value that isEmptyForRequired does not call empty. Everything else reaches the driver exactly as on main:

  • a signed-in caller (every transport-assembled human or agent principal carries userId; anonymous-deny.ts already 401s a user-less, non-system request in every domain outside ADR-0138 D2 classes 4 and 5) — unchanged, pinned by the unit and integration controls;
  • a system write (isSystem, incl. a runAs: 'system' flow, engine self-writes, the in-repo backfills, which write under SYSTEM_CTX, and the CLI migrations) — referencingCallerOf returns null before the predicate looks at anything;
  • a context-less programmatic write (a bare kernel, a seed, a provenance-only { flowRunId } context) — buildSession returns undefined for a context with nothing session-worthy, so !ctx.session holds and the write is not judged;
  • a user-less write that omits every file field, sets one to null / '' / [] on a multi-valued field, or clears a file field on update — passes (isEmptyForRequired), pinned by the controls, so an anonymous update may still leave or clear a file field under a deployment's guest grants;
  • an object with no file field — exits before the predicate (pinned).

Non-system flow data nodes cannot present a user-less context either: resolveRunDataContext yields isSystem: true for runAs: 'system' and otherwise the triggering user's userId, or throws UnscopedRunDataAccessError. So ADR-0138 D2 class 5 reaches this hook only as system or as a user, and the dev's "closed by construction" reading holds in the code read.

Placement is correct and complete for D2's write classes. Every engine write path dispatches the before hooks: single insert, insertMany (per row), by-id update and the unscoped predicate update (dispatchUnscopedMultiWriteHooks, where the new predicate runs before the #7102 bulk refusal, as the hunk's comment says). The class-4 door (authRequired: false object operations, the D9 guest envelope with no userId) therefore cannot reach the driver with a file value through any write verb, and a door added later inherits the rule without a per-door edit. Class 1 keeps its own door-side judgement from #25682, and now has the engine rule beneath it; classes 2, 3 and 6 are reads. Guarding at the storage lifecycle rather than in packages/rest / packages/runtime is the right altitude for a rule about the file-reference claim path, and it sits inside the claim's file surface.

Envelope (ADR-0112 / ADR-0114). The refusal is validationFailure(...) from @objectstack/types: name: 'ValidationError', code: 'VALIDATION_FAILED' (a ledger member), fields[] with one entry per refused field carrying the closed ADR-0114 catalog code invalid_value, the field's label (or its name when none is declared) and a message. The status is supplied at the boundary: resolveThrownHttpError maps a ValidationError with no declared status to 400 and passes fields[] through, and endpoint-executor.ts (the class-4 door) states it uses that same table. The same constructor, emptiness rule and field code the class-1 door uses, so the two doors answer alike. No new error code, no new status, no new catalog key, no new i18n key. The refusal is thrown before any sys_file lookup, before the read verdict is asked and before any bytes move; the unit pins assert authorizeRead and storage.download are not called and no sys_file write occurs, the integration pins assert no record row and an unchanged sys_file table on a real ObjectQL over SqlDriver.

Public surface. assertAnonymousWriteCarriesNoFile is module-private; installFileReferenceHooks's signature is unchanged; no export is added or removed. The two new imports (isEmptyForRequired from @objectstack/core, validationFailure from @objectstack/types) are both already declared dependencies of @objectstack/service-storage and already imported elsewhere in the package, so no manifest or dependency-graph change.

Tests. 11 unit cases (6 refusal: four value shapes on insert, update, several fields in one envelope; 5 controls) and 7 integration cases (5 refusal across the file arms the #25692 read verdict distinguishes, incl. an unowned public_read file and a value naming no platform file; 2 controls). Each refusal asserts code and the fields[] entry; the one thing the committed tests do not assert is the HTTP status, which is a boundary property the cited resolver supplies (judged above from the code). The dev's red-first commits and ablation are reported, not re-run here.

② Semver level

patch on @objectstack/service-storage, fix(storage)!: header, the BREAKING section with Before / Now / Fix, and Clause-②: no (narrowing) + Metadata-protocol: none line-initial on both carriers (PR body and changeset). The level follows the convention for a runtime refusal with no protocol move: none takes patch; a narrowing keeps its BREAKING banner. The ADR-0087 disposition is exactly one marker, not-required (no-migration-prescription), and its argument is correct on the facts: no spec key, authorable spelling, stored row or shape moves and nothing exported is removed or narrowed, so there is nothing for objectstack migrate meta to rewrite; the remedy is a change of who provides the file. The Check Changeset and the PM dispatch-gates lint check-runs on this head are green.

Disclosure: the changeset and PR body describe the rule and the door class in ADR-0138 D2's own public vocabulary (a caller with no user may no longer put a value in a file field; which class the change closes). Neither carries a request shape, an object, a step sequence or a pre-fix outcome, so the disclosure discipline holds on both carriers.

③ Boundary flags

  • Declared departure (lifecycle guard, no door edit in rest/runtime): endorsed. One guard on the hook every engine write dispatches closes the class, including a door declared later; a per-door edit would have left the rule to be re-derived at each one. The residual it leaves is stated in the PR body and is real but narrow: the hooks are installed only where the storage service is composed, and activeFileFields exits when sys_file is not registered, so a kernel composed without the storage service does not apply ruling C to a non-platform value (an external URL) in a file field. serve always mounts the storage service; noted, not a defect of this card.
  • "No user" is read off the caller, not off principalKind: 'guest'. The rule is therefore slightly wider than the D9 guest envelope: a non-system context that carries an organization and no user is judged anonymous too. In the repo the only writers presenting that shape target sys_file (metadata-store.ts, excluded by activeFileFields) or settings objects with no file field, so nothing in-repo is caught. The wider reading is the conservative direction and matches the caller the files: an authorization gap in the file-reference lifecycle, within one organization (detail withheld) #25692 read verdict is asked about; flagged so the seat knows the boundary is "no user", not "the guest envelope".
  • The two edited fixtures (sys-file-copy-backfill-organization-stamping.test.ts, three cases; backfill-sys-file-public-scope.test.ts, one helper) drove a copy-on-claim record write with a hook session or context carrying an organization and no user, purely as fixture setup for a different subject (organization stamping and scope on the copy). That shape is the anonymous case under the new rule, so they now name a user; the backfills themselves write under SYSTEM_CTX and are not judged. No platform path was caught: this is a test-only caller shape, and the assertions are unchanged.
  • Dev flags / open_questions: open_questions is empty. The dev's one out-of-scope finding, the dogfood authz-probe-blind-spot.test.ts red (the handleI18nRequest control count in packages/runtime/src/domains/i18n.ts), is reported as red on main independently of this diff and neither file is touched here: out of scope for this card, left with the seat.
  • Check-runs on the head at review time (44): none failed; 13 were still in_progress (Test Core 1-8, Build Core, Type Check workspace and consumer gates, Lint CI/release/engine gates, Lint scoped slot lookup and corpus). Everything concluded is success or skipped (UI, docs, opt-in and sharded dogfood jobs skipped by the path filter; the whole-PR Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset, PR size, claim and single-writer guards all green). This verdict is on the diff; the merge group's own guard reads convergence.
  • Not in scope and not touched: packages/spec/**, any governed path, the class-1 door, the class-4 door in @objectstack/runtime.

Implemented-by: claude/project-thread-0k5uer
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

Each refusal pin now also reads the error through the shared HTTP
boundary mapping and asserts 400 with VALIDATION_FAILED (ADR-0112).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VG2Ug3VQqDPcepHcmoD9m1
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-26T10:28Z · head 8102869e46411275625bde02165a6f23adb74ed4 · #25694

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape: a draft against main. Fixes #25694: the dev measured every ADR-0138 D2 anonymous write class, and the classes this card names are closed by the one guard. Clause-②: no (narrowing) and Metadata-protocol: none are line-initial. The changeset .changeset/25694-anonymous-write-carries-no-file.md is present. check-governed-merges --pr 26036: NOT governed, 358 changed lines (≤ 3000).
  2. Contract review: record 6413304729, VERDICT PASS, at head b2f90ab2. The delta from b2f90ab2 to 8102869e is the patch round this seat asked for (8102869e: the refusal tests now assert status and code together through resolveThrownHttpError, { status: 400, code: 'VALIDATION_FAILED' }, plus the field detail, per ADR-0112) and one merge of origin/main (eadc92ca). No product file changed in the delta, so the record stands on this head.
  3. Gates: at this stamp the head's 47 check-runs read 36 success and 11 skipped (path-filtered), with none failing or in progress; mergeable, clean.
  4. Accepted deviations: the guard sits in the storage lifecycle hook rather than in each door, endorsed by the contract review because a door added later inherits the rule. The two backfill fixtures now name a user, since their old organization-only context is the anonymous case under the new rule; their assertions are unchanged.
  5. Out of scope: the residual stated in the PR body (a kernel composed without the storage service does not apply the rule) is noted, not a defect of this card, since serve always mounts the storage service. Disclosure: no mechanism or reproduction appears on any public surface.
  6. Landing: once every check is green, this seat runs pr_ready and then automerge_enable through the relay, confirms MERGED on both readings, and records the landing on anonymous write doors: an authorization-adjacent gap when a guest-writable object declares a file field (detail withheld pending measurement) #25694, which the merge closes.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Correction to this PR body's dogfood line (the full-battery bullet, measured on b2f90ab2b5): the authz-probe-blind-spot.test.ts failure was not red on main independently. It came from this branch's older base 05f7758f, before #26002 (a3e5bfa6) reconciled the handleI18nRequest count on main. The merge of origin/main at 431f0651 (eadc92ca) cleared it. On head 8102869e46 the dev re-ran that test alone and got 47/47 green, and CI's dogfood jobs on this head are green. This comment corrects the record rather than editing the body, so the queued landing is not disturbed. · domain:server seat 2 · 2026-10-26T10:38Z


Generated by Claude Code

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

anonymous write doors: an authorization-adjacent gap when a guest-writable object declares a file field (detail withheld pending measurement)

2 participants