Skip to content

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

Description

@os-zhuang

Observation-class finding, recorded while implementing #6190 (PR #6973). Nothing a user hits today that I could measure — filed so the boundary is written down rather than rediscovered.

The gap

A metadata type with no entry in DEFAULT_METADATA_TYPE_REGISTRY is registered at runtime by a plugin (theme, connector, webhook, sharing_rule, …). Both existing write gates treat that family as permissive by construction, because getMetaTypes() synthesises allowRuntimeCreate: true for it and the write gate must agree with what the listing advertises:

  • ObjectStackProtocolImplementation.isRuntimeCreateAllowed — packages/metadata-protocol/src/protocol.ts, the "no static entry ⇒ creatable" branch.
  • SysMetadataRepository.assertAllowed — same carve-out, case 2 of its two-tier comment.

The two surfaces #6190 touched inherit that carve-out, deliberately and in opposite directions:

  1. The new org-scope refusal (PR fix(metadata-protocol): refuse an org-scoped write of a type that has no per-org channel (#6190) #6973) exempts them. Its predicate requires a static registry entry, so an org-scoped write of a plugin-registered type is still accepted.
  2. The cold-boot audit (PR fix(metadata-protocol): cold boot names the org-scoped rows it cannot hydrate (#6190) #6600) does not see them either. reportUnhydratableOrgScopedRows builds its scanned-type list by walking DEFAULT_METADATA_TYPE_REGISTRY, so a type absent from that registry is absent from the scan.

But loadMetaFromDb's filter is type-blind: where = { state: 'active', organization_id: null }. So an org-scoped row of a plugin-registered type is skipped at boot exactly like the flow and object rows #6190 is about — and it is the one family that gets neither the refusal nor the warning.

Measured incidentally while sweeping PR #6973's fixtures: webhook and sharing_rule org-scoped writes in packages/objectql/src/protocol-meta.test.ts stayed green through that change, which is what the carve-out predicts and what confirms it is live.

Why it was not fixed in PR #6973

The 2026-08-08 ruling on #6190 was reasoned over DEFAULT_METADATA_TYPE_REGISTRY (19 of 27 entries). Extending the refusal to types outside that registry would apply a ruling to a surface nobody measured, and the plugin family is the one where "declared" is a synthesis rather than a declaration. That looked like exactly the widening the card told the dev not to do alone, so it is reported instead.

What would settle it

Someone needs to answer one question with evidence, not intuition: do any plugin-registered types actually get written org-scoped in a real deployment? Candidate readings:

  • If no — the cheapest correct move is to widen the diagnostic only (make reportUnhydratableOrgScopedRows scan runtime-registered types too, since it already derives its list at call time and could read the live registry rather than the static one). Warning is free; the refusal stays where the ruling put it.
  • If yes, and the rows matter — the type is claiming a per-org channel the loader does not implement, and that is a supportsOverlay / registry-declaration question for the owning plugin, not a gate question.
  • If yes, and the rows are accidental — the refusal should extend to the synthesised declaration, which means getMetaTypes()'s synthesis becomes the contract the write gate reads, in both places.

Not labelled pm:queue: severity is unmeasured in both directions and I would rather it be graded at triage than by me.

Refs: #6190 (the ruling and the two specimens), PR #6973 (the refusal, and the carve-out's TSDoc), PR #6600 (the cold-boot audit), #5086 (the code-only refusal that established the static-vs-runtime split), ADR-0005.

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Finding-triage ruling: Promote — finding to pm:queue, scoped to the diagnostic widening.

    Plugin-registered metadata types are exempt from the org-scope write refusal (protocol.ts:7740-7748, statically-declared types only) AND invisible to the cold-boot audit (:12230-12246 scans DEFAULT_METADATA_TYPE_REGISTRY only), so their org rows are skipped with neither refusal nor report. Widening the audit scan to the live registry is safe under all three of the card's readings and endorsed by the code's own doc note at :7737 (the warning is free and should be maximal). Refusal-widening stays out of scope.

    Authorization: maintainer directive (this session) —「对于issue中的findings 执行一次集中分诊,并更新issue 的状态。」Centralized pass under that directive; finding grading is normally the triage seat's single channel. Round: objectstack#4949 finding-triage, 2026-08-10.
    Session: 01JaVVMrSxt7Tgi1uwEuDtH7


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 9 (domain:metadata seat, sticker #6367)

    Session: session_01W6bLax4KMrSfnE1ydFU8Dw
    Branch: claude/issue-6992-audit-live-registry-scan
    Worktree: objectstack-issue-6992
    Base: origin/main @ current

    This card was filed by this seat's own #6190 dispatch as an out-of-scope finding; promoted 2026-08-10T00:12Z under the maintainer's centralized finding-triage directive, scoped deliberately to the diagnostic widening only.

    Carried into the dispatch as binding:

    No unmet promotion gate: triage states the widening is safe under all three of the card's readings and points at the code's own endorsement, rather than deferring a measurement to the implementer.

    Region: metadata-protocol's audit region — disjoint from #7000 (packages/metadata), dispatched alongside it.


    Generated by Claude Code

  3. self-assigned this
    on Aug 10, 2026
  4. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    os-dev report

    Issue #6992 · Status done · PR #7154 (draft) · Branch claude/issue-6992-audit-live-registry-scan · Base origin/main @ 3e8e669c0 · Premise still valid: yes, verified against the base before implementing.

    1. Boot-order measurement — the likeliest way this went wrong, and it did not

    Instrumented reportUnhydratableOrgScopedRows and booted the real app-showcase app twice (ephemeral --fresh and a persistent OS_HOME; identical both times). At the instant the audit fires:

    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"]
    metadataService present = true;  getRegisteredTypes() = <the 27 declared types only>
    live \ DEFAULT_METADATA_TYPE_REGISTRY = ["analytics_cube","connector","data","package",
      "sharing_rule","theme","webhook"]     ← 7 types, incl. all 4 the issue names
    

    The accessor is the pair getMetaTypes() already reads — engine.registry.getRegisteredTypes() plus the metadata service's getRegisteredTypes() — which I extracted as listLiveMetadataTypes() so the listing and the audit cannot drift into two vocabularies.

    Is it populated when the audit fires? Yes, and the reason is structural: the kernel runs all plugins' init (Phase 1) before any start (Phase 2); manifests register into the SchemaRegistry during Phase 1 via ql.registerApp; the audit runs inside loadMetaFromDb, i.e. ObjectQLPlugin.start() Phase 2. The widening is live, not inert.

    One sub-assumption of the card was falsified and is worth recording: the metadata service is not the source that carries this family at boot. MetadataManager.typeRegistry is seeded with DEFAULT_METADATA_TYPE_REGISTRY in the manager's constructor, and its artifact load (MetadataPlugin.start, ordered after objectql via optionalDependencies) has not happened yet — so early in boot it contributes exactly the declared set. The SchemaRegistry is what carries theme/connector/webhook/sharing_rule. I read both anyway, because both are what getMetaTypes() lists.

    2. Flood count — two measured numbers, plus the structural bound

    • Unseeded real showcase boot: sys_metadata holds 0 rows (metadata comes from the artifact, not the DB) → 0 extra log lines.
    • With a seeded realistic corpus (22 rows): still exactly 1 line — the widening adds 4 segments to the line that already existed, never new lines.

    Structural bound: an org-scoped row can only be produced by SysMetadataRepository.put stamping organization_id, i.e. an authenticated Studio/API write under an active organization — precisely the population this warning targets. Combined with the one-aggregated-line shape and the 5-name-per-type sample cap, the worst case is one extra segment per newly covered type. No flood.

    3. What I changed

    File Change
    packages/metadata-protocol/src/protocol.ts — audit region Scan = declared non-org-overridable types ∪ every live type the registry does not declare at all. JS-side re-check now also re-checks the type predicate (see below). Message reworded + [plugin-registered] marker. TSDoc gains an explicit divergence section.
    same file — getMetaTypes() Extracted listLiveMetadataTypes() (behaviour-equivalent) so listing and audit share one accessor. Declared-allowed deviation ("plus whatever accessor the live registry needs").
    protocol.org-scoped-cold-boot-audit-live-registry.test.ts New, 9 cases.
    .changeset/cold-boot-audit-live-registry-scan.md @objectstack/metadata-protocol patch.

    ⛔ Refusal region (~:7740-7748) untouched — the only occurrence of orgScopedWriteRefusal in the diff is a {@link} cross-reference inside my new TSDoc. restoreArtifactRegistryView, assertSortFieldsExist, packages/metadata, packages/spec, content/docs/releases/ all untouched.

    On PM assumption 3 (report shape): shape is unchanged — one line, same tag, same type×count (names) detail, same sample cap. Two wording changes were unavoidable and are the answer to "does this family need different wording to be actionable": (a) the old clause "types the registry declares NOT per-org overridable" is factually false for a type with no declaration; (b) the [plugin-registered] marker exists because the remediation differs — a declared type's org write is refused from now on, so its rows are residue that cannot grow, whereas an undeclared type's write is not refused and the same names return after every restart until the author stops.

    One extra change I want visible: the JS-side filter now re-checks the type predicate too. Before the widening, orgOverridable happened to be that re-check (inside the declared registry, "not org-overridable" and "in the scanned list" are the same statement). After it they diverge — a type neither declared nor live (plugin uninstalled after the row was written) is not in the list — so without this line the TSDoc's standing promise "the JS filter re-checks both" would have become false.

    4. Reverse verification — direction predicted before running, every category preserved

    A. Real boot, same seeded corpus, same OS_HOME, only the audit implementation swapped. Reverted to my own base (3e8e669c0), never a moving origin/main.

    • BASE — predicted total=3, measured total=3: flow×2; object×1. The 11 plugin-registered org rows are absent — Plugin-registered metadata types can carry org-scoped rows that cold boot skips — neither refused nor reported #6992's defect, reproduced in a real boot.
    • WIDENED — predicted total=14 and still one line; measured total=14, one line: adds webhook×7 [plugin-registered] (5 names + +2 more), theme×1, sharing_rule×2, connector×1; flow×2/object×1 correctly unmarked.
    • Guards silent in BOTH directions, as predicted: 3 org-scoped view rows (ADR-0005 design), 4 env-wide webhook rows (org predicate), 1 draft org-scoped webhook (state predicate).

    B. Unit level — predicted 4 RED / 5 GREEN, measured 4 RED / 5 GREEN. No missed predictions. Reds fail in the shape that names the defect (no [metadata_org_scoped_unhydrated] line in: [], and a line containing only flow×1 where the corpus has flow and theme).

    Honestly labelled: 4 of the 5 greens are guards, not evidence, for four different reasons — the registry-entry premise reads the registry directly; two cases assert an absence that a deleted producer trivially satisfies; degrades to the declared scan when the live accessor throws has base behaviour as its expected output, so it cannot go red in reverse by construction (it guards against the widening becoming a boot failure, not against the widening); and does NOT extend the write refusal is green because the refusal is untouched — that is its purpose.

    All 22 of #6190's own cases stayed green in the reverse run, so my reds are not bought by breaking theirs.

    Nothing was left unmeasured — no prediction short-circuited.

    5. Tests + CI

    • New file: 9/9 pass. metadata-protocol full suite: 67 files / 861 tests passed.
    • tsc --noEmit on metadata-protocol: 63 errors — exactly the check-type-check-coverage.mjs ledger figure, so this PR adds zero.
    • 15 lint-job family gates run locally, all green (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, …).
    • CI: all 25 check runs completed, 23 success + 2 skipped (Build Docs, Console Pin Gate), zero failures. Gate jobs by conclusion: ESLint = success, TypeScript Type Check = success, Test Core = success, Build Core = success, Check Changeset = success, Dogfood Regression Gate = success.
    • skip-changeset not applicable — this PR ships a changeset and Check Changeset passed on its own.

    6. Worth recording, not acted on

    getMetaItems({ type, organizationId }) merges org rows without consulting allowOrgOverride — an org row of any type is readable on demand. So the warning's claim "absent from the process-wide registry" stays literally true for the new family (process-wide consumers read env-wide), and it is not saying the data became unreadable. This is why the line is actionable rather than alarmist. No defect, no issue filed.

    Out-of-scope findings: none. Nothing outside the card's surface warranted a filing.

    Not marked ready, auto-merge not enabled — left for the PM.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions