Skip to content

[决策] 启动期「提供方尚未注册」与「根本没有提供方」在注册表里是同一个值 —— 一次 showcase 冷启暴露 3 个同形缺陷,是否收紧 kernel 注册表契约 #4776

Description

@os-zhuang

背景

2026-08-03 一次 showcase 冷启日志(24 条启动告警)被逐条查证后拆出 5 个 issue。其中三个是同一个形状,分别落在三个互不相干的子系统:

issue 消费方 提供方 时差 后果
#4771 service-automation 的 flow 节点类型校验 ApprovalsServicePlugin 注册 approval 执行器 0.8s 8 条「will fail at execution time」全是误报
#4772 plugin-auth 探测 cache 服务 CacheServicePlugin 注册 cache 21ms 误报,且可能限流真的一直没用上共享 cache
#4769 ADR-0104「空库即已迁移」自证 同一次启动的首启 seed ~1s 部署证明了一个它同一次启动就违反的契约,第二启起永久 10 条 ERROR

三处代码此前互不知情,由三拨人在三个时期写下,却犯了同一个错。这通常说明缺陷不在这三处,而在它们共同依赖的那个东西。

具体问题

共同依赖是这个:在启动期问注册表「有没有 X」,得到的「没有」和真正的「没有」是同一个值。

const cache = ctx.getService('cache');   // undefined
// 这个 undefined 有两种含义,而调用方无从区分:
//   (a) 这个部署没配 cache 服务          —— 结论成立,该告警
//   (b) CacheServicePlugin 还有 21ms 才注册 —— 结论不成立,不该告警

三个缺陷都是同一条推理链:

  1. 启动期读注册表 → 拿到 undefined / 空集合;
  2. 把它当成终局事实(而不是「此刻尚不知道」);
  3. 记账这个结论 —— 缓存进进程内变量(bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772)、打成断言式告警(bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771)、甚至写进数据库(bug(objectql): ADR-0104「空库即已迁移」自证写在首启 seed 之前 —— 部署证明了一个它同一次启动就违反的契约,第二次 pnpm dev 起永久 10 条 ERROR #4769attested: datastore-created-empty 写进 sys_migration);
  4. 提供方随后注册好了,但第 3 步的记账不会被撤销

第 3 步是关键。如果只是读到 undefined 然后什么都不做,提供方晚到几毫秒完全无害 —— 坏的是把一个尚未成型的世界的观测结果固化下来#4769 是这条链的极端形态:观测结果被写进了持久层,于是这次启动的误判变成了下次启动的强制契约

顺带一提三个副作用,都不是巧合:

可选方案

A. 只修个案(现状 + 已派发的三个 PR)

三个 issue 各自把检查推迟到 kernel:ready 或改成惰性解析。已经在做(#4769 / #4771 / #4772 本轮第 3 批已派发)。

  • 长远合理性:❌ 不解决任何结构问题。三处独立写下同一个错,说明第四处、第五处只是还没被人读到那行日志而已 —— 而发现它们的唯一途径是「有人恰好去查证一条启动告警」,本次就是这么发现的,成本是维护者的一整轮排查。
  • 防 AI 写错:❌ 反向。修完之后这三处日志变干净,下一个 agent 在别处写下同样的 if (!ctx.getService('x')) { warn(...); this.fallback = true } 时,没有任何东西会拦它 —— 而且现在仓里连坏样例都看不到了。

B. 加一条 lint / 门禁,禁止在 start() 里对注册表做终局判断

沿 #4632 的路子:立词表,把「启动期 getService 结果被缓存或被用于 warn」标成违规。

C. 收紧 kernel 注册表契约:让「尚未知道」在类型上就不等于「没有」

把启动期的注册表查询改成三态(或等价形式:kernel:ready 之前拒绝回答存在性问题、强制调用方要么等待要么声明依赖):

type ServiceLookup<T> = { status: 'present'; value: T }
                      | { status: 'absent' }        // 只在 kernel:ready 之后可能返回
                      | { status: 'not-yet-known' } // 启动期未完成时的唯一答案

调用方必须处理 not-yet-known —— 类型系统不允许把它和 absent 一起 ?? 掉。

  • 长远合理性:✅ 最贴合 Prime Directive Add comprehensive test suite for Zod schema validation #12(contract-first)。它把一个时序事实(启动是分阶段的、词汇表在某一刻才封闭)提升成类型事实。ADR-0018 已经明文允许插件在运行时扩展节点词汇表 —— 那么「词汇表何时封闭」就是这个系统的一等语义,而不是每个调用方各自揣摩的实现细节。三处独立犯错正是「这个语义没有被表达出来」的证据。代价诚实说明:这是公共契约变更,getService 的返回形状影响全部 47 个插件,迁移面很大;而且一旦做,就应该一次做对,不宜分批。
  • 防 AI 写错:✅ 三个方案里唯一结构性阻止的。当前形状下,AI 写出这个 bug 的成本是零 —— if (!svc) fallback() 是任何模型的第一反应,而且它在 kernel:ready 之后是完全正确的写法,所以连 code review 都很难判它有罪(它的对错取决于调用点在哪个启动阶段,而那一点在代码里根本不可见)。三态返回让「这段代码跑在哪个阶段」变成调用方必须回答的问题。这正是「契约收紧优于消费端宽容」——getService 现在返回的 undefined 就是最典型的宽容值,而 ?? 回退正是掩盖它的手段。

我的建议

倾向 C,但不建议现在就做,建议的落法是分两步,且第二步需要你拍板才启动:

  1. 先做 B(本轮即可派),把 [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 的词表扩到这一类:启动期对注册表的终局判断 + 结果被缓存/被写进持久层。它抓不全,但能立刻止血,而且和 [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 一样,词表本身会在被使用的过程中长出覆盖面。这一步在我的权限内,你不否决我就派。
  2. C 需要你定夺,因为它是公共契约变更(getService 影响 47 个插件),迁移面和节奏都超出我能自行裁定的范围。我想请你回答的其实是一个更小的问题:「启动是分阶段的」这件事,该不该成为 kernel 的显式契约? 如果是,C 的具体形状(三态返回 / 阶段门 / 依赖声明)可以再设计;如果你认为这属于「实现细节,靠纪律和 lint 管住就行」,那 B 就是终点,我不再往下推。

需要额外说明的一点:#4769 已经证明了这不只是日志质量问题。 那条链的终点是往数据库写了一行永久的、错误的合规证明 —— 一次启动给自己开了一张它随即违反的证明,而下一次启动会拿这张证明去拒绝数据。日志噪音可以忍,持久层里的假证据不能。这是我认为 B 不够、值得考虑 C 的主要理由。

关联

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions