Skip to content

Commit aa5bfdf

Browse files
fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's _lock* family is kept from the stored row, echoed, or refused (#23167)
Fixes #23119 Clause-②: no (narrowing) ## What changes The metadata save door (`PUT /api/v1/meta/{type}/{name}`, and every write that reaches `saveMetaItem`'s author arm) used to store a body's `_lock*` family (`_lock`, `_lockReason`, `_lockDocsUrl`, `_lockSource`) verbatim. A hand-written or echoed `_lock: 'full'` then bound an environment item for good: the next save and the delete each answered `403 ITEM_LOCKED`, and no door unlocks it. The reverse also held: an author save that sent no `_lock*` key dropped a lock a server face had stamped on the row. Per the binding triage direction (6135754247), the author arm now keeps the lock family the row it writes already stores, beside `protectionBlockRefusal` and in its refusal family: | body sends | outcome | |---|---| | no `_lock*` key | saved; the row keeps its stored family (not a removal) | | a family equal to one a read of this item serves | accepted and ignored (the row keeps its stored family) | | anything else: an add, a change (state or prose), a removal (`_lock: 'none'`, a partial echo), a family copied from another item | `400 VALIDATION_ERROR`, nothing written | - The refusal names every `_lock*` key sent and ADR-0010's door ("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)"); `issues[]` carries each key as a path. Not a silent strip. On an item that serves no lock, a family that binds nothing (a lone prose key, or `_lock: none`) is described as adding lock keys rather than setting a lock. - Server-stated faces (`source`, `writeFace: 'package-duplicate'`) keep stamping as before. The unmanaged import's strip (`stripUnmanagedImportLock`) is untouched. - A `_lock*` value the type's own declaration refuses (`_lock: 'locked'`, a non-string reason) is handed to the parse untouched and still answers `422 INVALID_METADATA`, so the unmanaged import's documented refusal of such an item (pinned in `package-unmanaged-install.test.ts`) does not move. - No stored lock is unbound. `item-lock.ts`: the docblock sentence that called a stored row lock "an administrator's stored lock" now reads it as amended in 6135754247 (one the runtime stamped, never one an author wrote). Prose only: the resolver, the `overlay` layer and the layer order are untouched. `shared/protection.zod.ts` is not edited: its guidance ("never authored") is true without naming the save door, and becomes more true with this change. ## What the echo is compared against, and why Two families count as "what the read served for that item": 1. **The item's resolved lock at the save's address**: the one item-lock resolution's answer, `resolveItemLock` over every shipping package's artifact (`artifactLockLayerAt`) and the strictest stored row in scope (`overlayLockLayerAt`, `otherSpelling: true`, exactly as the reads pass it), written as a family by `withItemLockFamily`. This is what `getMetaItem`'s `item`, the layered read's `effective` and the list serve, i.e. what `servedLockState` reports and what Studio and AI Studio echo. 2. **The family stored on the row this save writes** (the repository's write-address resolution, `headAt`, at the save's state, package and bundle member; a first draft reads the active row it overlays). This is what the draft read, the `previewDrafts` read and the layered read's `overlay` layer serve verbatim. Accepting it changes nothing, since it is the family carried forward anyway. It is load-bearing for a real producer: the permission-set data door merges its patch into `getMetaItemLayered().overlay` and saves into that row, and a row storing `_lock: 'none'` serves an empty resolved family. A new item has no row and serves no lock, so any family on it is an add. A first overlay of a packaged item echoes the artifact's family: accepted, the new row stores none, and the package's lock still binds through the artifact layer. The layered read's raw `code` layer is deliberately not an echo source (see Acceptance notes). ## Measured faces that reach the author arm The arm is `request.source === undefined && request.writeFace !== 'package-duplicate'`. Measured on `f931e765b7` (base) with a protocol probe saving `{ _lock: 'no-delete' }` on each face (all stored it verbatim before; after, every author face answers `400`, both server faces still stamp): | face | who | body carries `_lock*`? | treatment | |---|---|---|---| | `meta-envelope` | REST `PUT` | author's | judged | | `meta-dispatch` | runtime dispatcher | author's | judged | | no face | in-process doors: flow save and clone, permission-set clone, permission-set data door and backfill, position write-through and backfill, the package publish's app flip | the clones drop `_lock*`; the data door merges into the stored overlay body; the app flip re-saves the list's served body | judged (echoes pass) | | `external-import` | service-datasource "Import as Object" | never: the body is server-built from introspection plus name and label (code read of `importObject`) | carry-forward only | | `package-unmanaged-import` | `importUnmanagedPackage` | never a well-formed one: `stripUnmanagedImportLock` drops it, and the import writes only names no row held | carry-forward only (no row, so no family) | No face that reaches the arm stamps a lock in fact, so the direction's shape holds; no `needs_decision`. A type with no schema (`external_catalog`: the only registry type whose schema does not declare `_lock`) is judged too, because no parse refuses the key there and the `_lock` gate reads it from any stored body. ## Readings, before and after Protocol door (the `saveMetaItem` / `getMetaItem` / `deleteMetaItem` the REST `PUT` / `GET` / `DELETE` forward to), stub engine with real rows. A showcase boot was not re-run (see Narrowings). | reading | base `f931e765b7` | this branch | |---|---|---| | `PUT app/probe_rawlock_app` with `_lock: 'full', _lockReason: 'raw'` | `200`, row stores the family | `400 VALIDATION_ERROR`, no row | | `GET` after it | `lock: 'full'`, `editable: false`, `deletable: false` | item absent; the same body without the family saves and reads `lock: 'none'`, editable, deletable | | second `PUT` / `DELETE` | `403 ITEM_LOCKED` / `403 ITEM_LOCKED` | `200` / `200` | | a server-stamped `no-delete`, then an author save with no family | lock dropped: read `lock: 'none'`, `DELETE` `200` | lock kept: read `lock: 'no-delete'`, `DELETE` `403 ITEM_LOCKED` | | echo of a packaged `no-delete` on its first overlay | the overlay row stores the package's family | `200`, the overlay row stores none, the item still reads `no-delete` | ## Tests At `6bbc5475cc` unless stated. - New `packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts`: 25 tests, pass. On both author faces: the card's probe; the add (every lock state and a lone prose key), the change (state, reason, docs URL) and the removal (`none`, a partial echo) each refused with the envelope (`code` + `status` via `resolveThrownHttpError`) and the `issues[]` paths; draft add refused; echo accepted for a server-stamped row, for a packaged item's first overlay, in draft mode (first draft and the draft read's own echo, then publish keeps the lock), and on a clone of an unlocked item, while a clone carrying another item's lock is refused. Plus: both server faces stamp and the stamp survives author saves; `full` still answers `403 ITEM_LOCKED` first; CONTROL body without a family; CONTROL malformed values stay `422 INVALID_METADATA`; an in-process door; `external_catalog`. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` (whole package, run at `2ed18c717c`, implementation unchanged since): 230 files passed, 1 failed (`protocol.package-publish-audit-rows.test.ts`, 5 tests: its fixtures seeded a stored lock through the author face, the door this card closes). Fixture moved onto the server face (`source: 'migrate-stored'`) in `063ca29be4`; that file plus the new one re-run: 2 files, 43 tests pass. - Resolver and neighbour pins re-run, files untouched: `protocol.lock-one-resolution.test.ts` (#21738's resolver pin), `protocol.save-door-protection.test.ts`, `package-unmanaged-install.test.ts`, `protocol.lock-door-read-agree.test.ts`: 4 files, 24129 tests pass. - `packages/objectql`: the 53 test files that mention `saveMetaItem` or `_lock` (against a rebuilt `@objectstack/metadata-protocol` dist, 5 hits of the new method in `dist/index.js`): 53 files, 740 tests pass. `protocol-derived-provenance-doors.test.ts` pinned the verbatim store ("does NOT touch the _lock* family"); it now pins the same intent under the new rule: a stored family is kept while the three derived keys go, and an authored family is refused with nothing written. - `pnpm --filter @objectstack/metadata-protocol run typecheck` exit 0 (`--listFiles` includes both edited test files); `pnpm --filter @objectstack/objectql run typecheck` exit 0 (tsc plus `check:test-typecheck`, debt held). ## Ablation (one-time, not kept) `node scripts/ablation-replace.mjs` replaced the arm's `request.item = await this.authoredLockFamily({` with a no-op that returns the body (the verbatim store), on the committed tree, with a trap restore. On disk: anchor 1 to 0, marker 0 to 1, blob `5ce2896165` to `8faf50ed0d`. The subject resolves through `./protocol.js` (source, no `dist/` involved). - Ablated: 18 failed, 7 passed of 25. Red: every add, change and removal pin, the draft add, the clone-refused half, the in-process door, `external_catalog`, both server-stamp carry-forward pins (the verbatim store drops the stamped lock), and the packaged first-overlay echo pin (the verbatim store copies the package's family into the row, so its "stores none" assertion goes red: the "ignored" half). Green: the stored-row echo (both faces), the draft echo (both faces), `full` answering `ITEM_LOCKED`, and both CONTROLs. - Restored: blob equals the HEAD blob `5ce2896165`, `git diff HEAD` empty. ## Gates The dispatch's 80-command lead list plus the families derived on this diff (`node scripts/pm/dispatch-gates.mjs --commands`, 75 families; union 98 commands), each run sequentially with its exit captured before any pipe: 95 exit 0, including `check-adr-0087-registration --base origin/main` ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"), `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check:error-status-conformance`, `check:dispatcher-error-vocabulary`, `check:durability-log-level`, `check:nul-bytes`, `check:type-check-coverage`, `check:type-check-debt`, `check:test-source-alias`, `check:cross-package-test-inputs`. - `check:engine-double-contract` was red (exit 1) until the new test's pinned doubles were recorded (`--write`: 3 rows added, 0 lost) in `6bbc5475cc`; re-run exit 0. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:lean-entry-closure`, reason: both exited 3 (PREREQUISITE NOT MET), they load built entry points for packages this worktree did not build. CI runs both. - `dispatch-gates --ran` reconciliation: 75 derived families accounted for, 75 run. - Lint: `pnpm exec eslint --no-inline-config --format json` over the 5 touched TypeScript files at `6bbc5475cc`: 5 files, 0 errors, 0 warnings. Population: `eslint.config.mjs` lints `**/*.ts` minus its `NEVER_LINTED` list, and none of the 5 is ignored (no "file ignored" warning). Invariance: the config enables no type-aware linting (no `parserOptions.project`, stated at its own `:327`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Narrowings (declared) - After about an hour of `os-verify-lock` queue timeouts behind another seat's full builds and dogfood batteries, the light targeted runs above (the two-file, four-file and 53-file vitest runs, the ablation and the two typechecks) ran outside the lock with `--maxWorkers=2`. The closure build and the first full package run went through the lock. - No showcase boot: the readings are taken at the protocol door the REST routes forward to. `packages/qa/dogfood` (`install-local-unmanaged-mode.dogfood.test.ts` saves each copied item's served body back) was not run locally: it needs the full build, and it rides main's full battery. Its copied bodies carry no `_lock*` key, so they pass as no-family saves. ## Changeset `.changeset/23119-save-door-lock-envelope.md`, `@objectstack/metadata-protocol: minor`, `**BREAKING**`, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`. Level: the WHICH LEVEL rule in `pr-automation.yml` grades a widening `minor` and a fix that changes no public surface `patch`; this narrows a published accept set, which is a breaking change, and `check-changeset-no-major.mjs` states that during the launch window a breaking change ships `minor` (the grade #23096's entry took for the same door). The arm: `clause2-line.mjs` defines `no (narrowing)` as "NOT a widening, but breaking", which is this diff. No `@objectstack/spec` entry: its prose is not changed. The changeset supersedes one sentence of the pending `23096-save-door-protection` entry, its "A body without `protection` saves as before": that now holds only for a body that also sends no `_lock*` key or sends back exactly the `_lock*` family a read of that item serves. No sentence of a pending `15213-*` entry becomes false. ## Acceptance notes - **Deviations from the file surface.** The rule needs helpers: `LOCK_FAMILY_KEYS` (derived from `MetadataProtectionFields`, so this file adds no hand-kept list beside the one `item-lock.ts` already keeps), `lockFamilyOf`, `sameLockFamily`, `withLockFamily` and `authoredLockFamilyRefusal` sit beside `protectionBlockRefusal`, and the private `authoredLockFamily` sits just above `saveMetaItem`; the arm itself gains one call. Outside `protocol.ts`, three existing files had to move with the behaviour: the objectql provenance-door pin and the publish-audit fixtures (both relied on the author face storing a lock), and `scripts/engine-double-contract.pinned.json` (the new test's doubles). - **Cost.** Every author-arm save makes one more indexed `sys_metadata` head read (the target row, plus the active row for a first draft), with no `try`/`catch`: a stored lock that cannot be read fails the save rather than being written away. A body that carries a `_lock*` family not equal to the stored one adds the served-lock read (artifact lookup plus the rows in scope). - **Boundary: the layered `code` layer is not an echo source.** For a package that declares `protection.lock: 'none'`, that raw layer carries `_lock: 'none'` while every resolved read serves no family; a body copied from it is refused, loudly, with a one-step remedy (remove the keys). - **A malformed `_lock*` value** gets the parse's `422` first and this door's `400` only once it is well-formed. Accepted to keep the unmanaged import's refusal unchanged. - **Existing state is untouched.** A row (or a pending draft) that already stores an author-written lock keeps it, and publishing such a draft still promotes it: unbinding is the maintainer's (triage 6135754247), and a reported stuck item is the ADR-0010 §3.8 card. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32fa194 commit aa5bfdf

7 files changed

Lines changed: 717 additions & 13 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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).
14+
15+
**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.
16+
17+
**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.
18+
19+
**TO.** An author's save keeps the lock family the row it writes already stores.
20+
21+
- **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.
22+
- **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.
23+
- **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.
24+
- **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.
25+
- **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.
26+
- **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.

‎packages/metadata-protocol/src/item-lock.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@
3434
* lock and its prose (`_lockReason`, `_lockDocsUrl`, `_lockSource`). A layer
3535
* that declares `'none'`, or declares nothing, does not bind. `'none'` declares
3636
* no lock: it is not a grant over a lower layer's lock (the triage ruling on
37-
* #21738: an artifact's explicit `'none'` leaves an administrator's stored lock
38-
* binding, because letting it override would widen the door). When no layer
37+
* #21738, as amended on #23119: an artifact's explicit `'none'` leaves a stored
38+
* row's lock binding, because letting it override would widen the door). A
39+
* stored row's lock is one the runtime stamped (a loader's or a plugin's
40+
* `applyProtection`, an install, a migration's re-save), never one an author
41+
* wrote: the save door keeps the family a row stores and refuses a body that
42+
* would set, change or remove it (ADR-0010 §3.5 and §4.1). When no layer
3943
* binds, the answer is `'none'` with no prose: a lock reason explains a
4044
* refusal, and there is none to explain.
4145
*

‎packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,8 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => {
542542
type: 'view', name: 'locked_grid',
543543
item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }),
544544
packageId: PKG, actor: 'admin',
545+
// [#23119] A stored lock is one a server face stamps: an author's save never sets one.
546+
source: 'migrate-stored', force: true,
545547
} as any);
546548

547549
const auditedBefore = h.auditRows.length;
@@ -738,6 +740,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul
738740
type: 'view', name: 'solo_locked',
739741
item: viewBody('solo_locked', 'protected', { _lock: 'no-overlay' }),
740742
packageId: PKG, actor: 'admin',
743+
// [#23119] A stored lock is one a server face stamps: an author's save never sets one.
744+
source: 'migrate-stored', force: true,
741745
} as any);
742746

743747
let caught: any;
@@ -779,6 +783,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul
779783
type: 'view', name: 'locked_grid',
780784
item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }),
781785
packageId: PKG, actor: 'admin',
786+
// [#23119] A stored lock is one a server face stamps: an author's save never sets one.
787+
source: 'migrate-stored', force: true,
782788
} as any);
783789

784790
const res = await protocol.publishPackageDrafts({
@@ -845,6 +851,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul
845851
type: 'view', name: 'locked_grid',
846852
item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }),
847853
packageId: PKG, actor: 'admin',
854+
// [#23119] A stored lock is one a server face stamps: an author's save never sets one.
855+
source: 'migrate-stored', force: true,
848856
} as any);
849857
await protocol.publishPackageDrafts({
850858
packageId: PKG, actor: 'admin',
@@ -943,6 +951,8 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul
943951
type: 'view', name: 'locked_grid',
944952
item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }),
945953
packageId: PKG, actor: 'admin',
954+
// [#23119] A stored lock is one a server face stamps: an author's save never sets one.
955+
source: 'migrate-stored', force: true,
946956
} as any);
947957

948958
const res = await protocol.publishPackageDrafts({

0 commit comments

Comments
 (0)