Repository navigation
Plugin-registered metadata types can carry org-scoped rows that cold boot skips — neither refused nor reported #6992
Description
Activity
Finding-triage ruling: Promote —
findingtopm: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
Claim: PM loop round 9 (
domain:metadataseat, sticker #6367)Session:
session_01W6bLax4KMrSfnE1ydFU8Dw
Branch:claude/issue-6992-audit-live-registry-scan
Worktree:objectstack-issue-6992
Base:origin/main@ currentThis 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:
- In scope: widen the cold-boot audit's scan (
protocol.ts~:12230-12246) fromDEFAULT_METADATA_TYPE_REGISTRYto the live registry, so a plugin-registered type's org-scoped rows are reported. - ⛔ Out of scope: refusal-widening. The org-scope write refusal (
~:7740-7748, statically-declared types only) stays exactly as org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 landed it. Widening a refusal removes a capability and would extend a ruling measured over the static registry to a surface nobody measured; widening a warning does not. - The asymmetry is not an oversight but the code's own stated posture — the doc note at
~:7737says the warning is free and should be maximal, which is also how org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190's dev justified keepingreportUnhydratableOrgScopedRows(PR fix(metadata-protocol): cold boot names the org-scoped rows it cannot hydrate (#6190) #6600) hatch-blind while the refusal honours the hatch. This card makes the audit live up to that note.
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
- In scope: widen the cold-boot audit's scan (
os-dev report
Issue #6992 · Status
done· PR #7154 (draft) · Branchclaude/issue-6992-audit-live-registry-scan· Baseorigin/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
reportUnhydratableOrgScopedRowsand booted the realapp-showcaseapp twice (ephemeral--freshand a persistentOS_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 namesThe accessor is the pair
getMetaTypes()already reads —engine.registry.getRegisteredTypes()plus themetadataservice'sgetRegisteredTypes()— which I extracted aslistLiveMetadataTypes()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 anystart(Phase 2); manifests register into the SchemaRegistry during Phase 1 viaql.registerApp; the audit runs insideloadMetaFromDb, 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.typeRegistryis seeded withDEFAULT_METADATA_TYPE_REGISTRYin the manager's constructor, and its artifact load (MetadataPlugin.start, ordered after objectql viaoptionalDependencies) has not happened yet — so early in boot it contributes exactly the declared set. The SchemaRegistry is what carriestheme/connector/webhook/sharing_rule. I read both anyway, because both are whatgetMetaTypes()lists.2. Flood count — two measured numbers, plus the structural bound
- Unseeded real showcase boot:
sys_metadataholds 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.putstampingorganization_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 regionScan = 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.tsNew, 9 cases. .changeset/cold-boot-audit-live-registry-scan.md@objectstack/metadata-protocolpatch.⛔ Refusal region (
~:7740-7748) untouched — the only occurrence oforgScopedWriteRefusalin 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,
orgOverridablehappened 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 movingorigin/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×1correctly unmarked. - Guards silent in BOTH directions, as predicted: 3 org-scoped
viewrows (ADR-0005 design), 4 env-widewebhookrows (org predicate), 1 draft org-scopedwebhook(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 onlyflow×1where the corpus hasflowandtheme).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 throwshas 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); anddoes NOT extend the write refusalis 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-protocolfull suite: 67 files / 861 tests passed. tsc --noEmitonmetadata-protocol: 63 errors — exactly thecheck-type-check-coverage.mjsledger 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+ 2skipped(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-changesetnot applicable — this PR ships a changeset andCheck Changesetpassed on its own.
6. Worth recording, not acted on
getMetaItems({ type, organizationId })merges org rows without consultingallowOrgOverride— 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
- Unseeded real showcase boot:
- added a commit that references this issue
on Aug 17, 2026
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_REGISTRYis registered at runtime by a plugin (theme,connector,webhook,sharing_rule, …). Both existing write gates treat that family as permissive by construction, becausegetMetaTypes()synthesisesallowRuntimeCreate: truefor 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:
reportUnhydratableOrgScopedRowsbuilds its scanned-type list by walkingDEFAULT_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 theflowandobjectrows #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:
webhookandsharing_ruleorg-scoped writes inpackages/objectql/src/protocol-meta.test.tsstayed 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:
reportUnhydratableOrgScopedRowsscan 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.supportsOverlay/ registry-declaration question for the owning plugin, not a gate question.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.