产品方案 · ROLLICA V0.0.4 · 决策已锁定 · 产品方案 · 技术方案 · Review 回应

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

TL;DR
我们给「附件」发明了一套自己的授权模型 + 一套自己的 GC 模型,上游 Multica 两者都没有。 代价:7 处修复补同一根因、已造成不可恢复的 S3 数据丢失、今天仍在产出坏数据。
方案:附件回归上游语义 —— 删掉 staged 过期与 sweeper、4 个数据库触发器、附件级读授权、绑定的 uploader 等值判断; 只保留 Chat→Work 带来的 Work 会话云盘。
URL / 可渲染性那条轴(头像端点、avatar.go、下载按钮、GIF)已移出本 change,归入 MIA-279。 净效果是负分歧:与上游的差异变小。
00

Sweeper 是什么(整串事故的起点)

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

每 1 小时一轮、每批 100 行、循环抽干。找出 expires_at 到期且 issue_id / comment_id / chat_message_id 三个外键全为 NULL 的附件行,删库行并调用 store.DeleteKeys 删 S3 对象 —— 不可恢复。

坏在判断「没人引用」的方式
靠「三个外键都是 NULL」。而头像正好三个都 NULL —— 头像的引用是 agent.avatar_url 这个字符串字段,不是外键。于是正在被渲染的头像被判成垃圾删掉。
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 days —— 上线那一刻,所有超过 7 天的老头像立即到期。迁移 248 结尾的 RAISE NOTICE '... matching attachment rows were missing' 说明当时已有一批 S3 对象被删且找不回。

之后为救它又加了:sweeper 里 4 条 URL 排除、迁移 245 的 1 个 PL/pgSQL 函数 + 4 个数据库触发器、迁移 248 的正则回写。起点只是想清理临时文件。

01

Why

  1. 7 处修复补同一根因(07-14 → 07-29):2878abf43、fccd4bda6、62dae5966、e1549f5e8、迁移 245、迁移 248,以及已废弃分支上的 d9a9f6835。
  2. 已造成不可恢复的数据损失(迁移 248 自证)。
  3. 今天仍在产出坏数据:multica agent avatar 走 /api/upload-file,把不可渲染的 /api/attachments/{id}/download 写进 avatar_url;该值与 attachment.url 不相等,迁移 245 的触发器不生效,7 天后仍会被删。
  4. 故障点不可预测:语义靠「哪几个外键恰好为 NULL」推断,引用关系靠「URL 字符串相等」表达。每接入一个新引用方就漏一次,每改一次 URL 规则就断一次。
  5. 上游同步成本升高:attachment.sql 比上游多 9 条 fork 查询。
真正的 fork 需求只有一个
把上游 Chat 变成 Work,且 Work 有一个会话云盘。Issues、评论、头像本来就该按上游原样工作。
02

已锁定的产品决策

#决策结论
1Work 附件读取授权只校验 workspace 成员。以 MODIFIED Requirement 显式收窄已发布的 Work attachments remain creator-private across every access path
2attachment_latest_head保持已发布语义:每附件不可变 original + 编辑产生的至多一个 CAS latest,只暴露 原版/最新版。不做同名合并
3孤儿 blob不自动删除(仅禁止基于时间的后台回收)
4会话删除是否连带删对象不删,对象成为明确接受的永久 orphan
5URL / 可渲染性轴移出本 change,归入 MIA-279;277 / 278 已在 issue 树上挂到 279 之下
6基线分支origin/develop(不是 xisheng)
决策 1 的前提已在代码层验证
前提是「非 creator 成员拿不到 Work 附件的 id」。已验证:
  • loadWorkSession → GetWorkSessionForCreator(带 CreatorID,非 creator 直接 404);会话云盘 ListWorkSessionAttachments 的 handler 就走这个门禁。
  • ListTaskMessagesByUser 对 creator-private 任务返回 403。

所以私密性移到会话与消息读取层之后仍成立。但它现在是唯一一层,因此该门禁本身已写进 spec 要求,并有强制测试锁住(tasks 6.5)。

决策 1 的残余风险(已写入契约,不得再当 bug 修)
  • attachment id 是 UUIDv7(74 位随机)不可枚举,但一旦泄漏即无第二道边界。已知泄漏渠道:agent prompt 明文列出对话附件 id、runtime session 落盘记录、daemon 日志、截图、有人把 Work 附件的 markdown 链接贴进 Issue / 评论。
  • 该结论限定在「持久引用不可被匿名读取」的部署形态(如启用 CloudFront 签名)。在「公开 CDN 且无签名」的受支持部署上,buildMarkdownURL 会直接返回公开、无签名、不过期的对象 URL,暴露面会从 workspace 成员扩大到任意持链接者。该形态的 URL 策略归 MIA-279 决定。
03

删什么 / 留什么 / 新增什么 / 不做什么

删除(回归上游)

删除项来源
expires_at 列 + 索引 + sweeper 进程 + StagedAttachmentTTL + staged_ttl_secs0432ba618 / 迁移 176
迁移 245 的函数 promote_avatar_attachment() + 4 个触发器迁移 245
附件级读授权:CanTaskReadAttachment、authorizeAttachmentRead、authorizeAttachmentForTask、authorizeAttachmentForUsera21ae23a3
绑定的 uploader 等值判断(LinkAttachmentsToIssue 上的两行)0432ba618

保留

新增(基线上不存在,是 ADD 不是「保留」)

为什么是「新增」而不是「保留」
d9a9f6835 不在本分支基线内(只存在于已废弃的 fix/attachment-bind-authz-parity)。已在基线核实 ActorTaskID / resolveActorTaskID / actor_task_id / warnUnboundAttachmentIDs 全部不存在,而 LinkAttachmentsToIssue 仍带两行 uploader 条件。这是 code review 的 P1-1。

不做(交给 MIA-279 那条轴)

staged 附件 URL 改写是否移除及按部署形态的 URL 策略、是否删除 fork 专用 POST /api/avatar-upload、是否移植上游 avatar.go + /api/avatars/{sig}/*(MUL-5393 / #6024)、GIF 头像支持、下载按钮本体。

04

生命周期契约:两件事必须分开

这是 code review 指出的一处自相矛盾(P1-5),已修正。

行为结论
基于时间的后台回收(sweeper)禁止。不存在任何按时长自动删除的路径
删除 issue / comment / 显式删附件保留删对象(基线行为,deleteS3Object(s))
删除 Work / chat 会话只级联数据库行、不删对象;产生的 blob 是明确接受的永久 orphan
未被任何容器引用的上传(孤儿 blob)永久保留,与上游一致
05

实施:两阶段(expand-first / contract-later)

阶段一 · 只改代码不改 schema
第一优先级是停掉 sweeper(它正在持续删数据,可单独先发)。然后停止读写 expires_at、删除附件级读授权、绑定回归上游、新增失败告警。
做完这一阶段,expires_at 列还在但无人读写,迁移 245 的触发器还在但无害(只把该列置 NULL,已无消费者)。回滚 = 回滚代码,不碰数据库。
阶段二 · 迁移(号按 develop 当前最大值 +1)
才删 4 触发器、函数、索引、列。之前必须先产出四项生产数据盘点并人工核对,其中第 4 项是「有多少头像的 attachment 行已缺失」—— 把已发生的不可恢复损失量化。
顺序是硬约束:阶段二删列后旧服务端会读挂,必须在阶段一发布之后,且不与旧服务端并行。
06

Upstream Sync Impact:负分歧

净减少与上游的差异
attachment.sql 从 28 条查询回到接近上游 19 条 + 云盘所需数条;attachment_sweeper.go 整体消失;file.go、service/issue.go 的 fork 分支大幅减少。
07

Issue 结构(已落到父子关系上)

MIA-282  图片/附件文件存储与显示总入口(epic)
├── MIA-279  下载按钮无响应(urgent)——「URL / 可渲染性」轴的驱动 issue
│   └── MIA-278  头像方案用错需重设计
│       └── MIA-277  出厂默认头像丢失
└── MIA-284  Agent 建单附件挂到评论 ——「授权」轴,由本 change 覆盖
08

当前状态

正本位置
openspec/changes/attachment-model-upstream-realignment/ —— proposal.md / design.md / tasks.md / specs/{attachment-storage-model, work-session-attachment-drive}/spec.md。本页与之对齐,冲突时以仓库为准。