只是叫你刷新,别把正文也带上

通知可以是一声门铃,不必顺便搬走门里的东西。
1. 正门查了权限,旁门却在发摘要⌗
设想一个按类别隔离内容的 agent 系统:不同线程读取同一任务库,只能看到自己允许读取的正文。读取接口做得很认真,返回值也经过削减。
然后,写入成功时,系统顺手发了一条通知:
已更新 #<任务编号> <任务标题>
这条通知只是用来叫客户端刷新、给后台记录变更来源。可它带上了标题,评论操作还可能带上一段摘要。内容于是多出一份副本,进入另一套存储、订阅和转发流程。
读取入口有隔离,不代表从入口旁边发出的事件也有隔离。
MaxClaw 的这次改动没有给通知再造一套类别权限系统,而是先问了一个更省事的问题:这些消费者到底需要正文吗?
对这条写入标记通道,答案是否定的。它需要知道对象是谁、发生了什么,以及用于归因的少量状态,不需要知道任务在讲什么。
本文的机制依据是提交 e3074d0 中的写入模块、迁移和测试;下文的设计评价是据此作出的判断,不是对整个系统隔离能力的证明。
2. 一条通知,原来承担两件机器工作⌗
src/vikunja/writes.ts 集中封装任务创建、更新、评论、标签和看板移动。成功写入后,它生成一条 own-write marker,也就是“这是 agent 自己写的”标记。
它有两个用途:
| 消费者 | 要回答的问题 | 所需信息 |
|---|---|---|
| 客户端静默刷新 | 哪个对象变了? | 动作、任务编号 |
| 后台变更归因 | 这次变化是不是 agent 自己造成的? | 任务编号、动作、相关字段与状态 |
第二个用途很重要。agent 和用户可能通过同一身份访问任务服务,单看服务记录的作者,无法可靠区分人工操作和自动操作。后台因此需要额外的写入标记。
适配器里的 changesOf 展示了真实需求:完成状态靠 done 匹配,改期靠字段名与截止时间匹配,移动靠目标桶编号匹配,评论靠评论编号匹配。标题变化也只检查是否写过 title 字段,不拿标题内容做匹配。
这里有一个关键区分:
fields: ["title"] 表示改过标题
title: "……" 复制了标题内容
前者是操作结构,后者是业务正文。为了知道“刚刚动过哪里”,没有必要把“那里写了什么”一起运走。
3. 不给正文加遮罩,直接不生成正文副本⌗
旧实现允许调用方传入一段可读的 brief,用作通知标题和正文。于是每个写入分支都能把自己的业务内容塞进来:任务标题、评论片段、标签名、看板桶名。
新实现删除了这个参数。摘要由统一出口生成:
const brief = `Vikunja ${marker.action} #${marker.task_id}`
通知的 title 和 body 都使用这个固定格式;metadata 中则去掉 title、label_title、bucket_title。评论分支不再截取评论文字。
调整后的信息分工如下:
| 操作 | 标记保留 | 不再复制 |
|---|---|---|
| 创建 | 动作、任务编号 | 任务标题 |
| 更新 | 动作、任务编号、字段名,按需带完成状态或截止时间 | 任务标题 |
| 评论 | 动作、任务编号、评论编号 | 评论摘要 |
| 标签 | 动作、任务编号、标签编号、增删操作 | 标签名称 |
| 移动 | 动作、任务编号、目标桶编号 | 任务标题、目标桶名称 |
这里的“无正文”不等于“只有两个字段”,更不等于“没有信息”。 归因仍需要完成状态、截止时间等结构化数据。真正删掉的是这条通道不需要的自然语言内容。
相较于“先复制内容,再要求每个订阅者正确过滤”,我更赞成这个选择:它把责任放在生产者手里,消费者不必为一份本就用不上的正文重复实现隔离规则。
这不是隐藏 UI 上的文字。内容从未进入这条新标记,后面的存储和转发自然也就拿不到那份文字。
4. 只修新通知,还不算修完⌗
通知会持久化。修改生产者之后,历史记录中的旧标题和旧摘要不会自动消失。
这次改动带了一条数据迁移,限定处理同一 topic、同一 source 的写入标记,做两件事:
- 把历史通知的标题、正文重建为“服务名 + 动作 + 任务编号”。
- 从 metadata 中删除已知承载内容的三个名称字段。
迁移没有修改记录时间,也没有删除归因所用的结构化字段。缺少 metadata 的记录被改成不含正文的通用占位标记。
这比把整个通知表清空更克制:其他来源的通知、其他主题的通知都不在修改范围内。迁移测试也专门断言了这一点。
但边界必须讲清楚:它清理的是数据库里这类历史标记,不是撤回所有已传播的数据。 已经送达的旧推送、外部缓存或备份,不会因为一条数据库更新而凭空消失。
同样,这条迁移删除的是已知旧格式的内容字段,不是通用的任意 JSON 脱敏器。不要把一次定向清理宣传成全面的数据擦除。
5. 测试不能只证明“少了字”⌗
删字段很容易。难的是删完之后,刷新照常发生、归因照常工作、其他通知没被误伤。
这次测试覆盖体现了这几层约束:
| 测试位置 | 规格与证据 |
|---|---|
skills/vikunja-todo/tools.test.ts | 在不同类别上下文中执行写入,精确比对生成的整条标记;标题、评论文字、标签名和桶名不能混入其中 |
src/actions/builtin/vikunja.test.ts | 快捷操作仍然产生正确的完成、改期标记,不再依赖标题字段 |
src/notifications/apns.test.ts | 新格式仍能构造 content-available 静默推送,带动作与任务编号 |
src/storage/pg-migrations-vikunja-markers.test.ts | 历史正文被移除,结构化字段和时间保留,其他通知不变,重复执行迁移结果不变 |
这里最值得保留的习惯,是精确比较整条标记,而不只是搜索几个敏感词是否消失。
例如,只断言旧标题不在输出里,开发者以后换一种摘要写法,测试可能照样通过。断言完整结构,则把“这条通道允许承载什么”变成了可执行的规格。
也不要扩大测试的结论:推送测试使用替代的 HTTP 发送函数,验证的是请求载荷,不是真实设备一定会唤醒;迁移测试验证数据库状态,不证明此前没有任何正文泄露。
6. 这不是一堵新的权限墙⌗
这个补丁减少了一条旁路上的内容,但没有解决所有授权和一致性问题。
第一,编号和状态也可能敏感。 对象存在、活动时间、完成状态、截止时间,仍可能透露信息。如果威胁模型要求连这些都隔离,就还需要收件范围控制;不能因为字符串里没有正文,就允许向任何人广播。
第二,拿到编号不等于获得读取权限。 客户端刷新时需要重新取内容,那个读取入口仍然必须执行自己的权限规则。无正文通知不能代替授权。
第三,类型不是运行时清洗器。 当前实现把 marker 展开到 metadata。接口删除了内容字段,现有调用点也按新格式构造对象,但这并不意味着运行时会自动丢弃所有额外属性。未来若把不可信对象直接传进来,仍需要重新评估这条边界。
第四,写入和标记不是原子事务。 源码明确保留了原有策略:业务写入成功后,标记失败只记日志,不回滚写入。因此会出现写入已完成、归因标记却缺失的情况。这次改动处理内容最小化,并未提供恰好一次的归因保证。
这些不是要求补丁再附送四套系统,而是划清它实际解决的问题:去掉刷新与归因不需要的业务正文,保留原有机器协作能力。
7. 门铃响了,就去门口看⌗
这类改动的价值不在于发明了新权限模型,而在于拒绝了一次看似无害的便利:既然刚拿到标题,何不顺手放进通知?
因为“顺手”会增加一份内容副本,也会增加一条需要长期维护的隔离边界。
对于只负责唤醒、刷新、失效或归因的事件,可以先问三件事:
- 消费者需要内容,还是只需要知道内容变了?
- 保留哪些结构化字段,已经足够完成它的工作?
- 旧事件里已经落盘的正文,要如何定向清理?
需要展示的内容,留在受控读取路径里;只负责协调的通道,别兼任内容分发。
门铃的职责是让你知道有人来了,不是把屋里的文件广播一遍。