跟踪 ADR-0104 D3 wave 2。所有权模型已于 2026-07-27 定案(ADR addendum),序列相应重排。
定案:字段引用是独占的
附件面(sys_attachment)故意共享一个文件给多条记录——那是它的特性。字段引用取相反模型:一个文件最多被一个 (对象, 记录, 字段) 槽位拥有,所有权记在 sys_file.ref_object / ref_id / ref_field;把已被拥有的 id 写进第二个槽位会复制字节到新的 sys_file,而不是共享行。
理由是失败模式不对称,不是优雅:
- 共享的最坏情况——文件的可读集合变成所有引用者的并集(把私有记录的 id 拷进公开记录 = 无声泄漏),以及一次漏记就授权不可逆删除。
- 独占的最坏情况——字节重复占用。花钱,可恢复。
存储成本应在字节层用内容哈希去重解决(sys_file.etag 已在),而不是在行层共享——去重字节,不去重行。
核心安全原则
一个文件变得可回收,只能因为平台观察到它唯一的所有者放手了,绝不能因为一次扫描推断出没人引用它。
独占所有权彻底消除了计数:没有计数,就没有漏记能授权删除;也化解了新账本的冷启动隐患。
序列与进度
顺序修正(已落地)
旧计划把写切换和开 GC 打包、回填排在后面。两处都错了:
- 回填必须先于写切换。记录里还存着遗留值时就收窄可接受的存储形态,会让任何改写该字段的 update 被拒——在回填赶上之前,正常工作的应用会挂。
- 回填 + 对账必须先于开 GC。一本账在被证明与现实一致之前,不该被授予对不可逆删除的权力。
PR-5a 为什么今天不是破坏性变更
值形态检查是 warn-first(ADR-0104 R1/R2):还没回填的行照样写得进去,作者拿到的是指名字段的告警。硬拒绝只在部署方主动打开 OS_DATA_VALUE_SHAPE_STRICT_ENABLED 时发生——而那应该在跑完回填并用 verifyFileReferences() 确认对账之后。strict 默认值的翻转由 #3438 跟踪。
PR-5b 的两条硬约束
- 两半必须同一个 change:放松
scope === 'attachments' 墓碑护栏 和 扩展 sys_file reap guard 的扫描时复核到所有权列。只做前一半比完全不做更糟——打了墓碑而 guard 仍只复核 sys_attachment(对字段文件恒为空),会让每一次释放都变成保证性的字节删除,而不只是有风险。这一点在 releaseOwnership 的文档注释和一条专门的回归测试里都锁死了。
- R4 验收门(可执行):
verifyFileReferences() 必须在真实租户数据上、连续 ≥7 天报告零阻断项才允许合并。阻断项 = unowned_reference(持有但无人拥有)、foreign_owner(持有的文件被别的槽位拥有)、shared_reference(一个文件被两个槽位持有)。提示项(stale_owner / unreferenced_file)倒向保留,不阻断。
R5(公开姿态清单)和 R6(子键读取静态扫描)依然有效。
PR-5b 的合并与迁移的实际执行都需要明确的人工决定,不应自动进行。
跟踪 ADR-0104 D3 wave 2。所有权模型已于 2026-07-27 定案(ADR addendum),序列相应重排。
定案:字段引用是独占的
附件面(
sys_attachment)故意共享一个文件给多条记录——那是它的特性。字段引用取相反模型:一个文件最多被一个(对象, 记录, 字段)槽位拥有,所有权记在sys_file.ref_object/ref_id/ref_field;把已被拥有的 id 写进第二个槽位会复制字节到新的sys_file,而不是共享行。理由是失败模式不对称,不是优雅:
存储成本应在字节层用内容哈希去重解决(
sys_file.etag已在),而不是在行层共享——去重字节,不去重行。核心安全原则
独占所有权彻底消除了计数:没有计数,就没有漏记能授权删除;也化解了新账本的冷启动隐患。
序列与进度
/upload/complete返回 fileId,client 透出 — feat(lint,cli): flag flow update_record writes to readonly fields at design time (#3425) #3465FileValueSchema解析(批量、无 N+1、双模式安全)— feat(objectql): resolve file-field id references on read — ADR-0104 D3 wave 2 (PR-2) #3473ref_*列 + claim/release 写路径 + copy-on-claim — feat(storage): 字段引用文件的独占所有权 — ADR-0104 D3 wave 2 (PR-3) #3527acl: 'public_read'为 opt-out)+verifyFileReferences()R4 验收门 — feat(storage): 字段文件的受管下载 + R4 可执行验收门 — ADR-0104 D3 wave 2 (PR-4) #3534data:URI 转换;外部 URL 只报告不重托管)— feat(storage): 遗留文件值回填 — ADR-0104 D3 wave 2 (PR-6) #3535mimeType/mime_type大小写漂移)— objectui#2828accept/maxSize声明并服务端强制;存储形态收窄为sys_fileid,inline blob 降为expanded读形态 — feat(spec)!: 媒体字段 accept/maxSize 声明并强制 + 存储形态收窄为引用 — ADR-0104 D3 wave 2 (PR-5a) #3555顺序修正(已落地)
旧计划把写切换和开 GC 打包、回填排在后面。两处都错了:
PR-5a 为什么今天不是破坏性变更
值形态检查是 warn-first(ADR-0104 R1/R2):还没回填的行照样写得进去,作者拿到的是指名字段的告警。硬拒绝只在部署方主动打开
OS_DATA_VALUE_SHAPE_STRICT_ENABLED时发生——而那应该在跑完回填并用verifyFileReferences()确认对账之后。strict 默认值的翻转由 #3438 跟踪。PR-5b 的两条硬约束
scope === 'attachments'墓碑护栏 和 扩展sys_filereap guard 的扫描时复核到所有权列。只做前一半比完全不做更糟——打了墓碑而 guard 仍只复核sys_attachment(对字段文件恒为空),会让每一次释放都变成保证性的字节删除,而不只是有风险。这一点在releaseOwnership的文档注释和一条专门的回归测试里都锁死了。verifyFileReferences()必须在真实租户数据上、连续 ≥7 天报告零阻断项才允许合并。阻断项 =unowned_reference(持有但无人拥有)、foreign_owner(持有的文件被别的槽位拥有)、shared_reference(一个文件被两个槽位持有)。提示项(stale_owner/unreferenced_file)倒向保留,不阻断。R5(公开姿态清单)和 R6(子键读取静态扫描)依然有效。
PR-5b 的合并与迁移的实际执行都需要明确的人工决定,不应自动进行。