From a3abab5a2202e2a85426cd6a8b0b9a1dc0719488 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 01:16:03 +0000 Subject: [PATCH] fix(metadata-protocol): scan the live metadata-type registry in the cold-boot org-scope audit (#6992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- .../cold-boot-audit-live-registry-scan.md | 45 +++ ...oped-cold-boot-audit-live-registry.test.ts | 355 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 138 ++++++- 3 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 .changeset/cold-boot-audit-live-registry-scan.md create mode 100644 packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts diff --git a/.changeset/cold-boot-audit-live-registry-scan.md b/.changeset/cold-boot-audit-live-registry-scan.md new file mode 100644 index 0000000000..622bcd50b5 --- /dev/null +++ b/.changeset/cold-boot-audit-live-registry-scan.md @@ -0,0 +1,45 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): the cold-boot org-scoped audit scans the LIVE metadata-type registry (#6992) + +`reportUnhydratableOrgScopedRows` — the 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 write 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 the same accessor +`getMetaTypes()` lists from (`engine.registry.getRegisteredTypes()` plus the +`metadata` service's) — extracted as `listLiveMetadataTypes()` so the listing +and the audit cannot drift into two vocabularies of "which types exist here". + +Measured on a real `app-showcase` boot, at the instant the audit fires: 7 live +types have no registry entry (`analytics_cube`, `connector`, `data`, `package`, +`sharing_rule`, `theme`, `webhook`), all from the SchemaRegistry, which +manifests populate during kernel Phase 1 — before the audit runs in +`ObjectQLPlugin.start()` Phase 2. The widening is live, not defeated by boot +order. + +**What an operator sees.** Still exactly one aggregated line per boot, same +`[metadata_org_scoped_unhydrated]` tag and same `type×count (names)` detail with +a 5-name sample cap — the widening adds segments to that line, never new lines. +Two wording changes carry the new family: the line no longer claims "types the +registry declares NOT per-org overridable" (false for a type with no +declaration) and instead says "types with NO per-org channel"; and each +plugin-registered type is marked `[plugin-registered]`, because the remediation +differs — a declared type's org-scoped 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. + +**The write refusal is deliberately unchanged.** `orgScopedWriteRefusal` keeps +its "statically-declared types only" predicate: a warning is free and should be +maximal, a refusal removes a capability, and widening it would extend a ruling +reasoned over the declared registry onto a surface nobody measured. The +divergence is now stated in the audit's TSDoc and pinned by a test, so it reads +as a decision rather than as drift. diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts new file mode 100644 index 0000000000..2187045ebf --- /dev/null +++ b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6992 — the cold-boot audit scans the LIVE registry, not the static one. + * + * --------------------------------------------------------------------------- + * The gap this file pins + * --------------------------------------------------------------------------- + * A metadata type with no entry in `DEFAULT_METADATA_TYPE_REGISTRY` is + * registered at runtime by a plugin (`theme`, `connector`, `webhook`, + * `sharing_rule`, `analytics_cube`, …). Before this change that family fell + * through BOTH halves of the org-scoped-row protection: + * + * - `orgScopedWriteRefusal` (#6190) requires a static registry entry, so an + * org-scoped write of such a type is accepted; + * - `reportUnhydratableOrgScopedRows` (PR #6600) built its scanned-type list + * by walking `DEFAULT_METADATA_TYPE_REGISTRY`, so such a type was absent + * from the scan. + * + * …while `loadMetaFromDb`'s filter is type-BLIND (`organization_id: null`) and + * skips its rows exactly like a `flow`'s. Neither the gate nor the warning. + * + * Triage on #6992 scoped the fix to the DIAGNOSTIC half only. The refusal is + * untouched here and stays static-registry-keyed on purpose — see + * `protocol.org-scoped-write-refused.test.ts` and the divergence note in + * `reportUnhydratableOrgScopedRows`' TSDoc. The asymmetry is the file's own + * stated posture: a warning is free and should be maximal, a refusal removes a + * capability. The last case in this file PINS the divergence, so a future + * reader who "harmonises" the two sets gets a red test and the reason. + * + * --------------------------------------------------------------------------- + * Boot order — measured, because it is how this widening could have been inert + * --------------------------------------------------------------------------- + * The audit fires inside `loadMetaFromDb`, i.e. in `ObjectQLPlugin.start()` + * Phase 2 — after EVERY plugin's `init` (the kernel runs all inits, then all + * starts). Plugin metadata reaches the SchemaRegistry during Phase 1, via the + * `manifest` service's `ql.registerApp`. Probed on a real `app-showcase` boot, + * at the instant the audit runs: + * + * 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.getRegisteredTypes() = + * live \ DEFAULT_METADATA_TYPE_REGISTRY = ["analytics_cube","connector", + * "data","package","sharing_rule","theme","webhook"] + * + * So the SchemaRegistry is the source that actually carries the family, it is + * already populated when the audit runs, and the widening is live rather than + * inert. The metadata service contributes nothing beyond the declared set at + * boot (its `typeRegistry` is seeded with `DEFAULT_METADATA_TYPE_REGISTRY` in + * the manager's constructor) — it is read anyway, because it is the other half + * of what `getMetaTypes()` lists and the two must not diverge. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Ordinary red for the coverage cases, deliberate green for the guards. + * Reverting `reportUnhydratableOrgScopedRows`' scan to the static registry + * alone (the `origin/main` body) must turn the four "reports" cases red — + * they assert a line that names a plugin-registered type, which the static + * scan cannot produce — and must leave the guards green, because those assert + * an ABSENCE (`view` silence, env-wide silence) or read the registry directly + * (the premise pin, the divergence pin). Measured below in the PR body. + */ +import { describe, expect, it, vi } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / +// #5480 update). From `@objectstack/metadata-core`, never `@objectstack/objectql` +// — objectql depends on THIS package, so that import would close a cycle. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + metadata: string; +} + +/** Honours the two predicates the audit query relies on, like the #6190 file. */ +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + const actual = (r as any)[k]; + if (v !== null && typeof v === 'object') { + const ops = v as Record; + if ('$null' in ops) { + const isNull = actual === null || actual === undefined; + if (isNull !== ops.$null) return false; + } + if ('$in' in ops) { + if (!(ops.$in as unknown[]).includes(actual)) return false; + } + continue; + } + if (actual !== v) return false; + } + return true; +} + +/** + * The engine stub differs from the #6190 file's in exactly one way that + * matters: its registry ANSWERS `getRegisteredTypes()`. That method is the + * accessor this change reads, and the #6190 stub omits it — which is why every + * case in that file keeps passing unchanged (the audit degrades to the declared + * scan when the accessor is absent, and those cases only use declared types). + */ +function makeEngine( + rows: Row[], + opts: { liveTypes?: string[]; serviceTypes?: string[]; registryThrows?: boolean } = {}, +) { + const registered: Array<{ type: string; name: string }> = []; + const engine: any = { + async find(_table: string, q: { where: Record }) { + return rows.filter((r) => matchesWhere(r, q.where)); + }, + async findOne() { return null; }, + async insert() { return { id: 'x' }; }, + async update(_t: string, data: Record, o?: Record) { + assertEngineUpdateDispatch(data, o); + return { id: null }; + }, + async delete(_t: string, o?: Record) { + assertEngineDeleteDispatch(o); + return { deleted: 0 }; + }, + registry: { + getRegisteredTypes: () => { + if (opts.registryThrows) throw new Error('registry exploded'); + return opts.liveTypes ?? []; + }, + registerItem: (type: string, item: any) => { registered.push({ type, name: item?.name }); }, + registerObject: (item: any) => { registered.push({ type: 'object', name: item?.name }); }, + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }, + }; + const services = new Map(); + if (opts.serviceTypes) { + services.set('metadata', { getRegisteredTypes: async () => opts.serviceTypes }); + } + return { engine, registered, getServicesRegistry: () => services }; +} + +/** A body every type parses as "a named item" — these rows are never hydrated. */ +const body = (name: string) => JSON.stringify({ name, label: name }); + +const row = (over: Partial & Pick): Row => ({ + id: `r_${over.type}_${over.name}_${over.organization_id ?? 'env'}`, + organization_id: null, + state: 'active', + metadata: body(over.name), + ...over, +}); + +async function bootAndCapture( + engine: any, + getServicesRegistry?: () => Map, +): Promise<{ result: any; warns: string[] }> { + const warns: string[] = []; + const spy = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { + warns.push(a.map(String).join(' ')); + }); + try { + const protocol = new ObjectStackProtocolImplementation(engine, getServicesRegistry) as any; + const result = await protocol.loadMetaFromDb(); + return { result, warns }; + } finally { + spy.mockRestore(); + } +} + +const AUDIT = '[metadata_org_scoped_unhydrated]'; + +describe('#6992 — the cold-boot audit scans the live registry, not just the declared one', () => { + // ── the premise, read from the registry rather than restated ────────── + + it('the specimen types genuinely have NO entry in DEFAULT_METADATA_TYPE_REGISTRY', () => { + // If a later PR declares one of these, this case goes red and the + // corresponding case below should be re-pointed at a type that is + // still plugin-registered — not "repaired" by deleting the assertion. + for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { + expect( + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type), + `${type} gained a registry entry — re-read #6992 before touching this file`, + ).toBeUndefined(); + } + }); + + // ── the acceptance criterion: the new family is reported ────────────── + + it('reports an org-scoped row of a PLUGIN-REGISTERED type the engine registry knows', async () => { + const { engine, getServicesRegistry } = makeEngine( + [ + row({ type: 'webhook', name: 'org_hook', organization_id: 'org_a' }), + row({ type: 'webhook', name: 'platform_hook' }), + ], + { liveTypes: ['object', 'view', 'webhook'] }, + ); + + const { result, warns } = await bootAndCapture(engine, getServicesRegistry); + + // Hydration is UNCHANGED — #6992 widens the diagnostic, nothing else. + // The env-wide row loads; the org-scoped one still does not. + expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false }); + + const line = warns.find((w) => w.includes(AUDIT)); + expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); + expect(line).toContain('webhook×1'); + expect(line).toContain('org_hook@org_a'); + // The row that DID load is not accused. + expect(line).not.toContain('platform_hook'); + }); + + it('marks the plugin-registered family, because its remediation differs', async () => { + // A DECLARED type's org-scoped write is refused from now on (#6190), so + // its rows are historical residue that cannot grow. An UNDECLARED type's + // write is NOT refused, so the same names return after every restart + // until the author stops writing them org-scoped. The line has to say + // which of the two an operator is looking at. + const { engine, getServicesRegistry } = makeEngine( + [ + row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), + row({ type: 'theme', name: 'org_theme', organization_id: 'org_a' }), + ], + { liveTypes: ['flow', 'theme'] }, + ); + + const { warns } = await bootAndCapture(engine, getServicesRegistry); + + const line = warns.find((w) => w.includes(AUDIT))!; + expect(line).toContain('theme×1 [plugin-registered]'); + // The declared specimen in the SAME line carries no marker. + expect(line).toContain('flow×1 ('); + expect(line).not.toContain('flow×1 [plugin-registered]'); + expect(line).toContain('does NOT cover them'); + }); + + it('reads the metadata SERVICE as well as the engine registry', async () => { + // `getMetaTypes()` unions both sources; the audit must scan the same + // union or it accuses a type the listing does not admit exists, or + // stays silent about one it does. Here the type is known ONLY to the + // service. + const { engine, getServicesRegistry } = makeEngine( + [row({ type: 'sharing_rule', name: 'org_rule', organization_id: 'org_a' })], + { liveTypes: [], serviceTypes: ['sharing_rule'] }, + ); + + const { warns } = await bootAndCapture(engine, getServicesRegistry); + + const line = warns.find((w) => w.includes(AUDIT)); + expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); + expect(line).toContain('sharing_rule×1 [plugin-registered]'); + expect(line).toContain('org_rule@org_a'); + }); + + it('stays ONE aggregated line across declared and plugin-registered types alike', async () => { + // The shape #6600 chose is unchanged by the widening: a tenant with + // many such rows costs one line, with a capped name sample per type. + const rows: Row[] = []; + for (let i = 0; i < 7; i++) { + rows.push(row({ type: 'webhook', name: `hook_${i}`, organization_id: 'org_a' })); + } + rows.push(row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' })); + const { engine, getServicesRegistry } = makeEngine(rows, { liveTypes: ['flow', 'webhook'] }); + + const { warns } = await bootAndCapture(engine, getServicesRegistry); + + const audit = warns.filter((w) => w.includes(AUDIT)); + expect(audit).toHaveLength(1); + expect(audit[0]).toContain('8 active sys_metadata row(s)'); + expect(audit[0]).toContain('webhook×7'); + expect(audit[0]).toContain('+2 more'); + expect(audit[0]).toContain('flow×1'); + }); + + // ── the silence that is the design, not a miss ──────────────────────── + + it('says NOTHING about an ENV-WIDE row of a plugin-registered type', async () => { + const { engine, getServicesRegistry } = makeEngine( + [ + row({ type: 'webhook', name: 'platform_hook' }), + row({ type: 'theme', name: 'platform_theme' }), + ], + { liveTypes: ['webhook', 'theme'] }, + ); + + const { result, warns } = await bootAndCapture(engine, getServicesRegistry); + + expect(result.loaded).toBe(2); + expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); + }); + + it('still says NOTHING about an org-scoped VIEW — widening must not touch that silence', async () => { + // `view` is `allowOrgOverride: true`. Its org row is a per-org overlay + // served on demand — the ADR-0005 design, and the case that would break + // first if the widening were written as "scan every live type". + const { engine, getServicesRegistry } = makeEngine( + [row({ type: 'view', name: 'org_grid', organization_id: 'org_a' })], + { liveTypes: ['view', 'webhook', 'theme'] }, + ); + + const { warns } = await bootAndCapture(engine, getServicesRegistry); + + expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); + }); + + // ── the diagnostic can never become the outage ──────────────────────── + + it('degrades to the declared scan when the live accessor throws', async () => { + // A best-effort probe must not be able to cost a boot its report, let + // alone its verdict: an engine whose registry throws still gets the + // declared-type half of the audit, and a healthy `storeUnavailable`. + const { engine, getServicesRegistry } = makeEngine( + [ + row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), + row({ type: 'webhook', name: 'org_hook', organization_id: 'org_a' }), + ], + { registryThrows: true }, + ); + + const { result, warns } = await bootAndCapture(engine, getServicesRegistry); + + expect(result).toMatchObject({ errors: 0, storeUnavailable: false }); + const line = warns.find((w) => w.includes(AUDIT)); + expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); + expect(line).toContain('flow×1'); + expect(line).not.toContain('webhook'); + }); + + // ── the divergence from the refusal is deliberate, and pinned ───────── + + it('does NOT extend the write refusal to the family it now reports', async () => { + // #6992's scope is the diagnostic half ONLY. `orgScopedWriteRefusal` + // keeps its "statically-declared types only" predicate: a warning is + // free and should be maximal, a refusal removes a capability measured + // over a different set. This case is the guard against a future reader + // "harmonising" the two — if the refusal is ever widened, that is a new + // ruling and this case is where it gets recorded, not deleted. + const refuse = (ObjectStackProtocolImplementation as any).orgScopedWriteRefusal.bind( + ObjectStackProtocolImplementation, + ); + // Declared, not org-overridable → refused (#6190's landing, untouched). + expect(refuse('flow', 'x', 'org_a')).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + // Plugin-registered → still accepted, even though the audit now reports it. + for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { + expect(refuse(type, 'x', 'org_a'), `${type} write refusal changed`).toBeNull(); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8586f14c83..9763b804f4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3280,7 +3280,34 @@ export class ObjectStackProtocolImplementation implements }; } - async getMetaTypes() { + /** + * [#6992] The LIVE metadata-type set of this kernel: every type either + * registry has heard of, whether or not `DEFAULT_METADATA_TYPE_REGISTRY` + * declares it. Spellings are returned as each source stores them + * (singular or plural) — callers normalise through + * {@link PLURAL_TO_SINGULAR}, as they already did inline. + * + * Two sources, and both are needed: + * + * - `engine.registry.getRegisteredTypes()` — the SchemaRegistry. This is + * the one that actually carries the plugin-registered family: manifests + * register through the `manifest` service during kernel Phase 1 + * (`ql.registerApp`), so by Phase 2 it holds `theme`, `connector`, + * `webhook`, `sharing_rule`, `analytics_cube`, … — none of which have a + * registry entry. + * - the `metadata` service's `getRegisteredTypes()` — types the + * MetadataManager knows. Its `typeRegistry` is seeded with + * `DEFAULT_METADATA_TYPE_REGISTRY` in the manager's constructor, so + * early in boot this source contributes only declared types; it grows + * later (artifact load, `additionalTypes`) and is read for the types + * the SchemaRegistry has not been told about. + * + * Extracted from {@link getMetaTypes} rather than copied: the listing and + * {@link reportUnhydratableOrgScopedRows} must answer "which types exist + * here" identically, or the audit accuses a type the listing does not + * admit exists (and vice versa). One accessor, no second vocabulary. + */ + private async listLiveMetadataTypes(): Promise { const schemaTypes = this.engine.registry.getRegisteredTypes(); // Also include types from MetadataService (runtime-registered: agent, tool, etc.) @@ -3295,7 +3322,11 @@ export class ObjectStackProtocolImplementation implements // MetadataService not available } - const allTypes = Array.from(new Set([...schemaTypes, ...runtimeTypes])); + return Array.from(new Set([...schemaTypes, ...runtimeTypes])); + } + + async getMetaTypes() { + const allTypes = await this.listLiveMetadataTypes(); // Phase 3a-1: enrich response with per-type registry metadata so admin // UI can render directory pages, filter by domain, decide which types @@ -12209,6 +12240,38 @@ export class ObjectStackProtocolImplementation implements * hatch only unlocks the WRITE — an env-unlocked type's org rows are * hydrated no more than any other's, so silencing the line on that * flag would hide exactly the deployment most likely to have these rows. + * - **[#6992] …plus every LIVE type the registry does not declare at + * all.** {@link listLiveMetadataTypes} — the same accessor + * {@link getMetaTypes} lists from. A plugin-registered type (`theme`, + * `connector`, `webhook`, `sharing_rule`, `analytics_cube`, …) has no + * registry entry, so the loop above could not reach it, yet + * `loadMetaFromDb`'s filter 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. It has no declaration to consult, and + * `getMetaTypes()` synthesises `allowOrgOverride: false` for it, so + * "not per-org overridable" is its correct reading here. + * + * ── THE DIVERGENCE FROM THE REFUSAL IS DELIBERATE ── + * + * {@link orgScopedWriteRefusal} keys off the STATIC registry and + * returns `null` for exactly this family (its "Statically-declared + * types only" bullet); this audit keys off the LIVE set and reports it. + * The two sets are meant to differ, and a future reader should not + * "fix" one to match the other. The asymmetry is this file's own stated + * posture, three bullets up in that method: *warning is free and should + * be maximal; refusing removes a capability*. Widening the refusal + * would extend the 2026-08-08 ruling — reasoned over the 27 declared + * entries — onto a surface nobody measured; widening the warning costs + * an operator one more segment on a line that already exists. Same + * reasoning by which this method ignores `OS_METADATA_WRITABLE` while + * the refusal honours it. Ruled on #6992, scoped to the diagnostic. + * + * Measured, not assumed (#6992): at the instant this method runs — in + * `ObjectQLPlugin.start()` Phase 2, after every plugin's `init` — a + * real `app-showcase` boot has 7 live types with no registry entry + * (`analytics_cube`, `connector`, `data`, `package`, `sharing_rule`, + * `theme`, `webhook`), all of them from the SchemaRegistry. The + * widening is therefore live and not defeated by boot order. * - **Two predicates, both narrowing.** `organization_id IS NOT NULL` * plus the type list keeps the query empty-by-default: a healthy * deployment reads nothing and prints nothing. A driver that drops @@ -12227,15 +12290,42 @@ export class ObjectStackProtocolImplementation implements const SAMPLE_PER_TYPE = 5; try { const orgOverridable = new Set(); + /** Types `DEFAULT_METADATA_TYPE_REGISTRY` declares, whatever their flags. */ + const declaredTypes = new Set(); + /** [#6992] Live types with NO registry entry — reported, never refused. */ + const undeclaredTypes = new Set(); const scannedTypes: string[] = []; + const scan = (singular: string): void => { + scannedTypes.push(singular); + // Both spellings: `sys_metadata.type` may hold the legacy + // plural, exactly as the hydration loop above assumes. + const plural = SINGULAR_TO_PLURAL[singular]; + if (plural) scannedTypes.push(plural); + }; for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + declaredTypes.add(entry.type); if (entry.allowOrgOverride) { orgOverridable.add(entry.type); continue; } - scannedTypes.push(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) scannedTypes.push(plural); + scan(entry.type); + } + // [#6992] Widen to the live registry — see the TSDoc's "…plus every + // LIVE type the registry does not declare at all" bullet. Best + // effort like the rest of this method: a kernel whose accessors + // throw degrades to the declared scan it had before, never to a + // failed boot. + let liveTypes: string[] = []; + try { + liveTypes = await this.listLiveMetadataTypes(); + } catch { + liveTypes = []; + } + for (const liveType of liveTypes) { + const singular = PLURAL_TO_SINGULAR[liveType] ?? liveType; + if (declaredTypes.has(singular) || undeclaredTypes.has(singular)) continue; + undeclaredTypes.add(singular); + scan(singular); } if (scannedTypes.length === 0) return; @@ -12253,12 +12343,26 @@ export class ObjectStackProtocolImplementation implements // false accusation. const counts = new Map(); const samples = new Map(); + const scannedSingulars = new Set( + scannedTypes.map((t) => PLURAL_TO_SINGULAR[t] ?? t), + ); let total = 0; for (const row of rows) { const org = (row as { organization_id?: string | null }).organization_id; if (org === null || org === undefined || org === '') continue; const singular = PLURAL_TO_SINGULAR[String(row.type)] ?? String(row.type); if (orgOverridable.has(singular)) continue; + // [#6992] Re-check the TYPE predicate too, which is what the + // TSDoc above has always promised ("the JS filter re-checks + // both"). Before the live widening, `orgOverridable` was that + // re-check: within the declared registry, "not org-overridable" + // and "in the scanned list" were the same statement. They are + // not any more — a row of a type that is neither declared NOR + // live (a plugin uninstalled since the row was written) is + // absent from the list, so only this line keeps a driver that + // cannot lower `$in` from turning a superset into a line about + // a type this kernel never scanned. + if (!scannedSingulars.has(singular)) continue; total++; counts.set(singular, (counts.get(singular) ?? 0) + 1); const names = samples.get(singular) ?? []; @@ -12267,20 +12371,36 @@ export class ObjectStackProtocolImplementation implements } if (total === 0) return; + let reportedUndeclared = false; const detail = Array.from(counts.entries()) .map(([type, count]) => { const names = samples.get(type) ?? []; const more = count > names.length ? `, +${count - names.length} more` : ''; - return `${type}×${count} (${names.join(', ')}${more})`; + // [#6992] Mark the plugin-registered family. Not decoration: + // the operator's next step differs between the two: a + // DECLARED type's org-scoped write is refused from now on + // (#6190), so the listed rows are historical residue and + // cannot grow; an UNDECLARED type's write is not refused, + // so the same names come back after every restart until the + // author stops writing them org-scoped. + const mark = undeclaredTypes.has(type) ? ' [plugin-registered]' : ''; + if (mark) reportedUndeclared = true; + return `${type}×${count}${mark} (${names.join(', ')}${more})`; }) .join('; '); console.warn( `[Protocol] [metadata_org_scoped_unhydrated] ${total} active sys_metadata row(s) are ` + - `org-scoped on types the registry declares NOT per-org overridable, so boot hydration ` + - `skipped them and they are absent from the process-wide registry: ${detail}. ` + + `org-scoped on types with NO per-org channel (the registry declares allowOrgOverride=false, ` + + `or does not declare the type at all), so boot hydration skipped them and they are absent ` + + `from the process-wide registry: ${detail}. ` + `A 'flow' listed here will NOT bind its triggers in this process (the kernel:ready binder ` + `reads flows env-wide) — it fired until the last restart and stops now. ` + - `Re-save the item env-wide (no active organization), or delete the row. See #6190 / ADR-0005.`, + (reportedUndeclared + ? `Types marked [plugin-registered] have no metadata-type registry entry, so the #6190 ` + + `org-scope write refusal does NOT cover them: rows of those types can still be written ` + + `org-scoped, and will be listed here again after every restart until the author stops. ` + : '') + + `Re-save the item env-wide (no active organization), or delete the row. See #6190 / #6992 / ADR-0005.`, ); } catch { // Diagnostics never break boot — see the TSDoc. Deliberately not