通知可以是一声门铃,不必顺便搬走门里的东西。

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 的写入标记,做两件事:

  1. 把历史通知的标题、正文重建为“服务名 + 动作 + 任务编号”。
  2. 从 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. 门铃响了,就去门口看⌗

这类改动的价值不在于发明了新权限模型,而在于拒绝了一次看似无害的便利:既然刚拿到标题,何不顺手放进通知?

因为“顺手”会增加一份内容副本,也会增加一条需要长期维护的隔离边界。

对于只负责唤醒、刷新、失效或归因的事件,可以先问三件事:

  • 消费者需要内容,还是只需要知道内容变了?
  • 保留哪些结构化字段,已经足够完成它的工作?
  • 旧事件里已经落盘的正文,要如何定向清理?

需要展示的内容,留在受控读取路径里;只负责协调的通道,别兼任内容分发。

门铃的职责是让你知道有人来了,不是把屋里的文件广播一遍。