Skip to content

feat(storage): 字段文件的受管下载 + R4 可执行验收门 — ADR-0104 D3 wave 2 (PR-4)#3534

Merged
os-zhuang merged 2 commits into
mainfrom
d3w2/governed-download-v2
Jul 27, 2026
Merged

feat(storage): 字段文件的受管下载 + R4 可执行验收门 — ADR-0104 D3 wave 2 (PR-4)#3534
os-zhuang merged 2 commits into
mainfrom
d3w2/governed-download-v2

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

承接 #3527(PR-3 独占所有权)。这个 PR 交付 wave 2 的安全装置——在开启回收(PR-5b,不可逆、已 gated)之前必须先存在的两样东西。

1. 字段文件的受管下载

被某条记录的字段拥有的文件(sys_file.ref_object / ref_id,由 PR-3 写入),现在下载时和附件一样要授权:调用者必须能 READ 该文件的 parent 记录,或者是它的上传者。此前只有 attachments scope 的文件被 gate,所有字段文件都保留匿名 capability URL。

两个面的 parent 解析方式不同,而这个不对称正是所有权模型的意义所在:

parent 可读集合
附件 可能挂在多条记录上 所有 sys_attachment join 行的并集
字段文件 恰好属于一条记录 就是那一条记录的,仅此而已

如果字段引用走共享模型,它也必须取并集——那正是"把一个 fileId 拷进更公开的记录"会无声扩大访问权限的机制。

拒绝时返回 FILE_DOWNLOAD_DENIED(403),和附件路径的 ATTACHMENT_DOWNLOAD_DENIED 区分开:这个文件属于一条记录,而不是挂在若干条上,提示语不该说错。

acl: 'public_read' 是 opt-out,并且现在是一个显式声明,而不再是每个字段文件默认白拿的东西。真正需要公开的图片(任何嵌在 <img src> 里、无法携带 bearer token 的)必须声明它。

⚠️ 不会 gate 任何今天开放的东西

cutover 之前的字段存的是 inline blob 或外部 URL,从来不是 sys_file id,所以现存文件没有一个记录了 owner,也就没有一个会开始被 gate。只有字段真的认领过的文件才进入 gate,所有权释放后又自动退出。这两条都有回归测试。

2. verifyFileReferences() — R4 验收门的可执行形态

ADR 原本把开启回收的前提写成"reference counting is verified"——这是愿望,不是可检查的条件。这个函数让它可运行。

它把地面真相(记录的 file 类字段实际持有什么)和记录在案的所有权对账,并按"开启回收后会不会导致数据丢失"给每一类分歧分级——因为只有那一类该阻断发布:

阻断:

kind 含义 后果
unowned_reference 记录持有一个没人拥有的文件 开了回收就看起来是空闲的,字节被删
foreign_owner 记录持有的文件被别的槽位拥有 这条引用对生命周期不可见,登记的所有者一释放,字节就没了
shared_reference 一个文件被两个槽位持有 独占性被破坏(copy-on-claim 没跑),且一个文件的 ACL 在服务两个 parent

仅提示:

  • stale_owner — 拥有但已不再持有。倒向保留,文件只是永远不被回收。
  • unreferenced_file — committed 但无人指向。是存储成本,不是正确性问题。

扫描只读:不写、不打墓碑、不删除。要点在于——一本账在被证明与现实一致之前,不该被授予对不可逆删除的权力。所以它是设计来反复运行的:必须在真实租户数据上、连续多次报告零阻断项,被 gate 的开启回收那一步才允许合并。

测试

  • storage-routes.test.ts +6:字段文件被 gate(scope 不是 attachments 也 gate)、401 未认证、授权放行、public_read opt-out,以及两条双模式回归——未认领的文件不 gate、所有权已释放的文件不 gate。
  • verify-file-references.test.ts +13:三类阻断项各自断言、两类提示项不阻断、attachments scope 被正确排除、inline blob / URL 值下pre-cutover 租户读出全清白、分页(1200 条)、截断标记、按对象限定范围,以及一条锁定"哪些 kind 算阻断"的断言。
  • 全量:service-storage 144 ✅ · nul-bytes ✅ · lint ✅

后续

剩下的 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

claude added 2 commits July 27, 2026 02:54
…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
@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 Building Building Preview, Comment Jul 27, 2026 2:55am

Request Review

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): packages/services.

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

  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/plugins/packages.mdx (via packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)

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.

@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 03:10
@os-zhuang
os-zhuang merged commit 134df4f into main Jul 27, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the d3w2/governed-download-v2 branch July 27, 2026 03:10
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>
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 size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants