feat(storage): 字段引用文件的独占所有权 — ADR-0104 D3 wave 2 (PR-3) - #3527
Merged
Conversation
… wave 2 (PR-3)
A file/image/avatar/video/audio field holding a sys_file id now records its
owner on the file itself: sys_file.ref_object / ref_id / ref_field name the
single (object, record, field) slot that references it, maintained on the
engine write path — claimed on insert, reconciled on update, released when the
owning record is deleted.
Field references are EXCLUSIVE, unlike attachments. The attachments surface
deliberately shares one file across many sys_attachment join rows; a field
reference is owned by at most one slot, and writing an already-owned id into a
second slot copies the bytes into a fresh sys_file rather than sharing the row.
Two properties follow, and both exist so a mistake cannot become unrecoverable:
1. A file's read authorisation derives from exactly one parent record instead
of the union of every referrer's — so copying a private record's file id
into a world-readable record, which generated code does without any
visible error, cannot silently widen who can read the bytes.
2. Release is an OBSERVED transition ("my one owner let go"), never an
inferred absence ("I counted and found nobody"). With no count, no
miscount can authorise a delete — and a freshly-built ledger's ignorance
of pre-existing references cannot either.
Deletes nothing. This records and releases ownership; it never tombstones, and
the scope==='attachments' guardrail that keeps field-referenced files out of
the reap entirely is untouched, so shipping it cannot reclaim any bytes.
Enabling collection must extend releaseOwnership AND the sys_file reap guard's
sweep-time re-verify in the same change — half of it is worse than none, since
tombstoning while the guard still re-verifies only sys_attachment (always empty
for a field file) makes every release a guaranteed byte delete.
Also lifts isFileIdToken into @objectstack/spec/data as the single arbiter of
"is this stored string an opaque file id or a legacy/external URL?", replacing
the private copy in the engine read resolver so the reader and the writer
cannot drift into claiming files nobody expands, or vice versa.
Dormant until a field actually holds an id token: objects without file-class
fields, inline-blob values and URL-shaped values all exit before any I/O.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
check:nul-bytes caught a raw NUL used as an impossible-record-id sentinel in the copy-on-claim same-slot comparison. A raw NUL makes ripgrep treat the whole file as binary, so it silently drops out of code search and every grep-based lint. Replaced with an explicit null check plus a named isSameSlot helper, which says what it means and is reused by claimFile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
os-zhuang
marked this pull request as ready for review
July 27, 2026 02:53
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
做了什么
file/image/avatar/video/audio字段里存的sys_fileid,现在会把所有者记在文件行上:sys_file.ref_object/ref_id/ref_field指明唯一引用它的(对象, 记录, 字段)槽位,由 engine 写路径维护——insert 时认领(claim)、update 时对账、拥有它的记录被删时释放(release)。关键决策:字段引用是独占的,和附件不同
附件面(
sys_attachment)故意让一个文件被多个 join 行共享——把同一份文档挂到多条记录上正是它存在的意义。字段引用取相反的模型:一个文件最多被一个槽位拥有;把一个已被拥有的 id 写进第二个槽位,会复制字节到新的sys_file,而不是共享同一行。这个选择不是出于优雅,而是出于失败模式的不对称。AI 写出的朴素克隆代码
insert({...src, id: undefined})一定会让 fileId 扩散,任何模型都必须让这段代码跑通(报错拒绝会逼 AI 学一套专用文件 API,那才是出错的温床)。所以真正的问题是跑通之后会怎样:附件文件的读权限本来就从 parent 记录派生。共享模型下"哪个 parent"是歧义的,而歧义 ACL 的安全解读是并集——这意味着生成的代码可以在没有任何可见错误的情况下泄漏数据。独占从构造上消除了歧义:一个文件、一个 parent、一个 ACL。
让出的存储成本应该在另一层解决:去重字节,而不是去重行。存储适配器背后的内容哈希去重(
sys_file上本就带etag)能把空间省回来,同时每行保留各自的 owner 和 ACL——生命周期永远不必推理共享。由此得到的安全原则
现有附件路径已经遵守这条(墓碑由观察到 join 行死亡的 hook 打,而不是由发现"数为零"的扫描打)。独占所有权把同一条纪律扩展到字段,并且彻底消除了计数:没有计数,就没有漏记能授权删除;也顺带化解了新账本的冷启动隐患——刚建好的引用表对历史引用一无所知,信任它的零值会导致批量误删。
它只记录和释放所有权,从不打墓碑,
attachment-lifecycle.ts里那道scope === 'attachments'护栏(正是它让字段引用文件完全进不了 reap)原封未动。所以合进 main 不可能回收任何字节。开启回收必须在同一个 change 里同时扩展
releaseOwnership和sys_filereap guard 的扫描时复核——只做一半比完全不做更糟:打了墓碑而 guard 仍只复核sys_attachment(对字段文件恒为空),会让每一次释放都变成保证性的字节删除,而不只是有风险。这一点在releaseOwnership的文档注释和一个专门的回归测试里都写死了。顺带:
isFileIdToken收口到 spec把"这个存储的字符串是不透明 file id,还是遗留/外部 URL?"的判定提升为
@objectstack/spec/data的唯一裁决者,替换掉 engine 读解析器里的私有副本。读侧和写侧共用一个定义,就不会漂移成"认领了没人展开的文件"或反过来。休眠性(dual-mode 安全)
在字段真的存了 id token 之前,所有路径都在任何 I/O 之前退出:没有 file 类字段的对象、inline blob 值、URL 形态的值,全部零成本。
测试
file-reference-lifecycle.test.ts新增 24 项:认领(单值 /multiple:true/ 重新引用复活墓碑);休眠性(inline blob、四种 URL 形态、无 file 字段的对象、sys_file未注册);释放(按 id 删、where 多删的 stash 路径、换文件、清空、PATCH 不碰、同值幂等);独占(第二条记录复制、update 跨槽复制、同记录兄弟字段也复制、同槽不复制、复制失败则写入失败、无存储服务时绝不抢夺);未知 id 原样保留。status/deleted_at。check:api-surface✅ ·check:docs✅ADR
新增 2026-07-27 addendum:记录两大流派(Salesforce junction-for-everything vs Dataverse column-as-reference)、我们为何每个面各取其一、observed-transition 原则,以及由此对 wave 2 的重新排序——回填与对账必须先于开启 GC(旧计划把写切换和开 GC 打包、回填排在后面,顺序是错的)。R4 的验收门也从"仔细点"变成可执行的:
os storage verify-references连续 ≥7 天零 under-claim 才允许开 GC。🤖 Generated with Claude Code
https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
Generated by Claude Code