读了一切的线程没有污点:撤掉按行染色之后

一次删掉「读过什么就染什么」的改动:污点追踪在什么都读的协调线程上只会制造误报,隔离该落在读侧与内容本身,而「不许外复制」是通道的属性。
证据口径:机制与代码来自该次落地的源码、迁移与测试(48 个文件,+335 / −791,12 个分支提交;构建这次改动的任务跑了 343 个模型步、389 个工具结果)。出处写成 文件 或测试名,源码仓库不公开。标「推断」的是我的解读。
1. 场景:两条按设计读一切的线程⌗
这个 agent 系统里有两条特殊的线程:
| 线程 | 干什么 | 读什么 |
|---|---|---|
| Main | 注意力中枢:收各线程的进度报告,给 side chat 发消息、催、叫停,派任务 | 报告、现场摘要、它自己动手的那部分记录 |
| 记忆管家 | 唯一的记忆写入者:逐页读各代对话的积压,带证据写事实 | 每个它模型能读的线程的原文 |
两条线程的共同点不是权限大,而是按设计读得广:管家的工作就是「把每一条线程的积压都读一遍」,Main 的工作就是「知道所有线程现在怎么了」。它们不是「偶尔读到敏感内容」,而是「唯一的职能就是把所有内容读进来」。
系统里同时有三条正交的约束,全部作用在内容自己的属性上:
隔离(seal) 线程是 open 还是 isolated。孤岛内容只从岛内读,永不外流,永不进记忆。
类别(category) 一个线程最多一个类别。它的内容只发给打了该标签的模型,
只流向同类别的线程。
谱系(lineage) 一条事实携带它全部证据的标签:证据里有类别 X,事实就是 X 的。
被删掉的是第四条,也是当时看起来最自然的一条:读者读过什么,它写出来的东西就是什么。
2. 旧方案:按行污点⌗
旧机制(迁移 0590,后来扩到管家线程)的完整链路是这样的:
工具读到类别内容
│ 结果里带一个隐藏字段(模型看不到)
▼
direct loop 剥掉它 → 写进工具行元数据 readThreads
│ └→ 同时并入本回合「下一次」模型调用的 contentCategory
▼ (同一回合内立即生效,标签只增不减)
触发器把这一代读过的线程并集写进 messages.evidence_threads
│
▼
行标签 = fact_labels_of(evidence_threads)(读时计算,线程改类别就重算)
│
▼
六类消费者拿它做决定
那六类消费者,以及它们各自拿标签做的事:
| 消费者 | 用标签做什么 |
|---|---|
| 决策卡片 | 决策的类别取自它指向的那一行的标签——改进信号、发布请求、毕业事实 |
| 发出侧提示 | 发消息 / 派任务的结果里附一句「你的上下文带着对面读不了的类别,别带过去」 |
| 读 Main 的门 | 管家读 Main 时,要求 Main 当前上下文的类别全在管家模型的标签里 |
| 中立化 | 设备推送、卡片、全局作业对「带着类别」的 Main 只用中立文案 |
| 派任务 | task_start 有一个 category 参数,取值必须是上下文带着的类别之一 |
| 复制守卫 | 行带标签就不许复制到别的线程(evidence_threads IS NOT NULL) |
这就是教科书上的污点追踪(information-flow control):一个值从被读的行流到读者,读者写出的每一行继承这些标签,标签随着流动累积。
3. 它为什么在这条线程上必然误报⌗
不是实现写错了,是这个模型套在「读一切的读者」身上必然失真。三条理由,一条比一条具体。
1)并集是被读者的属性,不是被读行的属性。 它记录的是「这一代上下文装着哪些线程的内容」,而这个并集只增不减,新一代还从上一代继承(压缩摘要来自上一代全部内容)。一个按设计读一切的线程,几轮之后并集就饱和成全集。饱和的标签不区分任何东西——它对每一行都给出同一个答案,而决定恰好需要区分。
2)它回答的问题错位了。 它回答「这条记录从哪儿来」,而所有下游要回答的是「这条记录是什么」。这两者可以毫无关系:一张从类别 X 的线程里开出来的改进信号,问的可能是一个跟 X 无关的 UI 问题。那条记录的来源是 X,内容不是。
3)消费者要的是确定值,标签给的是集合。 门、卡片锁、措辞提示,本质上都是二值决定;而「这一代读过 {X, Y, Z}」离「这条内容属于哪个类别」隔着一整段语义。
三种误报形态(抽象化的例子):
| 场景 | 标签说什么 | 实际情况 |
|---|---|---|
| 从类别线程的一条记录抽出改进信号,据此开一张决策卡 | 被指向的那一行属于 X → 卡片是 X 的 | 卡片问的是「重试策略要不要改」,与 X 无关;X 类别之外的人全看不到这张卡 |
| 协调线程派一个「跑一次构建」的任务 | 上下文带着 X → 结果附一句「别把 X 的内容带过去」 | 任务目标里本来就没有那部分内容,提示无法执行 |
| 把协调线程里的一段话复制到新对话 | 这一代读过 X → 拒绝 | 拒绝的理由不是这段行里有什么,而是「这一代读过什么」 |
一句话:在一个被设计成饱和的读者身上,污点不携带信息。 它只会把「读者的广度」放大成「内容的属性」。
4. 新方案:三条线,各管一件事⌗
改动没有在原地修补标签,而是把隔离拆成三个位置,每一个位置判的都是确定的东西。
4.1 读侧:按读者自己的身份判⌗
// src/threads/visibility.ts
export function canRead(viewer: Viewer, rowScope: SealScope, rowCategory: ThreadCategory): boolean {
if (viewer.kind === 'max') return true
return (rowScope === null || readableScopes(viewer.scope).includes(rowScope))
&& (rowCategory === null || viewerCategories(viewer).includes(rowCategory))
}
viewerCategories(viewer) 的内容是:viewer 自己线程的类别,加上(只对管家)它钉选模型共有的标签。判据里没有历史——不看它读过谁,只看它是谁。配套的 SQL 谓词 hiddenThreadSql 对类别线程一视同仁:别的类别的 viewer、无类别的 viewer,一律只拿到中立状态(标题、状态、类别),拿不到正文。流向同理,assertCanDeliver 只有一条规则:类别 C 的内容只流向类别 C;原来的「载体例外」(携带者因为模型打了标签就放行)整段删除。
4.2 类别只来自内容本身,单值、不累积⌗
最典型的一处对照(src/threads/visibility.ts 的 contentCategoriesOfSession):
- SELECT t.thread_category,
- (SELECT l.categories FROM session_evidence_threads e,
- fact_labels_of(e.evidence_threads) l
- WHERE e.session_id = s.id) AS categories
+ SELECT t.thread_category
返回值从「线程类别 ∪ 上下文并集」变成「空,或一个值」。同一个原则扩散到所有调用点:
| 位置 | 改法 |
|---|---|
| 工具内部的模型调用 | toolModelCategory(会加并集)删除,统一用 toolContentCategory(= 当前线程自己的类别) |
| 输出审查 | 审查事件的 reviewCategories 只取源消息所在代的线程类别;readThreads 字段删除 |
| 决策 | 两支触发器全删(迁移 0641):Main / 管家线程提交的决策类别为 NULL;在类别线程里提交的仍取该线程的类别 |
| 派任务 | task_start 的 category 参数删除,任务只继承派发线程自己的类别 |
| 影子记忆 | 查询里的 label_categories 子查询删除 |
4.3 「不许外复制」是通道的属性⌗
Main 和管家线程的记录对任何 agent viewer 都不可见(它们自己的工具也一样),只有 Max 本人的界面和管家 viewer(readsMain)读得到。这是 2026-10-24 定的「最高密级」,与标签无关。
改动过程中这条守卫差点跟着标签一起消失——它原来写作「选中的行里有标签就拒绝」。标签一删,谓词恒假,守卫静默失效。恢复它的那一个提交把判据换成 channel:
// src/memory/row-copy.ts(示意)
const blocked = await tx.unsafe(
`SELECT 1 FROM messages WHERE ${where} AND channel IN (${mainChannel}, ${stewardChannel}) LIMIT 1`)
if (blocked.length > 0) throw new MainRecordCopyError(to.threadId)
copyChannelMessages 更直接:源 channel 是这两条之一,整段复制直接拒绝。这个判据不读内容、不看标签,因此不可能因为内容判定失灵而失效。
5. 删掉的东西:一张账单⌗
| 对象 | 规模 |
|---|---|
迁移 0642:messages.evidence_threads + 不可变触发器、messages_main_labels / main_row_reads、session_evidence_threads、evidence_threads_of 的载体分支 | 111 行 SQL |
迁移 0641:decisions_content_category 触发器与函数 | 15 行 SQL |
src/steward/flow.ts | 整个文件,71 行 |
src/steward/labels.ts | 108 行 → 65 行(读回报、载体判定、mainContextCategories) |
steward.test.ts / direct-loop-host.test.ts / task-runner.test.ts / turn-runner.freeze.test.ts / turn-delivery.e2e.test.ts | 分别 −84 / −48 / −32 / −19 / −19 行 |
| 合计 | 48 文件,+335 / −791 |
删除不留兼容层。在落地提交上对三个符号族做 grep:READ_THREADS_FIELD|_readThreads、isLabelCarrier|unclearedCarriedCategories|carriedCategoriesNotice、contextCategories —— 在 src/ 与 skills/ 下都是零命中。剩下的只是迁移历史文件里的旧 SQL,那是历史,不是代码路径。
6. 证据:测试怎么钉住结论⌗
决策误染色(src/storage/pg-migrations-decision-category.test.ts)。它先断言「东西真的没了」:
SELECT proname FROM pg_proc WHERE proname IN ('decisions_main_labels', 'decisions_content_category') -- 空
SELECT table_name, column_name FROM information_schema.columns
WHERE table_name = 'session_evidence_threads'
OR (table_name = 'messages' AND column_name = 'evidence_threads') -- 空
然后逐项构造决策,断言类别:Main 提交的普通问题、被指向行属于类别 X 的改进信号、发布请求、毕业事实——四条全是 NULL;同一张表里,在类别线程内提交的决策仍然拿到该线程的类别。最后一条是「修好了误报,没有顺手关掉正确的染色」。
读侧反转(src/steward/steward.test.ts)。旧版本断言「管家读了一个类别线程 → 它的上下文带上该类别 → Max 之后在那里说的话,写进事实时带该类别」;新版本断言的正好相反:Max 在 Main 里说的话进事实时 threadCategories 为空;而且无论管家模型有没有那个标签,它都读得到 Main 的那一代记录。
发出侧与措辞(src/progress/control.test.ts)。side_chat_message 现在能把消息发给一个类别 side chat,结果里带 category,并且不再有 caution;普通 side chat 返回 category: null;隔离、归档、管家、Main 仍然拒绝。
触发行(src/runtime/turn-runner.freeze.test.ts)。删掉的两条测试原本断言「上下文带着类别时,回合触发行里有一句『别把这些内容带过去』」。现在触发行只可能有一句 stay_silent 提示。
7. 代价,和这次仍然没解决的部分⌗
照例把说服不了自己的地方写出来。
- 机械约束变成了行为约束。 这是这次交换的核心代价。Main 仍然需要知道「对面是谁」——
side_chat_message/side_chat_inspect/progress_board的结果都带目标对话自己的category,但怎么措辞、要不要中性化,由模型判断,框架不拦。旧方案的提示同样是「由模型自己别带过去」,区别只是旧的还附带一个大致错误的类别集合:它说不清该避什么,只会说「你的上下文带着 X」。现在它至少说得出对面是 X。 - 转述逃逸没有解决,只是不再假装解决。 管家读 Main 的那一代时,标签已经不存在了:Main 若把类别内容用自己的话写进自己的记录,管家读到的是一段没有标签的转述。旧方案也没挡住这条路(并集只会把「可能涉及」放大成「涉及」,然后把整条事实染上颜色),但它至少留了一个记录。推断:真正的解法在写侧——不让类别内容进入 Main 的上下文——而那会打断 Main 作为注意力中枢的职能,所以这次没做,也不该手滑做。
- 标签的读者变少了,可观测性也跟着少了。 以前能从标签汇总出「Main 读过多少类别的内容」,现在这条统计不存在了。推断:可以接受,因为那个数字从来没对应过任何一个决定;但如果有天需要它,得从工具调用的原始记录重新算,而不是从标签表读。
- 漏报的形态变了。 以前的风险是「锁错卡片」(框架 bug,可复现、可加测试);现在的风险是「措辞不当」(模型质量问题,不可复现、只能靠提示与评审)。前者更糟、更显眼;后者更分散、更难观测。这次选择接受后者。
- 隔离与类别都不动,谱系标签也不动。 孤岛仍是最硬的边界,类别仍按 0330 的读侧与流向规则生效;事实仍按证据推导标签(0560)——包括那条有点反直觉的规则:一条事实的证据横跨两个类别,它就谁也读不到,只有同时打了两个标签的 viewer 和 Max 看得到。这条留下来了,因为它标的是事实自己的证据,不是谁读过它。
8. 结论⌗
污点追踪适合一个前提:值被少数人读,而读它的人想知道它从哪儿来。它不适合另一种前提:一个读者按设计读一切。在后者身上,标签度量的是读者的广度,而读者的广度是常量——常量不区分任何东西,于是每一个消费者都在拿常量做二值判断,结果只能是误报。
所以隔离被拆到三个位置,每个位置判的都是确定的、不随历史漂移的东西:
- 读侧:按读者自己的身份(它的线程、它的模型标签)判,历史不参与。
- 内容:类别只有一个值,来自内容所属的线程,不来自谁读过它。
- 通道:某两份记录不许出它们自己的线程,这是一条按 id 判的禁止,与内容无关,因此不会因为内容判断失灵而失效。
一句话:别给「读过一切」这件事打标签,去约束「谁在读」和「谁能复制」。