diff --git a/.changeset/23119-save-door-lock-envelope.md b/.changeset/23119-save-door-lock-envelope.md new file mode 100644 index 00000000000..9a4293c84f4 --- /dev/null +++ b/.changeset/23119-save-door-lock-envelope.md @@ -0,0 +1,26 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's `_lock*` family that would do so is refused with a located `400 VALIDATION_ERROR`, an echo of the served lock is accepted and ignored, and the row keeps the lock it stores (ADR-0010 §3.5, §4.1) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The `_lock` family (`_lock`, `_lockReason`, `_lockDocsUrl`, `_lockSource`) is the runtime's private lock envelope. A code package's loader stamps it from the package's `protection` block, and an install or a migration's re-save carries a stored one. ADR-0010 strips it on write and reattaches it on load (§4.1), and an item saved through the metadata API is authored in the environment, which takes no package lock (§3.5). + +**This entry supersedes one sentence of the entry `23096-save-door-protection`, in this same release.** That entry says, under its unchanged behaviour, "A body without `protection` saves as before". What holds now: a body without `protection` saves as before only when it also sends no `_lock*` key, or sends back exactly the `_lock*` family a read of that item serves; a body whose `_lock*` family would set, change or remove the item's lock answers `400 VALIDATION_ERROR` and writes nothing, as this entry states. Where the two entries disagree, this one is current. + +**FROM.** `PUT /api/v1/meta/{type}/{name}` stored a body's `_lock*` family verbatim. A hand-written `{ "_lock": "full" }`, or one an author or an AI copied from another item's served body, answered `200`, and the item then read `lock: 'full'`, `editable: false`, `deletable: false`: its next save and its delete both answered `403 ITEM_LOCKED`, and no door unlocks it. Any holder of the authoring capability could make an environment item permanently uneditable and undeletable. The other way round, an author save that sent no `_lock*` key dropped a lock the platform had stamped on the row, so a `no-delete` stopped refusing the delete. + +**TO.** An author's save keeps the lock family the row it writes already stores. + +- **No `_lock*` key in the body.** The save goes through, and the row keeps its stored lock. A body that says nothing about the lock does not remove it. +- **An echo is accepted and ignored.** Studio and the AI authoring surfaces send the served body back on every edit, so a family equal to one a read of the item serves passes: the family the item read, the layered read's `effective` and the list serve (the item's resolved lock), or the family stored on the row the save writes (what the draft read and the layered read's `overlay` layer serve). The row still keeps its own stored family, so an echo of a packaged item's lock on its first overlay stores none in the environment row; the package's lock still binds. +- **Anything else is refused**, `400 VALIDATION_ERROR`, and nothing is written: a family that would set a lock (on a new item, or on one that has none), change one (its state or its prose) or remove one (`_lock: 'none'`, or a partial echo that drops part of it), and a family copied from another item. The message names each `_lock*` key sent and the door that does lock an item, and `issues[]` carries each key as a path, so the Studio designer can highlight it. +- **Every authoring write**: the REST and dispatcher saves, draft and publish, the external-table import, the unmanaged install, and the domain doors that save through them (the flow and permission-set clones, the permission-set and position write-throughs, the package publish's app flip). A type with no schema (`external_catalog`) is judged too. +- **Unchanged.** A `_lock*` value the type's declaration refuses (`_lock: 'locked'`, a non-string reason) is still the schema's `422 INVALID_METADATA`, so the unmanaged install's refusal of such an item does not move. The server-side rewrites (`os migrate meta --stored --apply`, the stored flow credential move, package duplication) keep the family their body carries. A packaged item's lock still binds, and a save against a locked item still answers `403 ITEM_LOCKED` first. No lock stored today is released. +- **Remedy.** Delete the `_lock*` keys from the body you send to the metadata API, or send them back exactly as a read of that item served them. To lock an item, declare `protection` on it in a code package's source, where the package loader translates it into the item's lock. diff --git a/packages/metadata-protocol/src/item-lock.ts b/packages/metadata-protocol/src/item-lock.ts index 1721444e6ba..2ada03df255 100644 --- a/packages/metadata-protocol/src/item-lock.ts +++ b/packages/metadata-protocol/src/item-lock.ts @@ -34,8 +34,12 @@ * 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 + * #21738, as amended on #23119: an artifact's explicit `'none'` leaves a stored + * row's lock binding, because letting it override would widen the door). A + * stored row's lock is one the runtime stamped (a loader's or a plugin's + * `applyProtection`, an install, a migration's re-save), never one an author + * wrote: the save door keeps the family a row stores and refuses a body that + * would set, change or remove it (ADR-0010 §3.5 and §4.1). When no layer * binds, the answer is `'none'` with no prose: a lock reason explains a * refusal, and there is none to explain. * diff --git a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts index 2bb47078a19..13d2ad0e3e1 100644 --- a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts +++ b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts @@ -542,6 +542,8 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', + // [#23119] A stored lock is one a server face stamps: an author's save never sets one. + source: 'migrate-stored', force: true, } as any); const auditedBefore = h.auditRows.length; @@ -738,6 +740,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul type: 'view', name: 'solo_locked', item: viewBody('solo_locked', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', + // [#23119] A stored lock is one a server face stamps: an author's save never sets one. + source: 'migrate-stored', force: true, } as any); let caught: any; @@ -779,6 +783,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', + // [#23119] A stored lock is one a server face stamps: an author's save never sets one. + source: 'migrate-stored', force: true, } as any); const res = await protocol.publishPackageDrafts({ @@ -845,6 +851,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', + // [#23119] A stored lock is one a server face stamps: an author's save never sets one. + source: 'migrate-stored', force: true, } as any); await protocol.publishPackageDrafts({ packageId: PKG, actor: 'admin', @@ -943,6 +951,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', + // [#23119] A stored lock is one a server face stamps: an author's save never sets one. + source: 'migrate-stored', force: true, } as any); const res = await protocol.publishPackageDrafts({ diff --git a/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts b/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts new file mode 100644 index 00000000000..47305778ee3 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts @@ -0,0 +1,415 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23119, ADR-0010 §3.5 and §4.1] The save door's author faces never set, + * change or remove an item's lock. + * + * The `_lock*` family (`_lock`, `_lockReason`, `_lockDocsUrl`, `_lockSource`) + * is the runtime's private envelope: a code package's loader stamps it from + * `protection`, and an install or a migration's re-save carries it. The door + * used to store a body's family verbatim, so a hand-written or echoed + * `_lock: 'full'` bound the item: the next save and the delete answered + * `403 ITEM_LOCKED`, and no door unlocks it. And the other way round, an author + * save that sent no family dropped a lock a server face had stamped. + * + * The pins, on both author faces (`meta-envelope`, the REST `PUT`, and + * `meta-dispatch`, the runtime dispatcher): + * + * - the card's probe: a family on a new item is refused with a located + * `400 VALIDATION_ERROR`, nothing is stored, and the item stays editable + * and deletable; + * - the add, the change and the removal are each refused, on an item with a + * server-stamped lock and on one without, in publish and draft mode; + * - the echo is accepted and ignored: of a stored row's family, of a packaged + * item's served family on its first overlay (a new row), in draft mode, and + * on a clone of an item that serves no lock — while a clone carrying + * another item's lock is refused; + * - a lock a server face stamped (`source`, `package-duplicate`) still binds + * afterwards, and an author save without a family keeps it; + * - CONTROL: a body without a family saves as before, and the item stays + * editable and deletable; + * - CONTROL: a family value the type's declaration refuses is still the + * parse's `422 INVALID_METADATA`; + * - a type with no schema (`external_catalog`) is judged too. + * + * Refusals are asserted as the envelope an HTTP boundary answers with + * (`resolveThrownHttpError`), never as a bare throw. + * + * The engine below is a stub: this package cannot import `@objectstack/objectql` + * (that package depends on this one). It keeps real rows, so the REAL save, + * read and delete doors run against it, and its write verbs route through the + * producer's own dispatch predicates. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { applyProtection } from '@objectstack/spec/shared'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +type Row = Record; + +const PACKAGE_ID = 'com.example.locks'; +const LOCK_KEYS = ['_lock', '_lockReason', '_lockDocsUrl', '_lockSource'] as const; + +/** Equality and `null`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); + if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`); + if ((row[key] ?? null) !== want) return false; + } + return true; +} + +/** + * A stub engine that keeps rows, and a registry holding what a code package's + * loader registered: each artifact as `applyProtection` leaves it. + */ +function makeProtocol(artifacts: Array<{ type: string; item: Record }> = []) { + const tables = new Map(); + const table = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + let nextId = 0; + const writes: Array<{ op: string; table: string }> = []; + const shipped = (type: string, name: string) => artifacts + .filter((a) => a.type === type && a.item.name === name) + .map((a) => a.item); + const engine: any = { + async find(t: string, q?: { where?: Record; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(t: string, q: { where: Record }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + writes.push({ op: 'insert', table: t }); + nextId += 1; + const row = { id: `r_${nextId}`, ...data }; + table(t).push(row); + return { ...row }; + }, + async update(t: string, data: Row, q?: Record) { + assertEngineUpdateDispatch(data, q); + writes.push({ op: 'update', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const hit = table(t).filter((r) => matches(r, where)); + for (const r of hit) Object.assign(r, data); + return { id: hit[0]?.id ?? null }; + }, + async delete(t: string, q?: Record) { + assertEngineDeleteDispatch(q); + writes.push({ op: 'delete', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const keep = table(t).filter((r) => !matches(r, where)); + const deleted = table(t).length - keep.length; + tables.set(t, keep); + return { deleted }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + getArtifactItem: (type: string, name: string) => shipped(type, name).find((item) => isCodeArtifactBody(item)), + getItem: (type: string, name: string) => shipped(type, name)[0], + listItems: (type: string) => artifacts.filter((a) => a.type === type).map((a) => a.item), + getObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + const metadataWrites = () => writes.filter((w) => w.table === 'sys_metadata' && w.op !== 'delete'); + const rows = (type: string, name: string, state = 'active') => + table('sys_metadata').filter((r) => r.type === type && r.name === name && r.state === state); + /** The lock family the item's row stores, or `undefined` when it has no row. */ + const storedFamily = (type: string, name: string, state = 'active') => { + const [row] = rows(type, name, state); + return row ? familyOf(JSON.parse(row.metadata)) : undefined; + }; + return { protocol, metadataWrites, rows, storedFamily }; +} + +function familyOf(body: Record): Record { + return Object.fromEntries(LOCK_KEYS.filter((k) => body[k] !== undefined).map((k) => [k, body[k]])); +} + +/** + * A dashboard a code package ships, as its loader registers it: `protection` + * translated and stripped. A dashboard, because its type takes an environment + * overlay (an `app` a package ships is sealed against one). + */ +function packagedDashboard(name: string, protection: { lock: string; reason: string }) { + const item: Record = { name, label: `Packaged ${name}`, widgets: [], protection }; + applyProtection(item, { packageId: PACKAGE_ID, packageVersion: '1.0.0' }); + return { type: 'dashboard', item }; +} + +const app = (name: string, extra: Record = {}) => ({ name, label: `App ${name}`, ...extra }); + +/** The lock a server face stamps: a migration's re-save carries the family a stored body holds. */ +const STAMPED = { _lock: 'no-delete', _lockReason: 'Stamped by the platform.', _lockSource: 'package' } as const; + +async function stamp(protocol: any, name: string, family: Record = STAMPED): Promise { + const r = await protocol.saveMetaItem({ type: 'app', name, item: app(name, family), source: 'migrate-stored', force: true }); + expect(r.success).toBe(true); +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the call to be refused, but it resolved'); +} + +/** The refusal's envelope and its location: every family key sent, named in the message and as an issue path. */ +function expectLockFamilyRefusal(err: any, sentKeys: readonly string[], effect: string): void { + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(400); + expect(envelope.code).toBe('VALIDATION_ERROR'); + expect(err.issues.map((i: any) => i.path).sort()).toEqual([...sentKeys].sort()); + expect(err.message).toMatch(/^Remove `_lock/); + expect(err.message).toContain(`They would ${effect}`); + // It names the door ADR-0010 does state. + expect(err.message).toContain("code package's source"); +} + +const AUTHOR_FACES = ['meta-envelope', 'meta-dispatch'] as const; + +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_METADATA_WRITABLE'] as const; +const saved: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + resetEnvWritableMetadataTypes(); +}); + +for (const writeFace of AUTHOR_FACES) { + describe(`[${writeFace}] the card's probe: a hand-written lock on a new item`, () => { + it('is refused with a located 400 VALIDATION_ERROR, nothing is stored, and the item stays editable and deletable', async () => { + const { protocol, metadataWrites, rows } = makeProtocol(); + const name = 'probe_rawlock_app'; + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { _lock: 'full', _lockReason: 'raw' }), writeFace, + })); + expectLockFamilyRefusal(err, ['_lock', '_lockReason'], 'set a lock on it'); + expect(err.message).toContain(`app '${name}'`); + expect(rows('app', name)).toEqual([]); + expect(metadataWrites()).toEqual([]); + // The same body without the family saves, and binds nothing. + expect((await protocol.saveMetaItem({ type: 'app', name, item: app(name), writeFace })).success).toBe(true); + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read).toMatchObject({ lock: 'none', editable: true, deletable: true }); + expect((await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'Edited' }), writeFace })).success).toBe(true); + expect((await protocol.deleteMetaItem({ type: 'app', name })).success).toBe(true); + }); + }); + + describe(`[${writeFace}] the add, the change and the removal are each refused`, () => { + it('the add on an existing item with no lock — every lock state, and a lone prose key', async () => { + const { protocol, storedFamily } = makeProtocol(); + const name = 'plain_app'; + await protocol.saveMetaItem({ type: 'app', name, item: app(name), writeFace }); + for (const family of [{ _lock: 'full' }, { _lock: 'no-overlay' }, { _lock: 'no-delete' }, { _lock: 'none' }, { _lockReason: 'why' }]) { + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name, item: app(name, family), writeFace })); + // A lone prose key, or `none`, binds nothing: it adds keys, not a lock. + expectLockFamilyRefusal(err, Object.keys(family), family._lock && family._lock !== 'none' ? 'set a lock on it' : 'add lock keys to it'); + } + expect(storedFamily('app', name)).toEqual({}); + }); + + it('the change of a server-stamped lock: its state, and its prose alone', async () => { + const { protocol, storedFamily } = makeProtocol(); + const name = 'stamped_app'; + await stamp(protocol, name); + for (const family of [{ ...STAMPED, _lock: 'full' }, { ...STAMPED, _lockReason: 'Mine now.' }, { ...STAMPED, _lockDocsUrl: 'https://example.invalid/x' }]) { + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name, item: app(name, family), writeFace })); + expectLockFamilyRefusal(err, LOCK_KEYS.filter((k) => k in family), 'change its lock'); + } + expect(storedFamily('app', name)).toEqual(STAMPED); + const del = await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name })); + expect(resolveThrownHttpError(del)).toMatchObject({ status: 403, code: 'ITEM_LOCKED' }); + }); + + it('the removal of a server-stamped lock: `none`, and a partial echo that drops its prose', async () => { + const { protocol, storedFamily } = makeProtocol(); + const name = 'stamped_app'; + await stamp(protocol, name); + const none = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name, item: app(name, { _lock: 'none' }), writeFace })); + expectLockFamilyRefusal(none, ['_lock'], 'remove its lock'); + const partial = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name, item: app(name, { _lock: 'no-delete' }), writeFace })); + expectLockFamilyRefusal(partial, ['_lock'], 'change its lock'); + expect(storedFamily('app', name)).toEqual(STAMPED); + const del = await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name })); + expect(resolveThrownHttpError(del)).toMatchObject({ status: 403, code: 'ITEM_LOCKED' }); + }); + + it('in draft mode: the add is refused and no draft is written', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'draft_app'; + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name, item: app(name, { _lock: 'full' }), mode: 'draft', writeFace, + })); + expectLockFamilyRefusal(err, ['_lock'], 'set a lock on it'); + expect(rows('app', name, 'draft')).toEqual([]); + }); + }); + + describe(`[${writeFace}] the echo is accepted and ignored`, () => { + it("of a server-stamped lock: the read's own body sent back, edited", async () => { + const { protocol, storedFamily } = makeProtocol(); + const name = 'stamped_app'; + await stamp(protocol, name); + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(familyOf(read.item)).toEqual(STAMPED); + const r = await protocol.saveMetaItem({ type: 'app', name, item: { ...read.item, label: 'Edited' }, writeFace }); + expect(r.success).toBe(true); + expect(storedFamily('app', name)).toEqual(STAMPED); + expect((await protocol.getMetaItem({ type: 'app', name })).item.label).toBe('Edited'); + }); + + it("of a packaged item's lock on its first overlay: a new row, which stores none, while the package's lock still binds", async () => { + const artifact = packagedDashboard('vendor_dash', { lock: 'no-delete', reason: 'Shipped by the vendor.' }); + const { protocol, storedFamily } = makeProtocol([artifact]); + const read = await protocol.getMetaItem({ type: 'dashboard', name: 'vendor_dash' }); + expect(familyOf(read.item)).toEqual({ _lock: 'no-delete', _lockReason: 'Shipped by the vendor.', _lockSource: 'package' }); + const r = await protocol.saveMetaItem({ type: 'dashboard', name: 'vendor_dash', item: { ...read.item, label: 'Edited' }, writeFace }); + expect(r.success).toBe(true); + expect(storedFamily('dashboard', 'vendor_dash')).toEqual({}); + const after = await protocol.getMetaItem({ type: 'dashboard', name: 'vendor_dash' }); + expect(after).toMatchObject({ lock: 'no-delete', deletable: false }); + expect(after.item.label).toBe('Edited'); + expect(familyOf(after.item)).toEqual(familyOf(read.item)); + // A family the package does not ship is still refused on the overlay. + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'dashboard', name: 'vendor_dash', item: { ...after.item, _lock: 'full' }, writeFace, + })); + expectLockFamilyRefusal(err, ['_lock', '_lockReason', '_lockSource'], 'change its lock'); + }); + + it('in draft mode: the first draft echoes the active read, carries its lock, and publishing keeps it', async () => { + const { protocol, storedFamily } = makeProtocol(); + const name = 'stamped_app'; + await stamp(protocol, name); + const read = await protocol.getMetaItem({ type: 'app', name }); + const r = await protocol.saveMetaItem({ type: 'app', name, item: { ...read.item, label: 'Drafted' }, mode: 'draft', writeFace }); + expect(r.success).toBe(true); + expect(storedFamily('app', name, 'draft')).toEqual(STAMPED); + // The draft read serves the draft row's own family; sent back, it is an echo too. + const draft = await protocol.getMetaItem({ type: 'app', name, state: 'draft' }); + expect(familyOf(draft.item)).toEqual(STAMPED); + expect((await protocol.saveMetaItem({ type: 'app', name, item: { ...draft.item, label: 'Drafted again' }, mode: 'draft', writeFace })).success).toBe(true); + await protocol.publishMetaItem({ type: 'app', name }); + expect(storedFamily('app', name)).toEqual(STAMPED); + const del = await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name })); + expect(resolveThrownHttpError(del)).toMatchObject({ status: 403, code: 'ITEM_LOCKED' }); + }); + + it("on a clone: an unlocked item's body saved under a new name is accepted; a clone carrying another item's lock is refused", async () => { + const { protocol, storedFamily, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'app', name: 'source_open', item: app('source_open'), writeFace }); + const open = await protocol.getMetaItem({ type: 'app', name: 'source_open' }); + const ok = await protocol.saveMetaItem({ type: 'app', name: 'clone_open', item: { ...open.item, name: 'clone_open' }, writeFace }); + expect(ok.success).toBe(true); + expect(storedFamily('app', 'clone_open')).toEqual({}); + + await stamp(protocol, 'source_locked'); + const locked = await protocol.getMetaItem({ type: 'app', name: 'source_locked' }); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name: 'clone_locked', item: { ...locked.item, name: 'clone_locked' }, writeFace, + })); + expectLockFamilyRefusal(err, ['_lock', '_lockReason', '_lockSource'], 'set a lock on it'); + expect(rows('app', 'clone_locked')).toEqual([]); + }); + }); +} + +describe('a lock a server face stamps still binds afterwards', () => { + const faces = [ + { face: "`source: 'migrate-stored'`", extra: { source: 'migrate-stored', force: true } }, + { face: "`writeFace: 'package-duplicate'`", extra: { writeFace: 'package-duplicate' } }, + ] as const; + + for (const { face, extra } of faces) { + it(`${face}: the stamp lands; an author save without a family keeps it, and the delete stays refused`, async () => { + const { protocol, storedFamily } = makeProtocol(); + const name = 'stamped_app'; + const r = await protocol.saveMetaItem({ type: 'app', name, item: app(name, STAMPED), ...extra }); + expect(r.success).toBe(true); + expect(storedFamily('app', name)).toEqual(STAMPED); + for (const writeFace of AUTHOR_FACES) { + const edit = await protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: `Edited on ${writeFace}` }), writeFace }); + expect(edit.success).toBe(true); + expect(storedFamily('app', name)).toEqual(STAMPED); + } + const read = await protocol.getMetaItem({ type: 'app', name }); + expect(read).toMatchObject({ lock: 'no-delete', editable: true, deletable: false }); + const del = await refusalOf(() => protocol.deleteMetaItem({ type: 'app', name })); + expect(resolveThrownHttpError(del)).toMatchObject({ status: 403, code: 'ITEM_LOCKED' }); + }); + } + + it("`full`, stamped: an author's next save answers 403 ITEM_LOCKED before any lock-family judgement", async () => { + const { protocol } = makeProtocol(); + await stamp(protocol, 'full_app', { _lock: 'full', _lockReason: 'Stamped.' }); + for (const item of [app('full_app'), app('full_app', { _lock: 'none' })]) { + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name: 'full_app', item, writeFace: 'meta-envelope' })); + expect(resolveThrownHttpError(err)).toMatchObject({ status: 403, code: 'ITEM_LOCKED' }); + } + }); +}); + +describe('CONTROLS — what the rule leaves as it was', () => { + it('a body without a lock family saves as before: its row carries none, and the item stays editable and deletable', async () => { + const { protocol, storedFamily } = makeProtocol(); + for (const writeFace of [...AUTHOR_FACES, undefined]) { + const name = `plain_${writeFace ?? 'in_process'}`.replace(/-/g, '_'); + expect((await protocol.saveMetaItem({ type: 'app', name, item: app(name), ...(writeFace ? { writeFace } : {}) })).success).toBe(true); + expect(storedFamily('app', name)).toEqual({}); + expect(await protocol.getMetaItem({ type: 'app', name })).toMatchObject({ lock: 'none', editable: true, deletable: true }); + expect((await protocol.deleteMetaItem({ type: 'app', name })).success).toBe(true); + } + }); + + it("a family value the type's declaration refuses is still the parse's 422 INVALID_METADATA, and nothing is stored", async () => { + const { protocol, metadataWrites } = makeProtocol(); + for (const family of [{ _lock: 'locked' }, { _lock: null }, { _lockSource: 'tenant' }, { _lock: 'full', _lockReason: 7 }]) { + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'app', name: 'bad_app', item: app('bad_app', family), writeFace: 'meta-envelope', + })); + expect(resolveThrownHttpError(err)).toMatchObject({ status: 422, code: 'INVALID_METADATA' }); + } + expect(metadataWrites()).toEqual([]); + }); + + it('an in-process door that states no face meets the same rule', async () => { + const { protocol, rows } = makeProtocol(); + const err = await refusalOf(() => protocol.saveMetaItem({ type: 'app', name: 'door_app', item: app('door_app', { _lock: 'full' }) })); + expectLockFamilyRefusal(err, ['_lock'], 'set a lock on it'); + expect(rows('app', 'door_app')).toEqual([]); + }); + + it('a type with no schema (`external_catalog`) is judged too: no parse would refuse the key there', async () => { + const { protocol, rows } = makeProtocol(); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'external_catalog', name: 'probe_catalog', item: { name: 'probe_catalog', _lock: 'full' }, writeFace: 'meta-envelope', + })); + expectLockFamilyRefusal(err, ['_lock'], 'set a lock on it'); + expect(rows('external_catalog', 'probe_catalog')).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a6f3c3bb300..e5f31f6faed 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -188,6 +188,7 @@ import { evaluateLockForDelete, resolveLockState, MetadataLockSchema, + MetadataProtectionFields, type MetadataLock, type MetadataLockSource, type MetadataProvenance, @@ -1034,6 +1035,94 @@ function withoutProtectionBlock(type: string, item: unknown): unknown { return rest; } +/** + * [ADR-0010 §3.3] The lock family: the `_lock*` members of the protection + * envelope every metadata type spreads (`MetadataProtectionFields`), read off + * that declaration so this file keeps no list of its own. + */ +const LOCK_FAMILY_KEYS: readonly string[] = Object.freeze( + Object.keys(MetadataProtectionFields).filter((key) => key.startsWith('_lock')), +); + +/** The lock family `body` carries: each member with a value, and nothing else. */ +function lockFamilyOf(body: unknown): Record { + const family: Record = {}; + if (!body || typeof body !== 'object' || Array.isArray(body)) return family; + for (const key of LOCK_FAMILY_KEYS) { + const value = (body as Record)[key]; + if (value !== undefined) family[key] = value; + } + return family; +} + +/** Do two lock families state the same members with the same values? */ +function sameLockFamily(a: Record, b: Record): boolean { + return LOCK_FAMILY_KEYS.every((key) => a[key] === b[key]); +} + +/** + * `body` carrying exactly `family` as its lock family: every member `family` + * states, and no other `_lock*` key. Returns `body` itself when nothing + * changes, and a copy otherwise, so a caller's own body is never mutated. + */ +function withLockFamily(body: Record, family: Record): Record { + const unchanged = LOCK_FAMILY_KEYS.every((key) => + family[key] === undefined ? !Object.prototype.hasOwnProperty.call(body, key) : body[key] === family[key]); + if (unchanged) return body; + const out: Record = { ...body }; + for (const key of LOCK_FAMILY_KEYS) { + if (family[key] === undefined) delete out[key]; + else out[key] = family[key]; + } + return out; +} + +/** + * [ADR-0010 §3.5 and §4.1] The save door's refusal of a body whose lock family + * would set, change or remove the item's lock: an author's write never does + * any of the three. See {@link ObjectStackProtocolImplementation.authoredLockFamily} + * for what is judged against what, and for the echo it accepts. + * + * The refusal family {@link protectionBlockRefusal} gives the `protection` + * block, at the same position: `VALIDATION_ERROR` / 400, no code minted, the + * prescription first because a 4xx message crosses the REST boundary bounded + * at 500 characters with its tail truncated. It is located twice: the message + * names every `_lock*` key the body sent, and `issues[]` carries each as a + * path, the shape the Studio designer keys on. The remedy is to remove them — + * a body that sends no `_lock*` key keeps the lock the item has — and the + * door it names for a lock is the one ADR-0010 states: a code package's + * loader, translating `protection` (Phase 4.3). + */ +function authoredLockFamilyRefusal( + type: string, + saveName: string, + sent: Record, + served: Record, +): Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] } { + const keys = LOCK_FAMILY_KEYS.filter((key) => sent[key] !== undefined); + const servedLock = served._lock ?? 'none'; + const sentLock = sent._lock ?? 'none'; + const effect = servedLock === 'none' + ? (sentLock === 'none' ? 'add lock keys to it' : 'set a lock on it') + : sentLock === 'none' ? 'remove its lock' : 'change its lock'; + const err = new Error( + `Remove ${keys.map((key) => `\`${key}\``).join(', ')} from ${type} '${saveName}' and send it again. ` + + `Nothing was written. They would ${effect}, and the metadata API never sets, changes or removes a lock: ` + + 'an item saved here is authored in this environment (ADR-0010 §3.5). To lock an item, declare `protection` ' + + "on it in a code package's source, where the package loader translates it into the item's lock " + + '(ADR-0010 Phase 4.3).', + ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.issues = keys.map((key) => ({ + path: key, + message: "Not accepted through the metadata API: only a code package's loader sets an item's lock. " + + 'Sent back exactly as a read of this item serves it, the key is accepted and ignored.', + code: 'custom', + })); + return err; +} + /** * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape * Studio's designer keys on to highlight the offending form control. @@ -20350,6 +20439,115 @@ export class ObjectStackProtocolImplementation implements ); } + /** + * [#23119, ADR-0010 §3.5 and §4.1] The lock family an AUTHOR's save + * persists: the one the row it writes already stores. An author's write + * never sets, changes or removes an item's lock. ADR-0010 §4.1 strips the + * protection fields on write and has the loader reattach them on load, + * §3.5 gives an environment-authored item no package lock, and the spec + * calls the `_lock*` keys the runtime's private envelope, never authored + * (`shared/protection.zod.ts`). A stored row lock is one the runtime + * stamped: a loader's or a plugin's `applyProtection`, an install, a + * migration's re-save. Stored verbatim, which is what this door did, a + * hand-written or echoed `_lock: 'full'` bound the item for good: the next + * save and the delete answered `ITEM_LOCKED`, and no door unlocks it. + * + * Three outcomes, judged after the `protection` block's refusal: + * + * - **No `_lock*` key in the body.** The body says nothing about the + * lock, so the row keeps the family it stores. This is not a removal. + * Dropping the stored family instead would be the fail-open direction + * {@link DERIVED_PROVENANCE_KEYS}' note refuses, and it was measured + * here: a `no-delete` a server face had stamped was lost to the next + * author save, and the delete it refused went through. + * - **An echo.** Studio and the AI authoring surfaces send the served + * body back on every edit, so a family equal to one a read of this item + * serves is accepted and ignored. Two such families exist. One is what + * the active reads serve at the save's address ({@link getMetaItem}, + * the layered read's `effective`, the list): the one item-lock + * resolution's answer ({@link resolveItemLock} over every shipping + * package's artifact and the item's stored rows in scope), written onto + * the body by {@link withItemLockFamily}. The other is the family stored + * on the row this save writes, which the draft read, the preview read + * and the layered read's `overlay` layer serve verbatim. Accepting the + * second changes nothing, because it is the family carried forward. A + * new item, or a first overlay of a packaged one, has no row: its + * artifact's lock still binds through the resolution, and the row it + * gets stores none. + * - **Anything else** sets, changes or removes the lock, and is refused + * ({@link authoredLockFamilyRefusal}). That includes a family copied + * from another item, which neither read serves for this one. + * + * The row is the one the repository's `put` resolves for this save + * ({@link SysMetadataRepository.headAt}: its state, package and bundle + * member, the inherited draft binding included). A draft save with no + * draft row yet reads the ACTIVE row the draft overlays. That fallback is + * load-bearing: the draft is published by promotion, so a draft that + * dropped the active row's lock would publish the loss + * ({@link storedBodyForCarryForward} draws the same line for a credential). + * Read with no `try`/`catch`: a stored lock that could not be read must + * fail the save, never be written away. + * + * A member whose value the type's own declaration refuses is left to the + * parse: the body is handed on untouched, and the parse refuses it with + * `422 INVALID_METADATA`, as before. That is the path the unmanaged + * import's strip (`stripUnmanagedImportLock`) hands a lock it cannot parse + * down, and its refusal does not move. A type that declares no such key, + * or has no schema at all (`external_catalog`), is judged here: no parse + * would refuse the key there, and stored, the `_lock` gate reads it. + * + * Asked of every write that reaches the author arm: the REST `PUT` + * (`meta-envelope`), the runtime dispatcher (`meta-dispatch`), the + * external-table import (`external-import`, whose body the server builds + * and which never carries a `_lock*` key), the unmanaged import + * (`package-unmanaged-import`, stripped, and writing only names no row + * held), and the in-process doors that state no face. The server-stated + * rewrites (`source`, `package-duplicate`) keep stamping, as before. No + * stored lock is unbound here. + */ + private async authoredLockFamily(args: { + type: string; + name: string; + packageId: string | null | undefined; + mode: 'draft' | 'publish'; + item: unknown; + }): Promise { + const { item } = args; + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const body = item as Record; + const sent = lockFamilyOf(body); + for (const [key, value] of Object.entries(sent)) { + const declared = overlaySchemaDeclaredKey(args.type, key); + if (declared !== undefined && !declared.safeParse(value).success) return item; + } + const repo = this.getOverlayRepo(); + const ref = { type: args.type, name: args.name, org: 'env' } as Parameters[0]; + const state: 'active' | 'draft' = args.mode === 'draft' ? 'draft' : 'active'; + const member = itemDiscriminator(args.type, body); + const target = await repo.headAt(ref, { state, packageId: args.packageId ?? null, member }); + let head = target.head; + if (!head && state === 'draft') { + head = (await repo.headAt(ref, { state: 'active', packageId: target.packageId, member })).head; + } + const stored = lockFamilyOf(head?.body); + if (Object.keys(sent).length > 0 && !sameLockFamily(sent, stored)) { + const address: ItemAddress = { type: args.type, name: args.name, packageId: args.packageId ?? undefined }; + let overlayLockLayer: unknown; + try { + overlayLockLayer = await this.overlayLockLayerAt(address, { otherSpelling: true }); + } catch (error) { + // [#5532] A lock read that did not happen is not an unlocked item. + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const served = lockFamilyOf(withItemLockFamily({}, resolveItemLock({ + artifact: this.artifactLockLayerAt(address), + overlay: overlayLockLayer, + }))); + if (!sameLockFamily(sent, served)) throw authoredLockFamilyRefusal(args.type, args.name, sent, served); + } + return withLockFamily(body, stored); + } + // [#21207] `parentVersion` is a CALLER's version token — the keyed form a // receipt served — and is compared in that form (`storedParentForToken`). // `storedParentVersion` is the in-process twin for a caller that read the @@ -20714,9 +20912,24 @@ export class ObjectStackProtocolImplementation implements // and a refused save costs no read. The server-stated rewrites — the // same two the flow authoring rule above exempts — strip it instead. // See {@link protectionBlockRefusal}. + // + // [#23119, ADR-0010 §3.5 and §4.1] …and the lock family, at the same + // position: an author's write persists the family the row it writes + // already stores. A body echoing a family a read of this item serves + // is accepted and ignored; one that would set, change or remove the + // lock is refused in the block's refusal family. The server-stated + // rewrites keep the family their body carries, as before. See + // {@link authoredLockFamily}. if (request.source === undefined && request.writeFace !== 'package-duplicate') { const protectionRefusal = protectionBlockRefusal(singularType, request.item, request.name); if (protectionRefusal) throw protectionRefusal; + request.item = await this.authoredLockFamily({ + type: singularType, + name: request.name, + packageId: request.packageId, + mode, + item: request.item, + }); } else { request.item = withoutProtectionBlock(singularType, request.item); } diff --git a/packages/objectql/src/protocol-derived-provenance-doors.test.ts b/packages/objectql/src/protocol-derived-provenance-doors.test.ts index 05736c873a9..5a1bb073e07 100644 --- a/packages/objectql/src/protocol-derived-provenance-doors.test.ts +++ b/packages/objectql/src/protocol-derived-provenance-doors.test.ts @@ -28,8 +28,9 @@ * accumulating lies. Both land here. * * ⚠️ The keys stripped are EXACTLY those three. The `_lock*` family is NOT - * touched: a lock is author-declarable, and dropping one is the fail-open - * direction (the line cloud PR #2065 drew at its own producer). + * stripped: dropping a lock is the fail-open direction (the line cloud PR #2065 + * drew at its own producer). [#23119] An author's save keeps the family the + * row stores, and refuses a body that would set, change or remove it. * * This file lives in `@objectstack/objectql` for the same reason * `protocol-writepath-object-ownership.test.ts` does: the subject is the REAL @@ -255,10 +256,36 @@ describe('#16702 door 1 — saveMetaItem strips exactly the three DERIVED keys', expect(body).toMatchObject({ name: 'pet_hospital', label: 'Pet Hospital' }); }); - it('⛔ does NOT touch the _lock* family — dropping a lock is the fail-open direction', async () => { + // [#23119] The `_lock*` family is never silently stripped either — dropping + // a lock is the fail-open direction. An author's save keeps the family the + // row stores, and a body that would set one is refused, loudly. + it('⛔ does NOT strip the _lock* family: a stored lock is kept while the three keys go', async () => { const { driver } = makeStubDriver(); const { engine, protocol } = await boot(driver); + const family = { + _lock: 'no-delete', _lockReason: 'stamped by a migration', _lockSource: 'package', + _lockDocsUrl: 'https://example.invalid/locks', + }; + // A server face stamps the lock: a migration's re-save carries the stored family. + await protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', packageId: PKG, source: 'migrate-stored', force: true, + item: { name: 'pet_hospital', label: 'Pet Hospital', ...family }, + }); + // The author's round trip echoes the derived keys and sends no family. await protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', packageId: PKG, + item: { name: 'pet_hospital', label: 'Edited', _packageId: PKG, _provenance: 'package' }, + }); + const body = await storedBody(engine); + expect(body).toMatchObject({ label: 'Edited', ...family }); + expect(body).not.toHaveProperty('_packageId'); + expect(body).not.toHaveProperty('_provenance'); + }); + + it('⛔ does NOT strip a family an author sends to set a lock: the save is refused, and nothing is written', async () => { + const { driver } = makeStubDriver(); + const { engine, protocol } = await boot(driver); + await expect(protocol.saveMetaItem({ type: 'app', name: 'pet_hospital', packageId: PKG, item: { name: 'pet_hospital', label: 'Pet Hospital', @@ -266,14 +293,8 @@ describe('#16702 door 1 — saveMetaItem strips exactly the three DERIVED keys', _lockDocsUrl: 'https://example.invalid/locks', _packageId: PKG, _provenance: 'package', }, - }); - const body = await storedBody(engine); - expect(body._lock).toBe('full'); - expect(body._lockReason).toBe('author declared'); - expect(body._lockSource).toBe('package'); - expect(body._lockDocsUrl).toBe('https://example.invalid/locks'); - expect(body).not.toHaveProperty('_packageId'); - expect(body).not.toHaveProperty('_provenance'); + })).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(await engine.find('sys_metadata', { where: { type: 'app', name: 'pet_hospital' } })).toEqual([]); }); }); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index c0813bcdd22..1bdcb02f2f0 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1391,6 +1391,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.save-door-protection.test.ts", "verb": "delete",