flowchart LR
subgraph OLD["代码归属表"]
P1["路径"] --> O["owner(人)"]
O --> R["评审时提意见"]
end
subgraph NEW["路径不变量清单"]
P2["路径"] --> I["invariant / read / forbid / checks"]
I --> B["动手前:背景"]
I --> A2["动手后:判定"]
end
OLD -.->|"审查者变成 Agent<br/>三个前提失效"| NEW
14 ARBITER:路径的不变量
前两层判定作用在代码上:测试看行为,结构检查看放置。这一层作用在路径上, 而且它是五种载体里唯一一种零主动动作的(小节 8.6)—— 改到哪个路径,那个路径的背景自己浮上来,不需要任何人记得去查。
14.1 为什么不是代码归属表
代码归属表做的事情是把路径路由给一个人。这个机制在人写代码、人审代码的年代 很好用,但它建立在三个前提上,而当审查的一方变成 Agent、 而且是几十个并行的 Agent 时,这三个前提全塌了。
14.1.1 前提一:审查者是人
人会累、会不在、会在疲惫时快速点通过。这些都是老问题, 而且它们有已知的缓解办法 —— 轮值、双人审、检查清单。真正变了的是另一边。
Agent 不会主动去查”这条路径有什么讲究”。它只会照着看起来合理的方式 一直改下去,直到有人告诉它不行。这不是它的缺陷,这是它的工作方式: 它从上下文里能读到的东西出发,生成一个符合上下文的方案。 一条没有出现在上下文里的约定,对它来说就是不存在的。
14.1.2 前提二:路由到人就够了
路由到人的前提是那个人知道该说什么,而 owner 脑子里的那条不变量 从来没被写下来过。他离开、换组,或者这一次没被叫到评审,规则就跟着一起消失了。
这个问题在人的团队里也存在,只是被”传帮带”缓解了 —— 新人跟着老人做几次,那些没写下来的东西就传下去了。 Agent 没有传帮带,每一个会话都是第一天。 你没法指望它从上一次的教训里学到什么,因为对它来说没有上一次。
14.1.3 前提三:审查发生在写完之后
等 diff 出来才发现踩了红线,一整轮工作已经浪费掉了。 Agent 需要的恰恰相反 —— 它需要在动手之前就知道这里有什么。
这一条在 Agent 场景下的成本被并行度乘上了:一轮返工在人的团队里 是一个人半天,在几十个 Agent 并行的场景下是几十次同样的返工, 因为每一个 Agent 都会独立地踩同一个坑。它们之间不通气, 所以同一个教训要被学几十遍 —— 除非它被写在路径上。
14.1.4 于是换了一个问题问
不再问”这条路径归谁审”,改成问:这条路径必须保持什么。
这个替换的关键不在措辞,在于答案的形态变了。“归谁审”的答案是一个人名, 它的有效性依赖那个人在场;“必须保持什么”的答案是一段文本, 它一旦写进仓库就不再依赖任何人在场,路径一命中它就自己浮出来。
配套文档的第一句写得很直白:这些文件不是人的归属记录, 它们是给 Agent 工作流用的、机器可读的不变量清单。 两者的差别可以列成一张表:
| 代码归属表 | 路径不变量清单 | |
|---|---|---|
| 回答 | 这条路径归谁审 | 这条路径必须守住什么 |
| 消费者 | 评审的人 | Agent,动手前与动手后各一次 |
| 内容 | 路径 → 人 | 路径 → 风险 · 不变量 · 先读什么 · 禁止什么 · 跑什么 |
| 时机 | diff 出来之后 | 命中路径的那一刻 |
| 失效于 | 人不在 · 疲劳 · 没上下文 | 只失效于策略读不出来(那会报基建故障) |
最后一行是这张表里最容易被跳过、但最要紧的一行。代码归属表的失效是静默的 —— 一个疲惫的审查者点了通过,没有任何记录说明这次审查其实没发生。 路径清单的失效必然是响的:策略读不出来会被当成基建故障报出去, 而不是被当成”没有命中任何清单”放行。这是 章节 11 那条区分 在这一层的具体落地。
(二十份清单的全量表在 附录 B,按风险等级排。)
14.2 六个字段各回答一个问题
id = "migration-data-destruction"
risk = "critical"
paths = ["Backends/**/migrations/**/*.up.sql", ...]
invariant = "迁移只演进 schema,绝不整表清空或删分区。……"
read = ["…#Backend Data Integrity", "…/migrate.go"]
forbid = ["truncate *", "*drop partition*", "*delete where*", "*on cluster*"]
checks = ["guard:go"]六个字段各回答一个问题:paths 回答哪些路径会触发它,risk 回答风险有多高, invariant 回答必须保持什么,read 回答动手之前应该先读什么, forbid 回答新增哪些内容不被允许,checks 回答完成之后要跑什么。
值得注意的是 invariant 和 forbid 的分工:前者是给人和 Agent 读的自然语言, 后者才是机器执行的部分。这个分工是刻意的,而且它有一个重要推论 —— invariant 里可以写所有危险的动作,forbid 里只能写不该发生的动作。
混淆这两者,规则就会开始误报。误报的规则会被绕过, 被绕过的规则还在继续消耗信任 —— 这是我反复回到的那条链, 下一节会给出它的完整证据。
14.3 实现只有七百行,但每一处都在解决一个具体问题
引擎本身很小:七个源文件,七百多行。小到这个程度是有原因的, 小节 14.12 会回答;这里先逐个看它们在解决什么。
14.3.1 只匹配新增行,而且是字面意义上的
取证这一步用的 git 命令是 git diff --unified=0 <revision-range>, 其中 --unified=0 表示零行上下文,所以提取出来的新增文本里 只有新增的行,一行上下文都没有。
这不只是一个优化,它是一条语义保证:禁止模式永远不会命中 一段你没有改过的代码。如果留了上下文行,会发生一件很坏的事 —— 你在一个包含 ALTER TABLE ... DELETE WHERE 的迁移文件里改了一行注释, 而那条禁止模式命中了上下文里的原有语句,于是你因为别人半年前写的代码 被拦了下来,而且你完全不知道为什么。一条会因为你没做的事而拦住你的规则, 是最快被绕过的那一种。
同一个函数还合并了已暂存和未暂存的改动,所以它对还没提交的工作也生效 —— Agent 不需要先 commit 才能知道自己踩了什么。这一条在人的工作流里可有可无, 在 Agent 的工作流里是必需的:Agent 的一轮迭代往往在提交之前就结束了。
14.3.2 规则不扫自己
匹配模块里有一个三行的函数,以及它在路径匹配时的用法:
pub fn is_policy_path(path: &str) -> bool {
path.starts_with("guardrails/arbiters/")
}
paths.iter()
.filter(|path| !is_policy_path(path))
.any(|path| arbiter.matches_path(path))效果是编辑策略文件本身不会触发策略。它解决的是”规则咬到自己” 这一类问题里最基础的那个:当你在一份清单里写下一条禁止模式时, 那行文本本身就是这个模式的一个实例。没有这三行, 任何人添加或修改一条规则都会被自己拦住 —— 而这意味着规则集会停止增长。
这一类问题在这套系统里出现过好几次,形态各不相同,值得并排看:
| 形态 | 处理 |
|---|---|
| 规则文件里写下的模式命中自己 | 上面这三行 |
| 解释规则的散文命中规则 | 某份清单把文档目录排除在路径之外 |
| 讲解规则的注释命中规则 | 结构检查那边的”只看代码不看注释”开关 |
| 清单文件名让它对版本控制隐形 | 见下 |
最后一个是最阴的。某份清单的注释里记着一句:这份清单的 id 不能以某个前缀开头。 原因是忽略文件里有一条未锚定的通配规则,一旦文件名以它开头, 这份策略文件对版本控制就直接隐形了 —— 规则还在文件里写着, 在本地跑也正常,但它根本没被提交,所以 CI 上不存在。
这是形状 D 的极端形态:不只是”配置没生效”, 而是“配置根本不存在,但你在本地看得见它”。 它被记下来的方式也值得注意 —— 它是一行注释,不是一条检查。 这是一处真实的缺口:如果哪天有人给清单起了那个前缀开头的名字, 唯一拦住他的是有没有读到那行注释。
14.3.3 需要跑的检查是算出来的,不是列出来的
命中多条清单时,需要重跑的检查集合是这么算的:
affected.arbiters.iter()
.flat_map(|arbiter| arbiter.checks.iter().copied())
.collect::<BTreeSet<CheckAlias>>()收进一个有序集合,于是自动去重、顺序确定 —— 如果三份清单都要求跑同一个语言的检查,它只跑一次。
这个细节很小,但它体现了一条一致的态度:返回给 Agent 的应该是 “你需要重跑的最小集合”,而不是”所有相关的检查”。 一个返回了冗余工作的判定,会让 Agent 在正确的路上多花时间, 而多花的时间会变成更多的返工机会 —— 章节 17 会把这条从直觉变成一个可以算的量。
14.4 forbid 是拿真实数据调出来的
这是全书最能说明”规则不是拍脑袋写的”的一节。那份数据销毁清单的注释里, 完整记着当初的调参过程 —— 四条决定,每一条都附着它的数据依据。逐条读。
14.4.1 为什么不禁掉表
注释里的原话是:翻了一遍仓库,26 个迁移文件里有 69 处是完全合法的表退役。
这个数字决定了这条规则的边界。禁掉它,规则会在正确的工作上不停误报, 而接下来发生的事情是可以预见的:它会被绕过去,然后整条规则就废了。
这一句是整节的核心。它不是在说”我们很宽容”,而是在说 一条规则的有效性取决于它的误报率 —— 而误报率高到某个程度之后, 规则的实际效力是零,甚至是负的,因为它还在消耗每个人绕过它的时间。
14.4.2 为什么清空语句写了两种拼法
清空表的语句在实际文件里要么顶格出现、要么带缩进,所以模式写了两种。 两种写法都要求词边界,于是它不会命中某个文件注释里的一个英文单词 —— 那个词尾部多了一个 s,恰好破坏了尾随空格的匹配。
这一条是一行一行看完那 26 个迁移文件之后才写得出来的。 它不是从别处抄来的规则,它是被这个仓库的实际内容塑造出来的 —— 换一个仓库,同样的意图会长成一条不同的模式。
14.4.3 为什么用相邻两词而不是完整语法
删除语句用的是两个相邻的词,而不是某种数据库的完整语法,而这是刻意的。 前者打的是某个分析型数据库的变更删除语法,两个词是相邻的; 另一种数据库的写法中间隔着表名,不会匹配上 —— 因为有五个迁移正是靠它在收紧约束之前清理孤儿数据。
同一个英文单词,在两种数据库里是两个不同风险等级的操作。 规则精确地区分了它们,而区分的方式是利用了两种语法在词序上的差别。 这不是一条通用的技巧,它是一次针对具体语料的设计 —— 而这正是”规则要长出来而不是抄进来”的意思。
14.4.4 为什么保留期删除是故意不禁的
23 个 schema 声明了保留期删除,它就是这里既定的数据保留机制,不是异常。 注释接着写了一句更重要的话:改它一样会删掉生产数据, 但那个风险交给不变量文本和人的评审去承担,不由这个列表承担。
这半句是整章最该记住的一句:规则要守住的是”不该发生的动作”, 不是”所有危险的动作”。一条试图覆盖所有危险动作的规则, 必然会覆盖到大量正当的工作,然后它会被绕过, 然后它连它本来能守住的那部分也守不住了。
这四条决定合起来还说明了一件事:写这条规则的人知道 自己在放弃什么。 保留期删除的风险没有消失,它被显式地移交给了另一个机制。 一条规则的成熟标志不是它覆盖得多全,而是它对自己不覆盖的部分有明确交代。
14.5 反直觉的规律:超过一半在回答”归谁写”
把二十份清单放在一起看,会发现一个当初没预料到的现象: 其中七份的 id 里直接带着”归属”这个词,另外还有两份的不变量文本里 写着”只有一个 owner”。也就是说,超过一半的路径不变量, 本质上都在回答同一个问题 —— 这块状态归谁写。
这跟代码归属表形成了一个很有意思的对照:归属表问的是审查权, 路径清单问的是写入权,而后者才是能被机器验证的那一个。 “这段代码该由谁来看”是一个社会事实,机器验证不了; “这块状态只能由这一个地方写”是一个结构事实,机器一查依赖图就知道。
这也是形状 B 在这一层的确认。单一 writer 是这套系统里 反复出现次数最多的一条原则,多到它已经不像一条原则, 更像是这个系统看待问题的默认角度 —— 遇到一个奇怪的状态, 第一个问题就是”有几个地方在写它”。
14.6 风险等级的分布
清单按风险分级,而四条最高等级守的都是不可逆的东西:数据被删掉、 外键约束了删除语义、发布产生了外部副作用、 消费位点越过了尚未处理的记录。它们的共同点是出错之后重试也回不来。
这一点和 Agent 的行为模式直接冲突:“重试一次”恰恰是 Agent 遇到问题时最自然的反应。一个 Agent 看到某个操作失败了, 它的第一反应是再试一次 —— 这个反应在 99% 的情况下是对的, 正是这 99% 让它成为了默认反应。这四条守的就是那 1%: 在这些路径上,重试不是无害的。
值得把这条推论说完整:风险等级的实质,是标注”这里的重试会不会更糟”。 一条低风险路径出了错,Agent 自己试几次通常就收敛了; 一条最高风险路径出了错,多试一次就是多一次不可逆的损失。 所以风险等级不是一个提醒人类注意的标签, 它是在告诉自动化系统哪里不能用暴力搜索。
14.7 一次真实的拦截
一次完整的拦截长这样:改动命中了迁移文件路径,属于最高风险, 要求先读数据完整性那一节;新增行里出现了 + ALTER TABLE events DROP PARTITION 2026_07;,命中删分区模式; 判定为内容违规,理由是分区删除不可逆、重试恢复不了记录; 修正动作是回到迁移设计与 owner,保留可回滚语义,然后重跑指定的检查。
这个例子的重点不是拦下了一行 SQL。 重点是这次失败带着风险等级、 具体证据和下一步动作一起返回了 —— Agent 拿到的不是一句”不允许”, 而是一整套上下文。它不需要重新猜这条路径有什么讲究, 也就不会换个写法绕过去再试一次。
小节 13.4.4 用第四部的语言讲过这个差别:只返回”失败”的判定 给的是误差的符号,带上证据和下一步的判定给的是方向和大小。 路径清单这一层多给了一样东西 —— 最小重跑集合, 也就是”改完之后该验哪几条”。它省掉的不是一次判断,是一整轮盲目重跑, 而这个差别在几十个 Agent 并行的时候会被放大成完全不同的两种系统行为。
14.8 为什么”动手前读什么”这个字段值钱
六个字段里,最容易被当成可有可无的是 read。它既不拦人,也不产生判定, 只是列了几个链接 —— 在一份以”机器可读”为卖点的清单里,它看起来最不机器。
但它解决的是一个别的字段都解决不了的问题:禁止模式告诉 Agent 什么不能做, read 告诉它这条路径为什么特殊。这两者的差别在泛化能力上。
一个只知道”不能写删分区语句”的 Agent,会去找别的删数据的写法。 不是因为它想绕过,而是因为它不知道这条规则守的是什么 —— 它以为问题出在那个语句上。一个读过”这条路径上, 删掉的行没有任何回滚能还原”的 Agent,面对一种规则没有列举的新写法时, 能自己判断。前者只学到了模式,后者学到了意图。
这是 小节 13.4.4 那条纪律在路径这一层的形态:把”为什么”和”是什么” 一起给出去。 read 相比 incident 又多了一层 —— 它指向的是仓库里的真实文件,不是一段描述。Agent 读的是那个 迁移执行器的源码,而不是关于它的一段总结。这消除了一次转译, 而转译正是规模化时第一个断掉的环节(小节 6.10)。
14.9 二十份清单是怎么攒到二十份的
不是一次设计出来的。从版本历史看,整个机制诞生于一次提交, 十一天后扩到八条,然后一条一条加,加到二十条 —— 而每一条都对应一次具体的事故或一次具体的越界。
这个增长模式和规则那边一样(小节 15.2), 它揭示了一个可以直接抄走的做法:不要一次设计一套清单, 设计一个机制,然后让清单自己长出来。
两个部分的成本结构完全不同。机制的部分 —— 路径匹配、只扫新增行、 最小重跑集合、排除策略文件自身 —— 是一次性的工程投入, 它的正确性一次做对就长期有效。清单的部分是持续的、跟着事故走的, 它永远做不完,也不需要做完。
大部分团队把这两件事搞反了:他们花很多时间讨论”该有哪些规则”, 却用一个临时的机制去承载它们。结果是清单还没写完, 承载它的脚本已经开始漏判 —— 而漏判是静默的,没有人会发现。
14.10 风险等级:一个观察
二十份清单里,最高等级只有四份。这个比例是刻意的, 而且它有一个实际后果:当 Agent 看到”最高等级”这个标记时, 它是有信息量的。如果二十份里有十五份标着最高等级, 那这个标记就退化成了装饰 —— 和 小节 6.2.2 讲的空目录稀释信息量是同一个机制。风险等级的价值来自它的稀缺性。
判断一份清单该不该标最高等级,有一个很干净的判据: 出错之后重试能不能挽回? 能挽回就是高,不能挽回才是最高。
这个判据的好处在于它不涉及”这件事有多重要”这种主观判断, 它只问一个事实问题。正因为它是事实问题, 它在不同的人之间能得到一致的答案 —— 也就是说, 这个等级不会随着谁来标而漂移,而不漂移正是它能被 Agent 信任的前提。
14.11 这一层和另外两层的分工
三层判定容易被看成”三道关卡”,好像它们是同一件事的三次重复、 只是一次比一次严。但它们回答的是三个不同的问题,而且它们的失效方式也不同:
| 层 | 问什么 | 它测不到什么 |
|---|---|---|
| 测试 | 跑起来发生了什么 | 没被跑到的路径、跑了但没断言的行为 |
| 结构检查 | 代码放得对吗 | 放对了但做错了的事 |
| 路径不变量 | 这条路径的约定守住了吗 | 不在任何清单覆盖的路径上的问题 |
三层的覆盖面互不包含,这是它们必须同时存在的理由。 拿两个具体例子过一遍就清楚了:
flowchart TB E1["一次删除生产数据的迁移"] E2["一次并发竞态"] T["测试<br/>跑起来发生了什么"] G["结构检查<br/>代码放得对吗"] A["路径不变量<br/>路径约定守住了吗"] E1 -->|"迁移不被单测执行"| T E1 -->|"文件放对了,格式也对"| G E1 ==>|"匹配路径 + 新增行 ✓"| A E2 ==>|"若有并发测试且断言正确 ✓"| T E2 -->|"看不到运行期"| G E2 -->|"路径上没有对应约定"| A
一次删除生产数据的迁移,测试测不到(迁移文件不会被单元测试执行), 结构检查也测不到(这个文件放在正确的目录里,格式也对), 只有路径不变量测得到,因为它匹配的是路径加新增行。 反过来,一次并发竞态,只有测试可能测得到,另外两层都看不见。
没有哪一层是”更根本”的。 它们是三个正交的投影, 每一个都有它看不见的维度 —— 而这也意味着, 问”这三层里最该先建哪一层”和问”这三层能不能合并” 都是在问错的问题:它们不构成序列,也不构成包含关系。
14.12 为什么这一层的实现只有七百行
对比一下:结构检查那一层有六万七千行,路径不变量这一层只有七百行, 差了将近一百倍。原因不是这一层不重要,是它把大部分工作交给了别人。
它自己做的事情只有五件:从版本控制取新增行、路径通配匹配、 禁止模式的字面匹配、算出最小重跑集合、渲染报告。 真正的语义检查它一点不做 —— 一份清单的 checks 字段 声明”完成之后要跑什么”,那些检查是别的层实现的。 这一层只负责路由,不负责判定内容。
这个划分让它保持了小,而小带来两个好处。第一,它可以被完整读懂: 七百行,一个人一小时能读完全部,而一个能被完整读懂的机制, 它的边界和局限也是清楚的。第二,它很少需要改: 加一份新清单不需要动这七百行,因为清单是数据,机制是代码。
这是策略与机制分离(小节 13.2)在这一层的形态, 而它的效果可以量化:二十份清单,零行新代码。 这个数字是判断一次”策略与机制分离”是不是真的分离干净了的最好标尺 —— 如果每加一条策略都要改一点机制,那说明分离只做了一半。
14.13 不变量文本该怎么写
forbid 是机器读的,invariant 是人和 Agent 读的, 而后者的写法直接决定了这份清单的实际效力。 把同一条不变量的三种写法并排放,差别就很清楚了。
最常见的写法是”迁移不能删除数据”。好一些的写法会补上后果: “迁移只能演进 schema,不能删除数据。删除的行无法恢复。” 而那份清单里实际的写法是这样的:
迁移只演进 schema,从不整表清空或删分区,也从不扩大自己的影响半径。 删除一张退役的表是可接受的 schema 演进,而且必须配一份能工作的回滚脚本, 但清空、删库、变更删除和删分区销毁的是活数据,没有任何回滚能还原, 而集群广播会把语句扩散到迁移执行器指向的那个节点之外。
第三种写法比第一种多了四样东西:它明确了什么是允许的 (删除一张退役的表可以)、给出了允许的条件(必须配回滚脚本)、 解释了为什么不可接受(没有任何回滚能还原)、 解释了第四种禁止的机制(会扩散到执行器指向的节点之外)。
第一样最重要,而它最常被漏掉。 一条只说”不能做什么”的不变量, 会让 Agent(和人)过度保守 —— 它会连合法的表退役也不敢做, 然后来问,然后你花时间回答,然后下一个 Agent 再问一遍。 明确说出什么是允许的,成本是一句话,收益是省掉所有那些询问。
这和 小节 3.4 是同一条:边界越明确,可探索的空间越大。 一份只写禁止的清单,实际效果是把 Agent 推向”什么都不敢碰”, 而那不是安全,那是另一种形式的失效。
14.14 一份清单值多少
算一次账。成本这边很清楚:写一份清单大概一到两小时 —— 前提是你已经知道那条不变量,如果你还得先把它搞清楚,那笔时间不该算在清单头上, 因为那是你本来就欠的债。维护成本接近零,因为它是数据,不是代码。
收益这边是两个数的乘积:这条路径被改动的频率,乘以每次踩坑的代价。 这两个数在不同路径上差几个数量级:
| 路径 | 改动频率 | 每次代价 | 值不值 |
|---|---|---|---|
| 数据迁移 | 低 | 极高(不可逆) | 值 |
| 发布配置 | 低 | 极高 | 值 |
| 核心业务逻辑 | 高 | 中(能改回来) | 一般 |
| 界面样式 | 高 | 低 | 不值 |
注意前两行:它们的改动频率都很低。这是反直觉的 —— 最值得写清单的路径,恰恰是最少被改的那些。 因为清单的价值来自”每次踩坑的代价”,而不是”多久踩一次”。 一条一年只被改两次的迁移路径,两次里只要有一次被拦住, 这份清单就回本了,而它的成本是两小时。
这个算法也解释了为什么二十份清单是够的:不可逆的路径本来就不多。 一个仓库里真正会造成不可逆后果的位置,通常是个位数到二十几之间, 而且这个数不随代码量线性增长 —— 它随外部副作用的种类增长, 而外部副作用的种类是有限的。
14.15 路径不变量和代码归属表可以共存吗
可以,而且在有人类评审的团队里应该共存,因为它们回答的是两个不同的问题 (小节 14.1)。但共存有一个前提:不要用归属表去承载不变量。
常见的错误做法是在归属表的注释里写”改这个目录要小心 X”。 这个做法有三个问题:那段注释只有配置这个文件的人会看; 它不会在 Agent 改到那个路径时出现;它没有可执行的部分。 三个问题的共同点是它把一条应该由机制承载的知识, 放回了”谁记得”这个不可靠的载体上。
正确的分工是归属表管”通知谁”,路径清单管”必须保持什么”。 两者的路径可以重叠,因为它们服务不同的消费者 —— 重叠不是冗余,是两个正交维度在同一个位置上的取值。
14.16 这一层能不能被完全自动化
不能,而且不该。因为 invariant 字段的本质是一段需要被理解的自然语言, 而它之所以是自然语言,不是因为技术不够, 是因为它要表达的东西本身没有形式化的形态。
“删掉的行没有任何回滚能还原”—— 这句话可以被机器读, 但它不能被机器执行。执行它的是那七条禁止模式, 而那七条只是这句话的一个近似,而且这个近似永远是不完备的 (小节 14.4.4 里那个”改保留期一样会删数据, 但那个风险不由这个列表承担”就是证据)。
所以这一层的正确形态是三层叠起来的:
自然语言的不变量(完整,不可执行)
↓ 近似
可执行的禁止模式(不完整,可执行)
↓ 兜底
人的评审(覆盖两者之间的缝隙)
三层缺一不可,而且第三层永远存在 —— 这正是 小节 20.6 那句”审的东西变了,不是不审了”的具体形态。 值得强调的是第二层和第三层之间的那道缝隙不会随着投入增加而消失, 它只会变窄。承认这一点,比试图消灭它更有用: 承认之后你会把人的评审安排在缝隙最宽的地方, 而不是均匀地撒在所有地方。
14.17 写作顺序
写一份清单时按这个顺序,因为后面的字段依赖前面的。
先写 invariant,用完整的自然语言,包括允许什么、禁止什么、为什么, 这一步不要考虑能不能执行。然后写 read,回答这条不变量的完整背景 在哪几个文件里。接着写 paths,回答哪些文件的改动可能违反它 —— 注意这一步可能会让你回去改第一步,因为如果你发现路径范围大得离谱, 说明不变量写得太宽了。再写 checks,回答完成之后跑什么能验证它。
最后才写 forbid,而且它是可选的。 做法是翻真实的代码, 找出那些”绝对不该出现”的新增,并验证它们在现有代码里的命中数是零。 如果这一步做不出一个零基线的模式列表,就留空 —— 留空是一个合法的选择,而且它比一个会误报的列表好得多。
把 forbid 排在最后有一个隐含的理由:它是唯一一个能拦人的字段, 所以它也是唯一一个能把这份清单毁掉的字段。 前四个字段写错了顶多是没用,第五个字段写错了会让前四个一起失效。
14.18 这一层为什么必须”动手前”和”动手后”各一次
配套文档里那句”Agent,动手前与动手后各一次”值得展开,因为两次的作用完全不同。
14.18.1 动手前:提供背景
Agent 准备改某个文件,先查一下这个路径命中了哪些清单, 读它们的 invariant 和 read。这一次的作用是前馈 (小节 5.7 的第一层):它降低了做错的概率,但不产生判定。
它的价值取决于一件事 —— Agent 会不会真的去查。 这套系统的做法是把它写进常驻文件:“改完代码、最终报告之前, 跑一下路径不变量检查”。注意这是一条无条件的规则, 所以它必须常驻(小节 8.2.1): 一条只在某些情况下才需要被想起的规则,就是一条会被忘记的规则。
14.18.2 动手后:产生判定
改完之后再跑一次,这一次扫的是实际的新增行(小节 14.3.1), 产生一个真正的判定。这一次是反馈,而且它是 fail closed 的 —— 不通过就不能报告完成。
14.18.3 为什么不能只留一次
只留动手前,Agent 读了背景然后仍然可能违反,因为读到不等于遵守 —— 小节 8.13 那四步讲的正是这段距离。只留动手后, Agent 会先写错、再被拦、再返工,一整轮工作被浪费掉, 而这正是代码归属表的第三个失效前提(小节 14.1.3)。
两次合起来才完整:前一次降低概率,后一次兜住漏网的。 这正是前馈加反馈的标准结构,只不过这里两者用的是同一份数据, 只是在两个不同的时刻被消费 —— 这也是这份数据值得写得完整的原因, 它要同时服务”教”和”判”两个用途。
14.19 为什么本地和 CI 都要跑
同一条检查在两个地方跑,看起来是冗余的。它不是。 本地那次是给 Agent 的即时反馈,回路延迟以秒计; CI 那次是最终的判定,它的作用是防止本地那次被跳过。
这个设计有一个前提,小节 13.2 强调过: 两边必须跑同一条命令、同一份策略。否则会出现最坏的情况 —— 本地过了,CI 没过,而 Agent 认为是 CI 有问题, 于是它开始不信任 CI 的结论,而这个不信任会扩散到所有的判定上 (小节 3.9)。冗余的价值来自一致;不一致的冗余是负资产。
14.20 二十份清单的一个盲区
小节 B.4 列了它没覆盖的领域,这里补一个更本质的盲区: 这一层只看”新增的行”,所以它看不见”删掉的行”。
--unified=0 提取的是新增文本(小节 14.3.1), 而一次改动如果是删掉了某个东西 —— 删掉一个权限校验、 删掉一条断言、删掉一个必要的清理步骤 —— 这一层完全看不到。
这是一个刻意的取舍,不是实现的疏忽。扫新增行的误报率低, 因为你只对你写的东西负责;扫删除行的误报率高, 因为有大量合法的删除,比如重构、清理死代码。 误报的规则会被绕过,所以这个取舍是对的 —— 但盲区仍然存在,而且它必须被知道。
那么”删掉了不该删的东西”由谁守?分三处:删掉的东西如果有行为, 测试会红;删掉一个必需的目标,结构检查那边的依赖图会断; 剩下的归人的评审。第三处是不可避免的, 而它也是 小节 14.16 那句”不能,而且不该”的一个具体实例。
14.21 从这一层能学到的一条通用设计
这一层的整个设计可以被抽象成一句话, 而这句话适用于任何”给自动化系统加约束”的场景:
把”必须保持什么”和”禁止什么”分开写,前者给人和 Agent 读, 后者给机器执行,并且承认后者永远是前者的一个不完备近似。
三个部分,每一个都对应一个常见的失败。只写”禁止什么”, Agent 会换个写法绕过去,因为它不知道守的是什么(小节 14.8)。 只写”必须保持什么”,没有机器执行的部分,全靠自觉, 而自觉在几十个并行的 Agent 面前不成立。不承认近似是不完备的, 就会试图用禁止模式覆盖所有危险动作,然后误报,然后被绕过。
这三个失败,在任何一个”用规则约束自动化”的系统里都会出现 —— 代码检查、数据质量、权限策略、内容审核,形态不同但结构一样。 这一层给出的答案是一个可以直接迁移的结构, 它不依赖 Rust、不依赖 git,甚至不依赖代码。
14.22 ⚙️ 小规模怎么做
这一层的最小版本不需要任何工具支持:一个 Markdown 文件, 三条路径,每条四个字段。
选哪三条?按”出错之后能不能重试挽回”来选, 绝大多数团队的答案是数据迁移、发布部署、认证与权限。 四个字段是路径、必须保持什么、动手前读什么、完成后跑什么。 先不要写禁止模式 —— 那是需要拿真实数据调的,而你现在还没有数据。
然后加一条 CI 检查:如果本次改动触到了这些路径,就把对应的段落打印出来。 打印,不拦。 拦是后面的事(小节 15.3), 而在没有数据的时候拦人,误报几乎是必然的。
14.23 这一层和”人还要审什么”的关系
小节 20.6 列了四类人还要审的东西, 而这一层和其中一类有直接关系:一个不可逆的动作是否真的应该发生。 这一层不能替人做这个判断(小节 14.16), 但它改变了这个判断发生的方式。
没有这一层时,这个判断发生在评审里 —— 如果那次评审有人、 如果那个人注意到了、如果他知道这条路径的讲究。三个”如果”。 有这一层时,改动被拦下,带着风险等级、具体证据和这条路径的完整背景返回, 判断仍然由人做,但它被强制发生了。
这个区别可以被精确表述:这一层不保证判断是对的, 它保证判断没有被跳过。而 小节 20.12 那句 “大部分不可逆的错误,不是有人错误地决定要做它, 而是根本没有人意识到自己在决定”—— 这一层守的正是这个。
14.24 清单的生命周期
和规则一样(章节 15),清单也会过时, 但它的生命周期短一些,因为它的形态更简单。
它诞生于一次事故,或者一次”某人知道但没写下来”的发现; 然后进入稳定期,路径不变、不变量不变,只是偶尔命中; 然后可能范围失效 —— 一次重构改变了路径的形态, 清单还在,但它匹配的东西已经不在那里了;最后是退休, 条件是那条不变量被结构性地保证了。
范围失效是最需要主动检查的(小节 15.9.3), 而检查方式在 小节 8.14.0.4:记录每份清单的命中次数, 一份从来不命中的清单需要被查看 —— 它可能是路径写错了, 也可能是那条路径已经不存在了,而两种都需要人去看一眼。
退休在这二十份里还没有发生过,原因值得说: 路径不变量守的大多是”外部世界的不可逆性”,而那类东西 无法被结构性地消除 —— 你没法用类型系统保证”删掉的数据能被恢复”。 所以这一层的规则集会比结构检查那一层的更稳定,也更少退休。 这是它的性质,不是它的问题。
14.25 压成三句话
一、问题从”归谁审”换成了”必须保持什么”。而这个转换的价值在于 后者不依赖任何人在场(小节 14.1.4), 而前者的三个前提在 Agent 场景下全塌了。
二、不变量给人和 Agent 读,禁止模式给机器执行, 而后者永远是前者的一个不完备近似。承认这个不完备, 是这一层设计里最重要的一个决定(小节 14.16)—— 因为不承认它,就会试图用禁止模式覆盖所有危险动作, 然后误报,然后被绕过。
三、禁止模式必须拿真实数据调出来,零基线。 小节 14.4 那四段推理是全书最能说明”规则不是拍脑袋写的”的部分, 而它们的共同结论是:规则要守住的是”不该发生的动作”, 不是”所有危险的动作”。
14.26 这一层最小的可行形态
再压一次,压到读者今天下午就能做完:
1. 列出你系统里三个"做错了重试挽回不了"的动作
2. 对每一个,写下:
- 改哪些文件可能导致它(路径)
- 必须保持什么(一句自然语言,说清什么允许什么不允许)
- 动手前该读什么(一到两个链接)
3. 加一条 CI 检查:命中这些路径就打印对应的段落
4. 打印,不拦
四步,两小时。其中第 2 步里”说清什么允许”那半句是最容易被漏掉、 也最有价值的(小节 14.13)—— 它省掉的是所有那些”这个能不能做”的询问, 而那些询问在几十个并行的 Agent 面前会变成几十次。
第 4 步的”不拦”也是刻意的:你现在还没有数据来调禁止模式的边界, 而一条在没有数据的时候就开始拦人的规则, 会在它证明自己有用之前先被绕过。