diff --git a/.changeset/21738-lock-one-resolution.md b/.changeset/21738-lock-one-resolution.md new file mode 100644 index 00000000000..ace77360214 --- /dev/null +++ b/.changeset/21738-lock-one-resolution.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): the item reads take an item's ADR-0010 `_lock` from the same resolution the write doors enforce, so the read envelope and the doors agree on the artifact layer too (#21738) + +Clause-②: no + +The `_lock` gate of the write doors (save, publish, rollback, delete) resolves an item's lock from two layers in order: the packaged artifact's `_lock`, unless it is `'none'`, then the stored `sys_metadata` row's. The two item reads did not use that resolution. `getMetaItem` took the lock from its served document, onto which `mergeArtifactProtection` had copied any declared artifact `_lock`, `'none'` included. `getMetaItemLayered` (`GET /api/v1/meta/:type/:name/layers`) took it from the code layer whenever one existed. Now the gate, both reads' envelopes, the served body's lock fields and the `getMetaDiagnostics` per-type `locked` count all call one resolution, `resolveItemLock`. + +**Read answers that change**, on both kernel topologies: + +- An artifact that declares `_lock: 'none'` over a stored row that declares a lock (env-wide, or the organization's own row) now reads with the stored row's lock in `getMetaItem` and `getMetaItemLayered`. Under `'full'`, that is `editable: false` and `deletable: false`. The served body's `_lock`, the `getMetaItems` list item's `_lock` and the diagnostics `locked` count say the same. The doors already refused these writes with `403 ITEM_LOCKED`. An artifact's `'none'` declares no lock; it does not override an administrator's stored lock. +- `getMetaItemLayered` for a packaged item whose artifact declares no `_lock`, under a stored row that declares one, now reads the stored row's lock, as `getMetaItem` and the doors already did. Before, it read `lock: 'none'`, `editable: true`. +- `lockReason`, `lockSource` and `lockDocsUrl` are the binding layer's. When no layer binds, they are absent: a reason explains a refusal, and there is none. Before, they were whatever the served document carried, so an explicit `'none'` artifact's reason was reported. For the same reason, an explicit `'none'` artifact's `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl` are no longer copied onto a stored row's served body. +- A `_lock` that only a copy the doors never read declares is no longer reported as binding. Examples are a MetadataService copy the dev watcher reloaded after boot, and an item registered at runtime with no package. Such an item now reads as the doors answer it. + +**Unchanged.** Every write-door verdict, refusal code and refusal text: the gate still reads the stored row only when the artifact's lock does not bind, so a packaged lock is still answered without a store read. `provenance`, `packageId` and `packageVersion` on both reads, and the artifact's `_packageId`, `_packageVersion` and `_provenance` on served bodies. Which stored row each read serves. The one declared difference between the reads and the gate also stays: the reads still serve a row stored under the type's other (plural) spelling when no canonical row is in scope, and the gate does not read it. No export, accepted input, key or error code changes. diff --git a/packages/metadata-protocol/src/item-lock.ts b/packages/metadata-protocol/src/item-lock.ts new file mode 100644 index 00000000000..c5ea7004c07 --- /dev/null +++ b/packages/metadata-protocol/src/item-lock.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21738, ADR-0010 §3.3 / §5] THE item-lock resolution: the one answer to + * "which ADR-0010 `_lock` binds this item?", taken by every caller that + * enforces a lock or reports one. + * + * - The write doors' `_lock` gate (`getEffectiveLock` in `protocol.ts`, behind + * save, publish, rollback, delete and the package publish's promotion) + * reads its layers through {@link resolveItemLockLazily}. + * - Both item reads' protection envelope (`getMetaItem`, + * `getMetaItemLayered`, through `servedLockState`) and the per-type + * `locked` count of `getMetaDiagnostics` call {@link resolveItemLock}. + * - The lock family a served body carries (`mergeArtifactProtection`) calls + * it too, so a body never states a lock the envelope does not report. + * + * Before this module the reads took their lock from two derivations of their + * own, and neither was the door's: + * + * 1. `mergeArtifactProtection` copied any declared artifact `_lock` over the + * stored row's, an explicit `'none'` included. A packaged item whose + * artifact declared `'none'` under a stored row declaring `'full'` read + * `editable: true` while the door refused the save with `ITEM_LOCKED`. + * 2. `getMetaItemLayered` read the lock off `code ?? overlay`, so a packaged + * item whose artifact declared no lock read unlocked under a stored row + * declaring one, while `getMetaItem` and the door said locked. + * + * ## The rule + * + * The layers are read in {@link ITEM_LOCK_LAYERS} order. The first layer whose + * declared `_lock` is not `'none'` binds, and the answer carries that layer's + * lock and its prose (`_lockReason`, `_lockDocsUrl`, `_lockSource`). A layer + * that declares `'none'`, or declares nothing, does not bind. `'none'` declares + * no lock: it is not a grant over a lower layer's lock (the triage ruling on + * #21738: an artifact's explicit `'none'` leaves an administrator's stored lock + * binding, because letting it override would widen the door). When no layer + * binds, the answer is `'none'` with no prose: a lock reason explains a + * refusal, and there is none to explain. + * + * ## The layers ARE the resolution's inputs, by name + * + * {@link ITEM_LOCK_LAYERS} is both the precedence and the parameter set. The + * resolver iterates it, the input types are derived from it, and the + * acceptance pin (`protocol.lock-one-resolution.test.ts`) generates its rows + * from it. A layer added here without an axis there turns that pin's + * completeness check red, naming the layer. + * + * ⛔ Not a policy of its own. Each caller decides which document it hands each + * layer: the door hands the canonical-spelling, package-agnostic row; the reads + * hand the row they serve (see `findServedOverlayRow` for the one declared + * difference between the two). + */ +import { extractProtection, type MetadataLock, type MetadataLockSource } from '@objectstack/spec/kernel'; + +/** + * The layers an item's lock can come from, in precedence order: + * + * - `artifact`: the item a code package's loader registered (ADR-0010 §3.3, + * "an overlay cannot loosen a packaged lock"); + * - `overlay`: the stored `sys_metadata` row (ADR-0005), as the caller + * resolved it. + */ +export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const); + +/** One of {@link ITEM_LOCK_LAYERS}. */ +export type ItemLockLayer = (typeof ITEM_LOCK_LAYERS)[number]; + +/** Per layer, the document that layer contributes, or `undefined` when it has none. */ +export type ItemLockLayers = { readonly [L in ItemLockLayer]: unknown }; + +/** The resolution's answer. */ +export interface ItemLock { + /** The binding lock, or `'none'` when no layer binds. */ + readonly lock: MetadataLock; + /** The binding layer's `_lockReason`. */ + readonly lockReason: string | undefined; + /** The binding layer's `_lockDocsUrl`. */ + readonly lockDocsUrl: string | undefined; + /** The binding layer's declared `_lockSource`. */ + readonly lockSource: MetadataLockSource | undefined; + /** The layer whose lock binds, or `undefined` when none does. */ + readonly layer: ItemLockLayer | undefined; +} + +const UNLOCKED: ItemLock = Object.freeze({ + lock: 'none', + lockReason: undefined, + lockDocsUrl: undefined, + lockSource: undefined, + layer: undefined, +}); + +/** + * Resolve the item's lock from the documents its layers contribute. + * See this module's header for the rule. + */ +export function resolveItemLock(layers: ItemLockLayers): ItemLock { + for (const layer of ITEM_LOCK_LAYERS) { + const declared = extractProtection(layers[layer]); + if (declared.lock !== 'none') { + return { + lock: declared.lock, + lockReason: declared.lockReason, + lockDocsUrl: declared.lockDocsUrl, + lockSource: declared.lockSource, + layer, + }; + } + } + return UNLOCKED; +} + +/** + * {@link resolveItemLock}, reading each layer only when no layer above it + * binds. The answer is the same; what this saves is the reads below a binding + * layer. The write doors use it, so a packaged lock is answered without a + * `sys_metadata` read, and a store that cannot be read never turns a packaged + * lock's `ITEM_LOCKED` into a 503. + * + * A reader's failure propagates: each caller owns its #5532 / #5706 + * discrimination. + */ +export async function resolveItemLockLazily(read: { + readonly [L in ItemLockLayer]: () => unknown | Promise; +}): Promise { + const layers = Object.fromEntries(ITEM_LOCK_LAYERS.map((layer) => [layer, undefined])) as Record; + for (const layer of ITEM_LOCK_LAYERS) { + layers[layer] = await read[layer](); + const answer = resolveItemLock(layers); + if (answer.layer !== undefined) return answer; + } + return UNLOCKED; +} diff --git a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts new file mode 100644 index 00000000000..2802559298c --- /dev/null +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts @@ -0,0 +1,480 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21738, ADR-0010 §3.3 / §5, ADR-0005] The lock family's acceptance pin: ONE + * item-lock resolution (`resolveItemLock`, `item-lock.ts`) answers the write + * doors' `_lock` gate and both reads' envelopes, so for every input the + * resolution reads, `getMetaItem` equals `getMetaItemLayered` equals the door. + * + * The family so far: #21670 (the package door's verdict), #21694 (the topology + * axis), #21716 (the organization axis, PR #21737's 64-row enumeration pin). + * This card closes it by STRUCTURE instead of by a longer hand-named list: + * + * 1. The generated pin. Its rows are the product of the axes that decide each + * layer of the resolution (`ITEM_LOCK_LAYERS`), plus the caller's own + * axes. A completeness check turns red, naming the layer, when the + * resolution reads a layer this table has no axis for — or takes an input + * other than its layers. Every row asserts, on ONE protocol instance, that + * the two reads agree, that both report the lock the declared rule gives + * (an oracle written from the rule, not from the code), and that the door + * admits a save / delete exactly when the envelope says `editable` / + * `deletable`. PR #21737's 64 rows are a subset of this table, checked by + * name (they moved here from `protocol.lock-org-axis-agree.test.ts`, whose + * pins 2–4 stay where they are). + * 2. Position 1, named: an artifact declaring `_lock: 'none'` over a stored + * env-wide `'full'`. `'none'` declares no lock; the stored lock binds, on + * both reads, on the served body, on the diagnostics tile and at the door. + * 3. Position 2, named: a packaged item with no `_lock` and a stored `'full'` + * reads locked on `getMetaItemLayered`, as on `getMetaItem`. + * + * ## The one declared difference: a row stored under the other spelling + * + * The reads still serve a row stored under the type's other (plural) spelling + * when no canonical row is in scope (pre-#4432 at-rest residue); the `_lock` + * gate addresses the canonical spelling only (#4432's write-side rule, answer A + * on PR #21737; `findServedOverlayRow`'s `otherSpelling`). Those rows are IN + * the table: the reads report the residue's lock, and the door binds what the + * canonical layers give. Each such row asserts that difference, by name, and + * flips the day the reads' fallback is retired. + * + * ## Not an axis here + * + * A request naming a package (`?package=`, ADR-0048 prefer-local) selects the + * stored row by package on the reads while the gate asks package-agnostic. + * That is the row SELECTION, not the resolution, and is recorded on the PR as + * an acceptance note; this table names no package. + * + * `@objectstack/objectql` cannot be imported here: it depends on this package. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MetadataLockSchema, evaluateLockForDelete, evaluateLockForWrite } from '@objectstack/spec/kernel'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { ITEM_LOCK_LAYERS, resolveItemLock, type ItemLockLayer } from './item-lock.js'; + +const ENV_ID = 'env_1'; +const ORG = 'org_a'; +const PACKAGE_ID = 'com.example.pkg'; +const NAME = 'v_lock'; +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; +} + +// ── The axes ───────────────────────────────────────────────────────────────── + +/** What the loader registered for the item: nothing, an artifact with no `_lock`, or one declaring each level. */ +type ArtifactAxis = { artifact: 'absent' } | { artifact: 'no _lock' } | { artifact: 'declared'; lock: Lock }; +const ARTIFACT_VALUES: readonly ArtifactAxis[] = [ + { artifact: 'absent' }, + { artifact: 'no _lock' }, + ...MetadataLockSchema.options.map((lock) => ({ artifact: 'declared' as const, lock })), +]; + +/** The one stored row in the table: none, or env-wide / org-scoped, declaring each level, under either spelling. */ +type StoredRowAxis = + | { row: 'none' } + | { row: 'stored'; scope: 'env-wide' | 'org-scoped'; lock: Lock; spelling: 'canonical' | 'other (residue)' }; +const STORED_ROW_VALUES: readonly StoredRowAxis[] = [ + { row: 'none' }, + ...(['env-wide', 'org-scoped'] as const).flatMap((scope) => MetadataLockSchema.options.flatMap((lock) => + (['canonical', 'other (residue)'] as const).map((spelling) => ({ row: 'stored' as const, scope, lock, spelling })))), +]; + +const AXIS_VALUES = { + artifact: ARTIFACT_VALUES, + storedRow: STORED_ROW_VALUES, + requestScope: [undefined, ORG] as const, + topology: [{ kernel: 'environment', environmentId: ENV_ID }, { kernel: 'host-config', environmentId: undefined }] as const, + requestSpelling: ['view', 'views'] as const, + operation: ['save', 'delete'] as const, +}; +type AxisName = keyof typeof AXIS_VALUES; + +/** + * Per layer of the one resolution, the axes that decide the document the layer + * contributes. Keyed by `ItemLockLayer`, so a layer added to the resolution + * without an entry here fails the typecheck, and the completeness check below + * fails the run, naming it. + */ +const LAYER_AXES: { readonly [L in ItemLockLayer]: readonly AxisName[] } = { + artifact: ['artifact'], + overlay: ['storedRow', 'requestScope'], +}; +/** The caller's own axes: which kernel, how the request spells the type, which verb. */ +const CALLER_AXES: readonly AxisName[] = ['topology', 'requestSpelling', 'operation']; + +interface Row { + artifact: ArtifactAxis; + storedRow: StoredRowAxis; + requestScope: string | undefined; + topology: (typeof AXIS_VALUES.topology)[number]; + requestSpelling: (typeof AXIS_VALUES.requestSpelling)[number]; + operation: (typeof AXIS_VALUES.operation)[number]; +} + +const TABLE: Row[] = AXIS_VALUES.artifact.flatMap((artifact) => AXIS_VALUES.storedRow.flatMap((storedRow) => + AXIS_VALUES.requestScope.flatMap((requestScope) => AXIS_VALUES.topology.flatMap((topology) => + AXIS_VALUES.requestSpelling.flatMap((requestSpelling) => AXIS_VALUES.operation.map((operation) => ({ + artifact, storedRow, requestScope, topology, requestSpelling, operation, + }))))))); + +function describeArtifact(a: ArtifactAxis): string { + return a.artifact === 'declared' ? `artifact _lock=${a.lock}` : `artifact ${a.artifact}`; +} +function describeRow(r: StoredRowAxis): string { + return r.row === 'none' ? 'no stored row' : `${r.scope} row _lock=${r.lock} (${r.spelling} spelling)`; +} +function titleOf(row: Row): string { + return [ + `${row.topology.kernel} kernel`, + describeArtifact(row.artifact), + describeRow(row.storedRow), + `request: ${row.requestScope ? `organization ${row.requestScope}` : 'no organization'}`, + `/meta/${row.requestSpelling}`, + row.operation, + ].join(' · '); +} + +// ── The oracle: the declared rule, per layer, in the resolution's own order ── + +/** Is the stored row served for this request scope? (ADR-0005: org row to its organization, env-wide row to every request.) */ +function rowServed(row: Row): boolean { + if (row.storedRow.row === 'none') return false; + return row.storedRow.scope === 'env-wide' || row.requestScope === ORG; +} + +/** + * The lock each layer declares for `side` — the reads, or the door. The door + * differs on exactly one input: it does not see a row stored under the other + * spelling. + */ +const DECLARED: { readonly [L in ItemLockLayer]: (row: Row, side: 'reads' | 'door') => Lock } = { + artifact: (row) => (row.artifact.artifact === 'declared' ? row.artifact.lock : 'none'), + overlay: (row, side) => { + if (!rowServed(row) || row.storedRow.row === 'none') return 'none'; + if (side === 'door' && row.storedRow.spelling !== 'canonical') return 'none'; + return row.storedRow.lock; + }, +}; + +/** The rule: the first layer, in `ITEM_LOCK_LAYERS` order, whose declared lock is not `'none'` binds. */ +function expectedLock(row: Row, side: 'reads' | 'door'): Lock { + for (const layer of ITEM_LOCK_LAYERS) { + const lock = DECLARED[layer](row, side); + if (lock !== 'none') return lock; + } + return 'none'; +} + +// ── The harness ────────────────────────────────────────────────────────────── + +function storedRow(type: string, organizationId: string | null, lock: Lock, label: string, name = NAME): StoredRow { + return { + id: `r_${type}_${organizationId ?? 'env'}`, + type, + name, + organization_id: organizationId, + package_id: null, + state: 'active', + metadata: JSON.stringify({ + name, + label, + object: 'account', + _provenance: 'org', + ...(lock === 'none' ? {} : { _lock: lock, _lockReason: `Stored lock (${lock}).` }), + }), + }; +} + +/** What a code package's loader registers: package-stamped, with the envelope `applyProtection` writes. */ +function packagedView(a: ArtifactAxis, name = NAME): Record | undefined { + if (a.artifact === 'absent') return undefined; + return { + name, + label: 'packaged', + object: 'account', + _packageId: PACKAGE_ID, + _provenance: 'package', + ...(a.artifact === 'declared' + ? { _lock: a.lock, _lockReason: `Packaged lock (${a.lock}).`, _lockSource: 'package' } + : {}), + }; +} + +/** + * The engine double: `find` / `findOne` over `sys_metadata` rows, a registry + * whose artifact lookup answers what the loader registered, and an `insert` + * that keeps nothing (the gate writes its denial row through it). + */ +function harness(environmentId: string | undefined, rows: StoredRow[], artifact?: Record) { + const items: Record> = artifact ? { [String(artifact.name)]: artifact } : {}; + const registry = { + getArtifactItem(type: string, name: string) { + const hit = type === 'view' ? items[name] : undefined; + return hit && isCodeArtifactBody(hit) ? hit : undefined; + }, + getItem(type: string, name: string) { + return type === 'view' ? items[name] : undefined; + }, + listItems(type: string) { + return type === 'view' ? Object.values(items) : []; + }, + 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 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() { + return {}; + }, + registry, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); +} + +function harnessFor(row: Row) { + const rows = row.storedRow.row === 'none' + ? [] + : [storedRow( + row.storedRow.spelling === 'canonical' ? 'view' : 'views', + row.storedRow.scope === 'env-wide' ? null : ORG, + row.storedRow.lock, + `${row.storedRow.scope} row`, + )]; + return harness(row.topology.environmentId, rows, packagedView(row.artifact)); +} + +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 is not a lock verdict. + */ +async function door( + protocol: ObjectStackProtocolImplementation, type: string, operation: 'save' | 'delete', organizationId?: string, + name = NAME, +): 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 one request; they must agree with each other first. */ +async function envelope(protocol: ObjectStackProtocolImplementation, type: string, organizationId?: string, name = NAME) { + 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}: getMetaItemLayered disagrees with getMetaItem`).toEqual(pick(byName)); + return { ...pick(byName), byName, layered }; +} + +/** The envelope's flags for a lock, read off the lock algebra itself. */ +const flagsOf = (lock: Lock) => ({ + lock, + editable: evaluateLockForWrite(lock) === null, + deletable: evaluateLockForDelete(lock) === null, +}); + +afterEach(() => vi.restoreAllMocks()); + +// ── 1. The generated pin ───────────────────────────────────────────────────── + +describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaItem = getMetaItemLayered = the door', () => { + it('completeness: every layer the resolution reads has an axis here, and the resolution takes nothing else', () => { + const missing = ITEM_LOCK_LAYERS.filter((layer) => !Object.prototype.hasOwnProperty.call(LAYER_AXES, layer)); + expect(missing, 'resolution layers with no axis in this table').toEqual([]); + const stale = Object.keys(LAYER_AXES).filter((layer) => !(ITEM_LOCK_LAYERS as readonly string[]).includes(layer)); + expect(stale, 'axes for a layer the resolution no longer reads').toEqual([]); + expect(resolveItemLock.length, 'resolveItemLock takes exactly its layers record').toBe(1); + // Every axis is used, exactly once, and the table is their full product. + const used = [...Object.values(LAYER_AXES).flat(), ...CALLER_AXES]; + expect([...used].sort()).toEqual((Object.keys(AXIS_VALUES) as AxisName[]).sort()); + expect(new Set(used).size).toBe(used.length); + const product = (Object.keys(AXIS_VALUES) as AxisName[]) + .reduce((n, axis) => n * AXIS_VALUES[axis].length, 1); + expect(TABLE).toHaveLength(product); + expect(new Set(TABLE.map(titleOf)).size, 'row titles are unique').toBe(TABLE.length); + // Every lock level is an axis value on both layers (read off the schema itself). + expect(MetadataLockSchema.options).toEqual(['none', 'no-overlay', 'no-delete', 'full']); + }); + + it('PR #21737\'s 64-row enumeration (topology × row scope × request scope × lock × operation) is a subset of this table', () => { + const titles = new Set(TABLE.map(titleOf)); + const folded: string[] = []; + for (const topology of AXIS_VALUES.topology) for (const scope of ['env-wide', 'org-scoped'] as const) + for (const requestScope of AXIS_VALUES.requestScope) for (const lock of MetadataLockSchema.options) + for (const operation of AXIS_VALUES.operation) { + folded.push(titleOf({ + artifact: { artifact: 'absent' }, + storedRow: { row: 'stored', scope, lock, spelling: 'canonical' }, + requestScope, topology, requestSpelling: 'view', operation, + })); + } + expect(folded).toHaveLength(64); + expect(folded.filter((t) => !titles.has(t))).toEqual([]); + }); + + for (const row of TABLE) { + const title = titleOf(row); + it(title, async () => { + const protocol = harnessFor(row); + const read = await envelope(protocol, row.requestSpelling, row.requestScope); + // Both reads report the declared rule's lock. + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }, `${title}: the reads`) + .toEqual(flagsOf(expectedLock(row, 'reads'))); + // The door binds the rule's lock over the layers IT sees. + const verdict = await door(protocol, row.requestSpelling, row.operation, row.requestScope); + const doorAllows = row.operation === 'save' + ? evaluateLockForWrite(expectedLock(row, 'door')) === null + : evaluateLockForDelete(expectedLock(row, 'door')) === null; + expect(verdict, `${title}: the door`).toEqual(doorAllows ? 'admitted' : ITEM_LOCKED); + // …and the two agree: the door admits exactly when the envelope says + // it may. Except on the one declared difference + // (`findServedOverlayRow`'s `otherSpelling`): a row stored under the + // other spelling is served by the reads and not seen by the door, + // which the two oracle assertions above already state row by row — + // so those rows flip, by name, the day the reads' fallback retires. + const residue = row.storedRow.row === 'stored' && row.storedRow.spelling !== 'canonical'; + if (!residue) { + const readAllows = row.operation === 'save' ? read.editable : read.deletable; + expect(verdict, `${title}: the door and the read envelope (lock ${read.lock}) disagree`) + .toEqual(readAllows ? 'admitted' : ITEM_LOCKED); + } + }); + } + + it('lit control: the table holds refusals and admissions on both verbs, both layers bind somewhere, and the residue difference is reachable', () => { + const locks = TABLE.map((row) => ({ row, reads: expectedLock(row, 'reads'), door: expectedLock(row, 'door') })); + expect(locks.some((l) => l.row.operation === 'save' && evaluateLockForWrite(l.door) !== null)).toBe(true); + expect(locks.some((l) => l.row.operation === 'save' && evaluateLockForWrite(l.door) === null)).toBe(true); + expect(locks.some((l) => l.row.operation === 'delete' && evaluateLockForDelete(l.door) !== null)).toBe(true); + expect(locks.some((l) => l.row.operation === 'delete' && evaluateLockForDelete(l.door) === null)).toBe(true); + // An artifact's explicit `'none'` under a binding stored lock (position 1) + // and an artifact with no `_lock` under one (position 2) are both rows. + expect(locks.some((l) => l.row.artifact.artifact === 'declared' && l.row.artifact.lock === 'none' && l.reads === 'full')).toBe(true); + expect(locks.some((l) => l.row.artifact.artifact === 'no _lock' && l.reads === 'full')).toBe(true); + expect(locks.filter((l) => l.reads !== l.door).length).toBeGreaterThan(0); + }); + + it('lit control: an org-scoped request is served the env-wide row and reads its lock; an org-scoped row is never served to a request naming none', async () => { + const protocol = harness(undefined, [storedRow('view', null, 'full', 'env-wide row')]); + const read = await envelope(protocol, 'view', ORG); + expect({ lock: read.lock, served: read.byName.item?.label, overlayScope: read.layered.overlayScope }) + .toEqual({ lock: 'full', served: 'env-wide row', overlayScope: 'env' }); + const other = harness(undefined, [storedRow('view', ORG, 'full', 'org row')]); + const unscoped = await envelope(other, 'view'); + expect({ lock: unscoped.lock, overlayScope: unscoped.layered.overlayScope }).toEqual({ lock: 'none', overlayScope: null }); + expect(await door(other, 'view', 'save')).toBe('admitted'); + expect(await door(other, 'view', 'delete')).toBe('admitted'); + }); +}); + +// ── 2. Position 1, named ───────────────────────────────────────────────────── + +describe('[#21738] pin 2 — position 1: an artifact\'s explicit _lock: \'none\' does not override a stored env-wide \'full\'', () => { + const artifact = packagedView({ artifact: 'declared', lock: 'none' }, 'v_pos1')!; + const rows = () => [storedRow('view', null, 'full', 'env-wide row', 'v_pos1')]; + + for (const { kernel, environmentId } of AXIS_VALUES.topology) { + it(`${kernel} kernel: both reads say locked, the served body and the tile say so too, and save / delete are refused ITEM_LOCKED (403)`, async () => { + const protocol = harness(environmentId, rows(), artifact); + const read = await envelope(protocol, 'view', undefined, 'v_pos1'); + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) + .toEqual({ lock: 'full', editable: false, deletable: false }); + // The prose is the binding layer's — the stored row's, never the artifact's 'none'. + expect(read.byName.lockReason).toBe('Stored lock (full).'); + expect(read.layered.lockReason).toBe('Stored lock (full).'); + // Provenance is still the artifact's (mergeArtifactProtection's other fields, unchanged). + expect({ provenance: read.byName.provenance, packageId: read.byName.packageId }) + .toEqual({ provenance: 'package', packageId: PACKAGE_ID }); + expect({ provenance: read.layered.provenance, packageId: read.layered.packageId }) + .toEqual({ provenance: 'package', packageId: PACKAGE_ID }); + // The served body carries the binding lock, not the artifact's 'none'. + expect(read.byName.item?._lock).toBe('full'); + const listed: any = await protocol.getMetaItems({ type: 'view' }); + expect(listed.items.find((i: any) => i.name === 'v_pos1')?._lock).toBe('full'); + const diag: any = await protocol.getMetaDiagnostics({ type: 'view', severity: 'warning' } as any); + expect(diag.stats.view.locked).toBe(1); + + for (const operation of ['save', 'delete'] as const) { + const err: any = await settle(operation === 'save' + ? protocol.saveMetaItem({ type: 'view', name: 'v_pos1', item: { name: 'v_pos1', label: 'x', object: 'account' } }) + : protocol.deleteMetaItem({ type: 'view', name: 'v_pos1' })); + expect(err, operation).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status, lock: err.lock }, operation) + .toEqual({ code: 'ITEM_LOCKED', status: 403, lock: 'full' }); + } + }); + } +}); + +// ── 3. Position 2, named ───────────────────────────────────────────────────── + +describe('[#21738] pin 3 — position 2: getMetaItemLayered reads a stored lock under a packaged item with no _lock', () => { + const artifact = packagedView({ artifact: 'no _lock' }, 'v_pos2')!; + const rows = () => [storedRow('view', null, 'full', 'env-wide row', 'v_pos2')]; + + for (const { kernel, environmentId } of AXIS_VALUES.topology) { + it(`${kernel} kernel: the layered read says locked, as getMetaItem does, and the door refuses`, async () => { + const protocol = harness(environmentId, rows(), artifact); + const layered: any = await protocol.getMetaItemLayered({ type: 'view', name: 'v_pos2' }); + expect({ lock: layered.lock, editable: layered.editable, deletable: layered.deletable }) + .toEqual({ lock: 'full', editable: false, deletable: false }); + // The code layer is still reported as the package shipped it. + expect(layered.code).toMatchObject({ name: 'v_pos2', _packageId: PACKAGE_ID }); + expect(layered.code._lock).toBeUndefined(); + const byName: any = await protocol.getMetaItem({ type: 'view', name: 'v_pos2' }); + expect({ lock: byName.lock, editable: byName.editable, deletable: byName.deletable }) + .toEqual({ lock: layered.lock, editable: layered.editable, deletable: layered.deletable }); + expect(await door(protocol, 'view', 'save', undefined, 'v_pos2')).toEqual(ITEM_LOCKED); + expect(await door(protocol, 'view', 'delete', undefined, 'v_pos2')).toEqual(ITEM_LOCKED); + }); + } +}); 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 5b693b4cc88..b640b9c1a0f 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 @@ -17,11 +17,12 @@ * 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. + * 1. [#21738] MOVED: the family's 64-row enumeration pin (topology × row + * scope × request scope × every `MetadataLockSchema` level × operation) + * folded into the generated pin of `protocol.lock-one-resolution.test.ts`, + * whose table holds every one of those rows (checked there, by name) and + * generates its axes from the one item-lock resolution's inputs. Its lit + * control moved with it. * 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, @@ -174,55 +175,12 @@ 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 () => { 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 eb82983fb4f..a6102b9ef5d 100644 --- a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts @@ -557,15 +557,21 @@ describe('[#5840] a MetadataService outage stops arriving as "nobody declared th }); it('and that is what stops an outage from unlocking a locked artifact', async () => { - // The concrete widening. `lockSource = code ?? overlay ?? {}`, so a - // code layer that never arrived resolves the protection envelope from - // `{}` — `editable: true, deletable: true` on an item the packager - // locked. Left column: what the truth looks like. Right column: what - // the outage used to render, and now cannot. - const locked = { name: 'acct', label: 'Account', _lock: 'full' }; + // The concrete widening this was written against: the lock used to + // come from `code ?? overlay ?? {}`, so a code layer that never arrived + // resolved the protection envelope from `{}` — `editable: true, + // deletable: true` on an item the packager locked. [#21738] The lock now + // comes from the one item-lock resolution, over the artifact the + // registry holds (the layer the `_lock` gate reads), which no + // MetadataService outage can take away; the outage itself still answers + // 503. Left column: what the truth looks like — the packager's artifact, + // package-stamped, in the registry and in the service. Right column: + // what the outage used to render, and now cannot. + const locked = { name: 'acct', label: 'Account', _lock: 'full', _packageId: 'com.example.crm', _provenance: 'package' }; const healthy: any = await protocolWithService( metadataServiceHolding(locked), + { acct: locked }, ).getMetaItemLayered({ type: 'object', name: 'acct' } as any); expect(healthy.lock).toBe('full'); expect([healthy.editable, healthy.deletable]).toEqual([false, false]); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e6a207612cc..db49dc4d2fa 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -45,6 +45,7 @@ import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js'; import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js'; import { SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js'; import { packagedBaseRegimeSentence } from './packaged-base-regime.js'; +import { resolveItemLock, resolveItemLockLazily, type ItemLock } from './item-lock.js'; import { bumpWriteEpoch, metaOverlayCacheTtlMs, @@ -1715,10 +1716,20 @@ function viewIdentityPatch(overlay: Record, baseline: unknown): * always wins over whatever was persisted in the `sys_metadata` overlay * row. Returns `item` unchanged when no artifact baseline is available. * - * The artifact's `_lock`, `_lockReason`, `_packageId`, `_packageVersion`, - * and `_provenance` are the source of truth — an overlay copy may - * pre-date the artifact's protection declaration and would otherwise - * mask it. + * The artifact's `_packageId`, `_packageVersion` and `_provenance` are the + * source of truth — an overlay copy may pre-date the artifact's protection + * declaration and would otherwise mask it. + * + * [#21738] The lock family (`_lock`, `_lockReason`, `_lockDocsUrl`, + * `_lockSource`) is NOT decided here: it follows the one item-lock resolution + * ({@link resolveItemLock}). The artifact's family is copied over the item's + * only when the artifact's lock is the one that binds, which is exactly the + * case the sentence above exists for (an overlay never masks a packaged lock). + * An artifact that declares no lock, or an explicit `'none'`, leaves the + * item's own family in place. Before this, any declared artifact `_lock` was + * copied, `'none'` included, so an artifact's explicit `'none'` erased a + * stored row's `'full'` from the served body and from the read envelope while + * the write door, which skips `'none'`, still refused the save. */ function mergeArtifactProtection(item: unknown, artifactItem: unknown): unknown { if (item === undefined || item === null) return item; @@ -1726,16 +1737,28 @@ function mergeArtifactProtection(item: unknown, artifactItem: unknown): unknown const a = artifactItem as Record; if (typeof a !== 'object') return item; const out: Record = { ...(item as Record) }; - if (a._lock !== undefined) out._lock = a._lock; - if (a._lockReason !== undefined) out._lockReason = a._lockReason; - if (a._lockDocsUrl !== undefined) out._lockDocsUrl = a._lockDocsUrl; - if (a._lockSource !== undefined) out._lockSource = a._lockSource; + if (resolveItemLock({ artifact: a, overlay: item }).layer === 'artifact') { + if (a._lock !== undefined) out._lock = a._lock; + if (a._lockReason !== undefined) out._lockReason = a._lockReason; + if (a._lockDocsUrl !== undefined) out._lockDocsUrl = a._lockDocsUrl; + if (a._lockSource !== undefined) out._lockSource = a._lockSource; + } if (a._packageId !== undefined) out._packageId = a._packageId; if (a._packageVersion !== undefined) out._packageVersion = a._packageVersion; if (a._provenance !== undefined) out._provenance = a._provenance; return out; } +/** + * [#21738] The body a stored `sys_metadata` row holds, as written — the + * document a row contributes to the item-lock resolution's `overlay` layer + * ({@link resolveItemLock}), parsed the one way the `_lock` gate and both + * reads parse it. No conversion is replayed: none touches the `_lock` family. + */ +function storedRowDocument(row: { metadata?: unknown }): unknown { + return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; +} + /** * [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE * DERIVED, and therefore must never be persisted from a caller's body. @@ -7825,8 +7848,18 @@ export class ObjectStackProtocolImplementation implements // alone: a packaged base the write doors refuse reads locked on // its item envelope, so the per-type tile counts it too. The // derivation reads the registry only — no store read per item. + // [#21738] Its lock is the one item-lock resolution + // ({@link resolveItemLock}) over the item's artifact — the + // lookup the list's own merge made (ADR-0048 package scope) — + // and the document the list serves for it. const itemName = typeof item?.name === 'string' ? item.name : ''; - const served = this.servedLockState(t, itemName, item, this.isArtifactBacked(t, itemName)); + const itemLock = resolveItemLock({ + artifact: this.lookupArtifactItem( + t, itemName, request.packageId ?? (item?._packageId as string | undefined), + ), + overlay: item, + }); + const served = this.servedLockState(t, itemName, item, this.isArtifactBacked(t, itemName), itemLock); if (served.lock !== 'none') lockedCount += 1; const diag: MetadataDiagnostics | undefined = item?._diagnostics ?? computeMetadataDiagnostics(t, item); @@ -9620,6 +9653,14 @@ export class ObjectStackProtocolImplementation implements // `SchemaRegistry.getItem(type, name, pkg)`). [#21716] The ONE // served-row resolution, which the `_lock` gate's overlay limb // calls too — see {@link findServedOverlayRow}. + // + // [#21738] `overlayLockLayer` is that row's stored body, handed to the + // one item-lock resolution below as its `overlay` layer — the layer + // the `_lock` gate reads off the same resolution. Kept whether or not + // the row is ADOPTED as the served document: a shipped flow name does + // not serve its stored row (#20946), and the gate binds that row's + // `_lock` all the same. + let overlayLockLayer: unknown; try { const record = (await this.findServedOverlayRow({ type: request.type, @@ -9629,13 +9670,12 @@ export class ObjectStackProtocolImplementation implements ...(request.packageId ? { packageId: request.packageId } : {}), otherSpelling: true, }))?.row; + if (record) overlayLockLayer = storedRowDocument(record); // [#20946] The stored-row half — see `shippedFlowActiveRead` above. if (record && !shippedFlowActiveRead) { item = this.convertStoredItem( String(record.type ?? request.type), - typeof record.metadata === 'string' - ? JSON.parse(record.metadata) - : record.metadata, + storedRowDocument(record), ); // Surface the persisted software-package binding (parity with // the list path in getMetaItems) so provenance/UI can read it. @@ -9860,8 +9900,11 @@ export class ObjectStackProtocolImplementation implements // ADR-0010 — surface lock/provenance flags so Studio can render // the correct affordances without a second round trip. [#21670] They // report the write doors' verdicts — see {@link servedLockState}. + // [#21738] The lock is the one item-lock resolution's, over the layers + // this read resolved — never the served document's `_lock`. + const itemLock = resolveItemLock({ artifact: artifactItem, overlay: overlayLockLayer }); const artifactBacked = this.isArtifactBacked(request.type, request.name); - const lockState = this.servedLockState(request.type, request.name, decorated, artifactBacked); + const lockState = this.servedLockState(request.type, request.name, decorated, artifactBacked, itemLock); return { type: request.type, name: request.name, @@ -9949,12 +9992,13 @@ export class ObjectStackProtocolImplementation implements lock: MetadataLock; lockReason?: string; // `MetadataLockSource` (artifact | package | env-forced) — the only - // producer feeding this field on this path is `resolveLockState`, - // whose return is typed `MetadataLockSource | undefined`. The - // `'overlay'` arm this annotation used to carry was dead: the one - // `lockSource: 'overlay'` producer in this file belongs to - // `getEffectiveLock`, a write/delete-door helper that never feeds - // this response (commit 11b779e0f). + // producer feeding this field on this path is the item-lock + // resolution's `lockSource` ([#21738] {@link resolveItemLock}: the + // binding layer's declared `_lockSource`), typed + // `MetadataLockSource | undefined`. The `'overlay'` arm this annotation + // used to carry was dead: the door's `'artifact' | 'overlay'` is the + // resolution's `layer`, which `getEffectiveLock` reports in its refusal + // text and which never feeds this response (commit 11b779e0f). lockSource?: MetadataLockSource; lockDocsUrl?: string; provenance?: MetadataProvenance; @@ -10117,8 +10161,10 @@ export class ObjectStackProtocolImplementation implements // that carries package-provenance stamps under a name no package // ships is the same row: the hydrator restates its authorship over // whatever its bytes claim, and a stamp is not an artifact read, so - // `resolveLockState` below reads this item's code layer as `null` - // too (triage's ruling, overturnable by the maintainer). A + // the envelope's provenance fields below read this item's code + // layer as `null` too (triage's ruling, overturnable by the + // maintainer); its lock reads no code layer at all ([#21738], the + // item-lock resolution below). A // runtime-registered item with no package carries no tenant marker // and keeps its code layer. const runtimeOnly = (item: unknown): unknown => @@ -10135,14 +10181,17 @@ export class ObjectStackProtocolImplementation implements // [#5840] The code half of the rule #5707 wrote for the overlay half, // eleven lines below. `code: null` is not a shrug — this method states // it positively ("no packaged/code-layer definition exists"), and the - // response then DERIVES from it: `lockSource = code ?? overlay ?? {}` - // feeds `resolveLockState`, so an item whose code layer declares - // `_lock: 'full'` is rendered `editable: true, deletable: true` when - // the read that would have found that lock simply failed. An - // availability failure widening an affordance is precisely what - // ADR-0110 D3 forbids, and the overlay half of this very method - // already refuses to do it — the two halves were asymmetric only - // because the loader failure was invisible on this side. + // response then DERIVES from it: `effective` and the envelope's + // provenance fields. (It used to derive the lock too, from + // `code ?? overlay`, so an item whose code layer declared `_lock: + // 'full'` was rendered `editable: true, deletable: true` when the read + // that would have found that lock failed. [#21738] The lock is now the + // item-lock resolution's, over the registry's artifact lookup, which + // this read cannot lose.) An availability failure stated as an + // authorship fact is precisely what ADR-0110 D3 forbids, and the + // overlay half of this very method already refuses to do it — the two + // halves were asymmetric only because the loader failure was invisible + // on this side. // // Same narrow shape as the overlay half: the benign "nothing there" // still returns `code: null` normally (a clean miss is not degraded), @@ -10155,6 +10204,9 @@ export class ObjectStackProtocolImplementation implements // ── overlay layer: sys_metadata row (org-scoped wins, then env-wide) ── let overlay: unknown | null = null; let overlayScope: 'org' | 'env' | null = null; + // [#21738] The served row's stored body: the `overlay` layer of the one + // item-lock resolution below, as in {@link getMetaItem}. + let overlayLockLayer: unknown; try { // ADR-0048 prefer-local within each scope. [#21716] The ONE // served-row resolution {@link getMetaItem} and the `_lock` gate's @@ -10169,9 +10221,10 @@ export class ObjectStackProtocolImplementation implements }); if (served) { const rec = served.row; + overlayLockLayer = storedRowDocument(rec); overlay = this.convertStoredItem( String(rec.type ?? request.type), - typeof rec.metadata === 'string' ? JSON.parse(rec.metadata) : rec.metadata, + storedRowDocument(rec), ); overlayScope = served.scope; } @@ -10283,11 +10336,23 @@ export class ObjectStackProtocolImplementation implements // ADR-0010 — surface lock/provenance flags so the Studio editor // can render the correct affordances without a second round trip. const artifactBacked = this.isArtifactBacked(request.type, request.name); - // Lock resolution: artifact wins over overlay, matching getEffectiveLock. - const lockSource: any = code ?? overlay ?? {}; + // [#21738] The lock is the one item-lock resolution's — the + // `getMetaItem` call over this read's artifact lookup and the served + // row's stored body — never `code ?? overlay`, which took the code + // layer's (absent) lock over a stored row's `_lock`. A row-less name a + // stored container expands contributes no `overlay` layer: the + // container's row is not this name's row, and the `_lock` gate does not + // read it. The provenance fields still come from the layer the + // response reports first. + const itemLock = resolveItemLock({ + artifact: this.lookupArtifactItem(request.type, request.name, request.packageId), + overlay: overlayLockLayer, + }); // [#21670] …joined with the locked-packaged-base verdict the write // doors answer — the same derivation `getMetaItem` publishes. - const lockState = this.servedLockState(request.type, request.name, lockSource, artifactBacked); + const lockState = this.servedLockState( + request.type, request.name, code ?? overlay ?? {}, artifactBacked, itemLock, + ); // [#8154] The per-type credential redaction, on the ONE read exit // `decorateMetadataItem` does not reach — this method never calls it @@ -10310,7 +10375,7 @@ export class ObjectStackProtocolImplementation implements // rather than discover it. ⛔ It is NOT a licence to fold, govern or // inject on these layers — redaction subtracts, and only a credential. // - // Placed AFTER `_diagnostics` and AFTER `resolveLockState`, both of + // Placed AFTER `_diagnostics` and AFTER `servedLockState`, both of // which must read the raw bodies: the diagnostics ordering is the // migration-inventory badge (see `decorateMetadataItem`), and a lock // resolved from a redacted body would be a lock resolved from a @@ -15622,19 +15687,26 @@ export class ObjectStackProtocolImplementation implements /** * [#21670, ADR-0010 §5, ADR-0126 §2] The protection envelope a metadata READ * publishes beside the document — `lock`, `editable`, `deletable` and the - * rest — for `(type, name)`, whose served document is `document`. The ONE - * derivation both reads call: {@link getMetaItem} and - * {@link getMetaItemLayered} — and [#21694] the per-type `locked` count of - * {@link getMetaDiagnostics}, so the directory tile and the item agree. + * rest — for `(type, name)`, whose served document is `document` and whose + * item lock is `itemLock`. The ONE derivation both reads call: + * {@link getMetaItem} and {@link getMetaItemLayered} — and [#21694] the + * per-type `locked` count of {@link getMetaDiagnostics}, so the directory + * tile and the item agree. * * The flags are a promise about the write doors: `editable` says whether a * write of this item is refused on lock grounds, `deletable` whether its * removal is. Two limbs refuse such a write, so both are asked here, and * neither is re-derived: * - * - the item's own ADR-0010 `_lock` — `resolveLockState`, unchanged. Its - * door ({@link lockWriteRefusal} / {@link assertLockAllowsDelete}) - * answers on every topology since #21694, as the package limb does; + * - the item's own ADR-0010 `_lock` — `itemLock`, the answer of the one + * item-lock resolution ({@link resolveItemLock}, [#21738]) that the + * `_lock` gate ({@link getEffectiveLock}, behind {@link lockWriteRefusal} + * / {@link assertLockAllowsDelete}) takes too. The caller hands it the + * layers it resolved; this method never reads a lock off `document`, + * whose `_lock` used to be the reads' own derivation and disagreed with + * the gate on the artifact layer (an explicit artifact `'none'`; the + * layered read's `code ?? overlay`). The gate answers on every topology + * since #21694, as the package limb does; * - the locked packaged base: an item a code package ships, on a type with * no overlay channel — {@link packagedBaseRefusal}, the verdict the * `/meta` doors and the `/automation` doors already share (`NOT_OVERRIDABLE`, @@ -15653,9 +15725,11 @@ export class ObjectStackProtocolImplementation implements * the shape the spec declares for it: `editable` false iff `lock` is * `no-overlay` or `full`, `deletable` false iff `no-delete` or `full`. An * item's own `_lock` and the package verdict JOIN; neither replaces the - * other. `lockReason` / `lockSource` / `lockDocsUrl` stay what the document - * declares: the package limb adds no prose of its own, and `provenance` / - * `packageId` already name the package. + * other. `lockReason` / `lockSource` / `lockDocsUrl` are the binding + * layer's, from the same resolution, and absent when no layer binds: the + * package limb adds no prose of its own, and `provenance` / `packageId` / + * `packageVersion` (read off `document`, unchanged) already name the + * package. * * ⛔ Not a policy. Which writes are refused is decided at the doors; this * method only reports their answer, so a door that moves moves this read @@ -15666,11 +15740,12 @@ export class ObjectStackProtocolImplementation implements name: string, document: unknown, artifactBacked: boolean, + itemLock: ItemLock, ): ReturnType { - const declared = resolveLockState(document, artifactBacked); - const editable = declared.editable + const { provenance, packageId, packageVersion } = extractProtection(document); + const editable = evaluateLockForWrite(itemLock.lock) === null && this.packagedBaseRefusal({ type, name, operation: 'save' }) === null; - const deletable = declared.deletable + const deletable = evaluateLockForDelete(itemLock.lock) === null && this.packagedBaseRefusal({ type, name, operation: 'delete' }) === null; const lock = MetadataLockSchema.options.find((state) => (evaluateLockForWrite(state) === null) === editable @@ -15683,7 +15758,18 @@ export class ObjectStackProtocolImplementation implements `No ADR-0010 lock state answers editable=${editable}, deletable=${deletable}.`, ); } - return { ...declared, lock, editable, deletable }; + return { + lock, + lockReason: itemLock.lockReason, + lockSource: itemLock.lockSource, + lockDocsUrl: itemLock.lockDocsUrl, + provenance, + packageId, + packageVersion, + editable, + deletable, + resettable: artifactBacked, + }; } /** @@ -16164,6 +16250,18 @@ export class ObjectStackProtocolImplementation implements * case. It answers alike on every topology: no `environmentId` term, * and since #21694 neither caller gates on one. * + * ## [#21738] The rule is the one item-lock resolution's + * + * Which layer binds, and what an explicit `'none'` means, is decided by + * {@link resolveItemLock} — the resolution both reads' envelopes and the + * served body's lock family take too — never here. This method only + * gathers the two layers the gate reads (the artifact, then the overlay + * row, below) and reads the overlay row only when the artifact does not + * bind ({@link resolveItemLockLazily}), as it always has: a packaged lock + * is answered without a store read, and a store that cannot be read never + * turns its `ITEM_LOCKED` into a 503. The refusal's `source=` names the + * binding layer, as before. + * * ## [#21716] The overlay limb reads the row the READ serves * * The overlay limb used to query one row: `organization_id` equal to the @@ -16264,23 +16362,37 @@ export class ObjectStackProtocolImplementation implements }> { // [#9009] ONE key for BOTH limbs — see this method's header. const canonicalType = canonicalMetaType(type); - // 1. Artifact wins. `lookupArtifactItem` is shadow-immune: a - // sys_metadata overlay row hydrated into the registry's plain - // key cannot mask the packaged artifact's `_lock` envelope. - const artifactItem = this.lookupArtifactItem(canonicalType, name) as any; - if (artifactItem) { - const p = extractProtection(artifactItem); - if (p.lock !== 'none') { - return { lock: p.lock, lockReason: p.lockReason, lockSource: 'artifact' }; - } - } + // [#21738] The one item-lock resolution decides; the two readers below + // only gather its layers, the second only when the first does not bind. + const resolved = await resolveItemLockLazily({ + // 1. Artifact. `lookupArtifactItem` is shadow-immune: a + // sys_metadata overlay row hydrated into the registry's plain + // key cannot mask the packaged artifact's `_lock` envelope. + artifact: () => this.lookupArtifactItem(canonicalType, name), + overlay: () => this.readLockGateOverlayLayer(canonicalType, name, organizationId), + }); + return { lock: resolved.lock, lockReason: resolved.lockReason, lockSource: resolved.layer }; + } + + /** + * [#21738] The `_lock` gate's `overlay` layer for {@link getEffectiveLock}: + * the stored body of the row the gate binds, or `undefined` when there is + * none (or `sys_metadata` is not provisioned yet). Throws when the row + * cannot be read (#5706). + */ + private async readLockGateOverlayLayer( + canonicalType: string, + name: string, + organizationId: string | null | undefined, + ): Promise { // 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}. + // see {@link getEffectiveLock}'s header. Canonical spelling only + // (#4432), the one declared difference — see + // {@link findServedOverlayRow}. try { const served = await this.findServedOverlayRow({ type: canonicalType, @@ -16290,13 +16402,7 @@ export class ObjectStackProtocolImplementation implements otherSpelling: false, }); const row = served?.row; - if (row) { - const body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; - const p = extractProtection(body); - if (p.lock !== 'none') { - return { lock: p.lock, lockReason: p.lockReason, lockSource: 'overlay' }; - } - } + if (row) return storedRowDocument(row); } catch (error) { // #5706 — A LOCK GATE MUST NOT FAIL OPEN. This `catch` used to // swallow every read failure and fall through to `'none'`, and @@ -16337,7 +16443,7 @@ export class ObjectStackProtocolImplementation implements // one uncertain write beats performing one that had to be refused. this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } - return { lock: 'none', lockReason: undefined, lockSource: undefined }; + return undefined; } /** diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7e6d103a1eb..908a1973415 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-one-resolution.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts", "verb": "findOne",