Skip to content

Commit 18c2ddc

Browse files
fix(metadata-protocol)!: an item's lock is the strictest lock among the stored rows in scope for its address (#21761) (#21801)
Fixes #21761 Clause-②: no (narrowing) ## What changes An item's ADR-0010 lock is now selected from the item's **address** (type, name, organization scope, `packageId`) by one function, `resolveOverlayLockLayer` in `packages/metadata-protocol/src/item-lock.ts`. The write doors' `_lock` gate, `getMetaItem`, `getMetaItemLayered` and the list all call it. No caller pre-selects a row for the lock any more. - **The rows in scope.** ADR-0005 precedence, as before: the organization's rows when it holds any row of the item, else the env-wide rows. Within that scope, every row of the item counts, whichever package it is bound to. - **The lock is the strictest among them.** A write is refused when any row in scope refuses it, and a delete likewise (`strictestLock`, read off the lock algebra). Two rows that refuse different verbs (`no-overlay`, `no-delete`) join to `full`. - **Content stays prefer-local** (ADR-0048). `findServedOverlayRow` is unchanged in what it serves; it now decides the content only. A read naming package A is still served A's row. When the binding lock comes from another row, the served body carries the binding row's lock family (`withOverlayLockFamily`), so the body never states a lock the envelope does not report. - **Prose.** Among rows that declare the strictest lock, the reported `lockReason` / `lockDocsUrl` / `lockSource` are the address's own package row's, then the package-less row's, then other packages' by id. So the prose does not depend on row order either. Landing points, as the claim's file surface said: `item-lock.ts`, and the row selection in `protocol.ts` (`overlayLockLayerAt`; the gate's `readLockGateOverlayLayer`; the lock source of both reads; the list item and the `getMetaDiagnostics` tile through the list body). One test file outside that surface: `packages/objectql/src/protocol-meta.test.ts`, whose outage double now fails `find` as well as `findOne`, because the gate's first read is a `find`. The rest of the diff is tests, the changeset, and the engine-double ledger row `check:engine-double-contract --write` added. ## Rulings honoured, and one place they meet - Strictest-wins, content prefer-local, no prefer-local lock: as ruled in 5982313964 and restated in 5982824254. - **Rows in scope for a request naming package A.** The ruling names A's row and the package-less row. This PR keeps both and also keeps **other packages' rows of the same name** in scope (pin 7). Measured reason: before this change the gate asked with no package, so `findOne` could return package B's row. In arrangement `A none + B full` with B returned first, a save naming A was **refused** at the merge base. With A and the package-less row only, it would be **admitted**. That is a widening, which the ruling itself excludes ("never more permissive than either row the door could pick today"). The delete door also carries no package at all (`DeleteMetaItemRequest` declares none), so a package-scoped `deletable` can report that door only from a package-agnostic row set. Narrowing the set to A's row and the package-less row is a widening and stays the maintainer's to rule; it is raised in the report, not taken here. - **Rows in scope for a request naming no package.** Every row of the item in scope: exactly the set the gate's `findOne` could have returned. So the lock is never more permissive than any row the door could pick before. ## H1: the package axis, before and after **Before** (merge base `ebfe658c72`): - The gate went through `readLockGateOverlayLayer` → `findServedOverlayRow`, with no `packageId`, canonical spelling only, and bound the row `findOne` returned first. A save with `?package=` reached the gate **without** its package (measured: the gate's address was package-less for every request), and a delete carries none. - `getMetaItem` and `getMetaItemLayered` handed `findServedOverlayRow` the request's `packageId` (present or absent) and took that row's lock. - The list item and the diagnostics tile never called `findServedOverlayRow`. They read the lock off the body the list's per-slot merge picked (the *latest* of the package's row and the package-less row). **After** (this branch's sources at `05b41c9449`; the later merges of `origin/main` touch no file of `metadata-protocol`): every caller hands `resolveOverlayLockLayer` its address. The gate's address carries `packageId` for save and publish and none for delete and rollback; the reads carry the request's. Measured over an engine double, 30 requests, both `findOne` orders. `b→h` = merge base → this head; the door columns are the `_lock` gate's verdict; reads are store reads per call. | arrangement · row order · request | getMetaItem lock | layered lock | list item `_lock` | tile locked | door save | door delete | gate reads | getMetaItem reads | |---|---|---|---|---|---|---|---|---| | arr1 pkgless none + A full [as] · package A | full→full | full→full | full→full | 1→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | arr1 pkgless none + A full [as] · no package | none→full | none→full | full→full | 1→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | arr1 pkgless none + A full [rev] · package A | full→full | full→full | full→full | 1→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr1 pkgless none + A full [rev] · no package | full→full | full→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr2 A none + pkgless full [as] · package A | none→full | none→full | none→full | 0→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | arr2 A none + pkgless full [as] · no package | none→full | none→full | full→full | 1→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | arr2 A none + pkgless full [rev] · package A | none→full | none→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr2 A none + pkgless full [rev] · no package | full→full | full→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr3a pkgless no-overlay + A no-delete [as] · package A | no-delete→full | no-delete→full | no-delete→full | 1→1 | refused→refused | admitted→refused | 1→1 | 1→2 | | arr3a pkgless no-overlay + A no-delete [as] · no package | no-overlay→full | no-overlay→full | no-delete→full | 1→1 | refused→refused | admitted→refused | 1→1 | 1→2 | | arr3a pkgless no-overlay + A no-delete [rev] · package A | no-delete→full | no-delete→full | no-delete→full | 1→1 | admitted→refused | refused→refused | 1→1 | 1→2 | | arr3a pkgless no-overlay + A no-delete [rev] · no package | no-delete→full | no-delete→full | no-overlay→full | 1→1 | admitted→refused | refused→refused | 1→1 | 1→2 | | arr3b pkgless full + A no-overlay [as] · package A | no-overlay→full | no-overlay→full | no-overlay→full | 1→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr3b pkgless full + A no-overlay [as] · no package | full→full | full→full | no-overlay→full | 1→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr3b pkgless full + A no-overlay [rev] · package A | no-overlay→full | no-overlay→full | no-overlay→full | 1→1 | refused→refused | admitted→refused | 1→1 | 1→2 | | arr3b pkgless full + A no-overlay [rev] · no package | no-overlay→full | no-overlay→full | full→full | 1→1 | refused→refused | admitted→refused | 1→1 | 1→2 | | arr3c pkgless no-delete + A full [as] · package A | full→full | full→full | full→full | 1→1 | admitted→refused | refused→refused | 1→1 | 1→2 | | arr3c pkgless no-delete + A full [as] · no package | no-delete→full | no-delete→full | full→full | 1→1 | admitted→refused | refused→refused | 1→1 | 1→2 | | arr3c pkgless no-delete + A full [rev] · package A | full→full | full→full | full→full | 1→1 | refused→refused | refused→refused | 1→1 | 1→2 | | arr3c pkgless no-delete + A full [rev] · no package | full→full | full→full | no-delete→full | 1→1 | refused→refused | refused→refused | 1→1 | 1→2 | | xB1 A none + B full [as] · package A | none→full | none→full | none→full | 0→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | xB1 A none + B full [as] · no package | none→full | none→full | full→full | 1→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | xB1 A none + B full [rev] · package A | none→full | none→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | xB1 A none + B full [rev] · no package | full→full | full→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | xB2 pkgless none + A none + B full [as] · package A | none→full | none→full | none→full | 0→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | xB2 pkgless none + A none + B full [as] · no package | none→full | none→full | full→full | 1→1 | admitted→refused | admitted→refused | 1→1 | 1→2 | | xB2 pkgless none + A none + B full [rev] · package A | none→full | none→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | xB2 pkgless none + A none + B full [rev] · no package | full→full | full→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 1→2 | | xOrg org B full + env A none · org + package A | none→full | none→full | none→full | 0→1 | refused→refused | refused→refused | 1→1 | 5→6 | | xOrg org B full + env A none · org, no package | full→full | full→full | full→full | 1→1 | refused→refused | refused→refused | 1→1 | 1→2 | Summary over the 30 requests and 60 door verdicts: - 0 widenings (no write refused at the base is admitted at head). - 24 narrowings, all where the base verdict depended on row order. - 36 unchanged. - Order-dependent groups: 14 of 14 at the base, 0 of 14 at head. - Requests where `getMetaItem`, `getMetaItemLayered` and the door disagree: 8 of 30 at the base, 0 of 30 at head. ## H2, H3, H4, H5 - **H2 (what the door carries).** Measured: the save door had `request.packageId` in hand, but `assertLockAllowsWrite` never received it, so the gate saw no package; the delete request declares none. It now carries it for save and publish. Rows in scope are package-agnostic for every address (above), so carrying the package picks only the prose, never the rows. Widening check: 0 of 60 door verdicts. - **H3 (content stays prefer-local).** Both reads' full envelopes and bodies (layered: `code`, `overlay`, `effective`) and the list items were dumped for all 30 requests at the base sources and at `05b41c9449`. With the lock family masked (`lock`, `lockReason`, `lockSource`, `lockDocsUrl`, `editable`, `deletable`, and `_lock` / `_lockReason` / `_lockSource` / `_lockDocsUrl` anywhere), the dumps are byte-identical: 0 diffs in 90. - **H4 (one address, one selection).** `resolveOverlayLockLayer(address, rowsIn, { otherSpelling })`. The address fields are `ITEM_ADDRESS_FIELDS = ['type', 'name', 'organizationId', 'packageId']`. `rowsIn(organizationId, spelling)` returns every stored row of the item in one scope under one spelling; the function applies the precedence and the strictest join itself. Callers: - the protocol's `overlayLockLayerAt`, an engine `find` per scope, used by the gate, `getMetaItem` and `getMetaItemLayered`; - the list, a filter over the package-agnostic rows it read. The one declared difference is unchanged: the gate passes `otherSpelling: false`, the reads `true` (#4432). - **H5 (fail-closed, store reads).** A failing read in scope still refuses with 503 (pin 6, with and without a package; the #5706 pin's double now fails the gate's `find`). The gate's store reads per check are unchanged in count: one read per scope, stopping at the first scope that holds a row. Before that was `findOne`; now it is `find`, because the strictest lock needs every row of the item, and `findOne` cannot enumerate them. Measured: 1→1 on all 30 requests. The reads pay one extra `find` per active read (1→2; 5→6 in the org-plus-package case), because the content row (prefer-local) and the lock rows (all in scope) are different sets. A package-scoped list pays one extra package-agnostic row read, which the overlay cache answers on a hit. ## Pins In `protocol.lock-one-resolution.test.ts`: 1. **The generated pin** gains the address as input columns: `requestPackage` (`packageId` absent / present) and `packageRow` (the package's row: none, or each lock level), beside the existing package-less `storedRow`. - Its completeness check names every field of `ITEM_ADDRESS_FIELDS` against an axis (`ADDRESS_AXES`, keyed by `ItemAddressField`, so a new field fails typecheck and the run). - Every row runs under both orders the store can return the two rows in. - Every row asserts `getMetaItem` = `getMetaItemLayered` = the door, for save and delete, against an oracle written from the rule. - 16 320 rows, plus the subset check that PR #21737's 64 rows are still in the table. 2. Pin 4: arrangement 1 (package-less `none`, package `full`), both orders, package named and not. The reads and the door agree on `full`, the body says `full`, and content is the package's own row. 3. Pin 5: arrangement 2 (package `none`, package-less `full`), both orders. `full`, never `none`; the refusal carries the binding row's prose. 4. Pin 6: the #5706 outage. Every `sys_metadata` read fails, and save (with and without a package) and delete answer 503 from the gate. 5. Pin 7: a third package's row binds a save naming A, under both orders. An organization holding only another package's row is the scope for reads and door alike. The list item and the tile report the item's lock. (The dispatch order's pins 2 and 3 are this file's pins 4 and 5: the file already had #21738's pins 2 and 3.) ## Reverse verification Committed first (`68567308d1`). The gate's pre-change selection was restored through `scripts/ablation-replace.mjs`: one row, package-agnostic, canonical, `findOne` org then env. - The anchor hit 1 → 0 and the marker 0 → 1; blob `388e091cb01e` → `cf3701af4ce0`. - The tests resolve `./protocol.js` from `src`, so no `dist` was involved. - Predicted, then measured, on `-t '#21761. pin (4|5)'`: **5 red / 4 green**. - Red: pin 4 "package-less row returned first" (package named and not); pin 5 "package row returned first" (package named and not); pin 5's refusal-prose case. Each failed with `expected 'admitted' to deeply equal { refused: … }`, or a non-lock error where `ITEM_LOCKED` was expected. - Green: the four cases where the old gate's first row happened to be the binding one. - Restored with `git checkout HEAD -- PATH` (absolute path, under trap). Blob after = blob at HEAD = `388e091cb01e`; `git diff HEAD` empty. ## Tests At `7fe49587a0` (after merging `origin/main` at `e83c9f6154`). The final head `273ead3408` adds only a second merge of `origin/main` (`e27a7c0c9e`), which touches `cloud-connection`, `service-storage`, a pm reference doc and two ratchet baselines and no file of `spec`, `metadata-core`, `metadata-protocol` or `objectql`, so these readings carry: - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 213 files passed, 3 skipped; 19 952 tests passed, 19 skipped. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` includes all four edited test files. - objectql, against the rebuilt `metadata-protocol` dist: the 8 lock-touching protocol files pass (160 tests). The full objectql suite passed at `632a00751d`: 373 files, 7463 tests. ## Gates All at head `273ead3408`, after the final commit. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived **74** commands. All 74 ran, plus the **54**-row artifact-roster block it prints outside its total and the four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`): **132 commands, 129 exit 0**. - `dispatch-gates --ran` over the recorded `cmd :: exit N` list: "74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED (a DERIVED zero — all 74 recorded an exit code and none of them is 3)". - The three non-zero results are roster gates that read a pull request, and locally have none (exit 2, NOT WIRED): - `check-partof-closing-keyword`: re-run with this body as `PR_BODY`, exit 0; - `check-closing-target-claim` and `check-single-claim-paths`: **NOT MEASURED**. Each needs a PR number and an API token; this session's `GH_TOKEN` is invalid, and their guard workflows run on the PR. - `check-closing-keyword-parity --body`: only `#21761` binds. - Named readings: - `check:engine-double-contract`: OK, after recording the one new pinned row through its own `--write`. - `check:durability-log-level`: green, read-seam invention included. - `check:lean-entry-closure`: green, after building objectql. - `check:dual-build-cjs-loads`: green. - `check:nul-bytes`: OK. - `check:changeset-no-major`: the level axis was measured with a simulated PR body carrying this `Clause-②` line (`minor` passes; `patch` exits 1). - `check:adr-0087-registration`: one declared-breaking changeset, carrying its disposition. - **Lint, a proven narrowing.** - Command: `eslint --no-inline-config --format json` over the 7 touched `.ts` files, at `273ead3408`: 7 files, 0 errors, 0 warnings. - Population, read from eslint's own `calculateConfigForFile` / `isPathIgnored`: every one of the 7 is linted (none ignored). - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, which reads null for each file), so this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` is CI's. - **NOT MEASURED here, CI's:** - Test Core shards beyond the two packages above; - Temporal Conformance, Dogfood, Build Core; - the workspace type-check lane. ## Acceptance notes - **Changeset grade.** The dispatch order asked for `patch`. `check-changeset-no-major.mjs` refuses `patch` on a moved package under `Clause-②: no (narrowing)` during the launch window. It was measured on a throwaway local commit: exit 1, "it grades NO package whose `packages/**/src/**` it moves" at the level a narrowing owes. So the changeset is `minor`, with the `!` title and an ADR-0087 `not-required (no-migration-prescription)` disposition; `check:adr-0087-registration` reads it. - **Artifact layer, same family, not changed here.** The reads look the artifact up package-scoped (`lookupArtifactItem(type, name, packageId)`); the gate looks it up package-agnostically. Measured on a registry double that mirrors `getArtifactItem`'s prefer-local-then-first-composite order, with packages B (`full`, registered first) and A (no lock) shipping one view name: - `getMetaItem` naming A reads `lock: none, editable: true`; - a save naming A is refused `ITEM_LOCKED` with `source=artifact` and B's reason. Reported in the PR's report as a finding for the seat. - **The list serves the latest of a slot's rows.** `mergePackageAwareOverlay` picks, per package slot, the latest of that package's row and the package-less row. With both present, the list's body for package A is the package-less row when it comes back later, while `getMetaItem` naming A serves A's row (H1 table, list column at the base). The lock family on the list body now follows the binding rows; the content selection is unchanged and is reported as a finding. - `getMetaItemLayered`'s `effective` body now carries the binding overlay lock family as `getMetaItem`'s body does. `code` and `overlay` stay raw. On the artifact axis `effective` still never carried the artifact's family, as before. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1354e7b commit 18c2ddc

9 files changed

Lines changed: 923 additions & 194 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol)!: an item's lock is the strictest lock among the stored rows in scope for its address, at the write doors and on both reads (#21761)
6+
7+
Clause-②: no (narrowing)
8+
9+
ADR-0048 lets one item (type, name, organization scope) hold several stored `sys_metadata` rows: one per package (`?package=` saves) and a package-less one. The ADR-0010 `_lock` gate asked for the item with no package and bound whichever row the store returned first, while `getMetaItem` and `getMetaItemLayered` naming a package reported that package's row. So a read and the door could state two different locks for one item, and the door's answer depended on row order.
10+
11+
Now every caller selects the lock from the item's address through one function: the strictest lock among the item's stored rows in scope. The scope is ADR-0005's (the organization's rows when it holds any row of the item, else the env-wide rows), and within it every row of the item counts, whichever package it is bound to. The strictest lock refuses a write when any of those rows refuses it, and a delete likewise; two rows that refuse different verbs (`no-overlay` and `no-delete`) give `full`. Both reads report that lock in `lock`, `editable` and `deletable`, the served body carries its `_lock` family, and the list item and the `getMetaDiagnostics` locked count follow it. Content stays prefer-local: a read naming a package is still served that package's own row.
12+
13+
**What moves for consumers.** The door now refuses where it used to depend on which row the store returned first:
14+
15+
- a package-less row declaring no lock and a package's row declaring `full`: a save or delete, with or without `?package=`, is refused `403 ITEM_LOCKED` in every row order, where it was admitted when the package-less row came back first;
16+
- a package's row declaring `none` and a package-less row declaring `full`: refused in every row order, where it was admitted when the package's row came back first. A package row's explicit `none` is not a grant over another row's lock;
17+
- two rows refusing different verbs (`no-overlay`, `no-delete`): both a save and a delete are refused, where each was admitted under the row order that bound the other row;
18+
- another package's row of the same name declaring a lock binds a save naming this package too, as it did under the row order that returned it first.
19+
20+
No write the door refused before is admitted now. Both reads become stricter in exactly those arrangements: a read naming a package whose own row declares `none` now reports `editable: false` when another row in scope declares `full`. No key, export, status or error code changes.
21+
22+
<!-- adr-0087: not-required (no-migration-prescription) the write doors refuse, by the strictest lock among the stored rows in scope, writes they used to admit when the store returned a looser row first. No authorable key, spelling, export or stored shape moves: every stored row keeps parsing, nothing is read or rewritten at rest, and which row an administrator meant to lock is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers the lock gate (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts‎

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
*/
6666

6767
import { describe, expect, it } from 'vitest';
68-
import { organizationIdForMetaRead } from '@objectstack/metadata-core';
68+
import { assertEngineFindOnePredicate, organizationIdForMetaRead } from '@objectstack/metadata-core';
6969
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
7070
import { ObjectStackProtocolImplementation } from './protocol.js';
7171

@@ -108,34 +108,44 @@ const storedRow = (
108108
});
109109

110110
/**
111-
* The engine double: `findOne` over a row table, plus the registry surface the
112-
* single-item read path touches on its way past the overlay.
111+
* The engine double: `findOne` and `find` over a row table, plus the registry
112+
* surface the single-item read path touches on its way past the overlay.
113113
*
114-
* ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path
115-
* under test issues exactly one verb, and a double declaring verbs no case
116-
* exercises would owe `check:engine-double-contract` a dispatch contract that
117-
* protects nothing. Same shape the sibling org-read-gate pin drives.
114+
* ⛔ No `insert` / `update` / `delete`, deliberately — the read path under test
115+
* issues two read verbs (`findOne` for the served row, and since #21761 `find`
116+
* for the rows its lock is selected from), and a double declaring verbs no
117+
* case exercises would owe `check:engine-double-contract` a dispatch contract
118+
* that protects nothing. Same shape the sibling org-read-gate pin drives.
118119
*/
119120
function makeHarness(rows: StoredRow[]) {
121+
const matching = (table: string, opts?: { where?: Record<string, unknown> }): StoredRow[] => {
122+
if (table !== 'sys_metadata') return [];
123+
const where = opts?.where ?? {};
124+
// `check:where-matcher` — a hand-written matcher with no combinator
125+
// branch reads `$and` as a field name and answers the wrong
126+
// question rather than failing. Refuse the shape this double does
127+
// not implement, matching the sibling doubles' convention.
128+
for (const k of Object.keys(where)) {
129+
if (k.startsWith('$')) {
130+
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131+
}
132+
}
133+
return rows.filter((r) =>
134+
Object.entries(where).every(([k, v]) => {
135+
if (v === undefined) return true;
136+
return (r as unknown as Record<string, unknown>)[k] === v;
137+
}),
138+
);
139+
};
120140
const engine: any = {
121141
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
142+
// `check:engine-double-contract` — refuses what the real engine refuses.
143+
assertEngineFindOnePredicate(table, opts);
122144
if (table !== 'sys_metadata') return undefined;
123-
const where = opts?.where ?? {};
124-
// `check:where-matcher` — a hand-written matcher with no combinator
125-
// branch reads `$and` as a field name and answers the wrong
126-
// question rather than failing. Refuse the shape this double does
127-
// not implement, matching the sibling doubles' convention.
128-
for (const k of Object.keys(where)) {
129-
if (k.startsWith('$')) {
130-
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131-
}
132-
}
133-
return rows.find((r) =>
134-
Object.entries(where).every(([k, v]) => {
135-
if (v === undefined) return true;
136-
return (r as unknown as Record<string, unknown>)[k] === v;
137-
}),
138-
);
145+
return matching(table, opts)[0];
146+
},
147+
async find(table: string, opts?: { where?: Record<string, unknown> }) {
148+
return matching(table, opts);
139149
},
140150
registry: {
141151
registerItem: () => undefined,

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

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,56 @@
4545
* from it. A layer added here without an axis there turns that pin's
4646
* completeness check red, naming the layer.
4747
*
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).
48+
* ## [#21761] The overlay layer is selected from the item's ADDRESS
49+
*
50+
* The `overlay` layer is not a row a caller picks. Every caller hands
51+
* {@link resolveOverlayLockLayer} the item's address ({@link ItemAddress}) and
52+
* a reader of stored rows, and that one function selects the rows in scope
53+
* and contributes their strictest lock. So the reads and the doors cannot
54+
* select differently: before it, each caller pre-selected the row it handed
55+
* this module, and on the package axis (ADR-0048) a read naming a package
56+
* reported that package's row while the `_lock` gate bound whichever row
57+
* `findOne` returned first.
58+
*
59+
* - **The rows in scope.** ADR-0005 precedence, as the reads resolve it: the
60+
* organization's rows when it holds any row of the item, else the env-wide
61+
* rows. Within that scope every row of the item, whichever package it is
62+
* bound to: the address's own package row, the package-less row, and any
63+
* other package's row. Those are every row the doors could bind before this
64+
* rule (they asked with no package, so `findOne` could return any of them).
65+
* A row stored under the type's other spelling is in scope only for a
66+
* caller that passes `otherSpelling` (the reads), and only when no row
67+
* under the canonical spelling is in that scope (the one declared
68+
* difference, #4432).
69+
* - **The lock is the strictest among them.** A write is refused when any
70+
* row in scope refuses it, and a delete likewise. So the lock is never
71+
* more permissive than any row a door could bind before, and it does not
72+
* depend on the order the store returns rows in. For two rows that refuse
73+
* different verbs (`'no-overlay'` and `'no-delete'`) that is `'full'`, the
74+
* state that refuses both.
75+
* - **Content stays prefer-local** (ADR-0048). Only the lock is resolved
76+
* across the rows in scope. The served document is still the row the
77+
* address prefers, and its lock family is the binding layer's.
78+
*
79+
* ⛔ Not a policy of its own. Which artifact a caller hands the `artifact`
80+
* layer is still the caller's lookup.
5281
*/
53-
import { extractProtection, type MetadataLock, type MetadataLockSource } from '@objectstack/spec/kernel';
82+
import {
83+
MetadataLockSchema,
84+
evaluateLockForDelete,
85+
evaluateLockForWrite,
86+
extractProtection,
87+
type MetadataLock,
88+
type MetadataLockSource,
89+
} from '@objectstack/spec/kernel';
5490

5591
/**
5692
* The layers an item's lock can come from, in precedence order:
5793
*
5894
* - `artifact`: the item a code package's loader registered (ADR-0010 §3.3,
5995
* "an overlay cannot loosen a packaged lock");
60-
* - `overlay`: the stored `sys_metadata` row (ADR-0005), as the caller
61-
* resolved it.
96+
* - `overlay`: the stored `sys_metadata` rows in the item's scope (ADR-0005),
97+
* as {@link resolveOverlayLockLayer} selects them from the item's address.
6298
*/
6399
export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const);
64100

@@ -131,3 +167,172 @@ export async function resolveItemLockLazily(read: {
131167
}
132168
return UNLOCKED;
133169
}
170+
171+
// ── [#21761] The overlay layer, selected from the item's address ─────────────
172+
173+
/**
174+
* The fields of an item's address, by name: what a read or a write door names
175+
* when it asks about one item. {@link ItemAddress} is derived from it, and the
176+
* acceptance pin (`protocol.lock-one-resolution.test.ts`) gives each field an
177+
* axis, so a field added here without one turns that pin's completeness check
178+
* red, naming the field.
179+
*/
180+
export const ITEM_ADDRESS_FIELDS = Object.freeze(['type', 'name', 'organizationId', 'packageId'] as const);
181+
182+
/** One of {@link ITEM_ADDRESS_FIELDS}. */
183+
export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number];
184+
185+
/**
186+
* An item's address:
187+
*
188+
* - `type`: the canonical metadata type (#4432);
189+
* - `name`: the item's name;
190+
* - `organizationId`: the organization the request is scoped to, already
191+
* gated by `organizationIdForMetaRead` (ADR-0005), or `undefined` for the
192+
* env-wide scope alone;
193+
* - `packageId`: the package the request names (ADR-0048 `?package=`), or
194+
* `undefined`. It decides which row's prose the lock carries when several
195+
* rows in scope declare the strictest lock, never which rows are in scope.
196+
*/
197+
export type ItemAddress = {
198+
readonly type: string;
199+
readonly name: string;
200+
readonly organizationId: string | undefined;
201+
readonly packageId: string | undefined;
202+
} & { readonly [F in ItemAddressField]: unknown };
203+
204+
/** A stored `sys_metadata` row, as far as the overlay layer reads it. */
205+
export interface StoredOverlayRow {
206+
readonly id?: unknown;
207+
readonly package_id?: string | null;
208+
readonly metadata?: unknown;
209+
}
210+
211+
/**
212+
* The stored rows of the addressed item in one scope (`organizationId`, or
213+
* `null` for env-wide) under one spelling of its type: `'canonical'`, or
214+
* `'other'` (the type's singular/plural twin, pre-#4432 residue). Every row of
215+
* the item there, whichever package it is bound to. A failed read throws: each
216+
* caller owns its #5532 / #5706 discrimination.
217+
*/
218+
export type StoredOverlayRowReader = (
219+
organizationId: string | null,
220+
spelling: 'canonical' | 'other',
221+
) => readonly StoredOverlayRow[] | Promise<readonly StoredOverlayRow[]>;
222+
223+
/**
224+
* The body a stored `sys_metadata` row holds, as written: the document a row
225+
* contributes, parsed the one way the `_lock` gate and both reads parse it. No
226+
* conversion is replayed: none touches the `_lock` family.
227+
*/
228+
export function storedRowDocument(row: { metadata?: unknown }): unknown {
229+
return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
230+
}
231+
232+
/**
233+
* The strictest of `locks`: the state that refuses a write when any of them
234+
* does, and a delete when any of them does. Read off the lock algebra itself
235+
* (`evaluateLockForWrite` / `evaluateLockForDelete`), so `'no-overlay'` with
236+
* `'no-delete'` is `'full'`, and no lock is `'none'`.
237+
*/
238+
export function strictestLock(locks: readonly MetadataLock[]): MetadataLock {
239+
const refusesWrite = locks.some((lock) => evaluateLockForWrite(lock) !== null);
240+
const refusesDelete = locks.some((lock) => evaluateLockForDelete(lock) !== null);
241+
const strictest = MetadataLockSchema.options.find((state) =>
242+
(evaluateLockForWrite(state) !== null) === refusesWrite
243+
&& (evaluateLockForDelete(state) !== null) === refusesDelete);
244+
if (strictest === undefined) {
245+
// Unreachable while the lock algebra covers all four verdict pairs; a
246+
// state added without a write/delete answer must fail here, loudly.
247+
throw new Error(`No ADR-0010 lock state refuses write=${refusesWrite}, delete=${refusesDelete}.`);
248+
}
249+
return strictest;
250+
}
251+
252+
/** The ADR-0010 lock family a stored body carries, and nothing else. */
253+
const LOCK_FAMILY_KEYS = Object.freeze(['_lock', '_lockReason', '_lockDocsUrl', '_lockSource'] as const);
254+
255+
/**
256+
* [#21761] THE overlay layer's selection. The document the `overlay` layer of
257+
* {@link resolveItemLock} gets for `address`: the strictest lock among the
258+
* stored rows in the address's scope, read through `rowsIn`. See this module's
259+
* header for the rule. `undefined` when no row is in scope or none of them
260+
* declares a lock.
261+
*
262+
* The document is the lock family alone. It is the family of the row that
263+
* declares the strictest lock, preferring the address's own package row, then
264+
* the package-less row, then the other packages' rows by package id, so its
265+
* prose (`_lockReason`, `_lockDocsUrl`, `_lockSource`) is the same whatever
266+
* order the store returned the rows in. When no single row declares it (one
267+
* row refuses writes and another deletes), the document is that lock with no
268+
* prose.
269+
*
270+
* Every caller that enforces or reports an item's lock passes its address
271+
* here: the `_lock` gate (`otherSpelling: false`, #4432), both item reads and
272+
* the list (`otherSpelling: true`). Rows are read scope by scope and stop at
273+
* the first scope that holds one, so the gate reads no more than it did when
274+
* it read one row.
275+
*/
276+
export async function resolveOverlayLockLayer(
277+
address: ItemAddress,
278+
rowsIn: StoredOverlayRowReader,
279+
options: { readonly otherSpelling: boolean },
280+
): Promise<unknown> {
281+
const scopes: Array<string | null> = address.organizationId ? [address.organizationId, null] : [null];
282+
for (const scope of scopes) {
283+
let rows = await rowsIn(scope, 'canonical');
284+
if (rows.length === 0 && options.otherSpelling) rows = await rowsIn(scope, 'other');
285+
if (rows.length > 0) return strictestRowLockDocument(rows, address.packageId);
286+
}
287+
return undefined;
288+
}
289+
290+
function strictestRowLockDocument(rows: readonly StoredOverlayRow[], packageId: string | undefined): unknown {
291+
const declared = rows.map((row) => {
292+
const document = storedRowDocument(row);
293+
return { row, document, lock: extractProtection(document).lock };
294+
});
295+
const lock = strictestLock(declared.map((d) => d.lock));
296+
if (lock === 'none') return undefined;
297+
const rank = (row: StoredOverlayRow): number => {
298+
const own = row.package_id ?? null;
299+
if (packageId !== undefined && own === packageId) return 0;
300+
return own === null ? 1 : 2;
301+
};
302+
const ordered = [...declared].sort((a, b) =>
303+
rank(a.row) - rank(b.row)
304+
|| String(a.row.package_id ?? '').localeCompare(String(b.row.package_id ?? ''))
305+
|| String(a.row.id ?? '').localeCompare(String(b.row.id ?? '')));
306+
const binding = ordered.find((d) => d.lock === lock);
307+
if (binding === undefined) return { _lock: lock };
308+
const source = binding.document as Record<string, unknown>;
309+
const family: Record<string, unknown> = {};
310+
for (const key of LOCK_FAMILY_KEYS) {
311+
if (source[key] !== undefined) family[key] = source[key];
312+
}
313+
return family;
314+
}
315+
316+
/**
317+
* [#21761] `document` with the lock family of the `overlay` layer
318+
* ({@link resolveOverlayLockLayer}) when that layer binds, so a served body
319+
* states the lock the envelope reports even when the rows in scope that bind
320+
* are not the row the body was served from. A key that layer's document does
321+
* not declare is removed. Returns `document` itself when the overlay layer does
322+
* not bind or nothing changes (the binding row is the served row), and a copy
323+
* otherwise. The `artifact` layer's family is `mergeArtifactProtection`'s to
324+
* put, as before.
325+
*/
326+
export function withOverlayLockFamily(document: unknown, layers: ItemLockLayers): unknown {
327+
if (!document || typeof document !== 'object' || Array.isArray(document)) return document;
328+
if (resolveItemLock(layers).layer !== 'overlay') return document;
329+
const family = layers.overlay as Record<string, unknown>;
330+
const current = document as Record<string, unknown>;
331+
if (LOCK_FAMILY_KEYS.every((key) => current[key] === family[key])) return document;
332+
const out: Record<string, unknown> = { ...current };
333+
for (const key of LOCK_FAMILY_KEYS) {
334+
if (family[key] === undefined) delete out[key];
335+
else out[key] = family[key];
336+
}
337+
return out;
338+
}

0 commit comments

Comments
 (0)