Skip to content

feat(storage): 字段引用文件的独占所有权 — ADR-0104 D3 wave 2 (PR-3) - #3527

Merged
os-zhuang merged 2 commits into
mainfrom
d3w2/file-as-reference
Jul 27, 2026
Merged

feat(storage): 字段引用文件的独占所有权 — ADR-0104 D3 wave 2 (PR-3)#3527
os-zhuang merged 2 commits into
mainfrom
d3w2/file-as-reference

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

做了什么

file/image/avatar/video/audio 字段里存的 sys_file id,现在会把所有者记在文件行上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,那才是出错的温床)。所以真正的问题是跑通之后会怎样:

模型 最坏情况 可恢复?
共享 + 引用计数 文件的可读集合变成所有引用者的并集——把私有记录的 id 拷进公开记录会无声扩大访问权限;且一次漏记就授权了不可逆的字节删除
独占 + copy-on-claim 字节重复占用 ✅ 花的是钱,不是数据

附件文件的读权限本来就从 parent 记录派生。共享模型下"哪个 parent"是歧义的,而歧义 ACL 的安全解读是并集——这意味着生成的代码可以在没有任何可见错误的情况下泄漏数据。独占从构造上消除了歧义:一个文件、一个 parent、一个 ACL。

让出的存储成本应该在另一层解决:去重字节,而不是去重行。存储适配器背后的内容哈希去重(sys_file 上本就带 etag)能把空间省回来,同时每行保留各自的 owner 和 ACL——生命周期永远不必推理共享。

由此得到的安全原则

一个文件变得可回收,只能因为平台观察到它唯一的所有者放手了,绝不能因为一次扫描推断出没人引用它。

现有附件路径已经遵守这条(墓碑由观察到 join 行死亡的 hook 打,而不是由发现"数为零"的扫描打)。独占所有权把同一条纪律扩展到字段,并且彻底消除了计数:没有计数,就没有漏记能授权删除;也顺带化解了新账本的冷启动隐患——刚建好的引用表对历史引用一无所知,信任它的零值会导致批量误删。

⚠️ 这个 PR 不删除任何东西

它只记录和释放所有权,从不打墓碑attachment-lifecycle.ts 里那道 scope === 'attachments' 护栏(正是它让字段引用文件完全进不了 reap)原封未动。所以合进 main 不可能回收任何字节。

开启回收必须在同一个 change 里同时扩展 releaseOwnershipsys_file reap 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 原样保留。
  • R4 回归:显式断言释放路径从不写 status / deleted_at
  • 全量:service-storage 123 ✅ · objectql 1068 ✅ · spec 6857 ✅ · field-zoo dogfood 91 ✅(12.8s,无 hang)
  • 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

… 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
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 27, 2026 3:39am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling size/xl labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, packages/services, @objectstack/spec.

110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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
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 protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants