diff --git a/.changeset/21670-read-envelope-lock-flags.md b/.changeset/21670-read-envelope-lock-flags.md new file mode 100644 index 00000000000..dbe36580bd0 --- /dev/null +++ b/.changeset/21670-read-envelope-lock-flags.md @@ -0,0 +1,24 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/spec': patch +--- + +The metadata reads' `lock` / `editable` / `deletable` now say what the write doors do with a packaged item + +Clause-②: no + +Both metadata reads publish the ADR-0010 protection envelope beside the item: `GET /api/v1/meta/:type/:name/layers` (and its deprecated `?layers=true` spelling), and the by-name read `GET /api/v1/meta/:type/:name` where it resolves the envelope. The envelope was resolved from the item's own `_lock` alone, so it ignored the other refusal the write doors apply: an item a code package ships, on a type with no per-org overlay channel, is locked against in-place edits. + +**Before.** A packaged flow, action, object, hook, seed, mapping, datasource, external catalog, doc, picklist, field, job, api, capability or agent with no `_lock` read `lock: 'none'`, `editable: true` and `deletable: true`. A packaged page, app, dataset, book, permission set, position, tool or skill read the same. Yet `PUT` refused each of them with `403 NOT_OVERRIDABLE` (or `403 ITEM_LOCKED` when the write names the read-only package), and the removal of the first group was refused too. + +**After.** Each read reports what its doors answer: + +- The first group reads `lock: 'full'`, `editable: false` and `deletable: false`. +- The second group reads `lock: 'no-overlay'`, `editable: false` and `deletable: true`. Removing a leftover overlay row of these types is allowed: that is the repair path for overlays written before their per-org channel was withdrawn. +- Items of the overlay types (`view`, `dashboard`, `report`, `translation`, `email_template`) are unchanged. So are items no package ships, such as an organization's own flows and actions, and every item while the `OS_METADATA_WRITABLE` operator hatch opens its type. + +An item's own `_lock` still applies on top: the two refusals join, and neither replaces the other. `lockReason`, `lockSource` and `lockDocsUrl` are still present only when the item declares them. `provenance` and `packageId` already name the package. + +The verdict is the one the write doors already share, read rather than re-derived, so the read moves whenever a door moves. The `lock` field's description in `@objectstack/spec` now names both refusals it reports. No key, type or accepted value changes. + +**What to do.** Nothing, unless a client gated an edit or delete affordance on `editable` / `deletable`: it now hides that affordance for packaged items the server refuses, instead of offering a write that answers 403. The refusal itself names the sanctioned route for each type: for a packaged flow, clone it under a new name or switch it off; for a packaged action, switch it off. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 31a4fe51eda..e36b4da1125 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1344,7 +1344,7 @@ Enable package response | **name** | `string` | ✅ | Item name | | **item** | `any` | ✅ | Metadata item definition | | **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | -| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` refuse the corresponding write with 403 `ITEM_LOCKED`. Resolved from the document's `_lock`, with the packaged artifact winning over any org overlay. | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | | **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | | **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | | **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | diff --git a/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts b/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts new file mode 100644 index 00000000000..2728a35e1f5 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts @@ -0,0 +1,360 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21670, ADR-0010 §5, ADR-0126 §2] The read envelope's `lock` / `editable` / + * `deletable` say what the write doors do with the same item. + * + * Both metadata reads (`getMetaItem`, `getMetaItemLayered`) publish the + * ADR-0010 protection envelope beside the document. It was resolved from the + * document's own `_lock` alone, so an item a code package ships, on a type + * with no overlay channel, read `lock: 'none'`, `editable: true`, + * `deletable: true` while every write door refused it in place (`403 + * NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only + * package). A client, an MCP author or an agent reading `editable` was told + * the opposite of what the server enforces. + * + * The envelope now folds in `packagedBaseRefusal` — the locked-base verdict the + * `/meta` doors and the `/automation` doors already share — so this file pins + * the READ against the DOORS, never against that predicate (which would be + * the predicate agreeing with itself): + * + * 1. a packaged flow and a packaged action read locked and not editable, on + * both reads and both kernels; + * 2. an org-owned item of the same types reads unlocked and editable; + * 3. every type in `DEFAULT_METADATA_TYPE_REGISTRY` agrees with its write + * doors, table-driven, on an environment kernel (the protocol's own doors, + * end to end) and on a host-config kernel (where the same refusal is the + * repository's `assertAllowed`, the first statement of `put` / `delete`); + * 4. a lit control: the table measured refusals AND admissions for both + * verbs on both kernels, over the whole registry roster — so it cannot + * pass by measuring nothing. + * + * A door that ADMITS is proven to have got past every lock limb rather than + * failing early for some other reason: on the environment kernel the ADR-0010 + * `_lock` gate (the last lock limb) was reached and answered no refusal; on + * the host-config kernel the engine was touched, which happens only after the + * repository's gate passed. Anything else afterwards (validation, a double + * that does not persist) is not a lock verdict and is not read as one. + * + * Out of the table, deliberately: the six code-only types (`allowRuntimeCreate` + * and `allowOrgOverride` both false) have no org-owned arm, because no runtime + * door can author an item of those types (`NOT_CREATABLE`). Their packaged arm + * is in the table like every other type's. + * + * `@objectstack/objectql` cannot be imported here: it depends on this package. + */ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +const PACKAGE_ID = 'com.example.pkg'; +const ENV_ID = 'env_1'; + +type Kernel = 'environment' | 'host-config'; +type Arm = 'packaged' | 'org-owned'; +type Verdict = 'refused' | 'admitted'; + +interface Flags { lock: unknown; editable: unknown; deletable: unknown } + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +/** `field` artifacts are nested in their object (`isNestedArtifactField`), so the packaged field is the object's own. */ +const nameFor = (type: string, arm: Arm): string => + arm === 'packaged' + ? (type === 'field' ? 'pkg_object.title' : `pkg_${type}`) + : (type === 'field' ? 'org_object.title' : `org_${type}`); + +/** What a code package's loader registered: one artifact per registry type, package-stamped. */ +function artifactsFor(extra: Record> = {}): Map>> { + const out = new Map>>(); + for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) { + if (type === 'field') continue; // shipped inside `pkg_object`, below + const name = nameFor(type, 'packaged'); + const body: Record = { + name, + label: name, + _packageId: PACKAGE_ID, + _provenance: 'package', + ...(type === 'object' ? { fields: { title: { type: 'text', label: 'Title' } } } : {}), + ...(extra[type] ?? {}), + }; + out.set(type, new Map([[name, body]])); + } + return out; +} + +/** A tenant-authored row the store holds — what an org author's save leaves behind. */ +const orgRow = (type: string): StoredRow => { + const name = nameFor(type, 'org-owned'); + return { + id: `r_${type}`, + type, + name, + organization_id: null, + package_id: null, + state: 'active', + metadata: JSON.stringify({ name, label: name, _provenance: 'org' }), + }; +}; + +/** + * 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 `_lock` gate records its denial through it). + */ +function harness(environmentId: string | undefined, rows: StoredRow[] = [], extra: Record> = {}) { + const artifacts = artifactsFor(extra); + const registry = { + getArtifactItem(type: string, name: string) { + const hit = artifacts.get(type)?.get(name); + return hit && isCodeArtifactBody(hit) ? hit : undefined; + }, + getItem(type: string, name: string) { + return artifacts.get(type)?.get(name); + }, + listItems(type: string) { + return [...(artifacts.get(type)?.values() ?? [])]; + }, + 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); +} + +const isLockRefusal = (e: any): boolean => + e instanceof Error && (e as any).status === 403 + && ((e as any).code === 'NOT_OVERRIDABLE' || (e as any).code === 'ITEM_LOCKED'); + +const settle = (run: Promise) => run.then(() => null, (e: unknown) => e); + +/** + * The environment kernel's door, end to end: `saveMetaItem` / `deleteMetaItem`. + * Admitted ⇔ the ADR-0010 `_lock` gate — reached only past the code-only, the + * org-scope and the package refusals — was reached and refused nothing. + */ +async function environmentDoor( + protocol: ObjectStackProtocolImplementation, type: string, name: string, operation: 'save' | 'delete', +): Promise { + const gate = vi.spyOn(protocol as any, operation === 'save' ? 'assertLockAllowsWrite' : 'assertLockAllowsDelete'); + try { + const outcome = await settle(operation === 'save' + ? protocol.saveMetaItem({ type, name, item: { name, label: name } }) + : protocol.deleteMetaItem({ type, name })); + if (isLockRefusal(outcome)) return 'refused'; + 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(); + } +} + +/** + * The host-config kernel's door: `saveMetaItem` skips its own package door + * there (`environmentId === undefined`) because the repository's + * `assertAllowed` / `assertDeleteAllowed` — the first statement of `put` / + * `delete` — answers the same refusal at the write itself. Admitted ⇔ the + * engine was touched, which only happens past that gate. + */ +async function hostConfigDoor(type: string, name: string, operation: 'save' | 'delete', artifactBacked: boolean): Promise { + const pastTheGate = new Error('past the repository gate'); + const engine: any = { + async find() { throw pastTheGate; }, + async findOne(table: string, opts?: { where?: Record }) { + assertEngineFindOnePredicate(table, opts); + throw pastTheGate; + }, + async transaction() { throw pastTheGate; }, + }; + const repo = new SysMetadataRepository({ engine }); + const ref = { type, name, org: 'env' } as Parameters[0]; + const intent = artifactBacked ? 'override-artifact' : 'runtime-only'; + const outcome = await settle(operation === 'save' + ? repo.put(ref, { name, label: name }, { parentVersion: null, actor: null, intent }) + : repo.delete(ref, { parentVersion: 'sha256:x', actor: null, intent })); + if (isLockRefusal(outcome)) return 'refused'; + expect(outcome, `${type}/${name} ${operation}: neither refused nor past the gate`).toBe(pastTheGate); + return 'admitted'; +} + +async function readFlags(protocol: ObjectStackProtocolImplementation, type: string, name: string): Promise<{ layered: Flags; byName: Flags }> { + const pick = (r: any): Flags => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); + return { + layered: pick(await protocol.getMetaItemLayered({ type, name })), + byName: pick(await protocol.getMetaItem({ type, name })), + }; +} + +interface Row { + kernel: Kernel; + arm: Arm; + type: string; + name: string; + read: Flags; + byName: Flags; + save: Verdict; + del: Verdict; +} + +const CODE_ONLY = new Set( + DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => !e.allowOrgOverride && !e.allowRuntimeCreate).map((e) => e.type), +); + +async function measure(kernel: Kernel, arm: Arm, type: string): Promise { + const name = nameFor(type, arm); + const rows = arm === 'org-owned' ? [orgRow(type)] : []; + const reader = harness(kernel === 'environment' ? ENV_ID : undefined, rows); + const { layered, byName } = await readFlags(reader, type, name); + let save: Verdict; + let del: Verdict; + if (kernel === 'environment') { + save = await environmentDoor(harness(ENV_ID, rows), type, name, 'save'); + del = await environmentDoor(harness(ENV_ID, rows), type, name, 'delete'); + } else { + save = await hostConfigDoor(type, name, 'save', arm === 'packaged'); + del = await hostConfigDoor(type, name, 'delete', arm === 'packaged'); + } + return { kernel, arm, type, name, read: layered, byName, save, del }; +} + +afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('[#21670] packaged items read locked, org-owned items read editable', () => { + for (const environmentId of [ENV_ID, undefined]) { + const kernel = environmentId ? 'an environment' : 'a host-config'; + it(`a packaged flow and a packaged action read lock ≠ none, editable false, deletable false (${kernel} kernel)`, async () => { + for (const type of ['flow', 'action']) { + const { layered, byName } = await readFlags(harness(environmentId), type, nameFor(type, 'packaged')); + for (const flags of [layered, byName]) { + expect(flags.lock).not.toBe('none'); + expect(flags.editable).toBe(false); + expect(flags.deletable).toBe(false); + } + } + }); + + it(`an org-owned flow and an org-owned action read unlocked and editable (${kernel} kernel)`, async () => { + for (const type of ['flow', 'action']) { + const { layered } = await readFlags(harness(environmentId, [orgRow(type)]), type, nameFor(type, 'org-owned')); + expect(layered).toEqual({ lock: 'none', editable: true, deletable: true }); + } + }); + } +}); + +describe('[#21670] every metadata type: the read envelope agrees with its write doors', () => { + const table: Row[] = []; + const cases: Array<{ kernel: Kernel; arm: Arm; type: string }> = []; + for (const kernel of ['environment', 'host-config'] as const) { + for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) { + cases.push({ kernel, arm: 'packaged', type }); + if (!CODE_ONLY.has(type)) cases.push({ kernel, arm: 'org-owned', type }); + } + } + + beforeAll(async () => { + for (const c of cases) table.push(await measure(c.kernel, c.arm, c.type)); + }, 120_000); + + const row = (c: { kernel: Kernel; arm: Arm; type: string }) => + table.find((r) => r.kernel === c.kernel && r.arm === c.arm && r.type === c.type)!; + + for (const c of cases) { + it(`${c.type} (${c.arm}, ${c.kernel} kernel)`, () => { + const r = row(c); + expect({ editable: r.read.editable, deletable: r.read.deletable }) + .toEqual({ editable: r.save === 'admitted', deletable: r.del === 'admitted' }); + // The spec's own algebra for the three fields (`MetadataProtectionEnvelopeFields`): + // `editable` is false iff `lock` is `no-overlay` or `full`, `deletable` iff `no-delete` or `full`. + expect(r.read.editable).toBe(!['no-overlay', 'full'].includes(r.read.lock as string)); + expect(r.read.deletable).toBe(!['no-delete', 'full'].includes(r.read.lock as string)); + // The two reads are produced by one derivation, so they cannot disagree. + expect(r.byName).toEqual(r.read); + }); + } + + it('lit control: the whole registry roster was measured, and each verb was both refused and admitted on each kernel', () => { + // A floor on the roster, so a table that iterates nothing cannot pass. + expect(DEFAULT_METADATA_TYPE_REGISTRY.length).toBeGreaterThanOrEqual(28); + for (const kernel of ['environment', 'host-config'] as const) { + const packaged = table.filter((r) => r.kernel === kernel && r.arm === 'packaged'); + expect(packaged.map((r) => r.type)).toEqual(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); + const onKernel = table.filter((r) => r.kernel === kernel); + for (const verb of ['save', 'del'] as const) { + expect(onKernel.filter((r) => r[verb] === 'refused').length, `${kernel} ${verb} refused`).toBeGreaterThan(0); + expect(onKernel.filter((r) => r[verb] === 'admitted').length, `${kernel} ${verb} admitted`).toBeGreaterThan(0); + } + // …and the read answered each of the three lock states the + // registry's flags can produce for a packaged item with no `_lock` + // (`no-delete` needs a `_lock`; the controls below cover it). + expect(new Set(packaged.map((r) => r.read.lock))).toEqual(new Set(['none', 'no-overlay', 'full'])); + } + }); +}); + +describe('[#21670] controls', () => { + it('the operator hatch opens the packaged base, and the read says so — the same predicate the door reads', async () => { + process.env.OS_METADATA_WRITABLE = 'flow,action'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + for (const type of ['flow', 'action']) { + const name = nameFor(type, 'packaged'); + const { layered } = await readFlags(harness(ENV_ID), type, name); + expect(layered).toEqual({ lock: 'none', editable: true, deletable: true }); + expect(await environmentDoor(harness(ENV_ID), type, name, 'save')).toBe('admitted'); + } + }); + + it('an item\'s own `_lock` still reads through, and joins the package verdict rather than being replaced by it', async () => { + const extra = { view: { _lock: 'no-delete' }, flow: { _lock: 'no-delete' } }; + const view = await readFlags(harness(ENV_ID, [], extra), 'view', 'pkg_view'); + expect(view.layered).toEqual({ lock: 'no-delete', editable: true, deletable: false }); + expect(await environmentDoor(harness(ENV_ID, [], extra), 'view', 'pkg_view', 'save')).toBe('admitted'); + expect(await environmentDoor(harness(ENV_ID, [], extra), 'view', 'pkg_view', 'delete')).toBe('refused'); + const flow = await readFlags(harness(ENV_ID, [], extra), 'flow', 'pkg_flow'); + expect(flow.layered).toEqual({ lock: 'full', editable: false, deletable: false }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f853b383e1b..c462702ad97 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -161,6 +161,7 @@ import { evaluateLockForWrite, evaluateLockForDelete, resolveLockState, + MetadataLockSchema, type MetadataLock, type MetadataLockSource, type MetadataProvenance, @@ -9734,9 +9735,10 @@ export class ObjectStackProtocolImplementation implements } catch { /* reference diagnostics are best-effort */ } } // ADR-0010 — surface lock/provenance flags so Studio can render - // the correct affordances without a second round trip. + // the correct affordances without a second round trip. [#21670] They + // report the write doors' verdicts — see {@link servedLockState}. const artifactBacked = this.isArtifactBacked(request.type, request.name); - const lockState = resolveLockState(decorated, artifactBacked); + const lockState = this.servedLockState(request.type, request.name, decorated, artifactBacked); return { type: request.type, name: request.name, @@ -10188,7 +10190,9 @@ export class ObjectStackProtocolImplementation implements const artifactBacked = this.isArtifactBacked(request.type, request.name); // Lock resolution: artifact wins over overlay, matching getEffectiveLock. const lockSource: any = code ?? overlay ?? {}; - const lockState = resolveLockState(lockSource, artifactBacked); + // [#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); // [#8154] The per-type credential redaction, on the ONE read exit // `decorateMetadataItem` does not reach — this method never calls it @@ -15520,6 +15524,70 @@ export class ObjectStackProtocolImplementation implements return null; } + /** + * [#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}. + * + * 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; + * - 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`, + * or `ITEM_LOCKED` when the write names the read-only package). It + * carries the registry's flags, the #6960 removal carve-out and the + * `OS_METADATA_WRITABLE` hatch, and answers alike on every topology. + * + * Asked from the first limb alone, a packaged flow or action read + * `lock: 'none'`, `editable: true`, `deletable: true` while every door + * refused it in place: a client or an agent that reads `editable` was told + * the opposite of what the server enforces. + * + * `lock` is then the state whose ADR-0010 verdicts are exactly the two + * booleans — read off the lock algebra itself (`evaluateLockForWrite` / + * `evaluateLockForDelete`), never a second table — so the envelope keeps + * 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. + * + * ⛔ 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 + * with it. + */ + private servedLockState( + type: string, + name: string, + document: unknown, + artifactBacked: boolean, + ): ReturnType { + const declared = resolveLockState(document, artifactBacked); + const editable = declared.editable + && this.packagedBaseRefusal({ type, name, operation: 'save' }) === null; + const deletable = declared.deletable + && this.packagedBaseRefusal({ type, name, operation: 'delete' }) === null; + const lock = MetadataLockSchema.options.find((state) => + (evaluateLockForWrite(state) === null) === editable + && (evaluateLockForDelete(state) === null) === deletable); + if (lock === undefined) { + // Unreachable while the lock algebra covers all four verdict pairs; + // a state added to it without a write/delete answer must fail here, + // loudly, not publish a guessed lock. + throw new Error( + `No ADR-0010 lock state answers editable=${editable}, deletable=${deletable}.`, + ); + } + return { ...declared, lock, editable, deletable }; + } + /** * [#20913, #20761 ruling rule 1, ADR-0126 §2 / §3] Is `name` a FLOW name * the loader's set holds ({@link packagedArtifactOwner})? Every other type, diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 05fc78c7063..14f88292fc4 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -305,19 +305,20 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ /** * ADR-0010 read-side protection envelope — the flags a metadata READ publishes - * alongside the document, all derived from one `resolveLockState()` call. + * alongside the document, all derived in one place. * * These are the UN-prefixed, envelope-level counterparts of the `_lock` / * `_provenance` fields `MetadataProtectionFields` splices into the document - * itself: the document stores `_lock`, and the read RESOLVES it into `lock` - * plus the three `editable` / `deletable` / `resettable` verdicts Studio - * renders affordances from (ADR-0010 §5), so no consumer re-implements the - * lock algebra. + * itself: the document stores `_lock`, and the read RESOLVES it — joined with + * the write doors' locked-packaged-base verdict — into `lock` plus the three + * `editable` / `deletable` / `resettable` verdicts Studio renders affordances + * from (ADR-0010 §5), so no consumer re-implements the lock algebra. * * Shared by {@link GetMetaItemResponseSchema} and * {@link GetMetaItemLayeredResponseSchema} — both are produced by the SAME - * `resolveLockState` call in `metadata-protocol`, so a mixin is what keeps the - * two declarations from drifting apart key by key. Module-local on purpose: it + * derivation in `metadata-protocol` (`resolveLockState` joined with + * `packagedBaseRefusal`), so a mixin is what keeps the two declarations from + * drifting apart key by key. Module-local on purpose: it * is a shape these two responses share, not a new public vocabulary. * * Every key is optional HERE and tightened per-response where the producer @@ -329,9 +330,12 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ const MetadataProtectionEnvelopeFields = { lock: MetadataLockSchema.optional().describe( 'Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; ' - + '`no-overlay` / `no-delete` / `full` refuse the corresponding write with ' - + '403 `ITEM_LOCKED`. Resolved from the document\'s `_lock`, with the packaged ' - + 'artifact winning over any org overlay.', + + '`no-overlay` / `no-delete` / `full` mean the write doors refuse the ' + + 'corresponding write with 403. Joins two refusals: the document\'s own ' + + '`_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), ' + + 'and the locked packaged base — an item a code package ships, on a type with ' + + 'no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the ' + + 'write names the read-only package).', ), lockReason: z.string().optional().describe( 'Human-readable explanation shown next to a refused write. Present only when ' diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index a9cc6e7fd8c..36690341b8d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1171,6 +1171,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts", "verb": "findOne",