Skip to content

把 #4632 词表扩到「启动期对注册表的终局判断」—— 一次冷启抓出 3 例,当前零防线 #4777

Closed

Description

@os-zhuang

Blocked-by: #4769
Blocked-by: #4771
Blocked-by: #4772

背景

2026-08-03 一次 showcase 冷启日志暴露了三个同形缺陷,分别落在三个互不相干的子系统(#4769 objectql 的 ADR-0104 自证、#4771 service-automation 的 flow 节点校验、#4772 plugin-auth 的 cache 探测)。三处代码此前互不知情,由三拨人在三个时期写下。

共同形状:启动期问注册表「有没有 X」,把得到的「没有」当成终局事实并记账下来 —— 缓存进进程内变量、打成断言式告警、或者(最坏的 #4769)写进数据库。提供方随后注册好了,记账却不会撤销。

完整的两轴分析和「是否要更进一步收紧 kernel 契约」的裁定请求在 #4776(needs-user-decision)。本 issue 是那份分析里不需要维护者拍板的第一步,PM 已裁定直接派发。

要做什么

沿 #4632 已经建立的机制(静默降级词表 + 门禁),把词表扩到这一类。#4632 的词表已经证明这条路能自己找活:#4669 引用它落地,#4755 的 dev 实测把 saveMetaItem 加进词表,当场抓出 8 处全静默 catch(→ #4754)。

要抓的模式,大致是这三者的组合(单独出现都可能是合法的,组合起来才是违规):

  1. 在插件 start() / 构造期 / kernel:ready 之前,读服务注册表或能力注册表(getService(...)、节点执行器表、driver 表、capability 表等);
  2. 对「读不到」这个结果做出终局判断;
  3. 把该判断记账 —— 赋值给实例字段 / 模块级变量(缓存)、logger.warn 断言式措辞、或写入持久层。

第 3 点是判定的关键,请在实现时守住:只读不记账的调用是完全合法的,不要误报。三个已知实例分别命中三种记账方式,可以拿来当测试夹具:

实例 记账方式 修复后应当不再违规
#4772 plugin-auth 缓存进实例字段 ✅ 惰性解析后不再有启动期终局判断
#4771 service-automation 断言式 warn(“will fail at execution time”) ✅ 校验推迟到 kernel:ready
#4769 objectql 写进数据库(sys_migration 的 attested) ✅ attestation 推迟到首启 seed 之后

为什么要等三个 PR 先落地(Blocked-by)

词表要抓的正是这三处现存代码。若门禁先于修复进 main,main 会立刻变红。三个修复都已在第 3 轮派发中,合并后再落这条门禁,可以顺带用它们验证词表:门禁在修复前的 commit 上必须报出这三处,在修复后的 commit 上必须干净。请把这个双向验证做进 PR 的证据里 —— 一个只在当前 main 上绿的门禁,无法证明它抓得到东西(参见 #4690:check:react-declaration-parity 无 MANIFEST 时静默 skip 退出 0,永远不可能红)。

已知的覆盖边界(请在 PR 里如实写明,不要粉饰)

这是文本模式匹配,覆盖面天然有限:

换句话说:这条门禁能止血,不能根治。根治与否是 #4776 要裁定的事。请不要为了让门禁看起来更强而放宽匹配 —— 误报会比漏报更快地让人关掉它。

顺带

#4773(SQL driver 注册两次)尚未定性,可能是同族第四例(若第二次注册的调用方也在做「我没看到 X,所以我来注册」这类过早判断)。该 issue 独立派发,定性后如果确属同族,回到本 issue 补进夹具表。

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    三个 Blocked-by 全部解除 —— 本条现在可派发。

    阻塞 PR 状态
    #4769 #4794 ✅ 已合并
    #4771 #4791 ✅ 已合并
    #4772 #4788 ✅ 已合并

    夹具表现在可以照着实际落地的代码写了,三种记账方式各有一个真实样本:

    实例 记账方式 修复后的正确形态(已在 main)
    #4772 plugin-auth 缓存进实例字段 createLazyCacheRateLimitStorage() —— 消费时才解析
    #4771 service-automation 断言式 warn sealNodeTypeVocabulary() —— 词汇表封闭时才下结论
    #4769 objectql 写进数据库 自证订阅 app:seeded,且先读「已放行违规值」台账

    双向验证按原计划做:门禁在这三个 PR 之前的 commit 上必须报出这三处,在 main 上必须干净。请把这个验证做进 PR 证据 —— 一个只在当前 main 上绿的门禁,无法证明它抓得到东西(#4690 就是这个教训:无 MANIFEST 时静默 skip 退出 0,永远不可能红)。

    派发前补充:#4769 的修复给了词表一个它抓不到、但值得记录的形态

    原文已写明本门禁抓不到 #4769(它的「注册表」是数据库表,不是服务注册表)。#4794 落地后又多了一个同族形态,同样抓不到,但更值得写进文档:

    「拿不到答案」被当成「答案是否」缓存下来。 #4794 修的记忆化就是这个 —— sys_migration 尚未注册时的那次读根本没读到账本,却被冻结成了整个进程的结论。它与「提供方尚未注册」是同一个错的两种表述,而后者是本门禁的目标。

    建议:词表抓不到的部分,在门禁的文档/错误信息里明确列出已知盲区,而不是让读者以为绿就等于没有。这与 #4776 待裁定的结构性方案是互补的,不是替代。

    ⚠️ 车道提醒:落点在 packages/lint。@xuyushun441-sys 账号近期在该目录活动(见 #4763、#4784),派发前请先看 #4604 登记表与本 issue 的评论,确认无人认领。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    认领:PM 循环第 8 轮
    会话:session_018iARDqtrhQgz6fVHDeDkbQ
    分支:claude/issue-4777-startup-registry-verdict-vocabulary
    Worktree:objectstack-issue-4777

    车道:domain:devx —— 维护者已指定本会话独占认领该标签,登记在 #4604。

    前置已全部解除 ✅

    issue 头部的三条 Blocked-by 现已全部合入 main,逐条核过:

    前置 合入
    #4769(objectql ADR-0104 自证) c44dd5ed4
    #4771(service-automation 节点校验推迟) 25784cfec(PR #4791)
    #4772(plugin-auth cache 惰性解析) f2eb85007(PR #4788)

    这正是 issue 要求的派发时机 —— 门禁若先于修复进 main,main 会立刻变红。现在三处都已修好,可以落门禁,而且可以用它们做双向验证。

    派发时会原样带上的三条约束(都来自 issue 自己)

    1. 双向验证写进 PR 证据:门禁在三个修复之前的 commit 上必须报出这三处,在之后(即当前 main)必须干净。只在当前 main 上绿的门禁证明不了它抓得到东西 —— issue 自己就点名了反例 check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690(check:react-declaration-parity 无 MANIFEST 时静默 skip 退出 0,永远不可能红)。
    2. 判定关键是第 3 点「记账」:只读不记账的注册表调用完全合法,不得误报。三个已知实例分别命中三种记账方式(缓存进实例字段 / 断言式 warn / 写进数据库),正好当夹具。
    3. 覆盖边界如实写明,不许为了显得更强而放宽匹配:getService('cache') 抓得到,包三层的 resolveCacheOrFallback() 抓不到;bug(objectql): ADR-0104「空库即已迁移」自证写在首启 seed 之前 —— 部署证明了一个它同一次启动就违反的契约,第二次 pnpm dev 起永久 10 条 ERROR #4769 那例词表根本抓不到(它的「注册表」是 DB 里的 sys_migration,不是服务注册表)—— 它能进夹具表是因为它的修复可被验证,不是因为词表能发现它。误报会比漏报更快地让人关掉这条门禁。

    范围只到 issue 说的「不需要维护者拍板的第一步」;是否要进一步收紧 kernel 契约由 #4776(needs-user-decision)裁定,不在本单。


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    PM 复核:通过(ACCEPT),等 CI 全绿后进合并队列

    PR:#4833(草稿,base main,body 首行 Fixes objectstack-ai/objectstack#4777)

    文件面(6 个,packages/** 零改动 —— 车道守住了):scripts/check-startup-registry-verdict.mjs(新)、scripts/startup-registry-verdict.baseline.json(新)、.github/workflows/lint.yml、package.json、AGENTS.md、changeset。

    我自己核的四条

    1. 真的接进了 CI,不是加个没人跑的脚本 —— lint.yml 的 Lint & Type Check job 里有 pnpm check:startup-registry-verdict,而该 job 的 check run 现在是绿的,等于新门禁已在 CI 里真跑并通过。这一点在本仓格外要紧:check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690 就是「脚本存在、从未接线」。
    2. AGENTS.md 那 66 行有先例:[convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 在 AGENTS.md 567-595 行有同形的规则段(含同样的「deliberately narrow — it cannot discover a new seam, only stop the...」措辞)。是沿用既有模式,不是自创。
    3. baseline 是真空的("entries": []),并写明为什么空(三个实例在门禁落地前都已修好)。更值得记的是它刻意不做 --fix / --update 标志,理由写在文件里:「生成器会让新违规靠『跑一下更新命令』就被收编,闸门正是这样失去意义的」。
    4. package.json 脚本沿用邻居的 --self-test && <real run> 约定,与 check:durability-log-level / check:init-service-contract 同形。

    双向验证做得比我要求的还细

    我要求「修复前报出、修复后干净」。它做了四点,把「门禁能跟着修复逐个变绿」也证了:

    commit 结果
    f2eb85007^(#4771 与 #4772 缺陷都在) ❌ 2 处违规,点名 auth-plugin.ts:346(cached + warn)与 engine.ts:3317(announces warn@3329)
    25784cfec^(#4772 已修、#4771 仍在) ❌ 恰好 1 处 —— auth-plugin 那条消失,engine 那条还在
    当前 main(三处全修) ✅ 干净
    c44dd5ed4^(#4769 修复前) ✅ 干净 —— 即 #4769 抓不到

    第 4 行正是我派发时钉的那条:#4769 必须如实报成覆盖盲区,不许算作已覆盖。它不仅写了,还实测了 —— 拿修复前的 commit 跑出「干净」来证明抓不到,而不是口头声明。

    失败路径也证了:扫描目录缺失 → exit 1;目录为空 → exit 1 并打印 refusing to report success(注释直接引 #4690 的教训);baseline 有陈旧条目 → exit 1。一个连「无事可扫」都当成功的门禁,就是 #4690。

    最该表扬的一点:它收紧了,而不是兜底

    第一版在 main 上报出 7 条。它把 7 条逐条读完,判定全部是误报或已被仓内契约许可,于是收紧匹配器而不是把它们塞进 baseline:

    • ADR-0116 的声明(dependencies / optionalDependencies / requiresServices,经全仓 providesServices 索引解析)使「absent」成为事实,该判断被许可;
    • 只有 warn / error 算断言,info / debug 是叙述;
    • 规则 B 只算注册表枚举(keys/values/entries/size/forEach/spread/for-of),不算带键的 has() / get()。

    我派发时的原话是「不要为了让门禁看起来更强而放宽匹配 —— 误报会比漏报更快地让人关掉它」。它面对的是相反方向的同一诱惑:把误报塞进 baseline 就能让门禁看起来立刻可用。它没有。18 条自测夹具里包含 ApprovalsServicePlugin、getUnknownNodeTypeAudit、registerNodeExecutor 这些真实的仓内负例,都是第一版会误伤的。

    衍生 #4835

    scripts/check-init-service-contract.mjs(#4471 / ADR-0116 声明覆盖门禁)只匹配 getService,不匹配 getServiceAsync —— 而这正是 #4772 溜过去的那个洞:修复前的 AuthPlugin.init() 调的就是 getServiceAsync('cache'),一个本该在 #4471 下变红的点位对它完全不可见。目前不红(现有 getServiceAsync 调用点都在请求期),是潜伏洞。未在本 PR 修:不同规则、不同语义,应当独立成 PR。已未认领归档。


    Generated by Claude Code

  4. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    结案:PR #4833 已合入 main,issue 由 Fixes 自动关闭

    「启动期对注册表的终局判断」这一类缺陷从今天起有了机械防线:scripts/check-startup-registry-verdict.mjs(AST 门禁,pnpm check:startup-registry-verdict)已接进 lint.yml 的 Lint & Type Check job,沿 #4632 的既有机制(词表 + shrink-only baseline + --self-test + --list)扩展,未另起炉灶。

    复核记录的要点:

    遗留:


    Generated by Claude Code

  5. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    同一块地的第二个入口:#4825 的 dev 从读侧撞上了同一个问题,且给出了很强的论证

    #4825(PR #4872,已复核通过入队)修的是 nextEventSeq() 把驱动读失败当成「表还没建」、静默从 1 重新发号。issue 末尾留了个问题:_find 这类调用该不该进 DURABILITY_CRITICAL_CALLEES?dev 按指示只报告未动手,结论值得原样记下来。

    为什么不能直接把 _find 加进词表

    匹配器按 callee 名字匹配,加 _find 会连带把 find / findOne / count 一起拖进来,在数百个良性接缝上开火。产出的是一个塞满约 200 条例外的 shrink-only baseline —— 而那正是该 baseline 自己的 $comment 警告的「门禁不再有意义」的状态。那会让 #4632 更弱,不是更强。

    这一条我完全同意,而且它对本单(#4777,启动期注册表判断)是同样的约束:词表扩张的边际代价不是线性的,每加一个高频 callee,例外清单就会淹没信号,然后所有人学会「红了就加例外」。

    有害形状需要三个部分同时出现

    dev 把它拆得比"读失败"精确得多:

    读失败 → catch 用一个编造的值顶替没读到的数据 → 那个值随后被写入或记录。

    • nextEventSeq 三条全中:_find 失败 → return 1 → event_seq: 1 被插入;
    • catch { return [] } 只用来渲染一个空列表:中前两条,完全正常。

    这个三部分判据是关键 —— 它把「读失败」这个太宽的类,收窄成一个真正有害的类。而 #4777 关心的启动期注册表判断,恰好也是这个形状:读失败 → 顶替成「没有 provider」→ 把这个结论记下来(缓存 / 断言 / 写库)。

    两条单是同一个规则的两个实例。

    建议的处理顺序:先立规则,后立门禁,门禁上线前先量准确率

    dev 推荐的是「在 AGENTS.md 写一条规则(形状照抄它下面那条已有的 Startup registry reads 规则),机械门禁另立窄单、先测命中率再决定」。理由:

    这里的机械门禁需要数据流分析 ——「catch 的返回值是否流到了一个被持久化的字段」—— 它的一般形式就是污点分析。一个约 60% 准确率、再配一个 shrink-only baseline 的门禁,比没有门禁更糟,因为 agent 会学会加例外。

    我接受这个判断。补充一点本单的角度:#4632 真正起作用的部分本来就是那条写下来的规则,机械检查是它的放大器而非本体 —— 今天 #4728 / #4825 / #4754 三处都是人按规则找出来的,不是 gate 报出来的。

    因此本单的范围建议收敛为

    1. AGENTS.md 加一条读侧规则,用上面那个三部分判据,与既有的 Degradation log levels / Startup registry reads 并列;
    2. DURABILITY_CRITICAL_CALLEES 保持现状(五个写/预置 callee 不动)—— 这与本单原本"扩词表"的方向相反,是根据新证据的修正;
    3. 机械门禁若要做,另立窄单,只针对 catch { return <literal> } 且该值随后被持久化的形状,并在信任它之前先量命中率与误报率。

    ⚠️ 车道:AGENTS.md 与 scripts/check-durability-degradation-log-level.mjs 不在本会话(session_015Br2xsJsczFsTR9bvbh2Ny)认领的 engine / services / identity / cli 范围内,偏 devx。本会话不派发本单,仅把这份论证归档在此供接手方使用。跨车道协调按登记表走 #4604。

    关联:#4825(PR #4872,读侧实例)、#4632(写侧规则与门禁)、#4867(canonical 路径上的同一缺陷)、#4776。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions