diff --git a/.changeset/layered-read-org-gate-after-fold.md b/.changeset/layered-read-org-gate-after-fold.md new file mode 100644 index 0000000000..807403f7b9 --- /dev/null +++ b/.changeset/layered-read-org-gate-after-fold.md @@ -0,0 +1,15 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +`getMetaItemLayered` no longer reports a phantom org-scoped row as a tenant customization. + +`getMetaItemLayered` is the three-layer diagnostic behind Studio's "Code default vs Overlay vs Effective" view, and the third `/meta` read verb in the series `getMetaItems` (plural) and `getMetaItem` (singular) were repaired in. Unlike those two it applied no registry read gate of its own: whatever organization a caller passed was spent on whatever type it passed. On a type the registry declares `allowOrgOverride: false` — everything outside the ADR-0005 tier-A five (`view`, `dashboard`, `report`, `translation`, `email_template`) — a deployment with history can hold pre-#6190 phantom org-scoped rows, which boot hydration deliberately walks past. Read back through this verb they surfaced as `overlay` with `overlayScope: 'org'`: an operator was shown a customization that does not exist, in the one surface built to be authoritative about customizations. + +It was not only displayed. Two doors return that layer **as the response** when it is non-null — the runtime metadata dispatcher and REST `GET /meta/:type/:name/published` — so on those paths the phantom was served as the item. + +The read now resolves its organization through `organizationIdForMetaRead`, the same registry-derived predicate the REST `/meta` doors have applied since #9454 and the twin of the write side's `organizationIdForMetaWrite`. A type with a per-org read channel still resolves the caller's organization and still reports `overlayScope: 'org'`; every other type reads env-wide, which is the partition that actually runs. + +**The gate is bound after the canonical type fold, and that ordering is load-bearing.** In the two sibling verbs the binding already sat below `canonicalizeMetaRequestType`, so the fix there was a substitution. Here it sat above it, and dropping the same expression in place would have gated on the raw `/meta/:type` segment: `declaresOrgOverride` tolerates the manifest plurals but not the URL-only spellings (`translations` and `email_templates` have no manifest key), so a raw segment splits one item across two partitions, addressed by spelling. The repair is therefore a reorder, and it is pinned by a test that fails if the binding moves back above the fold. + +Callers that name no organization — four of the five `plugin-security` invocations, and every import/analytics/auth reader — are unaffected, and a door that already computed the same predicate receives the scope it did before. diff --git a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts new file mode 100644 index 0000000000..b09fba3136 --- /dev/null +++ b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts @@ -0,0 +1,495 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14907] `getMetaItemLayered` — the THIRD `/meta` read verb — applies the + * registry read gate ITSELF, so a caller cannot spend a raw active + * organization on a type that has no per-org read channel. + * + * ── The defect, and why this verb was graded on its own harm ────────────── + * + * The series is #9454 → #14683 (plural `getMetaItems`) → #14770 (singular + * `getMetaItem`) → this one. On the plural verb an ungated organization can + * only ADD a row; on the singular verb it SUBSTITUTES the served document. + * Here the affected value is the `overlay` LAYER of a three-layer diagnostic + * whose entire purpose is to answer "what did this tenant customize" — so a + * pre-#6190 phantom org-scoped row of an `allowOrgOverride: false` type is + * reported as `overlay` + `overlayScope: 'org'` and rendered by the Studio + * "Code default vs Overlay vs Effective" diff tab as evidence of a + * customization that does not exist. §1 is that case. + * + * ⚠️ It is not merely displayed. TWO doors return that layer AS the response + * when it is non-null — `runtime/src/domains/meta.ts` and `rest-server.ts`'s + * `/meta/:type/:name/published` — so on those paths the phantom is SERVED. + * §6 pins the predicate those two branches read. + * + * ── ⭐ §3 is the section that does not exist on either twin ─────────────── + * + * In both twins the fix is "replace the `orgId` binding with the gated call", + * and it is correct there because the binding already sat AFTER + * `canonicalizeMetaRequestType`. Here the binding sat BEFORE the fold, so the + * one-liner does NOT port: dropping the same expression in place would gate on + * the RAW type. #10340 measured what that costs — `declaresOrgOverride` + * tolerates the MANIFEST plurals but not the URL-only ones (`translations` / + * `email_templates` have no manifest key), so a raw segment splits one item + * across two partitions. The fix is therefore a REORDER, and §3 is what fails + * if a later author moves the binding back above the fold: it asserts that a + * URL-only spelling of an OVERRIDABLE type still reaches its org partition. + * + * ── §4–§5 are the idempotence proof the #14683 ruling made this conditional + * on, discharged over THIS door's caller population ────────────────────── + * + * That ruling makes a callee-side gate conditional on proving no already-gating + * caller is double-scoped or wrongly denied, discharged PER DOOR over that + * door's own callers. #14770's proof covers none of this verb's population, so + * it is re-discharged here: §4 covers `f(t, undefined) === undefined` (the four + * `plugin-security` invocations that name no organization) and §5 covers + * `f(t, f(t, o)) === f(t, o)` over the COMPLETE accepted-spelling population + * (the `/layers` door, which gates on the folded type and passes the raw + * segment), including the direction that idempotence must NOT be achieved by + * denying everyone. + * + * ── Why the observation channel is the WHERE multiset AND the layer ─────── + * + * `partitions()` reads the `organization_id` partitions the engine was asked + * for — the whole of what this change moves — so it is body-independent and + * covers every declared type. The layer assertions pay for that by naming the + * real `overlay` / `overlayScope` on both sides of the gate, so neither half + * rests on a query nobody proved returns a row. + */ + +import { describe, expect, it } from 'vitest'; +import { declaresOrgOverride, organizationIdForMetaRead } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_acme'; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +const storedRow = ( + type: string, + name: string, + extra: Partial = {}, +): StoredRow => ({ + id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, + type, + name, + organization_id: null, + package_id: null, + state: 'active', + // `label` is the observation channel for WHICH row became the layer — the + // two rows of a pair differ only by scope, so a served label names its + // origin. + metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), + ...extra, +}); + +/** + * The engine double: `findOne` over a row table, plus the registry surface the + * layered read touches on its way past the overlay. + * + * ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path + * under test issues exactly one verb, and a double declaring verbs no case + * exercises would owe `check:engine-double-contract` a dispatch contract that + * protects nothing. Same shape the two sibling read-gate pins drive. + */ +function makeHarness(rows: StoredRow[]) { + const findOnes: Array> = []; + const engine: any = { + async findOne(table: string, opts?: { where?: Record }) { + if (table !== 'sys_metadata') return undefined; + const where = opts?.where ?? {}; + findOnes.push({ ...where }); + // `check:where-matcher` — a hand-written matcher with no combinator + // branch reads `$and` as a field name and answers the wrong + // question rather than failing. Refuse the shape this double does + // not implement, matching the sibling doubles' convention. + for (const k of Object.keys(where)) { + if (k.startsWith('$')) { + throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + } + return rows.find((r) => + Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + return (r as unknown as Record)[k] === v; + }), + ); + }, + registry: { + registerItem: () => undefined, + registerObject: () => undefined, + listItems: () => [], + getItem: () => undefined, + getObject: () => undefined, + getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + return { protocol, findOnes }; +} + +/** Every `organization_id` partition the engine was asked for, deduplicated. */ +const partitions = (finds: Array>): Array => + [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( + (a, b) => String(a).localeCompare(String(b)), + ); + +/** The same, folded per canonical type. */ +function partitionsByType(finds: Array>): Map> { + const out = new Map>(); + for (const f of finds) { + const t = canonicalMetaUrlType(String(f.type)); + if (!out.has(t)) out.set(t, new Set()); + out.get(t)!.add((f.organization_id ?? null) as string | null); + } + return out; +} + +/** The label the `overlay` LAYER carries — `'env …'` or `'org_acme …'`. */ +const overlayLabel = (res: any): string | undefined => res?.overlay?.label; + +/** + * The COMPLETE accepted-spelling population: every URL spelling the `/meta` + * doors fold, unioned with every registry singular. Derived, never listed — + * a newly declared type arrives in this sweep on its own. + */ +const ALL_SPELLINGS: string[] = [ + ...new Set([ + ...Object.keys(META_URL_TO_SINGULAR), + ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), + ]), +].sort(); + +/** The org-overridable canonical types, derived. */ +const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY + .filter((e) => e.allowOrgOverride) + .map((e) => e.type); + +// ═══════════════════════════════════════════════════════════════════════════ +// §0 — the population this rests on, pinned so a registry change is visible +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§0 the org-overridable set', () => { + it('is exactly the ADR-0005 tier-A five', () => { + expect([...OVERRIDABLE].sort()).toEqual( + ['dashboard', 'email_template', 'report', 'translation', 'view'], + ); + }); + + it("`object` — the type the reachable ungated callers can carry — is NOT overridable", () => { + expect(OVERRIDABLE).not.toContain('object'); + expect(organizationIdForMetaRead('object', ORG)).toBeUndefined(); + }); + + it("`permission` — the type every `plugin-security` call site hard-codes — is NOT overridable", () => { + // The premise of the third moving caller named in the gate's comment: + // `permission-set-projection.ts` forwards `evt.organizationId` into + // this verb on `type: 'permission'`. If that flag ever flips, that + // caller stops moving and the comment must be re-derived. + expect(OVERRIDABLE).not.toContain('permission'); + expect(organizationIdForMetaRead('permission', ORG)).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §1 — ⭐ THE CARD'S CASE: a phantom must not become the `overlay` layer +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§1 a pre-#6190 phantom is not reported as a customization', () => { + const rows = () => [ + // The live env-wide document. + storedRow('object', 'showcase_task'), + // The phantom: an org-scoped row of an `allowOrgOverride: false` type. + // `loadMetaFromDb` walks past it at boot, so it is dead — it exists + // only because the runtime used to stamp `organization_id` on every + // type before #6190. + storedRow('object', 'showcase_task', { organization_id: ORG }), + ]; + + it('reports the env-wide row to a caller that passes a RAW active organization', async () => { + const { protocol, findOnes } = makeHarness(rows()); + const res = await protocol.getMetaItemLayered({ + type: 'object', + name: 'showcase_task', + organizationId: ORG, + }); + expect(overlayLabel(res)).toBe('env showcase_task'); + // ⭐ The field the Studio diff tab renders as "this tenant customized + // it". A phantom must never reach it. + expect(res.overlayScope).toBe('env'); + // And the phantom's partition was never even read — the gate resolves + // to `undefined`, so the `if (orgId)` arm is skipped whole. + expect(partitions(findOnes)).toEqual([null]); + }); + + it('answers identically whether or not the caller names the organization', async () => { + const withOrg = makeHarness(rows()); + const withoutOrg = makeHarness(rows()); + const a = await withOrg.protocol.getMetaItemLayered({ + type: 'object', name: 'showcase_task', organizationId: ORG, + }); + const b = await withoutOrg.protocol.getMetaItemLayered({ + type: 'object', name: 'showcase_task', + }); + expect(overlayLabel(a)).toBe(overlayLabel(b)); + expect(a.overlayScope).toBe(b.overlayScope); + expect(partitions(withOrg.findOnes)).toEqual(partitions(withoutOrg.findOnes)); + }); + + it('holds for every non-overridable declared type, not just `object`', async () => { + const nonOverridable = DEFAULT_METADATA_TYPE_REGISTRY + .filter((e) => !e.allowOrgOverride) + .map((e) => e.type); + expect(nonOverridable.length).toBeGreaterThan(5); + for (const type of nonOverridable) { + const { protocol, findOnes } = makeHarness([ + storedRow(type, 'probe'), + storedRow(type, 'probe', { organization_id: ORG }), + ]); + const res = await protocol.getMetaItemLayered({ + type, name: 'probe', organizationId: ORG, + }); + expect(overlayLabel(res), `${type} reported the phantom`).toBe('env probe'); + expect(res.overlayScope, `${type} claimed an org customization`).toBe('env'); + expect(partitions(findOnes), `${type} read the org partition`).toEqual([null]); + } + }); + + it('reports NO overlay at all when the phantom is the only row', async () => { + // The sharpest shape: nothing env-wide exists, so before this gate the + // diagnostic answered `overlay: , overlayScope: 'org'` — a + // customization claim with no live document behind it at all. + const { protocol, findOnes } = makeHarness([ + storedRow('object', 'ghost', { organization_id: ORG }), + ]); + const res = await protocol.getMetaItemLayered({ + type: 'object', name: 'ghost', organizationId: ORG, + }); + expect(res.overlay).toBeNull(); + expect(res.overlayScope).toBeNull(); + expect(partitions(findOnes)).toEqual([null]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §2 — the gate must not be achieved by denying everyone +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§2 an overridable type still reports its org-scoped customization', () => { + it('prefers the org row and says `overlayScope: org`', async () => { + const { protocol } = makeHarness([ + storedRow('view', 'task_list'), + storedRow('view', 'task_list', { organization_id: ORG }), + ]); + const res = await protocol.getMetaItemLayered({ + type: 'view', name: 'task_list', organizationId: ORG, + }); + expect(overlayLabel(res)).toBe(`${ORG} task_list`); + expect(res.overlayScope).toBe('org'); + }); + + it('falls back to the env-wide row when the org has no overlay of its own', async () => { + const { protocol, findOnes } = makeHarness([storedRow('view', 'task_list')]); + const res = await protocol.getMetaItemLayered({ + type: 'view', name: 'task_list', organizationId: ORG, + }); + expect(overlayLabel(res)).toBe('env task_list'); + expect(res.overlayScope).toBe('env'); + // BOTH partitions read — the org one first, the env-wide one as the + // fallback. (`partitions` returns a SORTED set, so this asserts + // membership, not read order.) + expect(partitions(findOnes)).toEqual([null, ORG]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §3 — ⭐ THE REORDER: the gate reads the FOLDED type, never the raw segment +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§3 the gate resolves AFTER canonicalizeMetaRequestType', () => { + /** + * The URL-only spellings — accepted at `/meta/:type` and folded by this + * method's first statement, but absent from the manifest map + * `declaresOrgOverride` tolerates. Derived from the predicate itself, so + * this cannot go stale against a changed map: a spelling qualifies when it + * folds to an OVERRIDABLE canonical type and yet answers `false` raw. + */ + const URL_ONLY_OVERRIDABLE = ALL_SPELLINGS.filter( + (s) => declaresOrgOverride(canonicalMetaUrlType(s)) && !declaresOrgOverride(s), + ); + + it('the #10340 hazard is real on this population — it is not an empty sweep', () => { + // ⛔ Positive control. Without this the three assertions below could + // pass over zero spellings and read as proof of nothing. The card names + // `translations` and `email_templates`; the derivation must find them. + expect(URL_ONLY_OVERRIDABLE.length).toBeGreaterThan(0); + expect(URL_ONLY_OVERRIDABLE).toContain('translations'); + expect(URL_ONLY_OVERRIDABLE).toContain('email_templates'); + }); + + it('a URL-only spelling of an overridable type still reaches its org partition', async () => { + // ⭐ THE PIN THAT FAILS IF THE BINDING MOVES BACK ABOVE THE FOLD. + // Gated raw, `organizationIdForMetaRead('translations', ORG)` is + // `undefined` and this read never asks for the org partition — one item + // split across two partitions, addressed by spelling. Gated folded, it + // is `ORG` and the org row wins. + for (const spelling of URL_ONLY_OVERRIDABLE) { + const canonical = canonicalMetaUrlType(spelling); + const { protocol, findOnes } = makeHarness([ + storedRow(canonical, 'greeting'), + storedRow(canonical, 'greeting', { organization_id: ORG }), + ]); + const res = await protocol.getMetaItemLayered({ + type: spelling, name: 'greeting', organizationId: ORG, + }); + expect(overlayLabel(res), spelling).toBe(`${ORG} greeting`); + expect(res.overlayScope, spelling).toBe('org'); + // ONLY the org partition: the org row wins, so the `overlay === + // null` env fallback never runs. Gated on the raw segment this + // list is `[null]` instead — the partition split #10340 measured. + expect(partitions(findOnes), spelling).toEqual([ORG]); + } + }); + + it('answers the URL spelling and the canonical spelling identically', async () => { + // The #10340 statement restated as an equality: one item, ONE + // partition, whichever accepted spelling addresses it. + for (const spelling of URL_ONLY_OVERRIDABLE) { + const canonical = canonicalMetaUrlType(spelling); + const rows = () => [ + storedRow(canonical, 'greeting'), + storedRow(canonical, 'greeting', { organization_id: ORG }), + ]; + const viaUrl = makeHarness(rows()); + const viaCanonical = makeHarness(rows()); + const a = await viaUrl.protocol.getMetaItemLayered({ + type: spelling, name: 'greeting', organizationId: ORG, + }); + const b = await viaCanonical.protocol.getMetaItemLayered({ + type: canonical, name: 'greeting', organizationId: ORG, + }); + expect(overlayLabel(a), spelling).toBe(overlayLabel(b)); + expect(a.overlayScope, spelling).toBe(b.overlayScope); + expect(partitions(viaUrl.findOnes), spelling) + .toEqual(partitions(viaCanonical.findOnes)); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §4 — idempotence, leg 1: `f(t, undefined) === undefined` +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§4 a caller that names no organization is untouched', () => { + it('reads only the env-wide partition, for every accepted spelling', async () => { + // The population that covers the four `plugin-security` invocations + // naming no organization — `packaged-permission-set-lock-gate.ts`'s + // authoring gate and three of `permission-set-projection.ts`'s reads: + // the predicate short-circuits on `undefined` before it ever consults + // the registry flag. + for (const spelling of ALL_SPELLINGS) { + const { protocol, findOnes } = makeHarness([]); + await protocol.getMetaItemLayered({ type: spelling, name: 'probe' }) + .catch(() => undefined); + for (const [type, parts] of partitionsByType(findOnes)) { + expect([...parts], `${spelling} → ${type}`).toEqual([null]); + } + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §5 — idempotence, leg 2: `f(t, f(t, o)) === f(t, o)` over the whole +// accepted-spelling population +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§5 an already-gating caller receives the same scope it did before', () => { + it('the predicate is idempotent for every accepted spelling', () => { + for (const spelling of ALL_SPELLINGS) { + const once = organizationIdForMetaRead(spelling, ORG); + expect(organizationIdForMetaRead(spelling, once), spelling).toBe(once); + } + }); + + it('the REST `/layers` door gates on the string this method folds to', () => { + // The door computes `organizationIdForMetaRead(canonicalMetaUrlType( + // req.params.type), layeredCtx?.tenantId)` and then passes `type: + // req.params.type` — the RAW segment. This method's first statement + // folds that segment through `canonicalizeMetaRequestType`, which IS + // `canonicalMetaUrlType`. So the gate inside reads the identical STRING + // the door gated on, and the second application is the algebraic no-op. + for (const spelling of ALL_SPELLINGS) { + const doorGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); + const innerGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), doorGate); + expect(innerGate, spelling).toBe(doorGate); + } + }); + + it('a gated door reading an overridable type still reaches the org partition', async () => { + // The other direction of the no-op: a door that already resolved `ORG` + // for `view` gets the org partition read, exactly as before. + const gated = organizationIdForMetaRead('view', ORG); + expect(gated).toBe(ORG); + const { protocol, findOnes } = makeHarness([]); + await protocol.getMetaItemLayered({ + type: 'view', name: 'probe', organizationId: gated, + }); + expect(partitions(findOnes)).toEqual([null, ORG]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §6 — the two doors that return the `overlay` layer AS the response +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§6 the branch both serving doors read does not fire on a phantom', () => { + /** + * `runtime/src/domains/meta.ts` and `rest-server.ts`'s + * `/meta/:type/:name/published` both spell the same test: + * + * if (layered?.overlay !== undefined && layered?.overlay !== null) + * + * and then return `layered.overlay` as the response body. Both hand this + * method a RAW active organization on a `type` taken off the URL, so + * neither is confined to the overridable five. This asserts the predicate + * they read, on the input that used to make it true wrongly. + */ + const serveBranchFires = (layered: any): boolean => + layered?.overlay !== undefined && layered?.overlay !== null; + + it('does not fire for a non-overridable type whose ONLY row is an org phantom', async () => { + const { protocol } = makeHarness([ + storedRow('object', 'ghost', { organization_id: ORG }), + ]); + const layered = await protocol.getMetaItemLayered({ + type: 'object', name: 'ghost', organizationId: ORG, + }); + expect(serveBranchFires(layered)).toBe(false); + }); + + it('still fires for an overridable type with a real org-scoped publish', async () => { + // #8805's case, which the `/published` door exists to serve: a `view` + // published into the caller's organization must still be served. + const { protocol } = makeHarness([ + storedRow('view', 'task_list', { organization_id: ORG }), + ]); + const layered = await protocol.getMetaItemLayered({ + type: 'view', name: 'task_list', organizationId: ORG, + }); + expect(serveBranchFires(layered)).toBe(true); + expect(overlayLabel(layered)).toBe(`${ORG} task_list`); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts index 5a606b39e2..af3287f206 100644 --- a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts @@ -360,15 +360,24 @@ describe('[#5707] the layered read stops painting an outage as "nothing was cust // `try`, so the swallow hid BOTH: an env-wide overlay row that was // perfectly readable was reported as "no overlay" because the org read // failed ahead of it. + // + // ⚠️ [#14907] `view`, not `object` — and the type is now load-bearing + // rather than incidental. This verb gates its organization through + // `organizationIdForMetaRead`, so an `allowOrgOverride: false` type + // (`object`, which the rest of this file uses as its generic subject) + // resolves `orgId` to `undefined` and never issues an org-scope read at + // all. There would be no failing read to report, and this case would + // pass vacuously against a method that had stopped doing the thing it + // is about. `view` is tier-A, so the org arm is really taken. const err = connectionRefused(); const engine = engineWithRows([]); engine.findOne = vi.fn(async (_o: string, opts: any) => { if (opts?.where?.organization_id === 'org_acme') throw err; - return { type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) }; + return { type: 'view', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) }; }); const caught = await rejection( - () => p_layered(engine, { type: 'object', name: 'acct', organizationId: 'org_acme' }), + () => p_layered(engine, { type: 'view', name: 'acct', organizationId: 'org_acme' }), ); expectStoreUnavailable(caught, err); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 448267cdff..f5f22093b8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7809,12 +7809,106 @@ export class ObjectStackProtocolImplementation implements deletable: boolean; resettable: boolean; }> { - const orgId = request.organizationId; - // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The // three-layer diagnostic must answer for ONE namespace, or `code` and // `overlay` can be read from two. request = canonicalizeMetaRequestType(request); + + // ── [#14907] The registry read gate, resolved AFTER the fold ───────── + // + // {@link organizationIdForMetaRead} — the predicate the REST `/meta` + // read doors have applied since #9454, twin of the write side's + // `organizationIdForMetaWrite` (#6190 / #7018) and the same gate + // `getMetaItems` (#14683) and `getMetaItem` (#14770) now carry. Until + // this line `getMetaItemLayered` — the third `/meta` read verb — + // applied NO gate of its own: whatever organization arrived was spent + // on whatever type arrived. + // + // ⛔ THE BINDING MOVED, and that reorder IS the fix. It used to sit + // ABOVE the fold, so dropping the sibling verbs' one-liner in place + // would have gated on the RAW type. #10340 measured what that costs: + // `declaresOrgOverride` tolerates the MANIFEST plurals but not the + // URL-only ones (`translations` / `email_templates` have no manifest + // key), so a raw segment splits one item across two partitions. Both + // REST callers below hand this method an unfolded `/meta/:type` + // segment, so gating before the fold would have been wrong on exactly + // the types the gate exists to admit. + // + // ⭐ THE HARM IS A FALSE POSITIVE CUSTOMIZATION CLAIM — which is why + // this verb was graded on its own rather than inheriting either twin's. + // On the plural verb a phantom can only ADD a row; on the singular verb + // it REPLACES the served document. Here the affected value is the + // `overlay` layer of a three-layer diagnostic whose entire purpose is + // to answer "what did this tenant customize", so a pre-#6190 phantom + // org-scoped row of an `allowOrgOverride: false` type is rendered by + // the Studio "Code default vs Overlay vs Effective" diff tab as + // evidence of a customization that does not exist. It compounds at the + // two doors that return the `overlay` layer AS the response: there the + // phantom is not merely displayed, it is served. + // + // ── The idempotence proof, discharged over THIS door's callers ────── + // + // Let `f(t, o) = organizationIdForMetaRead(t, o)`. `f` answers `o` when + // the registry declares `t` per-org overridable and `undefined` + // otherwise, so `f(t, undefined) === undefined` and + // `f(t, f(t, o)) === f(t, o)` for every `t` and `o`. #14683's and + // #14770's proofs do NOT carry: the #14683 ruling discharges this per + // door over that door's OWN caller population, and this verb's is a + // different set. Enumerated by grepping every `getMetaItemLayered(` + // invocation in the repo and tracing each `organizationId` argument to + // its source — no caller is denied a partition it can legitimately + // read: + // + // • `rest-server.ts`'s `/meta/:type/:name/layers` door (and the + // deprecated `?layers=` flag, which delegates to the same helper) + // computes `organizationIdForMetaRead(canonicalMetaUrlType( + // req.params.type), ctx?.tenantId)` and then passes `type: + // req.params.type`, the RAW segment. The fold above IS + // `canonicalMetaUrlType`, so `request.type` here is the identical + // STRING the door gated on — the algebraic no-op. + // • Four of the five `plugin-security` invocations name no + // organization at all (`packaged-permission-set-lock-gate.ts`'s + // authoring gate, and three of `permission-set-projection.ts`'s + // reads), so `f(t, undefined) === undefined` leaves them reading + // exactly what they read today. `permission-set-overlay-discard.ts` + // only feature-detects this method and delegates to + // `projectPermissionMutation`; it is not a call site. + // + // ⇒ THREE callers move, and each one is the defect. ⚠️ Only the first + // was named by the enumeration this repair was dispatched with, which + // asserted that the runtime dispatcher was the ONLY reachable site, + // that BOTH REST doors already gated, and that every `plugin-security` + // site passed no organization. The last two were measured false here — + // in the direction that adds callers to this gate, never one that + // removes them: + // + // • `runtime/src/domains/meta.ts` — `resolveActiveOrganizationId` + // straight into the request, on a `type` taken off the URL, so it is + // not confined to the overridable five. Its `if (layered?.overlay != + // null)` branch returns the overlay layer AS the response. + // • `rest-server.ts`'s `/meta/:type/:name/published` door — the REST + // twin of that dispatcher branch, and the door recorded as already + // gating. It passes `publishedCtx.tenantId` RAW and then + // `res.json(layered.overlay)`. Its own comment argues the raw tenant + // "is right for a READ" because the overlay lookup is + // org-first-then-env, so "nothing that resolves today stops + // resolving" — precisely the argument {@link + // organizationIdForMetaRead} was written to refute: failing open in + // that direction is what RESURRECTS the phantoms, which is why + // #6190 stopped minting them and why boot hydration walks past the + // survivors. + // • `permission-set-projection.ts`'s post-mutation read forwards + // `evt.organizationId` — a raw active organization on + // `type: 'permission'`, which is `allowOrgOverride: false`. Moving + // it makes the projection read the partition boot hydration + // actually serves: the same correction, one caller further out. + // + // ⚠️ ONE resolution for BOTH overlay arms, deliberately — the + // org-scoped read and the env-wide fallback below both spend this + // binding, and gating only the first would leave `overlayScope: 'org'` + // reachable for a type with no per-org read channel. + const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // ── code layer: MetadataService.get + registry, BYPASSING overlay ── let code: unknown | null = null; let codeDegraded: { degraded: boolean; errors: string[] } | undefined;