REVIEW 回应 · 2026-08-10 起草 · 2026-08-13 已全部答复 · 产品方案 · 技术方案 · Review 回应

Review 全中,其中两条推翻了已拍的决定

TL;DR
6 条 findings 全部成立,我逐条到代码里复核过,没有误报。P1-1 是我的硬错误。
更要紧的是两条:决策 A 会撤销一条已发布的 Work 隐私契约;「同名文件 latest」是我上次跟你描述错了,你确认的是一个不存在的行为。这两条不重拍,后面 spec 会继续错。
头像(MIA-278):你的记忆完全正确 —— 专用上传端点是我们自己加的,上游本来就走普通附件接口。能回去,GIF 问题会自动消失,但有个和 P1-4 共用同一个开关的卡点。
2026-08-13 更新:6 个问题已全部答复,规范已按答复修订并 rebase 到 origin/develop;URL 轴已移交 MIA-279,且 277/278 已在 issue 树上挂到 279 之下。
01

逐条复核:没有一条误报

#Finding复核结果
P1-1任务基于一个不在基线的提交成立,我的错。git merge-base --is-ancestor d9a9f6835 HEAD 返回否;分支内 grep ActorTaskID / warnUnboundAttachmentIDs / actor_task_id 全部为空
P1-2新 capability 没有取代已发布的 Work 附件契约成立,最严重(见 02)
P1-3「同名文件 latest」语义不符成立,我误导了你(见 03)
P1-4移除 URL 改写扩大可读范围机制成立。对我们自己的部署大概率不触发(CloudFront 签名),对「公开 CDN + 无签名」这类受支持部署成立
P1-5生命周期规范自相矛盾成立,是我 spec 自己的矛盾
P2CLI 换端点回归 GIF 支持成立。而且它反过来证实了你的判断(见 04)
范围基线分支选错成立。我基于 origin/xisheng,集成分支是 origin/develop(领先 12 个 commit;xisheng 另带 4 个无关 release-note commit)
P1-1 的根因
我先在 fix/attachment-bind-authz-parity 上做了 d9a9f6835,后来从 origin/xisheng 全新建了 feat/attachment-model-upstream-realignment,那个 commit 根本没进新分支 —— 但我写 tasks.md 时当它在。于是任务 4.1–4.7 一半是「删不存在的代码」,一半是「保留不存在的能力」。
02

必问一:决策 A 会撤销一条已发布的契约

已发布的 rollica-work-surface 里有这么一条硬性要求:

openspec/changes/rollica-work-surface/specs/work-navigation-and-direct-runtime/spec.md:637
### Requirement: Work attachments remain creator-private across every access path

"across every access path" —— 这正是决策 A(附件读取只校验 workspace 成员)要去掉的东西。两者不可能同时成立。

这是我的信息缺失导致你在不完整信息下拍板
上次我给了 A / B,我推荐 B,你选 A,理由是「别搞出额外的复杂度」。但我没告诉你 A 会撤销一条已发布契约 —— 那时我还没读 rollica-work-surface 的 spec。
仓库规则对此有明确态度:Absence of an OpenSpec never authorizes removal of deployed behavior. 而这里连「没有 OpenSpec」都不成立,契约写得明明白白。
选项含义代价
A′ 坚持 A
并显式撤销该契约
把 Work attachments remain creator-private 标为 REMOVED / superseded,写清「对同 workspace 成员在已知 UUID 时可读」Work 私密性从「每条访问路径」降级;私密文件夹(MIA-249)那条线的产品叙事要跟着改
B 回到容器继承
(我仍然推荐)
附件属于 Work/chat 会话时,读取要求会话可见性;其余五样照删比 A 多一处判断、一条查询。TTL / GC / 触发器 / URL 改写 / uploader 等值仍然全删
我的判断
你真正想删的是「莫名其妙的限制逻辑」,不是「Work 是私密的」这个产品事实。B 恰好只保留后者。
A 的复杂度收益很小(少一个 EXISTS 子句),代价是撤销一条已发布的隐私承诺 —— 我认为不值。
03

必问二:latest 语义我上次描述错了

你确认的是一个不存在的行为
上次我把 attachment_latest_head 描述成「同名文件最新版本」,你回「对,这是我要的产品行为」。我的描述是错的。

代码事实:

已发布契约(同一份 spec :1491)写的是另一回事:

Every cloud Session attachment SHALL retain its immutable message-bound original. Editable supported formats SHALL maintain at most one CAS-protected latest head. The product SHALL expose only Original / 原版 and Latest / 最新版.

即:每个附件保留不可变原版 + 最多一个 CAS 保护的最新版,最新版来自「编辑」而不是「重传同名」,UI 只暴露 原版 / 最新版 两档。

选项开发量
1 · 保持已发布语义(每附件 original + 编辑产生 latest)—— 推荐零。它已经在跑,spec 只需把我写错的措辞改回来
2 · 真要按「会话 + 文件名」合并(重传同名推进 head)新功能:新身份定义、新查询、迁移、冲突规则(同名不同类型?谁覆盖谁?),要单独立 change
04

MIA-278:头像怎么回到干净轨道

你的记忆完全正确
专用上传接口是我们自己加的:POST /api/avatar-upload + UploadAvatar + isAllowedAvatarContentType,来自 e1549f5e8(2026-07-29)。
上游本身没有这个接口 —— 我查了 origin/main:没有 UploadAvatar、没有 avatar-upload 路由。上游 avatar.go 注释原话:
avatar_url columns (user / agent / squad / workspace) store the raw storage object URL the upload returned.
也就是上游就是走普通附件上传接口拿到存储 URL、直接存进 avatar_url。

我们当初为什么会长出这个接口

1 · a21ae23a3 改写 URL staged url → /api/attachments/{id}/download 2 · 头像全挂 原生 <img> 带不上 Authorization 3 · e1549f5e8 新加专用端点绕过 没动根因,另开一条路 + 自带格式白名单 结论:这个端点从一开始就是「绕过我们自己制造的问题」的补丁,不是产品需求 —— P2 的 GIF 漂移就是它的副作用
Figure 1 · 专用头像端点的成因链:补丁叠在未修的根因之上。
能回去,而且 GIF 问题自动消失
只要执行阶段一任务 3(移除 URL 改写),/api/upload-file 就又返回可渲染的存储 URL,头像走通用附件接口自然恢复。届时:
  • /api/avatar-upload、UploadAvatar、isAllowedAvatarContentType 全部变成死代码 → 删掉。
  • P2 的 GIF 回归不复存在 —— 没有格式白名单了,GIF 走通用接口本来就支持。
  • MIA-278 的「方案用错、应改为合理设计」正面达成,而且是减代码。

但有一个卡点(必问三)

上游后来发现:在私有 bucket、无公开 CDN 的部署上,原始存储 URL 在浏览器里必然 403(upstream #6024)。上游的解法不是回到专用上传端点,而是加了一个签名的公开头像读取路由:

server/internal/handler/avatar.go        # MUL-5393 / #6024
r.Get("/api/avatars/{sig}/*", h.ServeAvatar)

Rollica 没有这个文件 —— 上游是在我们快照之后加的。而我们的部署看起来正是「需要它」那一类:helm values 有 cloudfrontDomain / cloudfrontKeyPairId,本地 .env 有 CLOUDFRONT_DOMAIN / CLOUDFRONT_KEY_PAIR_ID / ATTACHMENT_DOWNLOAD_MODE / S3_BUCKET。CloudFront 签名一旦启用,storageURLIsPubliclyReadable 恒为 false,原始 URL 不可直接渲染。

⚠️ 我只看了配置项名字,没有读生产环境的实际取值(也不该读)。所以这是强指示、不是证据。
P1-4 和头像方案是同一个开关的两面 —— 必须一起决策
URL 可公开渲染 → 头像简单(删端点即可),但 Work/chat 附件变成匿名可取(P1-4 触发)。
URL 不可公开渲染 → Work 附件安全,但头像需要移植上游 avatar.go,否则再挂一次。
05

其余 P1 我打算怎么改

06

六个问题 · 已全部答复

#问题答复已落地
1A 撤销已发布的 Work creator-private 契约:坚持 A′ 还是回 B?坚持 A。理由:非 creator 成员事实上拿不到附件 id,私密性仍在已在代码层验证前提(GetWorkSessionForCreator 404 / ListTaskMessagesByUser 403);写成 MODIFIED Requirement + 强制测试
2latest 语义:保持已发布,还是做同名合并?保持已发布语义spec 已改回「每附件 original + 编辑产生 latest」,重传同名产生独立附件
3生产是否启用 CloudFront 签名?(决定头像方案与 P1-4)并入 MIA-279 一起决定URL 轴整体移交 MIA-279;本 change 保留 attachmentRequiresCreatorScope 的 URL 用途以便并行
4Work/Chat 会话删除要不要连带删 blob?不删已写进 spec 为「明确接受的永久 orphan」,并与「禁止基于时间的回收」拆开
5基线换到 origin/develop?是,但保留分支与 worktree用 rebase --onto 只重放 spec commit,甩掉 4 个无关 release-note commit;2026-08-13 再次 rebase 到最新 tip
6GIF 头像继续支持?继续随 URL 轴一起处理:删掉专用端点后格式白名单消失,问题自动解决
P1-5 无需决策,已直接修
原 spec 同一条 requirement 既禁止在线删对象、又要求容器删除连带删对象。已拆成「禁止基于时间的后台回收」与「保留用户触发的容器删除删对象」,并明确会话删除只级联 DB 行。
下一步
规范已定稿。按 tasks.md 从第 0 组(停 sweeper)开始动代码,每组一个 checkpoint commit。