From 655e9b4fbbb33394bf8b4021ca106d9519183883 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:22:06 +0000 Subject: [PATCH] fix(metadata-protocol): an org-scoped overlay row no longer reaches the process-wide SchemaRegistry (#6602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both runtime hydration seams gated on `environmentId` alone and said nothing about `organization_id`, so on an unscoped (control-plane) kernel a per-org overlay reached the shared registry under the plain key — through the #4521 write-through, and again through the `getMetaItems` read hydration one listing call later. The row-scope verdict now lives in `hydrateOverlayIntoRegistry`, the one choke point all three hydration callers already share, with a REQUIRED `organizationId` argument so a fourth caller cannot forget it. The kernel-scope gate stays with the callers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- .changeset/org-overlay-registry-gate.md | 58 +++ packages/metadata-protocol/src/protocol.ts | 122 ++++- ...protocol-org-overlay-registry-gate.test.ts | 466 ++++++++++++++++++ 3 files changed, 636 insertions(+), 10 deletions(-) create mode 100644 .changeset/org-overlay-registry-gate.md create mode 100644 packages/objectql/src/protocol-org-overlay-registry-gate.test.ts diff --git a/.changeset/org-overlay-registry-gate.md b/.changeset/org-overlay-registry-gate.md new file mode 100644 index 0000000000..d2f28a955e --- /dev/null +++ b/.changeset/org-overlay-registry-gate.md @@ -0,0 +1,58 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): an org-scoped overlay row no longer reaches the process-wide SchemaRegistry (#6602) + +ADR-0005 (revised 2026-05) says only **env-wide** rows (`organization_id IS NULL`) +enter the process-wide `SchemaRegistry`; per-org overlays are served on demand and +never grafted into the registry every org in the process shares. The registry has +exactly one plain key per `(type, name)` and no org dimension to hold two orgs' +bodies apart, so a per-org body sitting under that key IS the other orgs' body. + +Boot obeyed the rule — `loadMetaFromDb` filters `organization_id: null` and says so +in its own comment. Both **runtime** seams did not: + +- **The write-through.** `applyRegistryWriteThrough` gated on `environmentId` alone. + Its TSDoc already claimed the rule ("a project-scoped row must not be registered + into a registry that unscoped callers share. The write must not be more permissive + about that than the read is") while the code said nothing about `organization_id`. + On an unscoped kernel a per-org `view` write hydrated straight into the registry + under the plain key. +- **The read hydration.** `getMetaItems` merges this caller's org rows into the + env-wide set and then hydrated the whole merged set under the same + `environmentId === undefined` gate — so one org-scoped listing call grafted that + org's bodies too, and would have undone a write-side-only fix at the next listing. + +Both were observable rather than theoretical: once org A's body sat under the plain +key, org B's listing started from org A's body, and where the names did not collide +org A's item was simply **in** org B's list. Per #5086 a host config boots +`new ObjectQLPlugin()` with no `environmentId`, so the flagship showcase runs on +exactly this kernel shape. + +**The fix restores the stated invariant at both seams at once, in one place.** +`hydrateOverlayIntoRegistry` is the single choke point all three hydration callers +(boot, read-side, write-through) already route through since #4521, so the row-scope +verdict now lives there — and its `organizationId` argument is **required**, not +optional: an omitted org would default to "env-wide" and reinstate the hole, while a +required one makes every caller state the row's scope to compile. The kernel-scope +gate (`environmentId === undefined`) stays with the callers, because that is a fact +about the kernel, not about the row. + +Not changed, deliberately: + +- **What org readers see.** The merged listing, `getMetaItem`'s org-preferred read, + and the org-scoped write itself are all untouched — this closes a registry leak, + never a write or a read. Per-org overlays keep working exactly as ADR-0005 + designed them: served on demand. +- **#4521 read-your-writes.** An env-wide save is still dispatchable the moment it + lands, with no listing call in between. +- **The `object` branch.** An `object` is `allowOrgOverride: false` and its physical + table is env-wide, so the registry entry backing it is env-wide too; + `assertObjectRegistered` fails closed on a missing entry, so gating that branch + would make a runtime-created object unreachable for data CRUD rather than merely + un-listed. That branch has never carried the `environmentId` gate either, for the + same reason. +- **The delete chain.** `restoreArtifactRegistryView` stays `(type, name)`-addressed: + with both entry seams refusing org rows there is nothing org-scoped in the registry + for it to mis-address, so no re-keying is needed (pinned in both directions). diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e2e42453c3..869a9ed464 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3398,7 +3398,11 @@ export class ObjectStackProtocolImplementation implements if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { (data as any)._packageId = recPkg; } - return { data, packageId: recPkg }; + // [#6602] The row's own scope travels with its body. The + // merged set below is env-wide rows PLUS this org's rows, + // and the two are only distinguishable here, at the row. + const recOrg = (record as { organization_id?: string | null }).organization_id ?? null; + return { data, packageId: recPkg, organizationId: recOrg }; }); // ADR-0048 (#1828) — package-aware merge: a package-scoped row @@ -3423,9 +3427,22 @@ export class ObjectStackProtocolImplementation implements // shared {@link hydrateOverlayIntoRegistry} that both callers // use: a read and a write that register differently would put // the registry in two different states for the same row. + // + // [#6602] The kernel gate below is only half the rule, and the + // half that was missing is the ROW's: `overlays` is the MERGED + // env-wide + org-scoped set, so this loop used to graft this + // caller's org bodies into the registry every other org in the + // process reads from — one listing call was enough, and it also + // undid the write-side gate for anything already saved. The + // per-row verdict now lives in the shared hydrator, which each + // row's own `organizationId` answers to; the merged LIST above + // is unchanged, so org readers still get their overlays. if (this.environmentId === undefined) { - for (const { data, packageId: recPkg } of overlays) { - this.hydrateOverlayIntoRegistry(request.type, data, recPkg); + for (const { data, packageId: recPkg, organizationId: recOrg } of overlays) { + this.hydrateOverlayIntoRegistry(request.type, data, { + packageId: recPkg, + organizationId: recOrg, + }); } } } @@ -7547,14 +7564,52 @@ export class ObjectStackProtocolImplementation implements * so a colliding overlay no longer grafts the first-registered package's * provenance/lock onto another package's row. * - * Returns whether anything was registered (bodies without a `name`, and - * registry doubles without `registerItem`, are no-ops). + * ── [#6602] THE ROW-SCOPE GATE LIVES HERE, AND ITS ARGUMENT IS REQUIRED ── + * + * ADR-0005 (revised 2026-05): **only env-wide rows + * (`organization_id IS NULL`) enter the process-wide SchemaRegistry.** + * Per-org overlays are served on demand by `getMetaItem` / + * `getMetaItems` and never grafted into the shared registry, because that + * registry has exactly one plain key per `(type, name)` and no org + * dimension to hold them apart. + * + * Boot already obeyed this — `loadMetaFromDb` filters + * `organization_id: null` and states the rule in its own comment — but + * the two RUNTIME seams did not: {@link applyRegistryWriteThrough} gated + * on `environmentId` alone (its TSDoc claimed the rule and the code said + * nothing about org), and the `getMetaItems` hydration loop walked the + * merged env-wide + org record set. Measured on an unscoped kernel, an + * org-scoped `view` write landed in the registry under the plain key, and + * one org-scoped listing call did the same — so org B's next listing + * started from org A's body (#6602). + * + * `organizationId` is therefore a REQUIRED parameter and not an optional + * one: an omitted org would default to "env-wide" and reinstate the exact + * hole, whereas a required one makes every caller state the row's scope. + * Declared = enforced, at the ONE choke point all three hydration callers + * (boot, read-side, write-through) already share — a fourth caller cannot + * forget a gate it has to answer to compile. + * + * The KERNEL-scope gate (`environmentId === undefined`) deliberately + * stays with the callers: that is a fact about the kernel this protocol + * instance serves, not about the row in hand. + * + * Returns whether anything was registered (org-scoped rows, bodies + * without a `name`, and registry doubles without `registerItem`, are + * no-ops). */ - private hydrateOverlayIntoRegistry(type: string, data: unknown, packageId?: string | null): boolean { + private hydrateOverlayIntoRegistry( + type: string, + data: unknown, + options: { packageId?: string | null; organizationId: string | null }, + ): boolean { + // [#6602] ADR-0005 — a per-org overlay is served on demand, never + // grafted into the registry every org in this process shares. + if (options.organizationId !== null && options.organizationId !== undefined) return false; if (!data || typeof data !== 'object' || !('name' in data)) return false; const registry: any = (this.engine as any)?.registry; if (!registry || typeof registry.registerItem !== 'function') return false; - const artifact = this.lookupArtifactItem(type, (data as any).name, packageId ?? undefined); + const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined); registry.registerItem(type, mergeArtifactProtection(data, artifact), 'name' as any); return true; } @@ -7591,15 +7646,43 @@ export class ObjectStackProtocolImplementation implements * gate the read-side hydration carries: a project-scoped row must not be * registered into a registry that unscoped (control-plane) callers share. * The write must not be more permissive about that than the read is. + * + * [#6602] That sentence was true of the ENVIRONMENT dimension and false + * of the ORGANIZATION one: the gate above says nothing about + * `organization_id`, so on an unscoped kernel a per-org overlay write + * hydrated straight into the process-wide registry under the plain key — + * the designed per-org overlay leaking out of its org. `organizationId` + * is now part of this request and is handed to + * {@link hydrateOverlayIntoRegistry}, which owns the row-scope verdict + * for all three hydration paths. Callers pass the SAME `orgId` they wrote + * the row with, so the registry's view cannot disagree with the row's + * scope. */ - private applyRegistryWriteThrough(request: { type: string; name: string; item?: any; packageId?: string | null }): void { + private applyRegistryWriteThrough(request: { + type: string; + name: string; + item?: any; + packageId?: string | null; + /** The row's org scope — `null` for an env-wide row. [#6602] */ + organizationId: string | null; + }): void { if (request.type === 'object' || request.type === 'objects') { + // NOT org-gated, deliberately: an `object` is `allowOrgOverride: + // false` (ADR-0005) and its physical TABLE is env-wide, so the + // registry entry backing it is env-wide too — `assertObjectRegistered` + // fails CLOSED on a missing entry, and refusing to register here + // would make a runtime-created object unreachable for data CRUD + // rather than merely un-listed. This branch has never carried the + // `environmentId` gate either, for the same reason. this.applyObjectRegistryMutation(request); return; } if (this.environmentId !== undefined) return; try { - this.hydrateOverlayIntoRegistry(request.type, request.item, request.packageId ?? undefined); + this.hydrateOverlayIntoRegistry(request.type, request.item, { + packageId: request.packageId ?? undefined, + organizationId: request.organizationId, + }); } catch (err: any) { // Best-effort, exactly like the object branch: the row is already // persisted, so a registry hiccup must not fail the write that @@ -8287,6 +8370,9 @@ export class ObjectStackProtocolImplementation implements name: request.name, item: request.item, packageId: request.packageId ?? null, + // [#6602] The SAME scope the row was just written with — + // a per-org overlay stays out of the shared registry. + organizationId: orgId, }); await this.ensureObjectStorage(request.type, request.name); } @@ -8914,6 +9000,8 @@ export class ObjectStackProtocolImplementation implements name: args.name, item: args.body, packageId: args.packageId, + // [#6602] The promoted draft carries the org it was drafted in. + organizationId: args.orgId, }); // Create the object's table now so it's CRUD-able without a restart. await this.ensureObjectStorage(args.requestType, args.name); @@ -10434,6 +10522,9 @@ export class ObjectStackProtocolImplementation implements name: request.name, item: result.item.body, packageId: rollbackPackageId, + // [#6602] A rollback restores the row IN ITS OWN SCOPE — an + // org-scoped restore must not graft the body process-wide. + organizationId: orgId, }); return { success: true, @@ -11025,10 +11116,21 @@ export class ObjectStackProtocolImplementation implements // When artifacts load after this hydration the merge // finds nothing and the row registers unchanged — same // as before, scoped or not. + // + // [#6602] The org argument states what the WHERE + // clause above already selected for. It is a no-op + // today by construction — and that is the point: the + // rule this branch's comment states ("hydrate only + // env-wide rows") stops depending on a query filter + // staying correct, because the hydrator refuses an + // org-scoped row whatever selected it. this.hydrateOverlayIntoRegistry( normalizedType, data, - (record as { package_id?: string | null }).package_id ?? undefined, + { + packageId: (record as { package_id?: string | null }).package_id ?? undefined, + organizationId: (record as { organization_id?: string | null }).organization_id ?? null, + }, ); } loaded++; diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts new file mode 100644 index 0000000000..07fbd98402 --- /dev/null +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -0,0 +1,466 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6602 — an org-scoped overlay row must NEVER reach the process-wide + * SchemaRegistry on an unscoped (control-plane) kernel. + * + * The invariant is not new; the repo already stated it in two places and + * enforced it in only one: + * + * • `loadMetaFromDb` queries `organization_id: null` and says why in a + * comment — "Per-org overlays are loaded on demand by getMetaItem to + * avoid cross-org leakage into the process-wide SchemaRegistry." + * • `applyRegistryWriteThrough`'s own TSDoc said "a project-scoped row must + * not be registered into a registry that unscoped (control-plane) callers + * share. The write must not be more permissive about that than the read + * is." — and then gated on `environmentId` alone, which says nothing + * about `organization_id`. + * + * So BOTH runtime seams were org-blind: + * + * PROBE P3 (issue body) rows = [{type view, name org_grid, org org_a}] + * → registry write-through = [{type view, name org_grid}] + * + * and the read side did the same thing one listing call later: `getMetaItems` + * merges `orgRecords` into `overlays` and hydrates the whole merged set under + * `if (this.environmentId === undefined)`. Fixing only the write would have + * been undone by the next org-scoped listing, which is why both are pinned + * here in one file. + * + * The disclosure is observable, not theoretical: once org A's body sits under + * the PLAIN key, org B's listing starts from org A's body — and where the + * names do not collide, org A's item is simply IN org B's list. Per #5086 a + * host config boots `new ObjectQLPlugin()` with NO environmentId, so the + * flagship showcase runs on exactly this kernel shape. + * + * ── The fix, and where it lives ───────────────────────────────────────── + * + * `hydrateOverlayIntoRegistry` is the ONE shared choke point all three + * hydration callers already route through (#4521 made it so: boot, the + * read-side hydration and the write-through). The row-scope verdict now + * lives there and its `organizationId` argument is REQUIRED, so a fourth + * caller cannot forget it — declared = enforced. The kernel-scope verdict + * (`environmentId === undefined`) stays with the callers, because that is a + * fact about the kernel, not about the row. + * + * ── Reverse verification, direction predicted BEFORE running ──────────── + * + * Ordinary red on the two leak seams, with deliberately green controls. + * Restoring either org-blind seam (deleting the `organizationId` refusal in + * `hydrateOverlayIntoRegistry`) must turn the leak cases red and leave the + * env-wide cases green — the env-wide rows are the #4521 read-your-writes + * behaviour this fix must NOT regress, and they never carry an org to + * refuse. Measured direction is recorded in the PR body. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { SchemaRegistry } from './registry.js'; +import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; +import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; + +/** + * A `sys_metadata`-shaped fake engine over one row array, so the repository + * write path (insert/update) and every read see one store. Both destructive + * verbs are pinned to the producer's OWN dispatch predicates (#4550 delete / + * #5480 update) rather than a hand-mirrored approximation — a double looser + * than `ObjectQL.` converts a green suite into no suite at all. + */ +function makeEngine(registry: SchemaRegistry) { + let rows: any[] = []; + let nextId = 1; + const matches = (r: any, w: Record): boolean => + Object.entries(w).every(([k, v]) => { + if (v === undefined) return true; + if (v !== null && typeof v === 'object') return true; // operator clause — not exercised here + return r[k] === v; + }); + const engine: any = { + registry, + find: vi.fn(async (_table: string, opts: any) => rows.filter((r) => matches(r, opts?.where ?? {}))), + findOne: vi.fn(async (table: string, opts: any) => (await engine.find(table, opts))[0] ?? null), + insert: vi.fn(async (_table: string, data: any) => { + const row = { id: data.id ?? `row_${nextId++}`, ...data }; + rows.push(row); + return row; + }), + update: vi.fn(async (_table: string, data: any, opts: any) => { + assertEngineUpdateDispatch(data, opts); + const target = rows.find((r) => matches(r, opts?.where ?? {})); + if (target) Object.assign(target, data); + return target ?? null; + }), + delete: vi.fn(async (_table: string, opts: any) => { + assertEngineDeleteDispatch(opts); + const before = rows.length; + rows = rows.filter((r) => !matches(r, opts?.where ?? {})); + return { deleted: before - rows.length }; + }), + count: vi.fn(async (_table: string, opts: any) => rows.filter((r) => matches(r, opts?.where ?? {})).length), + aggregate: vi.fn(async () => []), + execute: vi.fn(async () => undefined), + /** Plant a row without going through the write path (read-seam cases). */ + plant: (row: any) => { rows.push({ id: `row_${nextId++}`, ...row }); }, + getRows: () => rows, + }; + return engine; +} + +const viewBody = (name: string, label: string) => ({ + name, + label, + object: 'showcase_task', + columns: [{ field: 'name', label: 'Name' }], +}); + +const flowBody = (name: string, label: string) => ({ + name, + label, + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'showcase_task', triggerType: 'record-after-update' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +const metaRow = (type: string, body: Record, organizationId: string | null) => ({ + type, + name: body.name, + organization_id: organizationId, + package_id: null, + state: 'active', + metadata: JSON.stringify(body), +}); + +/** Names in a bare item array — what `SchemaRegistry.listItems` hands back. */ +const namesIn = (items: unknown[]): string[] => + items.map((i) => (i as { name?: string })?.name).filter((n): n is string => typeof n === 'string'); + +/** Names visible in a `getMetaItems` result, which answers an `{ items }` envelope. */ +const namesOf = (listed: { items?: unknown[] }): string[] => namesIn(listed?.items ?? []); + +/** One item out of a `getMetaItems` result, by name. */ +const itemNamed = (listed: { items?: unknown[] }, name: string): any => + (listed?.items ?? []).find((i) => (i as { name?: string })?.name === name); + +describe('#6602 — the premise, read from the registry rather than restated', () => { + it('view is the specimen: a legitimately per-org-overridable type', () => { + // If a later ruling closes this flag, the leak this file pins stops + // being reachable through the overlay tier and the whole file should + // be re-read, not repaired. + expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'view')).toMatchObject({ + allowOrgOverride: true, + }); + // `flow` reaches the same seam through the OTHER write tier: not + // per-org overridable (#6283 / PR #6478) but still runtime-creatable, + // which is the tier a Studio-authored flow uses. + expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'flow')).toMatchObject({ + allowOrgOverride: false, + allowRuntimeCreate: true, + }); + }); +}); + +describe('#6602 — WRITE seam: applyRegistryWriteThrough refuses org-scoped rows', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + engine = makeEngine(registry); + // No environmentId — the unscoped control-plane kernel #5086 measured + // the flagship showcase booting with. + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it('an ORG-scoped view write does not reach the process-wide registry (PROBE P3)', async () => { + const saved = await protocol.saveMetaItem({ + type: 'view', + name: 'org_grid', + item: viewBody('org_grid', 'Org A grid'), + organizationId: ORG_A, + }); + expect(saved.success).toBe(true); + // The row IS persisted — this fix closes a registry leak, never a write. + expect(engine.getRows().some((r: any) => r.name === 'org_grid' && r.organization_id === ORG_A)).toBe(true); + + // Pre-fix: the body sits here under the PLAIN key, visible to every + // registry-direct reader in the process. + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + expect(namesIn(registry.listItems('view'))).not.toContain('org_grid'); + }); + + it('an ORG-scoped flow write does not reach it either (runtime-create tier)', async () => { + const saved = await protocol.saveMetaItem({ + type: 'flow', + name: 'org_sweep', + item: flowBody('org_sweep', 'Org A sweep'), + organizationId: ORG_A, + }); + expect(saved.success).toBe(true); + expect(registry.getItem('flow', 'org_sweep')).toBeUndefined(); + expect(namesIn(registry.listItems('flow'))).not.toContain('org_sweep'); + }); + + it('an ENV-WIDE write still writes through — #4521 read-your-writes is untouched', async () => { + const saved = await protocol.saveMetaItem({ + type: 'view', + name: 'env_grid', + item: viewBody('env_grid', 'Env grid'), + }); + expect(saved.success).toBe(true); + // The control that makes the two cases above evidence rather than a + // write-through that simply stopped working. + const hydrated: any = registry.getItem('view', 'env_grid'); + expect(hydrated).toBeDefined(); + expect(hydrated.label).toBe('Env grid'); + }); + + it('an env-wide entry survives a LATER org write of the same name', async () => { + await protocol.saveMetaItem({ + type: 'view', + name: 'shared_grid', + item: viewBody('shared_grid', 'Env grid'), + }); + await protocol.saveMetaItem({ + type: 'view', + name: 'shared_grid', + item: viewBody('shared_grid', 'Org A grid'), + organizationId: ORG_A, + }); + // Pre-fix the org body OVERWROTE the env-wide plain-key entry, so + // every org (and the control plane) started reading org A's body. + expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); + }); +}); + +describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + engine = makeEngine(registry); + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it('an org-scoped listing SERVES the org row but does not graft it', async () => { + engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); + + const listed = await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + + // Org readers keep their overlay — closing the leak must not close + // the feature. + expect(namesOf(listed as any)).toContain('org_grid'); + // …and the process-wide registry stays clean. + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + }); + + it('an ENV-WIDE listing still hydrates — the read side is not narrowed', async () => { + engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); + + const listed = await protocol.getMetaItems({ type: 'view' }); + + expect(namesOf(listed as any)).toContain('env_grid'); + expect((registry.getItem('view', 'env_grid') as any)?.label).toBe('Env grid'); + }); + + it('a MIXED listing hydrates the env-wide row and only that one', async () => { + engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); + engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); + + const listed = await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + + expect(namesOf(listed as any).sort()).toEqual(['env_grid', 'org_grid']); + expect(registry.getItem('view', 'env_grid')).toBeDefined(); + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + }); + + it('an org overlay of an env-wide name does not OVERWRITE the hydrated env-wide entry', async () => { + engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); + engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); + + // The env-wide body reaches the registry the way it legitimately does + // — an unscoped read (boot's `loadMetaFromDb` is the other one). + await protocol.getMetaItems({ type: 'view' }); + expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); + + // Org A's own listing still shows org A's body (org-over-env merge)… + const listedA = await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Org A grid'); + + // …and the shared registry still holds the ENV-WIDE one. This is the + // collision direction of the leak: pre-fix the org body overwrote the + // plain key here, so every reader in the process — the control plane + // and org B alike — started serving org A's customization. + expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); + }); + + it('an org-scoped listing hydrates NOTHING for a name its org overlays', async () => { + // Measured, and stated rather than presumed: `getMetaItems` merges the + // two record sets by (package, name) with the ORG row winning, so the + // env-wide row it shadows is not in the set this loop walks at all. + // The subtraction is the leak only — pre-fix this listing hydrated org + // A's body under the plain key, never the env-wide one, so nothing + // legitimate is lost. The env-wide entry arrives from boot / the + // unscoped read, as the case above shows. + engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); + engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); + + await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + + expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); + }); +}); + +describe('#6602 — the disclosure shape, end to end', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + engine = makeEngine(registry); + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it("org B's listing never contains org A's item — write then list", async () => { + await protocol.saveMetaItem({ + type: 'view', + name: 'org_a_only', + item: viewBody('org_a_only', 'Org A only'), + organizationId: ORG_A, + }); + + const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + expect(namesOf(listedB as any)).not.toContain('org_a_only'); + }); + + it("org B's listing never contains org A's item — org A LISTS first", async () => { + // The seam the write-side fix alone would not have closed: one + // org-scoped listing call used to graft org A's body, and every + // later reader started from it. + engine.plant(metaRow('view', viewBody('org_a_only', 'Org A only'), ORG_A)); + + await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + + expect(namesOf(listedB as any)).not.toContain('org_a_only'); + // The unscoped control-plane listing is a third reader of the same + // shared registry, and it must not see org A's item either. + const listedControlPlane = await protocol.getMetaItems({ type: 'view' }); + expect(namesOf(listedControlPlane as any)).not.toContain('org_a_only'); + }); + + it("org B reads the ENV-WIDE body, not org A's, for a colliding name", async () => { + engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); + engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); + + await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); + const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + + expect(itemNamed(listedB as any, 'shared_grid')?.label).toBe('Env grid'); + }); +}); + +describe('#6602 — the on-demand per-org read still serves org readers', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + engine = makeEngine(registry); + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it('getMetaItem serves the org body to its own org and never hydrates it', async () => { + engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); + + const got: any = await protocol.getMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A }); + expect(got.item.label).toBe('Org A grid'); + + // ADR-0005's "loaded on demand … to avoid cross-org leakage": serving + // it must not be the thing that grafts it. + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + }); + + it('getMetaItem prefers the ORG body over the env-wide one for its own org', async () => { + engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); + engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); + + const forOrgA: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); + expect(forOrgA.item.label).toBe('Org A grid'); + + const forOrgB: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_B }); + expect(forOrgB.item.label).toBe('Env grid'); + }); + + it('a write-then-read round trip works for an org author (write seam, read seam)', async () => { + await protocol.saveMetaItem({ + type: 'view', + name: 'org_grid', + item: viewBody('org_grid', 'Org A grid'), + organizationId: ORG_A, + }); + + const got: any = await protocol.getMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A }); + expect(got.item.label).toBe('Org A grid'); + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + }); +}); + +describe('#6602 — the delete chain needs no re-keying under this fix', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + engine = makeEngine(registry); + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it('an env-wide delete still retires its plain-key entry (#5079 / #6687 intact)', async () => { + await protocol.saveMetaItem({ + type: 'view', + name: 'env_grid', + item: viewBody('env_grid', 'Env grid'), + }); + expect(registry.getItem('view', 'env_grid')).toBeDefined(); + + await protocol.deleteMetaItem({ type: 'view', name: 'env_grid' }); + expect(registry.getItem('view', 'env_grid')).toBeUndefined(); + }); + + it('an org-scoped delete has no plain-key entry of its own to retire', async () => { + // The whole argument for leaving `restoreArtifactRegistryView` alone: + // the delete chain is `(type, name)`-addressed and org-blind, but with + // both entry seams refusing org rows there is nothing org-scoped in the + // registry for it to mis-address. + await protocol.saveMetaItem({ + type: 'view', + name: 'org_grid', + item: viewBody('org_grid', 'Org A grid'), + organizationId: ORG_A, + }); + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + + const deleted = await protocol.deleteMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A }); + expect(deleted.success).toBe(true); + expect(registry.getItem('view', 'org_grid')).toBeUndefined(); + }); +});