附录 B — 路径不变量全量表

二十份清单,按风险等级排。和规则那张表一样, 这份表最大的用处不是被抄走,是给出一个对照 —— 让你看到二十份是一个什么样的密度、覆盖了哪些领域、又漏了哪些。

B.1 四条最高等级

它们守的都是不可逆的东西。

不变量 禁止的新增 出错的后果
迁移只演进 schema 整表清空 · 删库删 schema · 删分区 · 集群广播 删掉的行没有任何回滚能还原
不新增数据库外键 外键 · 级联 · 置空 删除语义被数据库隐式决定,无法断言
发布走指定通道 关掉预演 · 跳过确认 外部副作用已经发生
消费位点按已处理记录提交 批量提交 · 提前推进 越过的记录永久丢失

四条的共同点是出错之后重试也回不来, 而这一点和 Agent 的行为模式直接冲突 —— “重试一次”恰恰是 Agent 遇到问题时最自然的反应(小节 14.6)。 第二条值得单独看一眼:它禁的不是一个危险操作, 是一种把语义交给数据库隐式决定的做法 —— 外键本身不删数据,但它让”删这一行会发生什么”这个问题 的答案不再写在代码里,于是也就无法被断言。

B.2 十六条高等级

十六条里,归属类占了将近一半:构建产物归属按工作区、 宿主策略是唯一写者,依赖生态只有一个 owner, 测试结论属于跑测试的那一方(本地与 CI 各一条), 持久化配置只有一个 owner,跨产品身份表字面声明、 不加载任何产品声明,产品元数据由指定生成器在加载期投射。

七条的 id 里直接带着”归属”,另有两条的不变量文本里 写着”只有一个 owner”—— 超过一半的路径不变量 在回答同一个问题:这块状态归谁写(小节 14.5)。

另一半是契约类:声明是四端接口类型的唯一事实源; 生成物必须由其事实源派生、改生成器不改产物; 共享服务协议优先、经微内核注册; 一个 owner 每执行环境一个测试 bundle; 新增可发布产物必须同时登记类型; 埋点的交互粒度是一次用户意图对应一条事件; 收入字段的词表要求写入侧归一函数与消费侧声明必须同时改。

B.3 一份清单里最值钱的不是禁止模式

把两份清单放在一起对比就很清楚了。一份典型的清单有七条禁止模式、 三行不变量文本;而那份收入词表的清单,禁止模式是空的, 不变量文本却是一大段,里面含着一次完整的事故复盘。

空的禁止模式是刻意的,而注释里写明了原因: 这条不变量是”两处必须同时改”,不是”某个字面量不许出现”, 执行力由检查通道承担。知道一个机制表达不了什么, 比会用这个机制更难—— 而一个不知道自己边界的机制, 最终会被用在它做不到的地方,然后失效。

那段不变量文本里记着的事故是这样的:一端写大写、一端写小写、 下游过滤器比小写,三方各自都有测试且各自都绿, 而某一类收入数据一行都进不了任何报表。 这段文字是这份清单里最有价值的部分, 它比七条禁止模式加起来更能防住下一次同类问题 —— 因为下一次的形态一定不一样,而理解了机制的人能认出它。

B.4 二十份清单的覆盖面盘点

值得看一眼这二十份覆盖到了什么、漏了什么。 覆盖到的是构建与产物归属四份、服务与运行时契约三份、 数据与迁移三份、发布与交付两份、测试判定归属两份、 埋点与数据管道三份、产品元数据与身份三份。

明显没有覆盖的有三块:工具链自己章节 9), 那 28 条定时任务不在任何清单的路径下;基础设施配置, 容器编排、网络、存储的配置文件;依赖升级, 引入或升级外部依赖没有专门的不变量。

第一条是这套系统已经自己承认的缺口, 而后两条更有意思,因为它们连”缺口”都还没被识别出来。 依赖升级那条尤其值得说:一次外部依赖的升级, 可能同时改变构建行为、运行时行为和安全面 —— 它满足”高风险加需要额外上下文”这两个条件, 按这套系统自己的标准,它应该有一份清单。

它没有的原因大概率是这类事故还没有发生过。 这完全符合 小节 15.5 那条 —— 规则是从失败里长出来的,所以规则集的形状, 就是这个系统撞过的墙的形状。 这既是它的优点(没有装饰性规则), 也是它的局限(覆盖面等于经验面)。

B.5 六个字段各自能省掉什么

用”如果没有这个字段会怎样”来说明每个字段的价值。 没有 paths,这份清单永远不会被触发,等于不存在; 没有 risk,Agent 无法判断该多谨慎,会用同一种态度对待所有路径; 没有 invariant只知道不能做什么、不知道为什么, 于是换个写法绕过去;没有 read,需要的背景知识不在场, Agent 会基于不完整的理解动手;没有 forbid, 没有机器执行的部分,全靠自觉;没有 checks, 改完之后不知道该跑什么来验证。

第三项的代价最大,而它最容易被省—— 因为 forbid 看起来已经表达了同样的意思。 但它们表达的不是同一件事:forbid 是一个列举, invariant 是一个原则,而列举可以被绕过,原则不能。

B.6 二十份清单的规模分布

一个观察:这二十份清单的长度差别很大。最短的只有几行 —— 路径、一句不变量、几个禁止模式;最长的那份, 光 invariant 一个字段就是一大段, 包含了一次完整的事故复盘(小节 B.3)。

长度和风险等级不相关。 那份最长的是”高”,不是”最高”。 它长是因为它守的那条不变量最难被理解—— 它涉及三个系统、两种大小写、一个下游过滤器, 而且三方各自的测试都是绿的。

所以清单的长度应该由”理解这条不变量需要多少背景”决定, 而不是由”它多重要”决定。这条在写清单时很实用: 写完之后问一句 —— 一个不了解这个系统的人读完这段, 能不能自己判断一个新的写法算不算违反?不能就还得补。

B.7 什么时候用清单,什么时候用规则

书里有两套机制 —— 架构规则(附录 A)和路径清单 —— 而它们的适用范围有一处真实的重叠,值得说清楚该怎么选。

判据是这个约束是关于”代码长什么样”还是关于”这块地方有什么讲究”。 “日志只能在一个文件里定义”是前者:它跟路径无关, 任何一个模块里出现那个构造调用都是违规,所以它是一条规则。 “改迁移文件之前要先读数据完整性那一节”是后者: 它的全部内容就是绑在那条路径上的背景,所以它是一份清单。

还有一处更实用的差别:规则回答”对不对”,清单回答”要不要小心”。 一条规则的输出是二值的,而一份清单的主要产出其实是 invariantread 这两个不产生判定的字段 —— 它们改变的是 Agent 动手之前的理解,不是动手之后的结论。 一份 forbid 为空的清单仍然有价值 (小节 B.3),而一条没有匹配逻辑的规则毫无意义。

两者重叠的地方是”某个路径上不许出现某种写法”。 这时候的选择标准是这条约束换个位置还成不成立: 成立就写成规则(范围更大,覆盖更全), 不成立就写成清单(附带的背景更完整)。 如果你不确定,先写成清单 —— 因为清单是数据、改起来便宜, 而规则一旦上线就开始影响所有人。

B.8 清单最容易犯的错

三个,按频率排。

路径写得太宽。 一份覆盖 src/** 的清单会在几乎每次改动时触发, 然后它的输出会变成背景噪音(小节 13.9)。 判据很直接:如果一份清单在超过三分之一的改动上触发,它太宽了。

forbid 里放了”所有危险的东西”。 小节 14.4.4 讲过, 规则要守的是”不该发生的动作”,不是”所有危险的动作”—— 而这两者的差别,正是一条规则能活多久的差别。

invariant 写成了规则的复述。”禁止使用 X”不是不变量, 是禁令的复述。不变量应该是一个陈述句, 描述一个必须保持为真的事实——“这张表的每一行 都能被追溯到一个源事件”是不变量, “禁止直接插入这张表”是它的一个执行手段。 分清这两者的实际作用是:当执行手段被绕过时, 不变量还能让人判断这次绕过是不是安全的。

B.9 怎么判断自己漏了哪条路径

不用等事故,有一个可以主动做的盘点,四步。

列出你系统里所有”做错了重试挽回不了”的动作—— 提示:涉及外部世界的、涉及删除的、涉及钱的、涉及身份的。 对每一个,问”哪些文件的改动可能导致它”, 而那些文件的路径就是候选清单。 最后对每一条候选,问”这条路径上有没有一个只有某个人知道的讲究”—— 有就立刻写下来,那正是最该被清单化的东西 (小节 14.1.2 讲过为什么)。

这个盘点通常一两个小时就能做完,而它的产出是一份优先级清单。 值得注意的是第四步的产出质量取决于你问了谁: 问那个待得最久的人,比问文档有效得多, 因为这些”讲究”的定义就是”没有被写下来的东西”。

B.10 清单该由谁写

最后一个实际问题:这二十份清单是谁写的,以及你的该由谁写。

答案不是”架构师”,也不是”最资深的那个人”。 写一份清单的正确人选,是最近一次在那条路径上被咬过的那个人—— 因为清单的核心价值在 invariantread 两个字段里, 而这两个字段的质量取决于写的人知道那次是怎么错的

这个人选有一个副作用值得说:它意味着清单会写得很不整齐。 一份出自一次数据库事故的清单,invariant 会很长很具体; 一份出自一次例行讨论的清单,会短而抽象。 这种不整齐是对的小节 B.6)—— 把它们统一成同一个长度、同一种语气, 会把那份最有价值的事故复盘压缩掉。

在这个人选之外还需要一个角色:有人负责把清单接进机制。 写内容的人不必懂路径匹配、不必懂 checks 字段该填什么、 不必判断禁止模式的零基线 —— 那些是机制侧的工作, 而混在一起会让”写清单”这件事的门槛高到只有少数人能做。

内容归撞过墙的人,机制归维护这一层的人—— 这个分工和 小节 13.2 是同一条, 只不过这次分的不是代码,是人。 它的实际效果是清单的数量能长起来: 一份清单的写作成本落回到”把你知道的那件事说清楚”, 而那件事本来就在那个人脑子里。

B.11 清单的最小形态

给读者直接抄:

路径:      Backends/**/migrations/**/*.up.sql
风险:      最高
必须保持:  迁移只演进 schema,绝不整表清空或删分区。
            删除一张退役的表是可接受的演进,
            但清空和删分区删掉的行,没有任何回滚能还原。
动手前读:  <你们的数据完整性约定>
            <迁移执行器的源码>
完成后跑:  <你们的后端检查命令>

先不要写禁止模式。 它需要拿你自己仓库的真实数据来调 (小节 14.4),而你现在还没有那个数据 —— 而一条没有零基线的禁止模式,上线第一天就会误报, 然后这份清单的其余五个字段会跟着一起失去可信度。