一次删掉「读过什么就染什么」的改动:污点追踪在什么都读的协调线程上只会制造误报,隔离该落在读侧与内容本身,而「不许外复制」是通道的属性。

证据口径:机制与代码来自该次落地的源码、迁移与测试(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.ts108 行 → 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. 结论⌗

污点追踪适合一个前提:值被少数人读,而读它的人想知道它从哪儿来。它不适合另一种前提:一个读者按设计读一切。在后者身上,标签度量的是读者的广度,而读者的广度是常量——常量不区分任何东西,于是每一个消费者都在拿常量做二值判断,结果只能是误报。

所以隔离被拆到三个位置,每个位置判的都是确定的、不随历史漂移的东西:

  1. 读侧:按读者自己的身份(它的线程、它的模型标签)判,历史不参与。
  2. 内容:类别只有一个值,来自内容所属的线程,不来自谁读过它。
  3. 通道:某两份记录不许出它们自己的线程,这是一条按 id 判的禁止,与内容无关,因此不会因为内容判断失灵而失效。

一句话:别给「读过一切」这件事打标签,去约束「谁在读」和「谁能复制」。