Repository navigation
Commit aa5bfdf
fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's
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>_lock* family is kept from the stored row, echoed, or refused (#23167)1 parent 32fa194 commit aa5bfdf
7 files changed
Lines changed: 717 additions & 13 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
38 | | - | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
39 | 43 | | |
40 | 44 | | |
41 | 45 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
542 | 542 | | |
543 | 543 | | |
544 | 544 | | |
| 545 | + | |
| 546 | + | |
545 | 547 | | |
546 | 548 | | |
547 | 549 | | |
| |||
738 | 740 | | |
739 | 741 | | |
740 | 742 | | |
| 743 | + | |
| 744 | + | |
741 | 745 | | |
742 | 746 | | |
743 | 747 | | |
| |||
779 | 783 | | |
780 | 784 | | |
781 | 785 | | |
| 786 | + | |
| 787 | + | |
782 | 788 | | |
783 | 789 | | |
784 | 790 | | |
| |||
845 | 851 | | |
846 | 852 | | |
847 | 853 | | |
| 854 | + | |
| 855 | + | |
848 | 856 | | |
849 | 857 | | |
850 | 858 | | |
| |||
943 | 951 | | |
944 | 952 | | |
945 | 953 | | |
| 954 | + | |
| 955 | + | |
946 | 956 | | |
947 | 957 | | |
948 | 958 | | |
| |||
0 commit comments