从 #4661(#4535 C8 RetryPolicy 收敛)里分出来的独立发现,已实证,不是推测。与 #4650(基线可手编)、#4659(检查 (b) 按 leaf name 匹配)是同一族:门禁看起来绿,但它没在看你以为它在看的东西。
结论
packages/spec/scripts/build-schemas.ts 的可作者化面门禁 只比较 key 的集合。同一个 key 的 默认值 (.default()) 和 约束 (.min() / .max() / .positive()) 变更,对它完全不可见 —— 而这类变更恰恰能静默改变已部署元数据的运行时行为。
三条记录通道全部漏掉它:
| 通道 |
是否记录默认值变更 |
authorable-surface.json 比对(检查 (a) vanish) |
❌ 只有 key 名 |
retiredKey() tombstone(检查 (b)) |
❌ 只在 live → retired 时触发 |
spec-changes.json / upgrade guide(ADR-0087 D4) |
❌ 是 conversion + migration registry 的投影,而默认值变更不必然带 conversion |
也就是说:一个 PR 可以把 job.retryPolicy.maxRetries 的默认值从 3 改成 0(让所有依赖默认值的存量 job 静默停止重试),而全部门禁绿、基线不动、没有任何一行 tombstone 或 conversion 记录这件事。
实证(#4661 的 sabotage 验证)
在 #4661 的分支上,把 shared/retry-policy.zod.ts 的 maxRetries 默认值从 0 改成 3,只改这一个字符,然后跑门禁:
$ pnpm check:authorable-surface
✅ Generated bundled schema: objectstack.json (1686 definitions)
✅ Successfully generated 1703 schemas.
绿。 同一处改动被 #4661 手写的运行时 pin 抓住了:
$ npx vitest run src/shared/retry-policy.test.ts
× pins the opt-in defaults that no gate can observe
FAIL ... > pins the opt-in defaults that no gate can observe
AssertionError: expected 3 to be +0 // Object.is equality
即:今天唯一能挡住这类变更的,是作者自己想起来手写一个 pin 测试。 没有任何机制强制它存在。
为什么这比听上去更糟
- 它专挑最危险的语义。 默认值决定「作者没写这个 key 时会发生什么」。retry 次数、超时、
enabled、required、各种 allow* —— 这些的默认值翻转就是安全/可靠性事件,而且是静默的。
- AI 写的元数据大量依赖默认值。 省略 key 是 LLM 生成元数据的常态,所以默认值的实际覆盖面远大于人手写的时代。
- 约束收紧同理。
#4661 把 maxRetries 加了 max(10)、backoffMultiplier 加了 min(1),存量 maxRetries: 20 的 job 会硬拒。硬拒是响亮的、可接受的,但门禁一样没记录 —— 是人写了 semantic 迁移条目才留下痕迹。
- ADR-0087 的机器不建模它。 D2 conversion 的
ConversionApplication 是 {from, to, path} —— 描述的是值的重写,没有「这个 key 的默认语义变了」的表达形式。
可能的处置方向(未裁决,列出来供讨论)
- A. 把默认值/约束纳入
authorable-surface.json 的比对。 每个 key 除了名字再记一个形状指纹(默认值 + 约束的规范化摘要)。变更即失败,要求显式确认。
- B. 只对「默认值存在性/取值」做指纹,不管约束。 更窄、更少噪音,覆盖最危险的那一类。
- C. 不改门禁,改流程:要求任何
.default() 变更必须带 semantic 迁移条目,用 lint 规则(AST)检查 diff。
- 好处:噪音低。代价:靠 diff 检测,
git 语境下比 ratchet 脆。
- D. 什么都不做,只把「默认值变更必须写进 changeset」写进 AGENTS.md。 最便宜,但正是本 issue 想指出的「靠自觉」。
倾向 A 或 B —— 与 #4650 要加的可达性窄例外是同一批门禁加固,可以一起做。但选哪个取决于维护者对「ratchet 噪音 vs 覆盖面」的偏好,所以不预设。
关联
从 #4661(#4535 C8
RetryPolicy收敛)里分出来的独立发现,已实证,不是推测。与 #4650(基线可手编)、#4659(检查 (b) 按 leaf name 匹配)是同一族:门禁看起来绿,但它没在看你以为它在看的东西。结论
packages/spec/scripts/build-schemas.ts的可作者化面门禁 只比较 key 的集合。同一个 key 的 默认值 (.default()) 和 约束 (.min()/.max()/.positive()) 变更,对它完全不可见 —— 而这类变更恰恰能静默改变已部署元数据的运行时行为。三条记录通道全部漏掉它:
authorable-surface.json比对(检查 (a) vanish)retiredKey()tombstone(检查 (b))spec-changes.json/ upgrade guide(ADR-0087 D4)也就是说:一个 PR 可以把
job.retryPolicy.maxRetries的默认值从 3 改成 0(让所有依赖默认值的存量 job 静默停止重试),而全部门禁绿、基线不动、没有任何一行 tombstone 或 conversion 记录这件事。实证(#4661 的 sabotage 验证)
在 #4661 的分支上,把
shared/retry-policy.zod.ts的maxRetries默认值从0改成3,只改这一个字符,然后跑门禁:绿。 同一处改动被 #4661 手写的运行时 pin 抓住了:
即:今天唯一能挡住这类变更的,是作者自己想起来手写一个 pin 测试。 没有任何机制强制它存在。
为什么这比听上去更糟
enabled、required、各种allow*—— 这些的默认值翻转就是安全/可靠性事件,而且是静默的。#4661把maxRetries加了max(10)、backoffMultiplier加了min(1),存量maxRetries: 20的 job 会硬拒。硬拒是响亮的、可接受的,但门禁一样没记录 —— 是人写了semantic迁移条目才留下痕迹。ConversionApplication是{from, to, path}—— 描述的是值的重写,没有「这个 key 的默认语义变了」的表达形式。可能的处置方向(未裁决,列出来供讨论)
authorable-surface.json的比对。 每个 key 除了名字再记一个形状指纹(默认值 + 约束的规范化摘要)。变更即失败,要求显式确认。.default()变更必须带semantic迁移条目,用 lint 规则(AST)检查 diff。git语境下比 ratchet 脆。倾向 A 或 B —— 与 #4650 要加的可达性窄例外是同一批门禁加固,可以一起做。但选哪个取决于维护者对「ratchet 噪音 vs 覆盖面」的偏好,所以不预设。
关联
.type就能让一个 tombstone 冒充「已登记迁移」 #4659 —— 检查 (b) 按 leaf name 匹配 conversion surfaceauthorable-surface.json过度收集