feat(storage): 字段文件的受管下载 + R4 可执行验收门 — ADR-0104 D3 wave 2 (PR-4)#3534
Merged
Conversation
…wave 2 (PR-4) A file owned by a record's field (sys_file.ref_object / ref_id, set by PR-3) is now authorized on download the same way an attachment is: the caller must be able to READ the file's parent record, or be its uploader. Previously only attachments-scope files were gated and every field file kept an anonymous capability URL. Parent resolution differs by surface, and that asymmetry is the point of the ownership model. An attachment may hang off many records, so its readable-by set is the union over its sys_attachment join rows. A field-owned file belongs to exactly one record, so its readable-by set is that one record's and nothing more. Under a shared reference model the field case would have had to union too — which is exactly how copying a file id into a more public record would silently widen access. Denials report FILE_DOWNLOAD_DENIED, distinct from the attachments path's ATTACHMENT_DOWNLOAD_DENIED: the file BELONGS TO one record rather than being ATTACHED TO several, and the message should not claim otherwise. acl: 'public_read' is the opt-out, and now an explicit declaration rather than the silent default every field file used to get. Genuinely public images — anything embedded in an <img src>, which cannot carry a bearer token — must declare it. Gates nothing that is open today. A pre-cutover field holds an inline blob or an external URL, never a sys_file id, so no existing file has an owner recorded and none of them start being gated; the gate engages only for files a field has actually claimed, and disengages when ownership is released. Both are covered by regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
ADR-0104 D3 wave 2 planned to gate enabling collection on "reference counting is
verified", which is aspirational rather than checkable. This makes it runnable.
verifyFileReferences() compares GROUND TRUTH (what records' file-class fields
actually hold) against RECORDED OWNERSHIP (sys_file.ref_object/ref_id/ref_field)
and classifies every disagreement by whether it can cause DATA LOSS once
collection is on, because only that class should block a release:
blocking
unowned_reference a record holds a file nothing owns — with collection on
it looks free and its bytes go
foreign_owner a record holds a file owned by a different slot; that
reference is invisible to the lifecycle, so the bytes go
when the recorded owner releases
shared_reference one file held by two slots — exclusivity was violated,
so copy-on-claim did not run, and one file's ACL is
serving two parents
advisory
stale_owner owned but no longer held; fails toward retention
unreferenced_file committed and unpointed-at — storage cost, not risk
The scan is read-only: it never writes, tombstones or deletes. The point is that
a ledger may not be handed authority over an irreversible delete until it has
been shown to agree with reality, so this is meant to be run repeatedly, on real
tenant data, and to report zero blocking discrepancies on consecutive runs
before the gated collection change may merge.
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 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 27, 2026 03:10
This was referenced Jul 27, 2026
This was referenced Jul 27, 2026
Merged
os-zhuang
added a commit
that referenced
this pull request
Jul 27, 2026
…ervice (#3580) Fixes a regression from the governed-download change (ADR-0104 D3 wave 2, #3534), found by driving app-showcase in a browser as a real non-admin approver: a LEGITIMATE APPROVER COULD SEE A DECISION ATTACHMENT'S FILENAME BUT GOT 403 OPENING IT. The gate authorized a field-owned file by testing whether the caller can READ the owning row. For an ordinary business object that is right — row readability IS the access rule. For sys_approval_action it asks the wrong authority: the audit table is deliberately closed to ordinary approver positions ("operation 'find' on object 'sys_approval_action' is not permitted for positions [auditor, everyone]"), so the test denied exactly the person the attachment was filed for. The approvals service has always held the real rule — which is why the timeline listing the attachment returned 200 while the bytes returned 403. An object may now name a service to answer instead: - ObjectSchema.fileAccessDelegate — the kernel service that authorizes downloads of files owned by this object's media fields - IFileAccessDelegate.authorizeFileRead(recordId, context) — the contract - sys_approval_action declares 'approvals'; ApprovalService.authorizeFileRead reuses the SAME gate listActions applies (visibility of the parent request) rather than inventing a second, looser rule for the bytes Fails closed: a declared delegate that is missing or does not implement the method denies, rather than silently reverting to the raw read it was declared to replace. Objects that declare nothing are unchanged. Verified in the browser on both sides of the gate, not just the happy one. The approver now downloads the real PDF (200, %PDF-1.4 bytes); an anonymous request is still refused (401), so the anonymous capability URL #3534 set out to close stays closed. Checking the permissive side mattered: the delegate is exactly as open as listActions and no more, because approval history is tenant-scoped by design. The net invariant is that a decision attachment is exactly as readable as the decision it hangs off — never more, and no longer less. Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd Co-authored-by: Claude <noreply@anthropic.com>
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.
承接 #3527(PR-3 独占所有权)。这个 PR 交付 wave 2 的安全装置——在开启回收(PR-5b,不可逆、已 gated)之前必须先存在的两样东西。
1. 字段文件的受管下载
被某条记录的字段拥有的文件(
sys_file.ref_object/ref_id,由 PR-3 写入),现在下载时和附件一样要授权:调用者必须能 READ 该文件的 parent 记录,或者是它的上传者。此前只有attachmentsscope 的文件被 gate,所有字段文件都保留匿名 capability URL。两个面的 parent 解析方式不同,而这个不对称正是所有权模型的意义所在:
sys_attachmentjoin 行的并集如果字段引用走共享模型,它也必须取并集——那正是"把一个 fileId 拷进更公开的记录"会无声扩大访问权限的机制。
拒绝时返回
FILE_DOWNLOAD_DENIED(403),和附件路径的ATTACHMENT_DOWNLOAD_DENIED区分开:这个文件属于一条记录,而不是挂在若干条上,提示语不该说错。acl: 'public_read'是 opt-out,并且现在是一个显式声明,而不再是每个字段文件默认白拿的东西。真正需要公开的图片(任何嵌在<img src>里、无法携带 bearer token 的)必须声明它。cutover 之前的字段存的是 inline blob 或外部 URL,从来不是
sys_fileid,所以现存文件没有一个记录了 owner,也就没有一个会开始被 gate。只有字段真的认领过的文件才进入 gate,所有权释放后又自动退出。这两条都有回归测试。2.
verifyFileReferences()— R4 验收门的可执行形态ADR 原本把开启回收的前提写成"reference counting is verified"——这是愿望,不是可检查的条件。这个函数让它可运行。
它把地面真相(记录的 file 类字段实际持有什么)和记录在案的所有权对账,并按"开启回收后会不会导致数据丢失"给每一类分歧分级——因为只有那一类该阻断发布:
阻断:
unowned_referenceforeign_ownershared_reference仅提示:
stale_owner— 拥有但已不再持有。倒向保留,文件只是永远不被回收。unreferenced_file— committed 但无人指向。是存储成本,不是正确性问题。扫描只读:不写、不打墓碑、不删除。要点在于——一本账在被证明与现实一致之前,不该被授予对不可逆删除的权力。所以它是设计来反复运行的:必须在真实租户数据上、连续多次报告零阻断项,被 gate 的开启回收那一步才允许合并。
测试
storage-routes.test.ts+6:字段文件被 gate(scope 不是 attachments 也 gate)、401 未认证、授权放行、public_readopt-out,以及两条双模式回归——未认领的文件不 gate、所有权已释放的文件不 gate。verify-file-references.test.ts+13:三类阻断项各自断言、两类提示项不阻断、attachments scope 被正确排除、inline blob / URL 值下pre-cutover 租户读出全清白、分页(1200 条)、截断标记、按对象限定范围,以及一条锁定"哪些 kind 算阻断"的断言。后续
剩下的 wave 2 步骤按 ADR 2026-07-27 addendum 的重新排序执行:写切换(PR-5a)→ 回填(PR-6)→ 对账 soak → 开启回收(PR-5b,不可逆,仍 gated)。PR-5b 必须在同一个 change 里同时放松
scope === 'attachments'护栏和扩展 reap guard 的扫描时复核——只做一半会让每一次释放都变成保证性的字节删除。🤖 Generated with Claude Code
https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
Generated by Claude Code