OPENSPEC 产品方案 · ATTACHMENT-MODEL-UPSTREAM-REALIGNMENT · 2026-08-08

附件不该有自己的权限和自己的生命周期

TL;DR
我们给「附件」发明了一套自己的权限模型 + 自己的 GC 模型,上游 Multica 两者都没有。 代价:7 处修复补同一根因、已造成不可恢复的 S3 数据丢失、今天仍在产出坏数据。
方案:附件继承所属容器的权限,由容器决定存亡。删掉 TTL、GC、4 个数据库触发器、URL 改写、uploader 等值这五样; 只保留 Chat→Work 带来的真实能力 —— Work 会话云盘
净效果是负分歧:fork 与上游的差异变小,不是变大。
00

先回答:Sweeper 是什么?

server/cmd/server/attachment_sweeper.go上游 Multica 完全没有这个东西,是我们自己加的0432ba618,2026-07-14 11:37)。

它是一个后台垃圾回收进程:

它要解决的问题很小:用户在编辑器里贴了图然后没保存就走了,这个 blob 永远没人引用,占存储。所以给「还没绑定到任何东西」的上传加了 7 天 TTL。

问题出在它怎么判断「没人引用」
靠「三个外键都是 NULL」。而头像正好三个都是 NULL —— 头像的引用是 agent.avatar_url 这个字符串字段,不是外键。 于是 sweeper 把正在被 Web/Desktop/Mobile 渲染的头像判成垃圾删掉了。

这不是推测,是我们自己的修复迁移写在注释里的原话:

245_preserve_avatar_attachments.up.sql
"Migration 176 classified those rows as staged and could therefore expire an image that was still actively rendered by Web, Desktop, and Mobile."

而且迁移 176 还把所有已存在的未绑定附件回填成 created_at + 7 天 —— 上线那一刻,所有超过 7 天的老头像立即到期。迁移 248 结尾那句 RAISE NOTICE '... left % records unchanged because matching attachment rows were missing' 就是在说:有一批 S3 对象当时已经被删掉、找不回来了。

之后为了救它,又加了:sweeper 里 4 条 NOT EXISTS (... avatar_url = attachment.url) 排除、迁移 245 的 1 个 PL/pgSQL 函数 + 4 个数据库触发器、迁移 248 的正则回写。而这一切的起点,只是想清理「用户贴了图没保存」的临时文件。

01

Why

  1. 7 处修复在补同一个根因(07-14 → 07-29):2878abf43fccd4bda662dae5966e1549f5e8、迁移 245、迁移 248,加上本次 d9a9f6835。每处只打掉一个症状。
  2. 已造成不可恢复的数据损失(迁移 248 自证 attachment 行缺失 → S3 对象已删)。
  3. 今天仍在产出坏数据multica agent avatar 仍走 /api/upload-file,把不可渲染的代理 URL 写进 avatar_url;又因该 URL 与 attachment.url 不相等,迁移 245 的触发器不生效 → 7 天后仍被连 S3 一起删。
  4. 崩溃点不可预测:语义靠「哪几个外键恰好是 NULL」推断、引用关系靠「URL 字符串相等」表达。每接入一个新引用方就漏一次,每改一次 URL 规则就断一次 —— 这正是「你不知道还会崩在哪里」的机制性来源。
  5. 上游同步成本持续升高:在 attachment.sqlfile.goservice/issue.go 这些上游热文件里堆了 9 条 fork 专有查询和大量条件分支。
关键判断
这些额外机制没有对应的产品需求。我们真正的 fork 需求只有一个 —— 把上游的 Chat 变成 Work,且 Work 有一个会话云盘集中存放和展示本会话传过的附件。Issues、评论、头像这些子模块本来就该按上游原样工作
02

删什么 / 留什么

一句话:附件不再拥有自己的权限模型和 GC 模型;它继承所属容器的权限,由容器的生命周期决定存亡。

现在 附件行自己判权限 + 自己判存亡 CanTaskReadAttachment · attachmentRequiresCreatorScope expires_at + sweeper + 4 触发器 + URL 改写 改成 容器判权限,容器决定存亡 issue / comment → workspace 可见(上游原样) work / chat session → 会话可见性 保留:Work 会话云盘 ListWorkSessionAttachments attachment_latest_head(最新版本) Work 回复上传的任务门禁 删除:五样「额外限制逻辑」 ① expires_at + sweeper(含 S3 删除) ② 迁移 245 的函数 + 4 个触发器 ③ 附件级读授权 ④ staged 附件的 URL 改写 ⑤ 绑定的 uploader 等值判断 这五样上游都没有
Figure 1 · 权限判定从「附件行」上移到「容器」;fork 只保留 Work 会话云盘。

删除(回归上游原样)

删除项来源为什么删
Sweeper + expires_at0432ba618上游没有。删过正在使用的头像;判据(三 FK 全 NULL)与「是否被引用」不等价。孤儿 blob 改用离线报表 + 受控清理,不在线自动删 S3
迁移 245 的函数 + 4 触发器迁移 245只为给 sweeper 打补丁而存在。数据库触发器隐式改数据,是「莫名崩溃」的典型来源
附件级读授权a21ae23a3权限应由容器判定,附件行不该自己再判一次。两套模型不一致已直接造成 BUG-3 / MIA-284
staged 附件 URL 改写a21ae23a3头像渲染不出来(迁移 248)和很可能 MIA-279 下载失效的直接原因。回归上游:可渲染资源就给可渲染 URL
绑定的 uploader 等值判断0432ba618上游没有此条件。它让 agent 永远无法代人类挂图(BUG-3),且它防的「成员猜 UUID」路径实际不可达

保留(这才是真正的 fork 能力)

保留项为什么保留
Work 会话云盘
ListWorkSessionAttachmentsattachment_latest_head 及其 4 条查询、GetAttachmentForUpdate
Chat→Work 带来的真实产品能力:集中列出/展示本会话传过的附件,并支持同名文件的最新版本。上游无 Work 概念,这部分必须是 fork 的
Work 回复上传的任务门禁
GetRunningNativeWorkTaskForAttachmentUpload
这不是「给附件加权限」,而是「限制谁能往正在运行的任务回复里塞文件」,属于 Work 执行契约
Work 会话本身的私密性Work 私密是产品定义,不删。但实现位置要换 —— 见下一节
最重要的一个方向性修正
改完之后,新增任何引用方默认是安全的(继承容器规则、不会被 GC 掉);而现在是默认危险(没登记就被当垃圾删)。这个默认值方向反了,是整串事故的真正来源。
03

需要你拍板的一个产品决策

Work 会话是私密的。但上游在附件读取层只校验 workspace 成员身份,没有会话级校验。所以「完全按上游」和「Work 保持私密」在这一点上有张力:

方案行为代价风险
A · 纯上游附件读取只校验 workspace 成员fork 分歧最小,代码最少 别人若拿到你 Work 附件的 UUID(截图 / 日志 / 转发链接),能读到。UUID 不可枚举,但一旦泄漏就无边界
B · 容器继承
(推荐)
附件若属于某 Work/chat 会话,读取时要求会话可见性(会话创建者,或该会话的运行中任务)比 A 多一处判断、一条查询无。TTL / GC / URL 改写 / uploader 等值仍全部删掉
我的推荐:B
它保住了「Work 私密」这个产品定义(你在 MIA-249 私密文件夹那条线上也是这个方向),但只用一处容器级判断实现,而不是给附件行发明一整套模型。
你要的「删掉莫名其妙的限制逻辑」在 B 里已完全达成 —— 被删掉的是 TTL、GC、触发器、URL 改写、uploader 等值这五样,而不是「Work 是否私密」这个产品事实。
若你确认要 A,我照做,但要在方案里明确写下「Work 附件在拿到 UUID 时对同 workspace 成员可读」这个已知结论,避免以后又被当 bug 反复修。
04

Capabilities

New Capabilities

Modified Capabilities

Removed Capabilities

05

Impact

Historical Data 发布闸口(仓库硬规则)
删列 + 删触发器是破坏性 schema 变更,必须:expand-first、在生产形状快照上演练、统计「若按新规则判断会被删/保留的行数」并人工核对、给出回滚路径。
已被删掉的历史 S3 对象无法恢复,本方案只保证不再发生。
Upstream Sync Impact —— 本方案是「负分歧」
净减少 fork 与上游的差异。attachment.sql 从 28 条查询回到接近上游 19 条 + 会话云盘所需少数几条;file.goservice/issue.go 的 fork 条件分支大幅减少;attachment_sweeper.go 整体消失。
剩余分歧集中在 attachment_latest_head(附加表)与 Work 会话查询,属于 AGENTS.md 鼓励的「narrow module / additive schema field」形态,未来上游导入冲突面显著下降。
不需要 daemon 协议变更;CLI 仅头像子命令改调用端点(附加式,无 flag / exit code 变化)。
06

不在本次范围

07

待确认(阻塞技术方案)

  1. A / B 决策(Work 附件读取是否做会话级校验)—— 我推荐 B
  2. 孤儿 blob 怎么处理:只出离线报表、不自动删?还是保留一个需人工触发的清理命令?(建议前者,先只观测)
  3. attachment_latest_head(会话云盘的「最新版本」)是否是你要的产品行为,还是也想简化?我按「保留」写的。
  4. 本 change 是否合并吸收已完成的 d9a9f6835(授权统一),还是让它独立先合?合并更完整,但会让那个已验证的修复等这个大方案。