diff --git a/.changeset/21716-lock-org-axis-agree.md b/.changeset/21716-lock-org-axis-agree.md new file mode 100644 index 00000000000..835c60a360a --- /dev/null +++ b/.changeset/21716-lock-org-axis-agree.md @@ -0,0 +1,15 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the ADR-0010 `_lock` gate reads the row the read serves for the request's organization, so an env-wide row's lock binds an organization with no row of its own (#21716) + +Clause-②: no (narrowing) + + + +**BREAKING**: an organization-scoped `/meta` write that used to be accepted can now be refused. The item reads (`getMetaItem`, `getMetaItemLayered`) serve an organization its own stored row, and the env-wide row when it has none (ADR-0005). The item-level `_lock` gate read only the organization's own row. So when the env-wide row declared a lock, an organization with no row of its own read `lock: "full"` and `editable: false`, while its `saveMetaItem`, `publishMetaItem`, `rollbackMetaItem` and `deleteMetaItem` were admitted. The gate now reads the row the reads serve, through the same resolution, and answers `403 ITEM_LOCKED` where the envelope says the write is not allowed. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed. + +**What is refused now.** On every kernel topology, a save, publish or rollback with an organization of an item whose env-wide stored row declares `_lock: "no-overlay"` or `"full"`, and a delete with an organization of one whose env-wide row declares `"no-delete"` or `"full"`, when that organization has no stored row of its own for the item. Each refusal writes its `denied` row to `sys_metadata_audit` under the requesting organization. Over the wire this reaches the five per-org overridable types (`view`, `dashboard`, `report`, `translation`, `email_template`): the REST and dispatcher write doors already send no organization for any other type. For those other types the reads never serve an organization-scoped row, and now neither does the gate: an in-process removal of a pre-#6190 organization-scoped row of such a type (`deletePackage`, `discardPackageDrafts`) is judged by the env-wide row's lock, the row both reads serve. If a deployment relied on writing such an item per organization: change or remove the env-wide row's lock (a `no-delete` row can still be saved, a `no-overlay` row deleted), or keep the lock and author the organization's variant under a new name. + +**Unchanged.** When the organization has a stored row of its own, that row is the one both reads serve, and its `_lock` decides, whatever the env-wide row declares (ADR-0005 precedence, never a merge). A request with no organization. The packaged artifact's lock, which still wins when it declares one. Both reads, apart from one case in `getMetaItemLayered`: it now serves an organization's own stored row whose body is JSON `null`, as `getMetaItem` already did, instead of falling back to the env-wide row, because the two reads now share one row resolution. Only residue can reach it: no live writer stores a `null` body (measured: `SysMetadataRepository.put`, the writer behind every `/meta` save, stores `{}` for an absent body; `saveMetaItem` refuses a `null` item with `400 INVALID_REQUEST`; and the only other `sys_metadata` writer, the datasource admin plugin, stores an object env-wide). The gate addresses the canonical type spelling only; the reads' last-resort read of a row stored under the type's other spelling is not extended to the write path. diff --git a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts new file mode 100644 index 00000000000..5b693b4cc88 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -0,0 +1,316 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21716, ADR-0010 §3.3 / §5, ADR-0005] One fact — "is this item locked?" — + * reported by the read and enforced by the write doors. This file closes the + * family where the two disagree: + * + * 1. #21670 — the read's envelope ignored the package door's verdict (PR #21693); + * 2. #21694 — the topology axis: the `_lock` gate never refused on a kernel + * with no `environmentId` (PR #21715); + * 3. #21716 — the organization axis: the reads resolve the org-scoped row, + * else the env-wide row, while the gate's overlay limb asked for + * `organization_id = ` only. An env-wide row + * declaring `_lock: 'full'` read locked for an organization with no row of + * its own, and that organization's save and delete were admitted. + * + * Pins, each against the real doors and the real reads, both sides on ONE + * protocol instance per row (a pin on one side only proves nothing here): + * + * 1. The family's enumeration pin. One table drives the read and the door + * over topology × row scope × request scope × every `MetadataLockSchema` + * level × operation. For every row the door admits exactly when the + * envelope says `editable` (save) / `deletable` (delete). A new axis value + * or limb that splits them turns its own row red, by name. + * 2. The measured defect, named: an env-wide `_lock: 'full'` row, an + * org-scoped read, save, delete, publish and rollback. + * 3. Precedence: with both rows present the read serves the org-scoped row, + * and the door binds THAT row's `_lock`, whatever the env-wide row says. + * 4. The organization gate: on a type with no per-org channel the reads + * never serve an org-scoped row, and neither does the door. + * + * It sits above PR #21693's and PR #21715's per-case pins and replaces neither. + * + * Every row here is stored under the canonical type spelling — every row a + * live write can mint. The reads' at-rest tolerance for the other spelling is + * the one declared difference from the gate (`findServedOverlayRow`'s + * `otherSpelling`), so it is outside this table on purpose. + * + * `@objectstack/objectql` cannot be imported here: it depends on this package. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MetadataLockSchema } from '@objectstack/spec/kernel'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ENV_ID = 'env_1'; +const ORG = 'org_a'; +type Lock = (typeof MetadataLockSchema.options)[number]; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +/** A tenant-authored row (a `view` unless named), as an author's save of a body declaring `_lock` leaves it at rest. */ +function viewRow(name: string, organizationId: string | null, lock: Lock, type = 'view'): StoredRow { + return { + id: `r_${name}_${organizationId ?? 'env'}`, + type, + name, + organization_id: organizationId, + package_id: null, + state: 'active', + metadata: JSON.stringify({ + name, + // Which row was served, readable off the document. + label: organizationId === null ? 'env-wide row' : 'org row', + object: 'account', + _provenance: 'org', + ...(lock === 'none' ? {} : { _lock: lock }), + }), + }; +} + +/** + * The engine double: `find` / `findOne` over `sys_metadata` rows, an empty + * registry (no packaged artifact, so the `_lock` gate's overlay limb is the + * one deciding), and an `insert` that records what it was handed (the gate + * writes its denial row through it) and keeps nothing. + */ +function harness(environmentId: string | undefined, rows: StoredRow[]) { + const registry = { + getArtifactItem: () => undefined, + getItem: () => undefined, + listItems: () => [], + getObject: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + ); + }; + const inserted: Array<{ table: string; values: Record }> = []; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + async insert(table: string, values: Record) { + inserted.push({ table, values }); + return {}; + }, + registry, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); + return { protocol, inserted }; +} + +const settle = (run: Promise) => run.then(() => null, (e: unknown) => e); + +type Verdict = { refused: { code: unknown; status: unknown } } | 'admitted'; +const ITEM_LOCKED: Verdict = { refused: { code: 'ITEM_LOCKED', status: 403 } }; + +/** + * The door, end to end. Refused ⇔ the ADR-0112 `ITEM_LOCKED` / 403 envelope + * came back. Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no + * refusal; whatever the write does after that (validation, a double that + * persists nothing) is not a lock verdict and is not read as one. + */ +async function door( + protocol: ObjectStackProtocolImplementation, name: string, operation: 'save' | 'delete', organizationId?: string, + type = 'view', +): Promise { + const gate = vi.spyOn(protocol as any, operation === 'save' ? 'assertLockAllowsWrite' : 'assertLockAllowsDelete'); + try { + const scope = organizationId ? { organizationId } : {}; + const outcome: any = await settle(operation === 'save' + ? protocol.saveMetaItem({ type, name, item: { name, label: name, object: 'account' }, ...scope }) + : protocol.deleteMetaItem({ type, name, ...scope })); + if (outcome instanceof Error && (outcome as any).code === 'ITEM_LOCKED') { + return { refused: { code: (outcome as any).code, status: (outcome as any).status } }; + } + expect(gate, `${type}/${name} ${operation}: not refused, yet the _lock gate was never reached`).toHaveBeenCalledTimes(1); + expect(await gate.mock.results[0]?.value).toBeNull(); + return 'admitted'; + } finally { + gate.mockRestore(); + } +} + +/** Both reads' envelopes for the same request; they must agree with each other first. */ +async function envelope(protocol: ObjectStackProtocolImplementation, name: string, organizationId?: string, type = 'view') { + const scope = organizationId ? { organizationId } : {}; + const byName: any = await protocol.getMetaItem({ type, name, ...scope }); + const layered: any = await protocol.getMetaItemLayered({ type, name, ...scope }); + const pick = (r: any) => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); + expect(pick(layered), `${type}/${name}: the two reads disagree`).toEqual(pick(byName)); + return { ...pick(byName), served: byName.item?.label as string | undefined, overlayScope: layered.overlayScope }; +} + +afterEach(() => vi.restoreAllMocks()); + +const TOPOLOGIES = [ + { kernel: 'environment', environmentId: ENV_ID }, + { kernel: 'host-config', environmentId: undefined }, +] as const; +const ROW_SCOPES = [ + { rowScope: 'env-wide row', organizationId: null }, + { rowScope: 'org-scoped row', organizationId: ORG }, +] as const; +const REQUEST_SCOPES = [ + { requestScope: 'no organization', organizationId: undefined }, + { requestScope: `organization ${ORG}`, organizationId: ORG }, +] as const; +const OPERATIONS = ['save', 'delete'] as const; + +describe('[#21716] pin 1 — the family enumeration: the door admits exactly when the read envelope says it may', () => { + const table = TOPOLOGIES.flatMap((t) => ROW_SCOPES.flatMap((r) => REQUEST_SCOPES.flatMap((q) => + MetadataLockSchema.options.flatMap((lock) => OPERATIONS.map((operation) => ({ ...t, ...r, ...q, lock, operation, + rowOrganizationId: r.organizationId, requestOrganizationId: q.organizationId })))))); + + it('the table spans every axis value, and every lock level (read off MetadataLockSchema itself)', () => { + expect(MetadataLockSchema.options).toEqual(['none', 'no-overlay', 'no-delete', 'full']); + expect(table).toHaveLength(TOPOLOGIES.length * ROW_SCOPES.length * REQUEST_SCOPES.length + * MetadataLockSchema.options.length * OPERATIONS.length); + }); + + for (const row of table) { + const title = `${row.kernel} kernel · ${row.rowScope} · request: ${row.requestScope} · _lock=${row.lock} · ${row.operation}`; + it(title, async () => { + const name = 'v_enum'; + const { protocol } = harness(row.environmentId, [viewRow(name, row.rowOrganizationId, row.lock)]); + const read = await envelope(protocol, name, row.requestOrganizationId); + const verdict = await door(protocol, name, row.operation, row.requestOrganizationId); + const allowed = row.operation === 'save' ? read.editable : read.deletable; + expect(verdict, `${title}: the door and the read envelope (${JSON.stringify(read)}) disagree`) + .toEqual(allowed ? 'admitted' : ITEM_LOCKED); + }); + } + + it('lit control: the table holds refusals and admissions on both verbs, and the org axis reaches the env-wide row', async () => { + // An org-scoped request over an env-wide `full` row is the cell this card + // was filed on: the read serves the env-wide row, so it must read locked. + const { protocol } = harness(undefined, [viewRow('v_lit', null, 'full')]); + expect(await envelope(protocol, 'v_lit', ORG)).toMatchObject({ + lock: 'full', editable: false, deletable: false, served: 'env-wide row', overlayScope: 'env', + }); + // …and an org-scoped row is never served to a request naming no organization. + const { protocol: other } = harness(undefined, [viewRow('v_lit', ORG, 'full')]); + expect(await envelope(other, 'v_lit')).toMatchObject({ lock: 'none', editable: true, deletable: true, overlayScope: null }); + expect(await door(other, 'v_lit', 'save')).toBe('admitted'); + expect(await door(other, 'v_lit', 'delete')).toBe('admitted'); + }); +}); + +describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization with no row of its own', () => { + for (const { kernel, environmentId } of TOPOLOGIES) { + it(`${kernel} kernel: the org-scoped read says locked, and save / delete are refused ITEM_LOCKED (403)`, async () => { + const { protocol, inserted } = harness(environmentId, [viewRow('v_env_full', null, 'full')]); + expect(await envelope(protocol, 'v_env_full', ORG)).toMatchObject({ + lock: 'full', editable: false, deletable: false, served: 'env-wide row', + }); + for (const operation of OPERATIONS) { + const err: any = await settle(operation === 'save' + ? protocol.saveMetaItem({ type: 'view', name: 'v_env_full', item: { name: 'v_env_full', label: 'x', object: 'account' }, organizationId: ORG }) + : protocol.deleteMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG })); + expect(err, operation).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status, lock: err.lock }, operation) + .toEqual({ code: 'ITEM_LOCKED', status: 403, lock: 'full' }); + } + // The denial is recorded against the organization that asked, as the + // ADR-0010 §3.6 trail records every refused write. + const denials = inserted.filter((r) => r.table === 'sys_metadata_audit').map((r) => r.values); + expect(denials.map((d) => ({ operation: d.operation, outcome: d.outcome, organization_id: d.organization_id, lock_state: d.lock_state }))) + .toEqual([ + { operation: 'save', outcome: 'denied', organization_id: ORG, lock_state: 'full' }, + { operation: 'delete', outcome: 'denied', organization_id: ORG, lock_state: 'full' }, + ]); + }); + + it(`${kernel} kernel: an org-scoped publish and rollback are refused the same way (the shared write gate)`, async () => { + const { protocol } = harness(environmentId, [viewRow('v_env_full', null, 'full')]); + const published: any = await settle(protocol.publishMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG })); + expect({ code: published?.code, status: published?.status }).toEqual({ code: 'ITEM_LOCKED', status: 403 }); + const rolledBack: any = await settle(protocol.rollbackMetaItem({ type: 'view', name: 'v_env_full', toVersion: 1, organizationId: ORG })); + expect({ code: rolledBack?.code, status: rolledBack?.status }).toEqual({ code: 'ITEM_LOCKED', status: 403 }); + }); + } +}); + +describe('[#21716] pin 3 — both rows present: the door binds the lock of the row the read serves', () => { + const cases: Array<{ env: Lock; org: Lock }> = [ + { env: 'full', org: 'none' }, + { env: 'none', org: 'full' }, + { env: 'no-delete', org: 'no-overlay' }, + ]; + for (const { kernel, environmentId } of TOPOLOGIES) { + for (const c of cases) { + for (const q of REQUEST_SCOPES) { + it(`${kernel} kernel · env-wide _lock=${c.env}, org _lock=${c.org} · request: ${q.requestScope}`, async () => { + const rows = [viewRow('v_both', null, c.env), viewRow('v_both', ORG, c.org)]; + const { protocol } = harness(environmentId, rows); + const read = await envelope(protocol, 'v_both', q.organizationId); + // The read serves the org-scoped row to its organization, the + // env-wide row otherwise (ADR-0005 precedence, never a merge)… + const servedLock = q.organizationId ? c.org : c.env; + expect(read).toMatchObject({ + lock: servedLock, + served: q.organizationId ? 'org row' : 'env-wide row', + overlayScope: q.organizationId ? 'org' : 'env', + }); + // …and that row's lock is the one the doors enforce. + expect(await door(protocol, 'v_both', 'save', q.organizationId)) + .toEqual(read.editable ? 'admitted' : ITEM_LOCKED); + expect(await door(protocol, 'v_both', 'delete', q.organizationId)) + .toEqual(read.deletable ? 'admitted' : ITEM_LOCKED); + }); + } + } + } +}); + +describe('[#21716] pin 4 — a type with no per-org channel: neither the read nor the door serves an org-scoped row', () => { + // `page` declares `allowOrgOverride: false`, so an org-scoped row of it is + // pre-#6190 residue boot hydration walks past. The reads gate the + // organization away (`organizationIdForMetaRead`) and serve the env-wide + // row; the door asks the same gate, so it binds that row's `_lock` too. A + // save is refused earlier, by the org-scope door (`NOT_OVERRIDABLE`), so + // the removal is the verb that reaches the `_lock` gate with an organization. + const cases: Array<{ env: Lock; org: Lock }> = [ + { env: 'full', org: 'none' }, + { env: 'none', org: 'full' }, + ]; + for (const { kernel, environmentId } of TOPOLOGIES) { + for (const c of cases) { + it(`${kernel} kernel · page: env-wide _lock=${c.env}, org-scoped residue _lock=${c.org} · delete for ${ORG}`, async () => { + const rows = [viewRow('p_both', null, c.env, 'page'), viewRow('p_both', ORG, c.org, 'page')]; + const { protocol } = harness(environmentId, rows); + const read = await envelope(protocol, 'p_both', ORG, 'page'); + expect(read).toMatchObject({ lock: c.env, served: 'env-wide row', overlayScope: 'env' }); + expect(await door(protocol, 'p_both', 'delete', ORG, 'page')) + .toEqual(read.deletable ? 'admitted' : ITEM_LOCKED); + }); + } + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bdd30cd1078..e6a207612cc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9334,6 +9334,81 @@ export class ObjectStackProtocolImplementation implements return found; } + /** + * [#21716, ADR-0005, ADR-0010 §3.3] The stored `sys_metadata` row a + * by-name read SERVES for `(type, name)` in `orgId`'s scope, and the + * scope it was read from — the ONE resolution {@link getMetaItem} (both + * its draft-preview arm and its row read), {@link getMetaItemLayered} + * and the ADR-0010 `_lock` gate's overlay limb ({@link getEffectiveLock}) + * all call. So the row whose `_lock` a read reports is the row whose + * `_lock` the write doors enforce, for every request scope. + * + * Precedence is ADR-0005's, and it is precedence, never a merge (the + * ruling is recorded in {@link getMetaItem}): the org-scoped row wins, + * and the env-wide row (`organization_id` null) is the fallback. Within + * one scope, ADR-0048 prefer-local: with a `packageId`, that package's + * row first and then the package-less row, never another package's; with + * none, any row. + * + * `orgId` arrives already gated ({@link organizationIdForMetaRead}): an + * organization only selects a row on a type the registry declares per-org + * overridable, so a pre-#6190 phantom org row is never the served one. + * + * `otherSpelling` is the one declared difference between the callers. The + * reads pass `true` and keep the at-rest tolerance they have always had: a + * row stored under the type's other spelling (pre-#4432 residue) is the + * last resort in each scope. The `_lock` gate passes `false`: a write + * addresses the canonical namespace only (#4432, #9009 — the key + * `SysMetadataRepository.whereFor` stores under), and extending a tolerant + * lookup below the folding boundary into every write is what #4432 + * refused. So the two agree on every row stored under the canonical + * spelling, which is every row a live write can mint. + * + * Returns `undefined` when neither scope holds a row. A failed read + * propagates: each caller owns its #5532 / #5706 discrimination. + */ + private async findServedOverlayRow(args: { + type: string; + name: string; + orgId: string | undefined; + state: 'active' | 'draft'; + packageId?: string; + otherSpelling: boolean; + }): Promise<{ row: any; scope: 'org' | 'env' } | undefined> { + const inScope = async (oid: string | null): Promise => { + const lookup = async (t: string): Promise => { + const base: Record = { + type: t, name: args.name, state: args.state, organization_id: oid, + }; + if (args.packageId) { + const scoped = await this.engine.findOne('sys_metadata', { + where: { ...base, package_id: args.packageId }, + }); + if (scoped) return scoped; + // ADR-0048 — no package-owned row; fall back to the GLOBAL + // (package-less) row only. Must NOT match a different + // package's row, or a collision would serve package B's + // customization for a package A read. + return await this.engine.findOne('sys_metadata', { + where: { ...base, package_id: null }, + }); + } + // No package context (legacy/runtime reader) — match any. + return await this.engine.findOne('sys_metadata', { where: base }); + }; + const rec = await lookup(args.type); + if (rec || !args.otherSpelling) return rec; + const alt = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; + return alt ? await lookup(alt) : undefined; + }; + if (args.orgId) { + const row = await inScope(args.orgId); + if (row) return { row, scope: 'org' }; + } + const row = await inScope(null); + return row ? { row, scope: 'env' } : undefined; + } + async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); @@ -9350,7 +9425,7 @@ export class ObjectStackProtocolImplementation implements // ⭐ THE SHARPER HALF, and why the plural verb's fix did not cover it. // `getMetaItems` UNIONs its two `queryByOrg` reads, so an ungated // organization can only ADD rows — the resurrection commit 96326040f closed. - // The two `findOverlay` reads below combine with `??`, which is + // The two scope reads below ({@link findServedOverlayRow}) combine as // PRECEDENCE: an ungated organization can SUBSTITUTE. On a type the // registry declares `allowOrgOverride: false`, a pre-#6190 phantom // org-scoped row — the kind `loadMetaFromDb` walks past and @@ -9499,32 +9574,17 @@ export class ObjectStackProtocolImplementation implements // item is tagged `_draft:true` so the UI can badge it. if (request.previewDrafts && readState !== 'draft') { try { - const findDraft = async (oid: string | null): Promise => { - // ADR-0048 prefer-local (parity with the active-read overlay below). - const lookup = async (t: string): Promise => { - const base: Record = { - type: t, name: request.name, state: 'draft', organization_id: oid, - }; - if (request.packageId) { - const scoped = await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: request.packageId }, - }); - if (scoped) return scoped; - // ADR-0048 — global (package-less) draft only, never - // another package's draft. - return await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: null }, - }); - } - return await this.engine.findOne('sys_metadata', { where: base }); - }; - const rec = await lookup(request.type); - if (rec) return rec; - const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) return await lookup(alt); - return undefined; - }; - const draftRec = (orgId ? await findDraft(orgId) : undefined) ?? await findDraft(null); + // [#21716] The served-row resolution, on the draft partition + // (ADR-0048 prefer-local, parity with the row read below) — see + // {@link findServedOverlayRow}. + const draftRec = (await this.findServedOverlayRow({ + type: request.type, + name: request.name, + orgId, + state: 'draft', + ...(request.packageId ? { packageId: request.packageId } : {}), + otherSpelling: true, + }))?.row; if (draftRec) { const draftItem = this.convertStoredItem( String(draftRec.type ?? request.type), @@ -9555,43 +9615,20 @@ export class ObjectStackProtocolImplementation implements // Per ADR-0005 (revised), org-scoped row wins; env-wide // (organization_id IS NULL) row is the fallback before falling // through to the in-memory registry / MetadataService. + // ADR-0048 prefer-local within each scope (a package id prefers that + // package's row, then the package-less one, mirroring + // `SchemaRegistry.getItem(type, name, pkg)`). [#21716] The ONE + // served-row resolution, which the `_lock` gate's overlay limb + // calls too — see {@link findServedOverlayRow}. try { - const findOverlay = async (oid: string | null): Promise => { - // ADR-0048 prefer-local: when a package id is supplied and two - // installed packages ship the same type/name, prefer the row owned - // by that package before falling back to first-match (package-less - // query). This mirrors `SchemaRegistry.getItem(type, name, pkg)`. - const lookup = async (t: string): Promise => { - const base: Record = { - type: t, - name: request.name, - state: readState, - organization_id: oid, - }; - if (request.packageId) { - const scoped = await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: request.packageId }, - }); - if (scoped) return scoped; - // ADR-0048 — no package-owned overlay; fall back to the - // GLOBAL (package-less) overlay only. Must NOT match a - // different package's row, or a collision would serve - // package B's customization for a package A read. - return await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: null }, - }); - } - // No package context (legacy/runtime reader) — match any. - return await this.engine.findOne('sys_metadata', { where: base }); - }; - const rec = await lookup(request.type); - if (rec) return rec; - const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) return await lookup(alt); - return undefined; - }; - const record = (orgId ? await findOverlay(orgId) : undefined) - ?? await findOverlay(null); + const record = (await this.findServedOverlayRow({ + type: request.type, + name: request.name, + orgId, + state: readState, + ...(request.packageId ? { packageId: request.packageId } : {}), + otherSpelling: true, + }))?.row; // [#20946] The stored-row half — see `shippedFlowActiveRead` above. if (record && !shippedFlowActiveRead) { item = this.convertStoredItem( @@ -10119,52 +10156,24 @@ export class ObjectStackProtocolImplementation implements let overlay: unknown | null = null; let overlayScope: 'org' | 'env' | null = null; try { - const findOverlay = async (oid: string | null) => { - // ADR-0048 prefer-local: when a package is supplied, the row - // owned by that package wins over a package-less first match. - const lookup = async (t: string) => { - const base: Record = { - type: t, name: request.name, state: 'active', organization_id: oid, - }; - if (request.packageId) { - const scoped = await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: request.packageId }, - }); - if (scoped) return scoped; - // ADR-0048 — fall back to the GLOBAL (package-less) - // overlay only, never another package's row. - return await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: null }, - }); - } - return await this.engine.findOne('sys_metadata', { where: base }); - }; - let rec = await lookup(request.type); - if (!rec) { - const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; - if (alt) rec = await lookup(alt); - } - return rec; - }; - if (orgId) { - const rec = await findOverlay(orgId); - if (rec) { - overlay = this.convertStoredItem( - String(rec.type ?? request.type), - typeof rec.metadata === 'string' ? JSON.parse(rec.metadata) : rec.metadata, - ); - overlayScope = 'org'; - } - } - if (overlay === null) { - const rec = await findOverlay(null); - if (rec) { - overlay = this.convertStoredItem( - String(rec.type ?? request.type), - typeof rec.metadata === 'string' ? JSON.parse(rec.metadata) : rec.metadata, - ); - overlayScope = 'env'; - } + // ADR-0048 prefer-local within each scope. [#21716] The ONE + // served-row resolution {@link getMetaItem} and the `_lock` gate's + // overlay limb call too — see {@link findServedOverlayRow}. + const served = await this.findServedOverlayRow({ + type: request.type, + name: request.name, + orgId, + state: 'active', + ...(request.packageId ? { packageId: request.packageId } : {}), + otherSpelling: true, + }); + if (served) { + const rec = served.row; + overlay = this.convertStoredItem( + String(rec.type ?? request.type), + typeof rec.metadata === 'string' ? JSON.parse(rec.metadata) : rec.metadata, + ); + overlayScope = served.scope; } } catch (error) { // [#5707] The same rule as the four overlay reads in @@ -16152,9 +16161,31 @@ export class ObjectStackProtocolImplementation implements * lock (ADR-0010 §3.3). * * Returns `'none'` when nothing is locked, which is the common - * case. Safe to call when `environmentId` is undefined (control- - * plane bootstrap) — the lock check is only meaningful in tenant - * scope and the caller is expected to also gate on `environmentId`. + * case. It answers alike on every topology: no `environmentId` term, + * and since #21694 neither caller gates on one. + * + * ## [#21716] The overlay limb reads the row the READ serves + * + * The overlay limb used to query one row: `organization_id` equal to the + * request's organization, or null without one. The reads resolve by + * precedence instead — the org-scoped row, else the env-wide row + * (ADR-0005) — so for an organization with no row of its own, both reads + * served the env-wide row and published its `_lock` (`editable: false` + * under `full`) while this limb found nothing and answered `'none'`: an + * org-scoped save and delete of an item ADR-0010 §3.3 declares "Overlay + * writes rejected" were admitted. The door was looser than the read on the + * organization axis, as it had been on the topology axis (#21694). + * + * So the limb now asks {@link findServedOverlayRow} — the resolution both + * reads call — with the organization gated by the same + * {@link organizationIdForMetaRead} the reads apply. When the env-wide row + * is the one served, its `_lock` binds the organization's writes; when the + * organization's own row is served, that row's `_lock` does, whatever the + * env-wide row declares (the read reports that same row). ⛔ No second + * predicate: a change to the read's precedence moves this limb with it. + * The package-agnostic arm is asked (no `packageId`), as the doors carry + * none to this gate, under the canonical spelling only (#4432 — the one + * declared difference, stated on {@link findServedOverlayRow}). * * `'none'` is a VERDICT, not a default: both callers turn it into * "allow". So it is returned only when the absence of a lock was @@ -16246,14 +16277,19 @@ export class ObjectStackProtocolImplementation implements // 2. Overlay row — addressed by the SAME canonical key the repository // stores it under (`SysMetadataRepository.whereFor`), which is what // makes this limb read the row the artifact limb already folded to. + // [#21716] …and the row the READ serves for this organization: the + // reads' own resolution, behind the reads' own organization gate — + // see this method's header. Canonical spelling only (#4432), the + // one declared difference — see {@link findServedOverlayRow}. try { - const where: Record = { + const served = await this.findServedOverlayRow({ type: canonicalType, name, + orgId: organizationIdForMetaRead(canonicalType, organizationId ?? undefined), state: 'active', - organization_id: organizationId ?? null, - }; - const row = await this.engine.findOne('sys_metadata', { where }); + otherSpelling: false, + }); + const row = served?.row; if (row) { const body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; const p = extractProtection(body); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 15771271a4e..7e6d103a1eb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -826,6 +826,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.many-data-atomic.test.ts", "verb": "delete",