Skip to content
Merged
26 changes: 26 additions & 0 deletions .changeset/23119-save-door-lock-envelope.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: the `_lock*` keys stay declared on every type that declares them, a code package's `protection` block keeps translating into them at load, and no spec key, authorable spelling, export or stored shape is added, removed, renamed or re-shaped. No stored row is rewritten or unlocked: a row keeps its bytes and loads unchanged, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime write door: a body saved through the metadata API whose `_lock*` family would set, change or remove the item's lock is refused, and the remedy is in the request a caller sends to that door, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
8 changes: 6 additions & 2 deletions packages/metadata-protocol/src/item-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading