From f565417ee52d62bc9d84c3da656d12571f87f21d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:32:23 +0000 Subject: [PATCH 1/6] fix(metadata-protocol)!: the _lock gate's overlay limb reads the row the read serves for the organization getEffectiveLock's overlay limb asked for organization_id equal to the request's organization only, while getMetaItem and getMetaItemLayered resolve the org-scoped row, else the env-wide row (ADR-0005). An env-wide row declaring _lock: full read locked for an organization with no row of its own, and that organization's save, publish, rollback and delete were admitted. One resolution, findServedOverlayRow, now serves both reads (the draft preview arm included) and the gate's overlay limb, behind the reads' own organizationIdForMetaRead gate. The family's enumeration pin drives the read and the door over topology x row scope x request scope x every lock level x operation on one protocol instance per row. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../src/protocol.lock-org-axis-agree.test.ts | 283 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 252 +++++++++------- 2 files changed, 419 insertions(+), 116 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts 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..abf3ecb59e3 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -0,0 +1,283 @@ +// 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. + * + * It sits above PR #21693's and PR #21715's per-case pins and replaces neither. + * + * `@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 `view` row, as an author's save of a body declaring `_lock` leaves it at rest. */ +function viewRow(name: string, organizationId: string | null, lock: Lock): StoredRow { + return { + id: `r_${name}_${organizationId ?? 'env'}`, + type: 'view', + 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, +): 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: 'view', name, item: { name, label: name, object: 'account' }, ...scope }) + : protocol.deleteMetaItem({ type: 'view', 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, `view/${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) { + const scope = organizationId ? { organizationId } : {}; + const byName: any = await protocol.getMetaItem({ type: 'view', name, ...scope }); + const layered: any = await protocol.getMetaItemLayered({ type: 'view', name, ...scope }); + const pick = (r: any) => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); + expect(pick(layered), `view/${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); + }); + } + } + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bdd30cd1078..22f04c89253 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9334,6 +9334,71 @@ 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. A row stored under the type's other spelling is the last + * resort in each scope. + * + * `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. + * + * 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; + }): 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) 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 +9415,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 +9564,16 @@ 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 } : {}), + }))?.row; if (draftRec) { const draftItem = this.convertStoredItem( String(draftRec.type ?? request.type), @@ -9555,43 +9604,19 @@ 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 } : {}), + }))?.row; // [#20946] The stored-row half — see `shippedFlowActiveRead` above. if (record && !shippedFlowActiveRead) { item = this.convertStoredItem( @@ -10119,52 +10144,23 @@ 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 } : {}), + }); + 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 +16148,30 @@ 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. * * `'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 +16263,17 @@ 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. 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 }); + }); + const row = served?.row; if (row) { const body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; const p = extractProtection(body); From 94f22d15d4e5bd1208145b51f0165c922cb98580 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:46:38 +0000 Subject: [PATCH 2/6] fix(metadata-protocol): the _lock gate keeps to the canonical spelling inside the shared served-row resolution The reads' at-rest tolerance for a row stored under the type's other spelling is not extended into the write path: a write addresses the canonical namespace only. It is the one declared difference, a parameter of findServedOverlayRow rather than a second query. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../src/protocol.lock-org-axis-agree.test.ts | 5 ++++ packages/metadata-protocol/src/protocol.ts | 26 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) 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 index abf3ecb59e3..3edad5f19a5 100644 --- a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -29,6 +29,11 @@ * * 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'; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 22f04c89253..e6a207612cc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9348,13 +9348,22 @@ export class ObjectStackProtocolImplementation implements * 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. A row stored under the type's other spelling is the last - * resort in each scope. + * 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. */ @@ -9364,6 +9373,7 @@ export class ObjectStackProtocolImplementation implements 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 => { @@ -9387,7 +9397,7 @@ export class ObjectStackProtocolImplementation implements return await this.engine.findOne('sys_metadata', { where: base }); }; const rec = await lookup(args.type); - if (rec) return rec; + if (rec || !args.otherSpelling) return rec; const alt = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; return alt ? await lookup(alt) : undefined; }; @@ -9573,6 +9583,7 @@ export class ObjectStackProtocolImplementation implements orgId, state: 'draft', ...(request.packageId ? { packageId: request.packageId } : {}), + otherSpelling: true, }))?.row; if (draftRec) { const draftItem = this.convertStoredItem( @@ -9616,6 +9627,7 @@ export class ObjectStackProtocolImplementation implements orgId, state: readState, ...(request.packageId ? { packageId: request.packageId } : {}), + otherSpelling: true, }))?.row; // [#20946] The stored-row half — see `shippedFlowActiveRead` above. if (record && !shippedFlowActiveRead) { @@ -10153,6 +10165,7 @@ export class ObjectStackProtocolImplementation implements orgId, state: 'active', ...(request.packageId ? { packageId: request.packageId } : {}), + otherSpelling: true, }); if (served) { const rec = served.row; @@ -16171,7 +16184,8 @@ export class ObjectStackProtocolImplementation implements * 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. + * 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 @@ -16265,13 +16279,15 @@ export class ObjectStackProtocolImplementation implements // 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. + // see this method's header. Canonical spelling only (#4432), the + // one declared difference — see {@link findServedOverlayRow}. try { const served = await this.findServedOverlayRow({ type: canonicalType, name, orgId: organizationIdForMetaRead(canonicalType, organizationId ?? undefined), state: 'active', + otherSpelling: false, }); const row = served?.row; if (row) { From 7b37480d8c590310eb7f984d6c364ea049433368 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:48:32 +0000 Subject: [PATCH 3/6] test(metadata-protocol): pin the organization gate the _lock door shares with the reads Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../src/protocol.lock-org-axis-agree.test.ts | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) 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 index 3edad5f19a5..5b693b4cc88 100644 --- a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -26,6 +26,8 @@ * 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. * @@ -55,11 +57,11 @@ interface StoredRow { metadata: string; } -/** A tenant-authored `view` row, as an author's save of a body declaring `_lock` leaves it at rest. */ -function viewRow(name: string, organizationId: string | null, lock: Lock): StoredRow { +/** 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: 'view', + type, name, organization_id: organizationId, package_id: null, @@ -137,17 +139,18 @@ const ITEM_LOCKED: Verdict = { refused: { code: 'ITEM_LOCKED', status: 403 } }; */ 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: 'view', name, item: { name, label: name, object: 'account' }, ...scope }) - : protocol.deleteMetaItem({ type: 'view', name, ...scope })); + ? 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, `view/${name} ${operation}: not refused, yet the _lock gate was never reached`).toHaveBeenCalledTimes(1); + 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 { @@ -156,12 +159,12 @@ async function door( } /** Both reads' envelopes for the same request; they must agree with each other first. */ -async function envelope(protocol: ObjectStackProtocolImplementation, name: string, organizationId?: string) { +async function envelope(protocol: ObjectStackProtocolImplementation, name: string, organizationId?: string, type = 'view') { const scope = organizationId ? { organizationId } : {}; - const byName: any = await protocol.getMetaItem({ type: 'view', name, ...scope }); - const layered: any = await protocol.getMetaItemLayered({ type: 'view', name, ...scope }); + 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), `view/${name}: the two reads disagree`).toEqual(pick(byName)); + 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 }; } @@ -286,3 +289,28 @@ describe('[#21716] pin 3 — both rows present: the door binds the lock of the r } } }); + +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); + }); + } + } +}); From 2fdd67c33bff6f9eb2c1720bc39ea9b52634e5e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:50:31 +0000 Subject: [PATCH 4/6] chore(changeset): the _lock gate reads the row the read serves for the organization Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .changeset/21716-lock-org-axis-agree.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/21716-lock-org-axis-agree.md diff --git a/.changeset/21716-lock-org-axis-agree.md b/.changeset/21716-lock-org-axis-agree.md new file mode 100644 index 00000000000..ff77038455d --- /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. 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. From 87e350bbafe06af2b03431c6379c41c6078de950 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:01:40 +0000 Subject: [PATCH 5/6] chore(scripts): record the org-axis pin's findOne double in the engine-double ledger Written by check-engine-double-contract --write: 1 added, 0 lost. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) 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", From f508c21ce30f499c5125858d8bc55023890e874c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:46:22 +0000 Subject: [PATCH 6/6] chore(changeset): state the one getMetaItemLayered change the shared row resolution makes The layered read 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. No live writer stores a null body. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .changeset/21716-lock-org-axis-agree.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21716-lock-org-axis-agree.md b/.changeset/21716-lock-org-axis-agree.md index ff77038455d..835c60a360a 100644 --- a/.changeset/21716-lock-org-axis-agree.md +++ b/.changeset/21716-lock-org-axis-agree.md @@ -12,4 +12,4 @@ Clause-②: no (narrowing) **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. 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. +**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.