Skip to content

fix(storage)!: a record write naming a file its caller may not read is refused at the field - #26237

Merged
os-zhuang merged 3 commits into
mainfrom
claude/project-thread-3scpla
Oct 26, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/project-thread-3scpla

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26043

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

What this changes

A record write that names a file its caller may not read used to save, keeping the id as written (#25951's "quiet answer"). The field then held a file it did not own, and every reader got an unusable value. That is the state ADR-0104's verify-references counts as blocking. Now the write is refused at the field.

  • The refusal (file-reference-lifecycle.ts). Copy-on-claim judges every file a write names before it copies any, through the one lookup both halves of the claim path already share (findReferenceableFile). A file the caller may not read, or an id the acting organization cannot see, refuses its field. The write fails before the driver runs with one 400 VALIDATION_FAILED whose fields[] has one ADR-0114 invalid_value entry per refused field. The entry names the field and its label only, never the file or the record that holds it. The two cases answer with the same body, so the write door cannot tell "may not read" from "does not exist in this organization".
  • One envelope, one recognition. The refusal is built by the same helper as fix(storage)!: an anonymous write carries no file, at every anonymous door #26036's anonymous-write refusal, which is the form door's envelope (fix(rest)!: an anonymous public-form submission carries no file #25682). The form-submission door writes through the engine, so a submission naming such a file gets this answer too. No second copy of the judgement exists.
  • Order. The read check runs first. A field that would pass is never copied when another field is refused, and constraint verdicts (accept / maxSize) never answer ahead of the read check.
  • Unchanged. System writes and context-less programmatic writes keep their old behaviour, including keeping an id that names no file. A kernel with no read verdict bound asks nothing. An authorization-store outage still fails the write with its declared envelope. PR fix(storage)!: a file its record lets go keeps that record's read rule #26144's rule (a released file is judged by its former record) and PR fix(storage)!: an anonymous write carries no file, at every anonymous door #26036's rule (an anonymous write carries no file) are untouched.
  • Signed-link lifetime (second commit, storage-routes.ts). A file governed by a record's rule now gets the record-governed link lifetime at the download door. The door and the read verdict read the same recognition (releasingRecordOf in metadata-store.ts). Who gets a link does not change.
  • A stale dogfood fixture (third commit). The field-zoo HTTP round-trip wrote media ids that name no file, and so relied on the quiet answer. The first full dogfood run on this branch turned it red. It now uploads one real file per media field and asserts the read form carries that id. The matrix keeps its stored-shape stand-ins for the value-shape test.
  • ADR-0104. The 2026-10-26 addendum's "quiet answer" item becomes "the field refusal", with an amendment line in the status block. It changes D3's text, so this PR is Tier H and stays draft for the maintainer.

Envelope spelling. The order and claim wrote 400 VALIDATION_ERROR. They also say to match the #25682 form door and to carry an invalid_value entry. That door answers 400 VALIDATION_FAILED with fields[], and in this codebase VALIDATION_ERROR is the envelope with no fields[] (ADR-0104's 2026-10-19 note on D2). So this PR uses VALIDATION_FAILED, the form door's envelope, and says so here.

Declarations

  • Clause-②: no (narrowing), measured on this diff: a record write naming a file its caller may not read, or an id no file of the organization has, used to save and is now refused. Nothing that was refused becomes accepted.
  • 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).

Tests

HEAD c075544b6.

  • Red first on base 3993793dc (the new pins, same files): 8 failed, 64 passed. That is the 7 refusal pins in file-reference-read-verdict.integration.test.ts and the 1 link-lifetime pin in storage-routes.test.ts. The controls (a system write still copies, a context-less write still keeps an unknown id) were green on base.
  • file-reference-read-verdict.integration.test.ts (real ObjectQL + sqlite, StorageServicePlugin booted as shipped). It pins the whole refusal body with toEqual for a file owned by a record the writer cannot read, an attachments-scope file, a delegate refusal, an id that names no file, an update (the record keeps what it held), a multi-field write (nothing copied for the passing field), and a judgement fault. Each pin also checks that no record and no sys_file row was written.
  • file-reference-lifecycle.test.ts: the files: an authorization gap in the file-reference lifecycle, within one organization (detail withheld) #25692 unit pins move from "kept" to "refused". New pins: an unknown id gets the same body as an unreadable file, and every refused field is named in one answer.
  • file-reference-release-read-rule.integration.test.ts (files: an authorization gap in the file lifecycle after a file is released, within one organization (detail withheld) #26046): a write by a member refused the record is now refused instead of kept, under both collection settings.
  • Ablation through scripts/ablation-replace.mjs. Disabling the refusal throw turns 27 tests red across the three files (blob a9069ab6eba1 to 37aa77f91e7a). Reverting the lifetime line turns the link-lifetime pin red (blob 95d899032bca to 95bfe351ee89). Both were restored, and the blob equals HEAD with git diff HEAD empty.
  • pnpm --filter @objectstack/service-storage test: 66 files, 1226 tests passed. typecheck (including check:test-typecheck) passed. eslint --no-inline-config on packages/services/service-storage/src: clean.
  • Full dogfood battery at HEAD: pnpm --filter @objectstack/dogfood test under the verify lock, VERDICT command-exit 0, Test Files 296 passed | 3 skipped (299), Tests 2523 passed | 18 skipped (2541). The first run, one commit earlier, was red in one file (field-zoo-roundtrip.dogfood.test.ts, the stale fixture above). After the fix that file and field-zoo-value-shape.test.ts pass 91/91, and the rerun at HEAD is the one quoted here.

Gates

Local subset at HEAD c075544b6: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 85 families from this diff. All 85 ran with exit 0, each code captured before any pipe, and --ran reconciles 85 run, 0 NOT MEASURED. One commit earlier, check-comment-mask-corpus hit a 300 s local timeout on the loaded box and check-plugin-teardown-shape --self-test answered exit 3 (shallow clone). Both were 0 on rerun, the latter after fetching its control commit, and both are 0 in the HEAD run. pnpm --filter @objectstack/service-storage test and typecheck ran one commit earlier, at 66398e79. The last commit touches only two dogfood test files. Every other family is CI's, and this PR's check-runs are the record.

Acceptance notes

  • A record that already holds such an id. An update that sends back an id its caller may not read, or one that names no file, is refused like any other write. This includes the unchanged value of a record that got it under the quiet answer. The answer names the field, so the remedy is to clear it or name a readable file. Rows written under the quiet answer are the ones verify-references already reports.
  • The second card the maintainer named in 「先改措辞,再批,另开两张卡」 is not addressed here.

维护者速读(草稿)

改了什么:记录写入时,如果文件字段里填的是调用者无权读取的文件(或本组织里根本不存在的文件 id),现在直接在该字段上拒绝:返回 400 VALIDATION_FAILED,只点名字段,不暴露文件或其所属记录。以前(#25951)是"静默保留 id、保存成功"。另一个提交让受记录规则约束的文件,下载签名链接用记录级的短有效期。ADR-0104 2026-10-26 补记里"quiet answer"一条改写为"field refusal"。

为什么改:静默保留会让用户看到"保存成功但文件打不开",而这正是 ADR-0104 第 5 步 verify-references 判为阻断的状态。同类平台的做法也是直接拒绝挂接。这是你「先改措辞,再批,另开两张卡」里的第一张卡。

风险与代价(含回滚):这是收窄:以前能保存的这类写入现在被拒。系统写入和无上下文的程序写入不变,鉴权存储故障时仍按原信封失败。已经按旧规则存下悬空 id 的记录,再次带着该值更新时会被拒,需清空或换成可读文件。回滚即 revert 本 PR。

席位意见:

你要做的:审阅 ADR 那一条与修复,批准后合并(Tier H,保持 draft 等你)。


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv

…s refused at the field

A write that names a file its caller may not read, or an id the acting
organization cannot see, now fails before the driver runs with one
400 VALIDATION_FAILED naming each such field with the ADR-0114 code
invalid_value, the form door's envelope. The two answer identically and
name the field only. Every named file is judged before any is copied, so
a refused field never leaves a copy behind. System and context-less
writes, and the outage envelope, are unchanged.

Replaces the quiet answer (the id kept as written) in the ADR-0104
2026-10-26 addendum.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv
…rned link lifetime

The download door now picks the signed-link lifetime from the same
recognition the read verdict uses (releasingRecordOf), so every file a
record's rule governs gets the record-governed lifetime.

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

The matrix wrote ids that name no file, relying on the write keeping
them as written. Such a write is now refused at the field, so the HTTP
suite uploads one real file per media field and asserts the read form
carries its id. The matrix keeps its stored-shape stand-ins for the
value-shape test.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 15 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 FileFieldRefusal; a string literal in assertAnonymousWriteCarriesNoFile; a string literal in refuseUnreadableFile))
  • content/docs/data-modeling/field-types.mdx (via invalid_value (literal, a string literal in FileFieldRefusal; a string literal in assertAnonymousWriteCarriesNoFile; a string literal in refuseUnreadableFile))
  • content/docs/data-modeling/validation-rules.mdx (via invalid_value (literal, a string literal in FileFieldRefusal; a string literal in assertAnonymousWriteCarriesNoFile; a string literal in refuseUnreadableFile))
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 df8aac41f2d9d14db04afdfe48511216419dafc0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json df8aac41f2d9d14db04afdfe48511216419dafc0

⚠️ 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 df8aac41f2d9d14db04afdfe48511216419dafc0 → 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: c075544b6b3d23fad8f4da0fc0c7ede8459a511d
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/file-reference-release-read-rule.integration.test.ts, packages/services/service-storage/src/metadata-store.ts, packages/services/service-storage/src/storage-routes.ts, packages/services/service-storage/src/storage-routes.test.ts, packages/services/service-storage/src/storage-service-plugin.ts, packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts, packages/qa/dogfood/test/field-zoo.matrix.ts + packages/rest/src/form-routes.ts (the form door's field refusal the envelope is claimed to match), packages/types/src/validation-failure.ts (the envelope constructor and its boundary mapping), packages/services/service-storage/src/index.ts (the package's public export list), packages/spec/src/data/field-value.zod.ts (the id-token recogniser that decides which values the refusal can reach), docs/adr/0114-field-level-error-code-catalog.md (the closed field-code catalog), and the two prose faces read for the Tier H hunk: docs/adr/0104-field-runtime-value-shape-contract.md, .changeset/26043-unreadable-file-refused-at-field.md

Inputs: card #26043 (body, triage 6414315649, claim 6418687865 and its amendment 6421454634, os-dev report 6421410477), PR #26237's body and file list, the net diff of the three commits against main at the merge base 3993793dc8, and the 48 check-runs on the head (37 success, 11 skipped, none failing). Public record: the change is described in abstract terms only.

① Derived judgments

  • The accept-set change, as declared. A record write with a caller that names, in a file-typed field, a file the caller may not read, or an id that is no file of the acting organization, was saved with the id kept as written and is now refused at that field before the driver runs; nothing is claimed, copied or written. Read in the cited code: the refusal is raised in the before-write hooks on both insert and update, so no row is written and the record keeps what it held. Right, and it is exactly the narrowing the amended claim orders. One consequence the diff implies and the tests pin but the changeset does not spell out: an id that is token-shaped yet names no file (a legacy or external stand-in) is now refused for any signed-in write. The changeset's "an id that names no file of the acting organization gets the same answer" covers it; the dogfood fixture that relied on such stand-ins is the proof it bites, and it was fixed in this PR. Right, declared.
  • "May not read" and "does not exist in this organization" answer identically. One lookup serves both halves of the claim path, and when a caller is to be judged it collapses "no such file" and "verdict refused" into one outcome; one builder produces one entry per refused field with the same text for both. The integration test pins the whole body with toEqual for a refused file and for an id that names nothing, and asserts the two bodies are equal. Right. The refusal names the field and its label only; nothing about the file or who holds it reaches the wire, so no existence oracle is added (triage's condition). Right.
  • System and context-less programmatic writes unchanged. A write with no session or a system session has no caller to judge; for it the lookup answers as before, and an id that names no file is kept as written. Pinned on the real plugin: a system write still copies a file the door refuses the writer; a context-less write still keeps an id that names no file. Right. A kernel that binds no read verdict at all (hooks installed bare) asks nothing and is unchanged. Right. One sub-case to name: with the plugin booted but no authorization service wired, the bound verdict admits every file, yet an id that names no file is still refused for a session caller because the lookup answers before the verdict is asked. That is within the declared narrowing (the acting organization has no such file), not a second rule; noted so the next reader does not mistake it for the bare-kernel case.
  • An authorization-store outage still fails the write with its declared envelope. The plugin's binding re-raises the outage brand, and in the new judge-all-fields-first loop that throw escapes before any refusal is collected, so the write fails with the outage's own status and code, not with the field refusal. The pin for this is unchanged in the diff and still green. Right. Any other judgement fault fails closed: the verdict answers false, so the write is now refused at the field where it used to keep the id. Same posture as the download door (fail closed), pinned, and inside the declared narrowing. Right.
  • The envelope matches the form door's. The amended claim rules 400 VALIDATION_FAILED with one fields[] entry carrying the ADR-0114 invalid_value code and naming the field only. The code throws through the shared constructor in @objectstack/types (name and code set so both dispatcher exits and the REST door map it to 400 with fields[]), and the entry shape (field, code, message, label) is byte-for-byte the shape the form door builds for its own anonymous file refusal. invalid_value is a member of the closed catalog. The card body and triage spelled VALIDATION_ERROR; the amendment supersedes them and says why (in this codebase that is the fields-less envelope, ADR-0104's 2026-10-19 note), and the PR body states the departure. Right.
  • One recognition, no second copy. The "which file may this write reference" question is asked in one function by both halves of the claim path (copy-on-claim before the write, the claim after it), and the claim half now treats the refused answer as "take nothing", which is unreachable in practice because copy-on-claim refused the write first. The envelope is built by one helper, now shared with the anonymous-write refusal of PR fix(storage)!: an anonymous write carries no file, at every anonymous door #26036 (that refusal's inline label lookup was lifted into the same helper, no behaviour change). Right. The form door keeps its own anonymous judgement, which answers before the engine; that is pre-existing and the PR says so.
  • Order. Every file the write names is judged before any is copied or held to its field's declaration; a refused field fails the whole write; constraint verdicts never answer ahead of the read check. Pinned twice (unit and integration: the passing field's file is not copied when another field is refused; the read refusal, not the constraint code, answers a file that would also fail a constraint). Right.
  • Multi-field answer. Every refused field is named in one answer, in field order. Pinned. Right.
  • Link lifetime (second commit). A file governed by a record's rule now gets the record-governed signed-link lifetime at the download door, where before only files with a current owner or an attachments scope did; the download verdict and the lifetime choice read the same recognition, added as a module-internal helper and consumed by both. Who gets a link is unchanged: the verdict path is untouched and the new pin asserts the verdict is still asked once. Both download doors share the one gate, so both get it. Right. It is a shortening, covered by the single narrowing declaration, and the changeset names it.
  • PR fix(storage)!: a file its record lets go keeps that record's read rule #26144's rule kept (a released file is judged by its former record): the verdict arm is unchanged except for reading the recognition through the new helper; the files: an authorization gap in the file lifecycle after a file is released, within one organization (detail withheld) #26046 integration test's outcomes move only from "kept" to "refused" for the member the record refuses, under both collection settings, and the admitted member's "claimed" outcomes are unchanged. Right. PR fix(storage)!: an anonymous write carries no file, at every anonymous door #26036's rule kept (an anonymous write carries no file): its assertion runs first in both hooks, its pins are untouched, and only its envelope builder was shared. Right.
  • Public surface. The one new export is module-internal to the package (not re-exported from the package index); the widened return type is on a non-exported function; the authorizer type is unchanged; the download option's doc comment changes, not its type. No published declaration is added, removed or narrowed. packages/spec/** untouched, as the claim forbids. Right.
  • Dry and real paths. The write door has no dry-run path; the package's dry-run sweeps write as system and never pass through these hooks, so there is no second path to keep consistent. Right (not applicable).
  • Dogfood fixture (third commit). The HTTP round-trip now uploads one real file per media field and asserts the read form carries that id; the matrix keeps its stored-shape stand-ins for the value-shape test, with the comment corrected. The fixture change is a consequence of the narrowing, not a loosening of what the suite proves. Right.
  • Gate verdicts on the head. 37 success, 11 skipped (UI, docs-build and opt-in families with no path in this diff), none failing. Check Changeset, Governed Surface Queue Guard, the two single-writer guards and the claim-branch guard all green.

② Semver level

  • One patch changeset on @objectstack/service-storage, titled fix(storage)!:, with both line-initial declarations (Clause-②: no (narrowing), Metadata-protocol: none), the BREAKING section (before, now, fix), and exactly one ADR-0087 marker, not-required (no-migration-prescription). Per the repo rule, a non-protocol narrowing ships patch with its BREAKING banner and disposition: matches what the diff publishes. Metadata-protocol: none is right: nothing an author writes changes. The disposition is right: no spec key, authorable spelling or exported declaration moves, so there is nothing for a migration to rewrite; the remedy is naming a readable file or clearing the field, which the changeset states.
  • The changeset names both behaviour changes (the field refusal and the link lifetime) and the two unchanged classes. Clause-②: no (narrowing) is on the PR body and the changeset, and it is true of the diff: nothing refused before is accepted now.
  • The PR touches docs/adr/**, so it is Tier H whole-PR: this record does not lift the landing; the maintainer's approval does. The PR is draft and says so.

③ Boundary flags

  • Dev flags. open_questions is empty. The report names two departures: (1) the envelope spelling, VALIDATION_ERROR in the order and first claim versus VALIDATION_FAILED in the code; answered by the claim amendment 6421454634, which rules the form door's spelling wins, and by the PR body. (2) The dogfood fixture outside the first claim's file surface; answered by the same amendment, which adds both dogfood files (and the two source files the lookup's callers read) to the surface. The report also says the platform checklist was not run because no exported symbol was renamed or deleted; verified against the package index. Closed.
  • The dogfood fixture commit. It sits outside the first claim's surface and inside the amended one; its diff is two test files, nothing in the package under review, and the full battery at the head was green by the dev's report and the Dogfood Regression Gate is green on the head. Not a breach.
  • The ADR-0104 addendum edit says what the code does. The item "The quiet answer" becomes "The field refusal" with an amendment note, and the status block gains one amended line. Every sentence checked against the diff: refused at the field before the driver runs; one 400 VALIDATION_FAILED with one invalid_value entry per field; nothing claimed, copied or written; constraint verdicts behind the read check; an id the organization cannot see gets the same answer; the answer names the field only; a write with no caller keeps such an id as before. Says what the code does. Tier H: the maintainer's word is the landing record for this hunk, not this review.
  • Two non-blocking notes. (a) The PR body's "the form-submission door writes through the engine, so a submission naming such a file gets this answer too" is loosely stated: the public form door refuses any file value on an anonymous submission before the engine is reached, so the new refusal is reached only by a signed-in write; the envelope is the same either way, so nothing on the wire is misdescribed. (b) The code comment credits the form door's envelope to one card and the ADR and PR body to another; both are convenience links, not the citation, and the envelope they name is the same.
  • Security posture of the public surfaces. PR body, changeset, ADR hunk, code comments and the refusal text name the field only; no mechanism, column or reproduction appears on any public surface, and the refusal text carries no tracker number. Holds.

Implemented-by: claude/project-thread-3scpla
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么:记录写入时,如果文件字段里填的是调用者无权读取的文件,或本组织里不存在的文件 id,现在在该字段上直接拒绝,返回 400 VALIDATION_FAILED。答复只点名字段,不透露文件或其所属记录,"无权读"和"不存在"两种情况答复完全相同。以前是静默保留 id、保存成功。另有一处:受记录规则约束的文件,下载签名链接改用记录级的较短有效期,谁能拿到链接不变。ADR-0104 补记里对应一条同步改写。

为什么改:静默保留会让用户看到"保存成功但文件打不开",这正是 ADR-0104 的 verify-references 判为阻断的状态。这是你「先改措辞,再批,另开两张卡」里的第一张卡。

风险与代价(含回滚):这是收窄,以前能保存的这类写入现在会被拒。系统写入和无上下文的程序写入不变,鉴权存储故障仍按原信封失败。已按旧规则存下这类 id 的记录,带原值再更新时会被拒,需要清空字段或换成可读文件。回滚即 revert 本 PR。

席位意见:建议批准。合约复核(CONTRACT_REVIEW_TIER)在当前 head 判 PASS,CI 48 项无失败;信封与表单入口一致,ADR 改写与代码逐句相符。两条非阻断备注已记在复核记录里,都是措辞层面,不影响行为。

你要做的:在 PR 上批准(Approve)。批准后本席位清标签、转 ready、开自动合并。

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-26T16:59Z · head c075544b6b3d23fad8f4da0fc0c7ede8459a511d · #26043

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」. This PR touches docs/adr/**, so it is Tier H: it stays draft until a governed approver approves, and this record does not lift that.

  1. Shape: a draft against main with Fixes #26043. Clause-②: no (narrowing) and Metadata-protocol: none are line-initial. One patch changeset on @objectstack/service-storage (fix(storage)!:, BREAKING section, ADR-0087 marker not-required (no-migration-prescription)). check-governed-merges --pr 26237: GOVERNED (Tier H, docs/adr/0104), 533 changed lines.
  2. Contract review: 6422153367, CONTRACT_REVIEW_TIER, PASS on this head. The envelope spelling departure (VALIDATION_FAILED, the form door's envelope) and the dogfood fixture outside the first surface are both answered by the claim amendment 6421454634.
  3. Gates: at this stamp the head's 48 check-runs read 37 success and 11 skipped (UI, docs-build and opt-in families with no path in this diff), none failing or in progress; mergeable, clean.
  4. Accepted deviations: the two non-blocking wording notes the review carried (the form-submission sentence in the PR body; the card credited for the form door's envelope). Neither changes what the code does.
  5. Out of scope: none filed. The second card named in the maintainer's 「先改措辞,再批,另开两张卡」 is its own card.
  6. Landing: needs-user-decision is on this PR, and review is requested from os-zhuang and hotlong. Once a governed approver approves, this seat clears the label, runs pr_ready and automerge_enable through the relay, confirms MERGED on both readings, and records the landing on files: a record write that names a file its writer may not read keeps a dangling id (the quiet answer of #25951); refuse the field instead, so no slot holds a file it does not own #26043.

Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 26, 2026 17:13
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 7e4af20 Oct 26, 2026
57 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-3scpla branch October 26, 2026 17:47
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

3 participants