fix(metadata-protocol): scan the live metadata-type registry in the cold-boot org-scope audit (#6992) - #7154
Conversation
…old-boot org-scope audit (#6992) `reportUnhydratableOrgScopedRows` derived its scanned-type list from `DEFAULT_METADATA_TYPE_REGISTRY`, so a type registered at runtime by a plugin (`theme`, `connector`, `webhook`, `sharing_rule`, `analytics_cube`, ...) was absent from the scan — while `loadMetaFromDb`'s `organization_id: null` filter is type-blind and skips its org-scoped rows exactly like a `flow`'s. That family got neither the #6190 write refusal nor the PR #6600 warning. The scan now unions the declared non-org-overridable types with every live type the registry does not declare at all, read through `listLiveMetadataTypes()` — extracted from `getMetaTypes()` so the listing and the audit answer "which types exist here" from one accessor rather than two vocabularies. Measured on a real app-showcase boot, at the instant the audit fires: 7 live types have no registry entry, all carried by the SchemaRegistry, which manifests populate in kernel Phase 1 — before the audit runs in ObjectQLPlugin.start() Phase 2. The widening is live, not defeated by boot order. Shape is unchanged: one aggregated line, same tag, same per-type counts with a 5-name sample cap. The line no longer claims "types the registry declares NOT per-org overridable" (false for an undeclared type) and marks the plugin-registered family, whose remediation differs — its writes are not refused, so the rows recur until the author stops. The write refusal is deliberately untouched and stays statically-keyed; the divergence is stated in the audit's TSDoc and pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
|
ACCEPT — PM step-7 review ( CI on The failure mode I flagged as most likely was measured out, not argued away. I told the dispatch that if plugin types register after the audit fires, widening the scan changes nothing and the real fix is ordering — and that this had to be measured rather than assumed. It was, on a real app-showcase boot, instrumented, twice: at the instant the audit fires the registry holds 25 types, And a sub-assumption inside that was falsified and reported: the metadata service is not the carrier at boot — its The flood question is answered with the right unit. Unseeded real boot: 0 rows, 0 lines. Seeded 22-row corpus: still exactly one line — the widening adds segments, never lines. A boot-time warning that could fan out per type would have been its own defect; measuring that it cannot is what makes this safe to ship. The scope boundary is enforced structurally, not by good intentions. The only occurrence of Two consequences handled rather than skipped:
Reverse verification: zero missed predictions. Real boot on base predicted total=3 and measured 3 (the 11 plugin org rows absent — the defect itself); widened predicted total=14 on one line and measured exactly that. Unit level predicted 4 red / 5 green and measured 4/5. Four of the five greens are labelled guards, not evidence, each with its own reason, and all 22 of #6190's cases stayed green in the reverse run — which is the check that the refusal really was untouched. Recorded and correctly not acted on: Generated by Claude Code |
Fixes #6992
What this changes
reportUnhydratableOrgScopedRows— the cold-boot line that says which org-scopedsys_metadatarows hydration walked past (#6190, PR #6600) — built its scanned-type list by walkingDEFAULT_METADATA_TYPE_REGISTRY. A metadata type with no entry there is registered at runtime by a plugin (theme,connector,webhook,sharing_rule,analytics_cube, …), so it was absent from the scan — whileloadMetaFromDb's filter (organization_id: null) is type-BLIND and skips its org-scoped rows exactly like aflow's. That family was the one getting neither the refusal nor the warning.The scan now unions the declared non-org-overridable types with every live type the registry does not declare at all, read through
listLiveMetadataTypes()— extracted fromgetMetaTypes()rather than copied, so the listing and the audit answer "which types exist in this kernel" from one accessor instead of two vocabularies that can drift.Scope is deliberately one half, per the triage ruling on #6992: the diagnostic widens, the write refusal does not.
PM 假设的实测结果
假设 1 —— live registry 可达吗?在 audit 触发的那一刻已经填充了吗?
可达,且已填充。 这是本卡最可能失败的方式(boot order),所以实测而非推断:在
app-showcase真实启动中给 audit 打桩,读取它触发瞬间的两个 accessor。结论,三点都是测出来的:
manifest服务的ql.registerApp在内核 Phase 1(所有插件的init) 注册;audit 在ObjectQLPlugin.start()Phase 2 触发。内核是「先跑完全部 init,再跑全部 start」,所以顺序天然是对的 —— widening 不是 inert 的。MetadataManager.typeRegistry在构造函数里就被 seed 成DEFAULT_METADATA_TYPE_REGISTRY,artifact 载入(MetadataPlugin.start,因optionalDependencies排在 objectql 之后)还没发生。它依然被读取 —— 因为它是getMetaTypes()的另一半,两者不能各说各话。假设 2 —— audit 与 refusal 此后 key off 不同集合,要让它在代码里可见
已写入 audit 的 TSDoc:一个显式的
── THE DIVERGENCE FROM THE REFUSAL IS DELIBERATE ──段落,指明orgScopedWriteRefusal对这一族返回null而 audit 报告它,两个集合就是要不一样,后来的读者不要去「修」其中一个。理由用的是这个文件自己的原话(~:7737):warning is free and should be maximal; refusing removes a capability。并且由测试does NOT extend the write refusal to the family it now reports钉住 —— 将来谁真的要 widen refusal,那是新的裁决,应该记录在那个用例上,而不是删掉它。假设 3 —— 报告形状不需要变,只变覆盖面
形状确实没变:仍然是每次启动恰好一行
console.warn,同一个[metadata_org_scoped_unhydrated]标签,同样的type×count (names)明细与每类型 5 个名字的采样上限。widening 往同一行里加 segment,不产生新行。但措辞必须动两处,而且是因为原措辞对新一族而言是假的:
[plugin-registered]标记 + 一句只在出现该族时才输出的说明。这不是装饰,是补救动作不同:已声明类型的 org-scoped 写入从 org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 起会被拒绝,所以列出来的行是不会再增长的历史残留;未声明类型的写入不被拒绝,同样的名字会在每次重启后再次出现,直到作者停手。运维看到的下一步动作因此不同,一行日志必须说清自己属于哪一族。假设 4 —— widening 会不会造成 flood?
不会,而且是测出来的两个数字。
sys_metadata共 0 行,org-scoped 0 行 → 新增 0 行日志。metadata 走 artifact,不落 DB。结构上也封死了:org-scoped 行只可能由
SysMetadataRepository.put盖organization_id产生,即用户/Studio 在有 active organization 时的授权写入 —— 也就是这条警告本来就针对的那个人群。加上聚合成一行 + 每类型 5 名采样上限,「一千行只花一行」的原设计对新一族同样成立。上限就是:每个新覆盖类型,在同一行上多一个 segment。Reverse verification —— 方向在跑之前就写死了
预测记录在
predictions.md,先写后跑,两个层面各做一次。A. 真实启动(同一份播种语料,同一个 OS_HOME,只换 audit 实现)
语料:
flow×2 /object×1 org-scoped(已覆盖族);webhook×7、theme×1、sharing_rule×2、connector×1 org-scoped(新族);view×3 org-scoped(org-overridable,守卫);webhook×4 env-wide(守卫);webhook×1 org-scoped 但state='draft'(守卫)。BASE(我自己的 base,origin/main @
3e8e669c0)—— 预测 total=3,实测 total=3:→ 11 行 plugin-registered 的 org 行完全没被提到。这就是 #6992 报告的缺陷,在真实启动里复现。
WIDENED —— 预测 total=14 且仍为 1 行,实测 total=14 且仍为 1 行:
三个守卫两个方向都静默,与预测一致:3 行 org-scoped
view(ADR-0005 的设计)、4 行 env-widewebhook(org 谓词)、1 行 draft(state 谓词)—— 都没有出现在任何一个方向的输出里。采样上限也验证到了:webhook×7输出 5 个名字 ++2 more。B. 单元层(把
protocol.ts恢复到我自己的 base,保留测试文件)预测 4 红 / 5 绿,实测 4 红 / 5 绿,零漏判:
红的形状正好点名缺陷:
诚实标注后四个绿是守卫而非证据,原因各不相同,写在这里而不是让人自己猜:「no registry entry」直接读 registry,与本 PR 无关;两个静默用例断言的是缺席,删掉生产者也照样满足;
degrades to the declared scan的期望输出本身就是 base 行为(accessor 抛错时退回已声明扫描),它按构造就不可能在反向变红 —— 它防的是「widening 把 audit 变成启动的失败点」,不是防 widening 本身;does NOT extend the write refusal同理,refusal 本 PR 根本没碰,绿是它的目的。#6190自己的 22 个用例在反向那一跑里全绿,说明本 PR 的红不是靠破坏它们换来的。顺带测到、值得记下的一点
getMetaItems({ type, organizationId })的 org 合并不看allowOrgOverride—— 任何类型的 org 行按需读都读得回来。所以这条警告说的「absent from the process-wide registry」对新一族依然逐字为真(进程级消费者读 env-wide),它并不是在说「这行数据读不到了」。这也是为什么 warning 是可执行的:运维学到的是「你写的 org-scoped 行没挺过这次启动」。File surface —— 与派发声明的偏差,逐个说明
packages/metadata-protocol/src/protocol.tsaudit 区(~:12230-12246)getMetaTypes()(~:3283-3298)listLiveMetadataTypes(),getMetaTypes()改为调用它。行为等价(仍是同样的并集、同样不做单复数归一);目的是让 listing 与 audit 不可能漂移成两套「有哪些类型」的说法。~:7740-7748)orgScopedWriteRefusal的地方是我新写的 TSDoc 里的一行{@link}交叉引用。restoreArtifactRegistryView/assertSortFieldsExistpackages/metadata/packages/speccontent/docs/releases/一处值得单独说的改动:JS 侧的复查现在也复查类型谓词(
scannedSingulars)。这不是加戏 —— 该方法的 TSDoc 一直承诺「the JS filter re-checks both」,而在 widening 之前,orgOverridable这一步恰好就是类型复查(在已声明 registry 内,「非 org-overridable」与「在扫描列表里」是同一句话)。widening 之后两者不再等价:一个既未声明、也不再 live 的类型(行写入后插件被卸载)不在扫描列表里,只有这行复查能挡住「驱动无法下推$in时把超集变成一条指控」。不加的话,那句 TSDoc 就从此为假。Tests
packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts(9 例)。metadata-protocol全量:67 files / 861 tests passed。tsc --noEmit:63 errors,与check-type-check-coverage.mjs的账本条目完全一致 → 本 PR 新增 0。check:meta-type-normalized、check:engine-double-contract、check:error-code-casing、check:route-envelope、check:durability-log-level、check:startup-registry-verdict、check:nul-bytes、check:empty-changeset等)。Changeset:
.changeset/cold-boot-audit-live-registry-scan.md(@objectstack/metadata-protocolpatch)。Generated by Claude Code