Skip to content

fix(metadata-protocol): scan the live metadata-type registry in the cold-boot org-scope audit (#6992) - #7154

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6992-audit-live-registry-scan
Aug 10, 2026
Merged

fix(metadata-protocol): scan the live metadata-type registry in the cold-boot org-scope audit (#6992)#7154
os-zhuang merged 1 commit into
mainfrom
claude/issue-6992-audit-live-registry-scan

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6992

What this changes

reportUnhydratableOrgScopedRows — the cold-boot line that says which org-scoped sys_metadata rows hydration walked past (#6190, PR #6600) — built its scanned-type list by walking DEFAULT_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 — while loadMetaFromDb's filter (organization_id: null) is type-BLIND and skips its org-scoped rows exactly like a flow'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 from getMetaTypes() 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。

[PROBE] engine.registry.getRegisteredTypes() = ["action","analytics_cube","api","app","book",
  "capability","connector","dashboard","data","dataset","doc","email_template","flow","hook",
  "job","mapping","object","package","page","permission","report","sharing_rule","theme",
  "view","webhook"]
[PROBE] metadataService present=true, getRegisteredTypes() = <27 个已声明类型,无额外项>
[PROBE] live \ DEFAULT_METADATA_TYPE_REGISTRY = ["analytics_cube","connector","data",
  "package","sharing_rule","theme","webhook"]

结论,三点都是测出来的:

  1. 真正承载 plugin 类型的是 SchemaRegistry,不是 metadata service。manifest 通过 manifest 服务的 ql.registerApp 在内核 Phase 1(所有插件的 init) 注册;audit 在 ObjectQLPlugin.start() Phase 2 触发。内核是「先跑完全部 init,再跑全部 start」,所以顺序天然是对的 —— widening 不是 inert 的
  2. metadata service 在启动期只贡献已声明类型:MetadataManager.typeRegistry 在构造函数里就被 seed 成 DEFAULT_METADATA_TYPE_REGISTRY,artifact 载入(MetadataPlugin.start,因 optionalDependencies 排在 objectql 之后)还没发生。它依然被读取 —— 因为它是 getMetaTypes() 的另一半,两者不能各说各话。
  3. 真实 showcase 上有 7 个 无 registry entry 的活跃类型,其中 4 个正是 issue 点名的那些。

假设 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,不产生新行。

但措辞必须动两处,而且是因为原措辞对新一族而言是假的:

  1. 原句「on types the registry declares NOT per-org overridable」对一个根本没有 registry entry 的类型是错的 —— 它没有任何声明。改为「types with NO per-org channel (the registry declares allowOrgOverride=false, or does not declare the type at all)」。
  2. 新增 [plugin-registered] 标记 + 一句只在出现该族时才输出的说明。这不是装饰,是补救动作不同:已声明类型的 org-scoped 写入从 org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 起会被拒绝,所以列出来的行是不会再增长的历史残留;未声明类型的写入不被拒绝,同样的名字会在每次重启后再次出现,直到作者停手。运维看到的下一步动作因此不同,一行日志必须说清自己属于哪一族。

假设 4 —— widening 会不会造成 flood?

不会,而且是测出来的两个数字。

  • 真实 showcase 启动(未播种):sys_metadata 共 0 行,org-scoped 0 行 → 新增 0 行日志。metadata 走 artifact,不落 DB。
  • 播种一份现实语料后:仍然只有 1 行日志,只是这一行多了 4 个 segment(见下方实测输出)。

结构上也封死了:org-scoped 行只可能SysMetadataRepository.putorganization_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:

[Protocol] [metadata_org_scoped_unhydrated] 3 active sys_metadata row(s) are org-scoped on types
the registry declares NOT per-org overridable, ...: flow×2 (probe_flow_1@org_probe6992,
probe_flow_2@org_probe6992); object×1 (probe_object_1@org_probe6992). ...

→ 11 行 plugin-registered 的 org 行完全没被提到。这就是 #6992 报告的缺陷,在真实启动里复现。

WIDENED —— 预测 total=14 且仍为 1 行,实测 total=14 且仍为 1 行:

[Protocol] [metadata_org_scoped_unhydrated] 14 active sys_metadata row(s) are org-scoped on types
with NO per-org channel (the registry declares allowOrgOverride=false, or does not declare the type
at all), ...: flow×2 (probe_flow_1@org_probe6992, probe_flow_2@org_probe6992); object×1
(probe_object_1@org_probe6992); webhook×7 [plugin-registered] (probe_webhook_1@org_probe6992,
probe_webhook_2@org_probe6992, probe_webhook_3@org_probe6992, probe_webhook_4@org_probe6992,
probe_webhook_5@org_probe6992, +2 more); theme×1 [plugin-registered] (probe_theme_1@org_probe6992);
sharing_rule×2 [plugin-registered] (probe_sharing_rule_1@org_probe6992,
probe_sharing_rule_2@org_probe6992); connector×1 [plugin-registered] (probe_connector_1@org_probe6992).
... Types marked [plugin-registered] have no metadata-type registry entry, so the #6190 org-scope
write refusal does NOT cover them: ... See #6190 / #6992 / ADR-0005.

三个守卫两个方向都静默,与预测一致:3 行 org-scoped view(ADR-0005 的设计)、4 行 env-wide webhook(org 谓词)、1 行 draft(state 谓词)—— 都没有出现在任何一个方向的输出里。采样上限也验证到了:webhook×7 输出 5 个名字 + +2 more

B. 单元层(把 protocol.ts 恢复到我自己的 base,保留测试文件)

预测 4 红 / 5 绿,实测 4 红 / 5 绿,零漏判:

用例 预测 实测
reports an org-scoped row of a PLUGIN-REGISTERED type RED RED
marks the plugin-registered family RED RED
reads the metadata SERVICE as well as the engine registry RED RED
stays ONE aggregated line across both families RED RED
the specimen types genuinely have NO registry entry GREEN(守卫) GREEN
says NOTHING about an ENV-WIDE row of a plugin type GREEN(守卫) GREEN
still says NOTHING about an org-scoped VIEW GREEN(守卫) GREEN
degrades to the declared scan when the live accessor throws GREEN(守卫) GREEN
does NOT extend the write refusal GREEN(守卫) GREEN

红的形状正好点名缺陷:

AssertionError: no [metadata_org_scoped_unhydrated] line in: []: expected undefined to be defined
AssertionError: expected '...' to contain 'theme×1 [plugin-registered]'
  Received: "... skipped them ...: flow×1 (org_sweep@org_a). ..."   ← 只报了已声明的那半边

诚实标注后四个绿是守卫而非证据,原因各不相同,写在这里而不是让人自己猜:「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.ts audit 区(~:12230-12246) 本卡主体。
同文件 getMetaTypes()(~:3283-3298) ✅ 声明允许(「plus whatever accessor the live registry needs」) 把两个 accessor 的读取抽出listLiveMetadataTypes(),getMetaTypes() 改为调用它。行为等价(仍是同样的并集、同样不做单复数归一);目的是让 listing 与 audit 不可能漂移成两套「有哪些类型」的说法。
同文件 refusal 区(~:7740-7748) 未触碰 diff 里唯一出现 orgScopedWriteRefusal 的地方是我新写的 TSDoc 里的一行 {@link} 交叉引用。
restoreArtifactRegistryView / assertSortFieldsExist 未触碰
packages/metadata / packages/spec 未触碰 只读不改。
content/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。
  • 本地跑过 lint job 中与本改动相关的 15 个 family gate,全绿(check:meta-type-normalizedcheck:engine-double-contractcheck:error-code-casingcheck:route-envelopecheck:durability-log-levelcheck:startup-registry-verdictcheck:nul-bytescheck:empty-changeset 等)。

Changeset: .changeset/cold-boot-audit-live-registry-scan.md(@objectstack/metadata-protocol patch)。


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 1:17am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM step-7 review (domain:metadata seat, session session_01W6bLax4KMrSfnE1ydFU8Dw). Marking ready + auto-merge (SQUASH).

CI on a3abab5a: 25 check runs, 0 failures — 23 completed: success including ESLint and TypeScript Type Check, plus Build Core, Test Core 1-3/3, Dogfood gates, Temporal Conformance, Check Changeset; 2 skipped by path filter.

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, live \ static is 7 (connector, sharing_rule, theme, webhook, …), because the kernel runs all init (Phase 1, where manifests register) before any start (Phase 2, where the audit fires). The widening is live.

And a sub-assumption inside that was falsified and reported: the metadata service is not the carrier at boot — its typeRegistry is seeded with the static registry in the manager's constructor. The SchemaRegistry is. The dev read both anyway, on the correct reasoning that getMetaTypes() lists both and the two must not diverge — then extracted one shared accessor so the listing and the audit stop having two vocabularies.

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 orgScopedWriteRefusal in the diff is a {@link} cross-reference inside a new TSDoc section that states the divergence explicitly — and a test pins it, so a future reader cannot "harmonise" the audit set with the refusal set by accident. That is exactly what my assumption 2 asked for and better than what I asked: I wanted the divergence visible; this makes un-diverging it fail CI.

Two consequences handled rather than skipped:

  • Two wording changes were unavoidable. The old clause "types the registry declares NOT per-org overridable" is factually false for a type with no declaration at all — the widening made an existing sentence wrong, and it was fixed rather than left. Plugin-registered types are now marked [plugin-registered] because their remediation genuinely differs: their writes are not refused, so the rows recur after every restart.
  • One hardening that would otherwise have gone silently false: the JS-side filter now re-checks the type predicate too. Before the widening, orgOverridable happened to be that re-check; without adding it, the TSDoc's standing promise that "the JS filter re-checks both" would have quietly become a lie. Catching a doc-comment that a change turns false is the kind of thing that normally rots for a year.

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: getMetaItems({ type, organizationId }) merges org rows without consulting allowOrgOverride, so such a row is readable on demand. That was checked because it could have made the warning's "absent from the process-wide registry" an overclaim — it does not, so the wording stands and nothing was filed. Verifying that your own message is still literally true after a change, and reporting the check even when it comes back clean, is the habit worth naming.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 10, 2026 01:32
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit bf32d4a Aug 10, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6992-audit-live-registry-scan branch August 10, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin-registered metadata types can carry org-scoped rows that cold boot skips — neither refused nor reported

2 participants