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. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    on Aug 3, 2026
  2. 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

  3. added
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 3, 2026
  4. 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

  5. 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

  6. 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

  7. 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

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looptoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions