Skip to content

Commit b7a13c7

Browse files
fix(metadata-protocol): one item-lock resolution for the _lock gate, both reads and the served body (#21738) (#21759)
Fixes #21738 Clause-②: no ## What was wrong The ADR-0010 `_lock` gate of the write doors (`getEffectiveLock`, behind save, publish, rollback and delete) resolves an item's lock from two layers: the packaged artifact's `_lock` unless it is `'none'`, then the stored `sys_metadata` row's. The two item reads did not use that resolution. Each derived the lock its own way, and neither matched the door on the artifact layer: - **Position 1.** `mergeArtifactProtection` copied any declared artifact `_lock` over the stored row's, `'none'` included. A packaged view whose artifact declares `'none'`, under a stored row declaring `'full'`, read `editable: true` on both reads while the door refused the save with `ITEM_LOCKED`. - **Position 2.** `getMetaItemLayered` (`GET /api/v1/meta/:type/:name/layers`) read the lock off `code ?? overlay`. A packaged view with no `_lock` under a stored `'full'` read `lock: 'none'` there, while `getMetaItem` and the door said locked. Triage's direction 5980131521 (verbatim in the dispatch): one resolution, three callers. The door's semantics stand, and the reads follow the door. ## H1: every place an item's lock was derived, at `ff29410ed2` | site | caller | artifact layer | stored layer | rule | |---|---|---|---|---| | `getEffectiveLock` | the `_lock` gate (save, publish, rollback, delete, package publish's promotion) | `lookupArtifactItem(canonical type, name)`, no package | `findServedOverlayRow` (canonical spelling, no package, org-gated) | artifact unless `'none'`, else row, else `'none'` | | `mergeArtifactProtection` | served body of `getMetaItem`, `getMetaItems` list items, registry hydration, runtime view-container expansion | the caller's artifact | the item's own `_lock` | artifact's `_lock` whenever defined (`'none'` too), else the item's | | `getMetaItem` envelope | `servedLockState(document)` | via the merge above (package-scoped lookup) | the served row, or a registry / MetadataService item | the merged document's `_lock` | | `getMetaItemLayered` envelope | `servedLockState(code ?? overlay ?? {})` | the code layer (MetadataService copy, else artifact, else runtime-only registry item) | only when there is no code layer | the code layer's `_lock` | | `getMetaDiagnostics` `locked` count | `servedLockState(list item)` | via the list's merge | the list item | the merged list body's `_lock` | | `servedLockState` | the three above | none of its own | none of its own | joins the given document's lock with `packagedBaseRefusal` | `packages/rest/src` and `packages/runtime/src` derive no lock: both pass the protocol's envelope through (`git grep` of `resolveLockState`, `extractProtection`, `_lock` and the envelope flags). The one out-of-package consumer of a read's lock-adjacent fields, `plugin-security`'s packaged permission-set gate, reads `getMetaItemLayered`'s `code._packageId`, not its lock. ## What changed Landing spot: `packages/metadata-protocol/src/protocol.ts`, as the claim predicted, plus a new module beside it. No `packages/spec/src/**` path is touched. - **`item-lock.ts` (new, not exported from the package entry).** `ITEM_LOCK_LAYERS = ['artifact', 'overlay']` is the precedence and the parameter set. `resolveItemLock(layers)` returns the first layer whose declared `_lock` is not `'none'`, with that layer's prose, else `'none'` with no prose. `resolveItemLockLazily(readers)` asks the same function after each layer is read and stops at the first binding layer. - **The door** (`getEffectiveLock`) reads its two layers through `resolveItemLockLazily`, so the store read still happens only when the artifact does not bind. Its overlay read moved, unchanged, into `readLockGateOverlayLayer`. Refusal code, status, text and `source=` are byte-identical. - **`getMetaItem`** hands the resolver the artifact it already looked up and the stored body of the row `findServedOverlayRow` already served. It keeps that body even when the row is not adopted (a shipped flow name, #20946), because the gate binds it. No extra store read. - **`getMetaItemLayered`** hands it `lookupArtifactItem` (a registry read) and the served row's stored body. `code ?? overlay ?? {}` is no longer a lock source. It still feeds `provenance` / `packageId` / `packageVersion`, unchanged. A row-less name that a stored container expands contributes no overlay layer, because the gate does not read the container's row. - **`servedLockState`** takes the resolver's answer and derives `editable` / `deletable` from it, joined with `packagedBaseRefusal` as before. It reads only `provenance` / `packageId` / `packageVersion` off the document. No third derivation. - **`mergeArtifactProtection`** copies the artifact's `_lock*` family only when `resolveItemLock` says the artifact's lock binds. Its `_packageId` / `_packageVersion` / `_provenance` copies are unchanged (H3). - **The diagnostics count** resolves the same way, over the list's artifact lookup and the listed document. That is still a registry read, never a store read. ## Measured: the door does not move, and the reads now follow it The census used the real `ObjectStackProtocolImplementation` over an engine double, on a `view`. It covered 216 cells: 2 topologies × 6 artifact states (absent, no `_lock`, each of the 4 levels) × 9 stored states (none, or an env-wide / org-scoped row at each level) × 2 request scopes. Each cell read `getMetaItem`, `getMetaItemLayered`, the save door and the delete door. - Before (`ff29410ed2` code): 36 split cells, 18 per topology. 9 are position 1 (artifact `'none'` under a binding stored lock), and 9 are position 2 (artifact with no `_lock` under one). - After: 0 split cells. - **Door columns: identical in all 216 cells**, before against after. So no door verdict changed. - `getMetaItem` changed in 18 cells (position 1 only). `getMetaItemLayered` changed in 36 (positions 1 and 2). **H3, every other field.** A second probe ran at the merge base and at this branch on the same 216 cells. It dumped `getMetaItem`'s envelope and served body, `getMetaItemLayered`'s envelope and its `code` / `overlay` / `effective` / `overlayScope` / `_diagnostics`, and the `getMetaItems` list item. With the lock family masked (`lock`, `lockReason`, `lockSource`, `lockDocsUrl`, `editable`, `deletable` and the body's `_lock*`), **all 216 dumps are byte-identical**. The lock family differs in 54 cells: - 18 position-1 cells (both reads, the body and the list item now carry the stored lock); - 18 position-2 cells (`getMetaItemLayered` only); - 18 explicit-`'none'`-artifact cells where nothing binds. There the explicit `'none'` artifact's prose is no longer reported on the envelope, and its `_lock*` fields are no longer copied onto a stored row's body. ## H4: residue census (position 3), and why the fallback stays - Plural-spelled rows were writable through `@objectstack/metadata-protocol@16.1.0`: its `SysMetadataRepository.whereFor` keyed `type: ref.type` raw. The fold (#4432, commit `63b33e6a18`) first shipped in `17.0.0-rc.2`. Ancestry was read in both directions on a full (non-shallow) clone: `63b33e6a18` is not an ancestor of the `16.1.0` tag (exit 1); the control `16.0.0` is (exit 0). - `MIGRATION_SUPPORT_FLOOR` is 16, so 16.x deployments are on a supported upgrade path. - `os migrate meta --stored` (`migrateStoredMetadata`) rewrites bodies, never a stored type spelling. It reports the six URL-only plural spellings `skipped` (#8957), and it leaves a manifest-plural row at rest, re-saving a canonical row only when the body needs a conversion. - **So residue can exist on a supported path, and nothing rewrites it.** The reads' other-spelling fallback stays, and the difference stays declared (`findServedOverlayRow`'s `otherSpelling`; answer A on PR #21737). By the triage's own condition, no separate retirement card follows from this census. ## H5: the acceptance pin, generated from the resolver's inputs `protocol.lock-one-resolution.test.ts`: 1. **1632 generated rows.** The axes are: artifact (6) × stored row (none, or env-wide / org-scoped × 4 levels × canonical / other spelling: 17) × request scope (2) × topology (2) × request spelling (`view` / `views`) × operation (save / delete). The axes are grouped under `LAYER_AXES`, keyed by `ItemLockLayer`, so a layer added to `ITEM_LOCK_LAYERS` fails the typecheck. The completeness check also fails the run, naming the layer, and it asserts `resolveItemLock.length === 1` and that every axis is used exactly once. Each row runs on one protocol instance. Both reads must agree, both must equal an oracle written from the rule (iterating `ITEM_LOCK_LAYERS`), and the door must admit exactly when the envelope says `editable` / `deletable`. Rows stored under the other spelling assert the declared difference instead (the reads see the residue row and the door does not), so they flip by name when the fallback retires. 2. **PR #21737's 64 rows are folded in, with none lost.** A test rebuilds their 64 titles and asserts each is a row of this table. They and their lit control moved here out of `protocol.lock-org-axis-agree.test.ts`; that file keeps its pins 2 to 4. 3. **Position 1, named.** On both topologies: both reads say `full`, with `editable` and `deletable` false and `lockReason` from the stored row. Provenance is still the artifact's. The served body, the list item and the diagnostics tile say `full`. Save and delete are refused `ITEM_LOCKED` / 403 with `lock: 'full'`. 4. **Position 2, named.** On both topologies: `getMetaItemLayered` says `full` like `getMetaItem`, its `code` layer is still the package's (no `_lock`), and the door refuses. The #5840 outage pin (`protocol.metadata-store-outage.test.ts`) had a "healthy" leg. That leg's locked artifact existed only in the MetadataService double, never in the registry the gate reads. It now declares the artifact in the registry, package-stamped, as a loader does. Its outage leg is unchanged and still answers 503. ## Reverse verification Both legs ran from committed HEAD `db49dc4d2fab` (the `protocol.ts` blob), with `scripts/ablation-replace.mjs` in wrap mode. They ran inside a script carrying an absolute-path `trap` restore from `HEAD`. The subject resolves from source (`./protocol.js`), so no rebuild was in the path. Each direction was declared first. - **Leg A: `mergeArtifactProtection` restored as a lock source.** The copy became unconditional, and `getMetaItem` took its lock from the merged document again. Anchors 1 to 0 and markers 0 to 1, twice; blob `db49dc4d2fab` became `cb753a719d8b`, then `decca0d87278`. Predicted 146 red / 1494 green. **Measured 146 failed / 1494 passed**: pin 2 on both topologies, plus all 144 pin-1 rows with an explicit `'none'` artifact under a served stored lock. Pin 3 stayed green. - **Leg B: the layered read's `code ?? overlay ?? {}` restored.** Anchor 1 to 0 and marker 0 to 1; blob `db49dc4d2fab` became `bdd63140c660`. Predicted 292 red / 1348 green. **Measured 292 failed / 1348 passed**: pin 3 on both topologies, plus the 288 pin-1 rows whose artifact declares no `_lock` or `'none'` under a served stored lock. Pin 2 went red too, because its layered half was the same derivation. - **Restore, both legs:** blob equal to the HEAD blob `db49dc4d2fab`, `git diff HEAD` empty and `git status --porcelain` empty, checked by the tool and by the trap. ## Tests (all at `60232c3d17`, after merging `origin/main` `ea7ff394b6`) - `@objectstack/metadata-protocol`: `vitest run`. **213 files passed, 3 skipped. 5249 tests passed, 19 skipped.** That is base 3675, plus 1640 new, minus 66 moved. `tsc --noEmit` is green, and `--listFiles` compiles `item-lock.ts` and the new pin. - `@objectstack/objectql` against the rebuilt `metadata-protocol` dist: `--project local` gives **372 files, 7458 tests passed**, and `--project repo` gives 1 file, 5 tests passed. The dist was built from `ae121eac22`; `protocol.ts` and `item-lock.ts` are unchanged since. - Narrowed lint: `eslint --no-inline-config --format json` over the 5 changed TS files gives 5 file results, 0 errors and 0 warnings. The population was read from eslint's own config: `isPathIgnored` is false for all 5, and each computed config has 5 or 6 rules. Invariance: no computed config sets `parserOptions.project` or `projectService`, and `eslint.config.mjs` never enables type-aware linting, so this diff cannot move a verdict on an untouched file. - **NOT MEASURED:** HTTP (reach is on doubles); `rest` / `runtime` / `plugin-security` / `plugin-email` / `client` suites, which are consumers with no export or wire-shape change (their lock-field assertions were grepped and use fakes); CI-owned Dogfood, Temporal Conformance, type-check lanes and the full `pnpm lint`. ## Gates (at `60232c3d17`; exit codes captured before any pipe) - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 72 families: **72 run, all exit 0.** `--ran` reports "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED". `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET) and exited 0 after a full turbo build. - Artifact-roster block (54): 51 exit 0. The 3 PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) exited 2, NOT WIRED, before this PR existed; they are re-run with this PR's context in the report. - The four symbol-anchor sweeps exit 0: `adr-symbol-anchors` 2167 anchors across 140 records; `scripts-symbol-anchors` 3760 across 282 scripts; `spec-docblock-symbol-anchors` 4950 across 1868 sources; `adr-anchors` OK. - `check:engine-double-contract` asked for the new pin's `findOne` double, and its own `--write` recorded it: the ledger gained one `findOne` entry for the new file, and lost none. - `check:durability-log-level`: 70 read seams before and after. The `--depth-cost` census is identical apart from line numbers and one enclosing function's name (`getEffectiveLock` became `readLockGateOverlayLayer`). ## Acceptance notes (not filed here) - **The package axis, now measured on the double.** A read naming a package (`?package=`, ADR-0048 prefer-local) serves that package's row, while the gate asks package-agnostic (`findOne` with no `package_id`). The gate therefore binds whichever row the driver returns first. With a package-less row and a package A row of one `(type, name, scope)`, 2 of the 3 arrangements probed (which row declares the lock, and which row the driver returns first) split read from door. This is row selection, not the resolution, and this PR leaves it unchanged; it is reported to the seat. - **A lock only a copy the doors never read declares.** A MetadataService copy the dev watcher reloaded after boot, or a package-less runtime registration, used to read locked while the doors admitted the write. It now reads as the doors answer, and the changeset says so. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6a7547 commit b7a13c7

7 files changed

Lines changed: 828 additions & 122 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the item reads take an item's ADR-0010 `_lock` from the same resolution the write doors enforce, so the read envelope and the doors agree on the artifact layer too (#21738)
6+
7+
Clause-②: no
8+
9+
The `_lock` gate of the write doors (save, publish, rollback, delete) resolves an item's lock from two layers in order: the packaged artifact's `_lock`, unless it is `'none'`, then the stored `sys_metadata` row's. The two item reads did not use that resolution. `getMetaItem` took the lock from its served document, onto which `mergeArtifactProtection` had copied any declared artifact `_lock`, `'none'` included. `getMetaItemLayered` (`GET /api/v1/meta/:type/:name/layers`) took it from the code layer whenever one existed. Now the gate, both reads' envelopes, the served body's lock fields and the `getMetaDiagnostics` per-type `locked` count all call one resolution, `resolveItemLock`.
10+
11+
**Read answers that change**, on both kernel topologies:
12+
13+
- An artifact that declares `_lock: 'none'` over a stored row that declares a lock (env-wide, or the organization's own row) now reads with the stored row's lock in `getMetaItem` and `getMetaItemLayered`. Under `'full'`, that is `editable: false` and `deletable: false`. The served body's `_lock`, the `getMetaItems` list item's `_lock` and the diagnostics `locked` count say the same. The doors already refused these writes with `403 ITEM_LOCKED`. An artifact's `'none'` declares no lock; it does not override an administrator's stored lock.
14+
- `getMetaItemLayered` for a packaged item whose artifact declares no `_lock`, under a stored row that declares one, now reads the stored row's lock, as `getMetaItem` and the doors already did. Before, it read `lock: 'none'`, `editable: true`.
15+
- `lockReason`, `lockSource` and `lockDocsUrl` are the binding layer's. When no layer binds, they are absent: a reason explains a refusal, and there is none. Before, they were whatever the served document carried, so an explicit `'none'` artifact's reason was reported. For the same reason, an explicit `'none'` artifact's `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl` are no longer copied onto a stored row's served body.
16+
- A `_lock` that only a copy the doors never read declares is no longer reported as binding. Examples are a MetadataService copy the dev watcher reloaded after boot, and an item registered at runtime with no package. Such an item now reads as the doors answer it.
17+
18+
**Unchanged.** Every write-door verdict, refusal code and refusal text: the gate still reads the stored row only when the artifact's lock does not bind, so a packaged lock is still answered without a store read. `provenance`, `packageId` and `packageVersion` on both reads, and the artifact's `_packageId`, `_packageVersion` and `_provenance` on served bodies. Which stored row each read serves. The one declared difference between the reads and the gate also stays: the reads still serve a row stored under the type's other (plural) spelling when no canonical row is in scope, and the gate does not read it. No export, accepted input, key or error code changes.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21738, ADR-0010 §3.3 / §5] THE item-lock resolution: the one answer to
5+
* "which ADR-0010 `_lock` binds this item?", taken by every caller that
6+
* enforces a lock or reports one.
7+
*
8+
* - The write doors' `_lock` gate (`getEffectiveLock` in `protocol.ts`, behind
9+
* save, publish, rollback, delete and the package publish's promotion)
10+
* reads its layers through {@link resolveItemLockLazily}.
11+
* - Both item reads' protection envelope (`getMetaItem`,
12+
* `getMetaItemLayered`, through `servedLockState`) and the per-type
13+
* `locked` count of `getMetaDiagnostics` call {@link resolveItemLock}.
14+
* - The lock family a served body carries (`mergeArtifactProtection`) calls
15+
* it too, so a body never states a lock the envelope does not report.
16+
*
17+
* Before this module the reads took their lock from two derivations of their
18+
* own, and neither was the door's:
19+
*
20+
* 1. `mergeArtifactProtection` copied any declared artifact `_lock` over the
21+
* stored row's, an explicit `'none'` included. A packaged item whose
22+
* artifact declared `'none'` under a stored row declaring `'full'` read
23+
* `editable: true` while the door refused the save with `ITEM_LOCKED`.
24+
* 2. `getMetaItemLayered` read the lock off `code ?? overlay`, so a packaged
25+
* item whose artifact declared no lock read unlocked under a stored row
26+
* declaring one, while `getMetaItem` and the door said locked.
27+
*
28+
* ## The rule
29+
*
30+
* The layers are read in {@link ITEM_LOCK_LAYERS} order. The first layer whose
31+
* declared `_lock` is not `'none'` binds, and the answer carries that layer's
32+
* lock and its prose (`_lockReason`, `_lockDocsUrl`, `_lockSource`). A layer
33+
* that declares `'none'`, or declares nothing, does not bind. `'none'` declares
34+
* no lock: it is not a grant over a lower layer's lock (the triage ruling on
35+
* #21738: an artifact's explicit `'none'` leaves an administrator's stored lock
36+
* binding, because letting it override would widen the door). When no layer
37+
* binds, the answer is `'none'` with no prose: a lock reason explains a
38+
* refusal, and there is none to explain.
39+
*
40+
* ## The layers ARE the resolution's inputs, by name
41+
*
42+
* {@link ITEM_LOCK_LAYERS} is both the precedence and the parameter set. The
43+
* resolver iterates it, the input types are derived from it, and the
44+
* acceptance pin (`protocol.lock-one-resolution.test.ts`) generates its rows
45+
* from it. A layer added here without an axis there turns that pin's
46+
* completeness check red, naming the layer.
47+
*
48+
* ⛔ Not a policy of its own. Each caller decides which document it hands each
49+
* layer: the door hands the canonical-spelling, package-agnostic row; the reads
50+
* hand the row they serve (see `findServedOverlayRow` for the one declared
51+
* difference between the two).
52+
*/
53+
import { extractProtection, type MetadataLock, type MetadataLockSource } from '@objectstack/spec/kernel';
54+
55+
/**
56+
* The layers an item's lock can come from, in precedence order:
57+
*
58+
* - `artifact`: the item a code package's loader registered (ADR-0010 §3.3,
59+
* "an overlay cannot loosen a packaged lock");
60+
* - `overlay`: the stored `sys_metadata` row (ADR-0005), as the caller
61+
* resolved it.
62+
*/
63+
export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const);
64+
65+
/** One of {@link ITEM_LOCK_LAYERS}. */
66+
export type ItemLockLayer = (typeof ITEM_LOCK_LAYERS)[number];
67+
68+
/** Per layer, the document that layer contributes, or `undefined` when it has none. */
69+
export type ItemLockLayers = { readonly [L in ItemLockLayer]: unknown };
70+
71+
/** The resolution's answer. */
72+
export interface ItemLock {
73+
/** The binding lock, or `'none'` when no layer binds. */
74+
readonly lock: MetadataLock;
75+
/** The binding layer's `_lockReason`. */
76+
readonly lockReason: string | undefined;
77+
/** The binding layer's `_lockDocsUrl`. */
78+
readonly lockDocsUrl: string | undefined;
79+
/** The binding layer's declared `_lockSource`. */
80+
readonly lockSource: MetadataLockSource | undefined;
81+
/** The layer whose lock binds, or `undefined` when none does. */
82+
readonly layer: ItemLockLayer | undefined;
83+
}
84+
85+
const UNLOCKED: ItemLock = Object.freeze({
86+
lock: 'none',
87+
lockReason: undefined,
88+
lockDocsUrl: undefined,
89+
lockSource: undefined,
90+
layer: undefined,
91+
});
92+
93+
/**
94+
* Resolve the item's lock from the documents its layers contribute.
95+
* See this module's header for the rule.
96+
*/
97+
export function resolveItemLock(layers: ItemLockLayers): ItemLock {
98+
for (const layer of ITEM_LOCK_LAYERS) {
99+
const declared = extractProtection(layers[layer]);
100+
if (declared.lock !== 'none') {
101+
return {
102+
lock: declared.lock,
103+
lockReason: declared.lockReason,
104+
lockDocsUrl: declared.lockDocsUrl,
105+
lockSource: declared.lockSource,
106+
layer,
107+
};
108+
}
109+
}
110+
return UNLOCKED;
111+
}
112+
113+
/**
114+
* {@link resolveItemLock}, reading each layer only when no layer above it
115+
* binds. The answer is the same; what this saves is the reads below a binding
116+
* layer. The write doors use it, so a packaged lock is answered without a
117+
* `sys_metadata` read, and a store that cannot be read never turns a packaged
118+
* lock's `ITEM_LOCKED` into a 503.
119+
*
120+
* A reader's failure propagates: each caller owns its #5532 / #5706
121+
* discrimination.
122+
*/
123+
export async function resolveItemLockLazily(read: {
124+
readonly [L in ItemLockLayer]: () => unknown | Promise<unknown>;
125+
}): Promise<ItemLock> {
126+
const layers = Object.fromEntries(ITEM_LOCK_LAYERS.map((layer) => [layer, undefined])) as Record<ItemLockLayer, unknown>;
127+
for (const layer of ITEM_LOCK_LAYERS) {
128+
layers[layer] = await read[layer]();
129+
const answer = resolveItemLock(layers);
130+
if (answer.layer !== undefined) return answer;
131+
}
132+
return UNLOCKED;
133+
}

0 commit comments

Comments
 (0)