You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7e4af20
Browse filesBrowse the repository at this point in the historyBrowse files
fix(storage)!: a record write naming a file its caller may not read is refused at the field (#26237)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSuaEom6oxsAY7KX6uf4FcT)_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 #26036's anonymous-write refusal, which is the form door's
envelope (#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 #26144's
rule (a released file is judged by its former record) and PR #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 #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` (#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](https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(storage)!: a record write naming a file its caller may not read is refused at the field
6
+
7
+
A record write that names a file its caller may not read is now refused at that field, instead of saving the id as written. The answer is one `400 VALIDATION_FAILED` whose `fields[]` names each such field with the ADR-0114 code `invalid_value`, the same envelope the public form door gives an anonymous submission that carries a file. It names the field only. An id that names no file of the acting organization gets the same answer, so the write cannot tell the two apart. Nothing is claimed, copied or written. System writes and writes with no caller context reference files as before, and an authorization-store outage still fails the write with its declared envelope.
8
+
9
+
A file governed by a record's rule also gets the record-governed link lifetime at the download door.
10
+
11
+
Clause-②: no (narrowing) — a record write that names a file its caller may not read, or an id no file of the organization has, was saved and is now refused. Nothing that was refused becomes accepted.
12
+
13
+
Metadata-protocol: none (a write-time refusal on file fields; nothing a metadata author writes changes)
14
+
15
+
<!-- adr-0087: not-required (no-migration-prescription) No metadata author's surface moves: no spec key or authorable spelling is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which record writes the file-reference lifecycle accepts: a write naming a file its caller may not read is refused at the field. The remedy is naming a file the caller may read, or leaving the field empty, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16
+
17
+
**BREAKING** for a caller that writes a file id it may not read, shipped as `patch` because the metadata protocol does not move.
18
+
19
+
-**Before.** The write saved, and the field kept an id its readers could not use.
20
+
-**Now.** The write is refused with `400 VALIDATION_FAILED`, naming the field with `invalid_value`.
21
+
-**Fix.** Name a file the caller may read (upload it, or choose one they can open), or leave the field empty.
0 commit comments