背景
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 才注册 —— 结论不成立,不该告警
三个缺陷都是同一条推理链:
启动期读注册表 → 拿到 undefined / 空集合;
把它当成终局事实 (而不是「此刻尚不知道」);
记账 这个结论 —— 缓存进进程内变量(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 #4769 把 attested: datastore-created-empty 写进 sys_migration);
提供方随后注册好了,但第 3 步的记账不会被撤销 。
第 3 步是关键。如果只是读到 undefined 然后什么都不做,提供方晚到几毫秒完全无害 —— 坏的是把一个尚未成型的世界的观测结果固化下来 。#4769 是这条链的极端形态:观测结果被写进了持久层,于是这次启动的误判变成了下次启动的强制契约 。
顺带一提三个副作用,都不是巧合:
告警的信噪比归零 。真的没装 approvals 插件的部署,和装了的部署,拿到一模一样的 8 条告警。这条信号现在无法区分两种情况,也就不再是护栏,只是在训练人忽略 warn。bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 更糟 —— 它的建议(「多节点需要 Redis」)是错的指路 ,照做接完 Redis 还是同一条 warn。
首启为什么反而是绿的 ,靠的是巧合:bug(objectql): ADR-0104「空库即已迁移」自证写在首启 seed 之前 —— 部署证明了一个它同一次启动就违反的契约,第二次 pnpm dev 起永久 10 条 ERROR #4769 的进程恰好在 flag 写入前触发过一次读取,于是缓存了 false。也就是说当前代码的「正确行为」是由竞态的一个特定结果偶然提供的。
AI 写的 app 是最大受害者 。这类 bug 的现场是「数据没变、代码没变,只是重启了一次,从此永久报错」—— 归因难度极高,而 agent 拿到的唯一线索是一条主动误导它的日志。
可选方案
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,但不建议现在就做 ,建议的落法是分两步,且第二步需要你拍板才启动:
先做 B(本轮即可派) ,把 [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 的词表扩到这一类:启动期对注册表的终局判断 + 结果被缓存/被写进持久层。它抓不全,但能立刻止血,而且和 [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 一样,词表本身会在被使用的过程中长出覆盖面。这一步在我的权限内,你不否决我就派。
C 需要你定夺 ,因为它是公共契约变更(getService 影响 47 个插件),迁移面和节奏都超出我能自行裁定的范围。我想请你回答的其实是一个更小的问题:「启动是分阶段的」这件事,该不该成为 kernel 的显式契约? 如果是,C 的具体形状(三态返回 / 阶段门 / 依赖声明)可以再设计;如果你认为这属于「实现细节,靠纪律和 lint 管住就行」,那 B 就是终点,我不再往下推。
需要额外说明的一点:#4769 已经证明了这不只是日志质量问题。 那条链的终点是往数据库写了一行永久的、错误的合规证明 —— 一次启动给自己开了一张它随即违反的证明,而下一次启动会拿这张证明去拒绝数据。日志噪音可以忍,持久层里的假证据不能。这是我认为 B 不够、值得考虑 C 的主要理由。
关联
背景
2026-08-03 一次 showcase 冷启日志(24 条启动告警)被逐条查证后拆出 5 个 issue。其中三个是同一个形状,分别落在三个互不相干的子系统:
ApprovalsServicePlugin注册approval执行器CacheServicePlugin注册cache三处代码此前互不知情,由三拨人在三个时期写下,却犯了同一个错。这通常说明缺陷不在这三处,而在它们共同依赖的那个东西。
具体问题
共同依赖是这个:在启动期问注册表「有没有 X」,得到的「没有」和真正的「没有」是同一个值。
三个缺陷都是同一条推理链:
undefined/ 空集合;[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 #4769 把attested: datastore-created-empty写进sys_migration);第 3 步是关键。如果只是读到
undefined然后什么都不做,提供方晚到几毫秒完全无害 —— 坏的是把一个尚未成型的世界的观测结果固化下来。#4769 是这条链的极端形态:观测结果被写进了持久层,于是这次启动的误判变成了下次启动的强制契约。顺带一提三个副作用,都不是巧合:
[auth] no cache service registered在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 更糟 —— 它的建议(「多节点需要 Redis」)是错的指路,照做接完 Redis 还是同一条 warn。pnpm dev起永久 10 条 ERROR #4769 的进程恰好在 flag 写入前触发过一次读取,于是缓存了false。也就是说当前代码的「正确行为」是由竞态的一个特定结果偶然提供的。可选方案
A. 只修个案(现状 + 已派发的三个 PR)
三个 issue 各自把检查推迟到
kernel:ready或改成惰性解析。已经在做(#4769 / #4771 / #4772 本轮第 3 批已派发)。if (!ctx.getService('x')) { warn(...); this.fallback = true }时,没有任何东西会拦它 —— 而且现在仓里连坏样例都看不到了。B. 加一条 lint / 门禁,禁止在
start()里对注册表做终局判断沿 #4632 的路子:立词表,把「启动期 getService 结果被缓存或被用于 warn」标成违规。
active存储列喂进了 #4001 之后严格化的 permission spec #4669 引用它落地,fix(security): permission-set 投影只写 spec 认的键;失败的 backfill 变响亮 (#4669) #4755 的 dev 实测把saveMetaItem加进词表当场抓出 8 处全静默 catch)。但那是文本模式匹配:getService('cache')抓得到,一个包了三层的resolveCacheOrFallback()抓不到。bug(objectql): ADR-0104「空库即已迁移」自证写在首启 seed 之前 —— 部署证明了一个它同一次启动就违反的契约,第二次pnpm dev起永久 10 条 ERROR #4769 更是完全抓不到 —— 它的「注册表」是数据库里的sys_migration表,不是服务注册表。C. 收紧 kernel 注册表契约:让「尚未知道」在类型上就不等于「没有」
把启动期的注册表查询改成三态(或等价形式:
kernel:ready之前拒绝回答存在性问题、强制调用方要么等待要么声明依赖):调用方必须处理
not-yet-known—— 类型系统不允许把它和absent一起??掉。getService的返回形状影响全部 47 个插件,迁移面很大;而且一旦做,就应该一次做对,不宜分批。if (!svc) fallback()是任何模型的第一反应,而且它在kernel:ready之后是完全正确的写法,所以连 code review 都很难判它有罪(它的对错取决于调用点在哪个启动阶段,而那一点在代码里根本不可见)。三态返回让「这段代码跑在哪个阶段」变成调用方必须回答的问题。这正是「契约收紧优于消费端宽容」——getService现在返回的undefined就是最典型的宽容值,而??回退正是掩盖它的手段。我的建议
倾向 C,但不建议现在就做,建议的落法是分两步,且第二步需要你拍板才启动:
getService影响 47 个插件),迁移面和节奏都超出我能自行裁定的范围。我想请你回答的其实是一个更小的问题:「启动是分阶段的」这件事,该不该成为 kernel 的显式契约? 如果是,C 的具体形状(三态返回 / 阶段门 / 依赖声明)可以再设计;如果你认为这属于「实现细节,靠纪律和 lint 管住就行」,那 B 就是终点,我不再往下推。需要额外说明的一点:#4769 已经证明了这不只是日志质量问题。 那条链的终点是往数据库写了一行永久的、错误的合规证明 —— 一次启动给自己开了一张它随即违反的证明,而下一次启动会拿这张证明去拒绝数据。日志噪音可以忍,持久层里的假证据不能。这是我认为 B 不够、值得考虑 C 的主要理由。
关联
pnpm dev起永久 10 条 ERROR #4769(已派发)、bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771(已派发)、bug(plugin-auth):[auth] no cache service registered在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772(已派发)Driver already registered, skipping {"driverName":"com.objectstack.driver.sql"}—— SQL driver 被注册了两次 #4773(SQL driver 被注册两次 —— 尚未定性是真重复注册还是幂等重入,若是后者则是同一族的「启动期重入」问题)pnpm dev控制台每秒刷 48 行Field 'updated_at' is read-only—— outbox 的 claim/ack 显式写了平台自动盖的审计列 #4765(pnpm dev控制台刷屏)、bug(example-showcase): 启动日志暴露的 4 个 showcase 自身问题 —— 夜间 job 永不运行、权限集引用不存在的 capability、退役的 retry 字段、非法 cover 种子值 #4774(同一份日志里 showcase 自身的 4 个问题)