Skip to content

Commit 18fe681

Browse files
fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) (#21844)
Fixes #21803 Clause-②: no (narrowing) ## What changes Two installed code packages may ship one `(type, name)` (ADR-0048 §3.4). The ADR-0010 `_lock` gate looked the packaged artifact up with no package, so it bound the first package registered. `getMetaItem`, `getMetaItemLayered`, the list and the `getMetaDiagnostics` tile looked it up with the request's package. So one item had two lock answers, and the door's answer depended on registration order. Measured on the real `SchemaRegistry` (H1 below), the defect runs in both directions: - B ships `_lock: 'full'`, A ships no lock, **B registered first**: a read naming A says `none` while the door refuses. This is the card's case: it fails closed. - Same arrangement, **A registered first**: a read naming B says `full` while the door **admits**. This one fails open, because B's packaged lock is not enforced at all. Now: - **One selection of the artifact layer.** `resolveArtifactLockLayer(address, artifactsOf)` in `packages/metadata-protocol/src/item-lock.ts` returns the artifact of every installed package that ships the name, with the address's own package first and then the others by package id. The protocol's reader is `shippedArtifactsOf`, wrapped per address as `artifactLockLayerAt`. The gate's artifact limb, both reads, the list items and the diagnostics tile all take the layer there. No caller picks an artifact for the lock. - **The lock is the strictest per-package answer.** `resolveItemLock` reads the layers once per shipping package: that package's artifact over the stored rows in scope, first binding layer wins, exactly the rule as before. The item's lock is the strictest of those answers. With one package shipping the name, or none, the answer is unchanged. - **Content stays prefer-local.** A read naming A is still served A's artifact under A's provenance; only its lock family follows the binding package. - **The served body states the resolution's answer** (`withItemLockFamily`, which replaces `withOverlayLockFamily`). That means the binding layer's lock family, and no `_lock*` key when nothing binds. This also settles the position folded in from 5988387087 (H6 below). - The overlay rows are read lazily as before: only when some shipping package's artifact declares no lock, or no package ships the name. ## Rulings honoured, and the one mechanism hypothesis falsified - Triage's direction (5987395126, restated in 5987813232): strictest among the installed packages that ship the name, never a widening, one resolution for the gate and both reads, no prefer-local artifact lock at the door. All honoured. - **H3, taken literally, widens, so the route changed.** The hypothesis was to make the artifact layer the strictest artifact, then keep "first binding layer wins". The BASE census shows a cell where that admits a save the door refused. A ships no lock, B ships `no-delete`, and the env-wide row declares `no-overlay`. With A registered first, the door at BASE refuses the save, because A's artifact does not bind and the row's `no-overlay` does. A strictest artifact layer would bind B's `no-delete` and admit it. So the resolution takes the strictest **per-package** answer instead. That is the ruling's own wording read per package, and its "never a widening" holds by construction: the artifact the gate bound before is always one of the shipping packages. That cell now refuses both verbs under both orders (pin 9). ## H1: the artifact lookups at BASE `c4d57131b5`, on the real `SchemaRegistry` B ships `_lock: 'full'`, A ships no lock. Each call is listed with which package's artifact it bound. | order | request | gate (`getEffectiveLock`) | save / delete | `getMetaItem` = `getMetaItemLayered` | list slots | tile locked | |---|---|---|---|---|---|---| | B first | no package | B: full | refused / refused | B: full | B full, A none | 1 of 2 | | B first | naming A | B: full | refused / refused | **A: none** | A none | 0 of 1 | | B first | naming B | B: full | refused / refused | B: full | B full | 1 of 1 | | A first | no package | A: none | admitted / admitted | A: none | A none, B full | 1 of 2 | | A first | naming A | A: none | admitted / admitted | A: none | A none | 0 of 1 | | A first | naming B | **A: none** | **admitted** / admitted | B: full | B full | 1 of 1 | The call sites: - The gate's artifact limb (`lookupArtifactItem(canonicalType, name)`) binds the first package registered, whatever the request names. - `getMetaItem` and `getMetaItemLayered` (`lookupArtifactItem(type, name, packageId)`) bind the named package's artifact, or the first registered when no package is named. - The list items and the diagnostics tile (`packageId ?? item._packageId`) bind each slot's own package. - `mergeArtifactProtection` put whatever its caller had looked up. With B **disabled**, the gate bound B only when B was registered first, while a read naming A said `none` under both orders. After the change, every row above reads `full` with B's prose, is refused on both verbs, and keeps prefer-local content. ## H2: what the registry enumerates `SchemaRegistry.getArtifactItem(type, name, packageId?)` answers one entry: the asked package's own, else the first composite registered. The registry has no method that returns them all. The reader composes existing public methods only, so **no new public surface**: - `listItems(type)`, indexed by name, gives the packages whose entries ship each name. It hides a disabled package's entries. - `getAllPackages()` plus `isPackageDisabled(id)` add the disabled packages, probed for every name. - `getArtifactItem(type, name, P)` per candidate package, kept only when it is P's own (`_packageId === P`). Line 17607's view-expansion code already uses this idiom. - The package-less `getArtifactItem(type, name)` is always in the set. It is the old gate's answer, and the only way to reach a plain-key artifact. - An `object` has one owner (ADR-0029 D3), so its single owner-layer lookup is the whole set. - A registry whose listing throws contributes no package ids, so the set falls back to the package-less lookup. That is never looser than the old gate (pinned by `protocol.runtime-authoring-gate.test.ts`). ## H4: census, before and after, on the real `SchemaRegistry` There are 2000 door verdicts, from A's lock (5 values) × B's lock (5) × the env-wide row (5) × registration order (2) × request shape (save naming none, A or B; delete naming none) × B enabled or disabled. Every verdict comes from the real gate (`getEffectiveLock`). | transition | cells | |---|---| | refused → admitted (widenings) | **0** | | admitted → refused (narrowings) | 416 | | unchanged | 1584 | Verdicts that depend on registration order: **416 of 1000 cell pairs before, 0 after.** The narrowings split evenly, 26 per (enabled or disabled × order × shape × verb) group. ## H5: the family's enumeration pin `protocol.lock-one-resolution.test.ts` pin 1 is now the family's enumeration: - **Positions.** Each position is a layer of `ITEM_LOCK_LAYERS` × a family axis (topology, organization, package), with the table axes that open it and the card that measured it. - **Completeness check, failing by name**, when: - a layer × family axis has no position; - a position is opened by no slice; - an axis of the table belongs to no position; - an `ITEM_ADDRESS_FIELDS` field has no axis. - **Rows.** The union of three slices, each a full product: 24 000 rows in all, each run under both stored-row orders and both registration orders. - The family product: PR #21801's 16 320 rows. It is checked against a frozen copy of their own titles, so no row is lost. - **The artifact layer × package** (7 200 rows): another installed package ships the name at each lock level, crossed with the package's own artifact, the env-wide row's lock, every request shape (no package, the package, the other package), both organization scopes, both topologies and both spellings. - **The stored rows × organization × package** (480 rows): the organization holds only another package's row. - **Every row asserts** that both reads report the oracle's lock, that the door's verdict matches, that the door agrees with the envelope (bar the one declared spelling difference), and that a served body states the envelope's lock. - **Named pins:** - **pin 8**, the card's case under both orders, for reads naming A, B or no package, plus the list and the tile; - **pin 9**, the never-widening join; - **pin 10**, a disabled package; - **pin 11**, the folded position. - The same case runs on the **real `SchemaRegistry`** in `packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`, because `@objectstack/metadata-protocol` cannot import objectql. ## H6: the folded position (5988387087) This is fixed without a ruling. Only the served body disagreed: the envelope and the door already agreed on `none`. `withItemLockFamily` writes the resolution's answer onto the body and removes any `_lock*` key the answer does not carry. Pin 11 covers it; the layered read still reports the stored layer as stored. Slice 3 of the pin carries the same position. ## Reverse verification (one-shot, committed state) - **Gate limb.** At `323ab0b07a`, through `scripts/ablation-replace.mjs` in wrap mode, the gate's artifact limb was put back to the package-agnostic first artifact (`[this.lookupArtifactItem(canonicalType, name)]`). The anchor went 1 → 0 and the blob `182c6677` → `c3840b80`. - The pin file went **2506 red** / 21538 green. - **Pin 8 is red under "package A registered first"** for all three request shapes, and green under "B registered first". - Also red: pin 9 (6), pin 10 (1), and 2496 rows of the artifact × package slice. Nothing red outside rows where another package ships the name. - Restored with `git checkout HEAD -- PATH`: the blob equals HEAD (`182c6677`) and `git diff HEAD` is empty. - **H6 body clearing.** Ablated the same way: **14 red** (the 12 slice-3 cells of the folded arrangement, plus pin 11 × 2). Restored, with `git diff HEAD` empty. ## Tests - `pnpm --filter @objectstack/metadata-protocol test` at `323ab0b07a`: 214 files passed (3 skipped), 27 745 tests passed (19 skipped). - objectql, the 30 test files that touch locks or read envelopes (the new one included), at `323ab0b07a`: 940 passed. The real-registry file again at `d1551fde71`: 14 of 14 (the last commit only made its `find` double hold `limit`). - `typecheck` for both packages at `323ab0b07a`: clean. objectql's test layer (`check:test-typecheck`) compiles the new file, with no new debt. ## Gates, at `d1551fde71` - `node scripts/pm/dispatch-gates.mjs --commands` derived 74 commands, and the artifact-roster block printed 54 more outside that total. With the four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`) that makes **131 commands: 128 exit 0.** - 3 are **NOT MEASURED** locally (NOT WIRED: they need a pull request context): `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. Their guard workflows run them on this PR. - `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded. - `check:engine-double-contract` asked for one pin row for the new objectql double. It was recorded through its own `--write`. ## Lint (a proven narrowing; the repo-wide `pnpm lint` is CI's run) Run: `eslint --no-inline-config --format json` over the four touched TS files: 4 files, 0 errors, 0 warnings. - **Population.** Read from eslint's own config: `isPathIgnored` is false for all four. - **Count.** Read from the JSON output. - **Invariance.** `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), and its plugins are local per-file AST rules with no import resolution. The one removed export (`withOverlayLockFamily`) has zero remaining references. So this diff cannot move any untouched file's verdict. ## Landing points - `packages/metadata-protocol/src/item-lock.ts` and `protocol.ts`, as the claim's file surface said. - The pin (`protocol.lock-one-resolution.test.ts`) and the changeset. - Outside that surface: - `packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`: the real-registry pin, since the protocol package cannot import objectql. - `scripts/engine-double-contract.pinned.json`: the gate's own `--write`. - No `packages/spec/src/**`, no governed surface, no new public export: `item-lock.ts` is not re-exported from the package entry. ## Acceptance notes - `packagedArtifactOwner` (the ADR-0126 flow-owner classification) and `isArtifactBacked` still take the package-less first artifact. They classify owner and existence, not a lock: existence does not depend on order, but the reported owner of a flow name two packages ship is the first registered. This is not a lock-family position; noted, not filed. Carrier: none. - A read can report a lock with no body served: content never serves another package's row, while the lock's scope includes it. The envelope and the door agree there, so the pin asserts the body only when one is served. - A served body's lock family is now normalized through `extractProtection`. An explicit `_lock: 'none'` is no longer kept on a body, and a `_lockSource` outside the spec's enum is not echoed. The full metadata-protocol suite and the objectql envelope tests stay green. - Cost: the list and the tile read the registry's listing once per type and probe only the packages that ship each name (commit `f38762577c`). The gate reads the listing once per write. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b2d189 commit 18fe681

6 files changed

Lines changed: 1151 additions & 208 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name, at the write doors and on both reads (#21803)
6+
7+
Clause-②: no (narrowing)
8+
9+
ADR-0048 lets two installed code packages ship one `(type, name)`. The ADR-0010 `_lock` gate looked the packaged artifact up with no package, so it bound the artifact of whichever package was registered first, while `getMetaItem`, `getMetaItemLayered`, the metadata list and the `getMetaDiagnostics` locked count looked it up with the request's package. One item had two lock answers, and the door's answer depended on registration order.
10+
11+
Now every caller takes the artifact layer from one selection: the artifact of every installed package that ships the name, a disabled package included (it is still installed). The lock is resolved once per shipping package, that package's artifact over the stored rows in scope exactly as before, and the item's lock is the strictest of those answers. With one package shipping the name, or none, nothing changes.
12+
13+
**What moves for consumers.** The door now refuses where it used to depend on registration order:
14+
15+
- one package ships a lock and another ships none: a save or delete, with or without `?package=`, is refused `403 ITEM_LOCKED` under both registration orders, where it was admitted when the unlocked package was registered first;
16+
- one package ships no lock, the stored row in scope declares a lock, and another package ships a lock refusing the other verb (for example `no-overlay` on the row and `no-delete` on the artifact): both a save and a delete are refused, where each was admitted under the registration order that bound the other answer;
17+
- a disabled package's packaged lock binds under both registration orders, where it bound only when that package was registered first.
18+
19+
No write the door refused before is admitted now: the artifact the door bound before is always one of the shipping packages. Both reads report the same lock as the door in `lock`, `editable` and `deletable`, under both orders: a read naming a package that ships no lock now reports `editable: false` when another installed package ships one. The refusal and the reads carry the prose of the binding package (the request's own package first, then the others by package id), and no prose when no single package's answer is the strictest. Content stays prefer-local: a read naming a package is still served that package's own artifact, under that package's provenance.
20+
21+
A served body (`getMetaItem`'s `item`, `getMetaItemLayered`'s `effective`, the list items) now carries exactly the lock family of the resolution's answer, and no `_lock` key when nothing binds. Before, a body served from a stored row outside the lock's scope kept that row's `_lock` while the envelope reported `none` (an organization holding only another package's row, with the request's package served its env-wide row), and an explicit `_lock: 'none'` stayed on a body. No key, export, status or error code changes.
22+
23+
<!-- adr-0087: not-required (no-migration-prescription) the write doors refuse, by the strictest lock among the installed packages that ship an item's name, writes they used to admit when an unlocked package was registered first. No authorable key, spelling, export or stored shape moves: every artifact and stored row keeps parsing, nothing is read or rewritten at rest, and which package 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/item-lock.ts‎

Lines changed: 149 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
* - Both item reads' protection envelope (`getMetaItem`,
1212
* `getMetaItemLayered`, through `servedLockState`) and the per-type
1313
* `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.
14+
* - The lock family a served body carries is that resolution's answer
15+
* ({@link withItemLockFamily}, through `mergeArtifactProtection` and the
16+
* layered read's `effective`), so a body never states a lock the envelope
17+
* does not report.
1618
*
1719
* Before this module the reads took their lock from two derivations of their
1820
* own, and neither was the door's:
@@ -76,8 +78,54 @@
7678
* across the rows in scope. The served document is still the row the
7779
* address prefers, and its lock family is the binding layer's.
7880
*
79-
* ⛔ Not a policy of its own. Which artifact a caller hands the `artifact`
80-
* layer is still the caller's lookup.
81+
* ## [#21803] The artifact layer is every installed package that ships the name
82+
*
83+
* The `artifact` layer is not an artifact a caller picks either. Two installed
84+
* code packages may ship one `(type, name)` (ADR-0048 §3.4), and before this
85+
* rule the `_lock` gate looked the artifact up with no package (the first
86+
* package registered) while both reads looked it up with the request's
87+
* package (prefer-local). One item had two lock answers, and which one the
88+
* door gave depended on registration order: with package B shipping
89+
* `_lock: 'full'` and package A shipping no lock, a read naming A reported
90+
* `'none'` while the door refused (B registered first), and a read naming B
91+
* reported `'full'` while the door admitted (A registered first).
92+
*
93+
* Every caller now hands {@link resolveArtifactLockLayer} the item's address
94+
* and a reader of every artifact the installed packages ship under the name,
95+
* and gets the layer: those artifacts, the address's own package first. The
96+
* resolution then reads the layers once per shipping package and takes the
97+
* strictest answer:
98+
*
99+
* - **Per package, the rule above.** Each package's artifact over the
100+
* overlay layer, first binding layer wins, so a package's answer is
101+
* exactly what the door gave when that package's artifact was the one it
102+
* looked up.
103+
* - **The lock is the strictest of those answers** ({@link strictestLock}).
104+
* So the lock is never looser than the door's answer under ANY
105+
* registration order, and it no longer depends on one. Taking the
106+
* strictest artifact alone, and then the first binding layer, would not
107+
* hold that: with A shipping no lock, B shipping `'no-delete'` and the
108+
* stored row declaring `'no-overlay'`, the door refused the save when A was
109+
* registered first (A's artifact does not bind, the row does), and B's
110+
* `'no-delete'` binding as the whole artifact layer would admit it.
111+
* - **Prose** is the answer of the first package, in the layer's order,
112+
* whose answer is the strictest one: the address's own package, then the
113+
* others by package id, so it does not depend on registration order
114+
* either. When no single package's answer is the strictest (one refuses
115+
* the write, another the delete), the lock carries no prose and no layer.
116+
* - **Content stays prefer-local** (ADR-0048). The served document and its
117+
* provenance are still the address's own package's artifact.
118+
*
119+
* With one package shipping the name, or none, the answer is exactly what it
120+
* was: one per-package answer is the strictest of one.
121+
*
122+
* ## The served body carries the resolution's answer
123+
*
124+
* {@link withItemLockFamily} writes the answer's lock family onto a served
125+
* body and removes any `_lock*` key the answer does not carry. A body is often
126+
* not the document a layer binds from (the address's own package's artifact,
127+
* a row served by content precedence outside the lock's scope), so a family
128+
* left in place could state a lock the envelope does not report.
81129
*/
82130
import {
83131
MetadataLockSchema,
@@ -91,8 +139,10 @@ import {
91139
/**
92140
* The layers an item's lock can come from, in precedence order:
93141
*
94-
* - `artifact`: the item a code package's loader registered (ADR-0010 §3.3,
95-
* "an overlay cannot loosen a packaged lock");
142+
* - `artifact`: the items the code packages' loaders registered (ADR-0010
143+
* §3.3, "an overlay cannot loosen a packaged lock"), one per installed
144+
* package that ships the name, as {@link resolveArtifactLockLayer} selects
145+
* them from the item's address;
96146
* - `overlay`: the stored `sys_metadata` rows in the item's scope (ADR-0005),
97147
* as {@link resolveOverlayLockLayer} selects them from the item's address.
98148
*/
@@ -101,8 +151,18 @@ export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const);
101151
/** One of {@link ITEM_LOCK_LAYERS}. */
102152
export type ItemLockLayer = (typeof ITEM_LOCK_LAYERS)[number];
103153

104-
/** Per layer, the document that layer contributes, or `undefined` when it has none. */
105-
export type ItemLockLayers = { readonly [L in ItemLockLayer]: unknown };
154+
/**
155+
* Per layer, what that layer contributes:
156+
*
157+
* - `artifact`: [#21803] the artifact of every installed package that ships
158+
* the item, in {@link resolveArtifactLockLayer}'s order (empty when none
159+
* does);
160+
* - `overlay`: the overlay layer's document, or `undefined` when it has none.
161+
*/
162+
export type ItemLockLayers = {
163+
readonly artifact: readonly unknown[];
164+
readonly overlay: unknown;
165+
} & { readonly [L in ItemLockLayer]: unknown };
106166

107167
/** The resolution's answer. */
108168
export interface ItemLock {
@@ -127,12 +187,25 @@ const UNLOCKED: ItemLock = Object.freeze({
127187
});
128188

129189
/**
130-
* Resolve the item's lock from the documents its layers contribute.
131-
* See this module's header for the rule.
190+
* Resolve the item's lock from what its layers contribute. See this module's
191+
* header for the rule: per shipping package, the first layer whose declared
192+
* `_lock` is not `'none'` binds; [#21803] the item's lock is the strictest of
193+
* those answers.
132194
*/
133195
export function resolveItemLock(layers: ItemLockLayers): ItemLock {
196+
const shipped: readonly unknown[] = layers.artifact.length > 0 ? layers.artifact : [undefined];
197+
const perPackage = shipped.map((artifact) => firstBindingLayer({ artifact, overlay: layers.overlay }));
198+
const lock = strictestLock(perPackage.map((answer) => answer.lock));
199+
if (lock === 'none') return UNLOCKED;
200+
// The first package, in the layer's order, whose answer is the strictest.
201+
// None is when the strictest joins two answers that refuse different verbs.
202+
return perPackage.find((answer) => answer.lock === lock) ?? { ...UNLOCKED, lock };
203+
}
204+
205+
/** The rule for ONE package's artifact over the overlay layer: the first binding layer. */
206+
function firstBindingLayer(documents: { readonly [L in ItemLockLayer]: unknown }): ItemLock {
134207
for (const layer of ITEM_LOCK_LAYERS) {
135-
const declared = extractProtection(layers[layer]);
208+
const declared = extractProtection(documents[layer]);
136209
if (declared.lock !== 'none') {
137210
return {
138211
lock: declared.lock,
@@ -147,25 +220,26 @@ export function resolveItemLock(layers: ItemLockLayers): ItemLock {
147220
}
148221

149222
/**
150-
* {@link resolveItemLock}, reading each layer only when no layer above it
151-
* binds. The answer is the same; what this saves is the reads below a binding
152-
* layer. The write doors use it, so a packaged lock is answered without a
153-
* `sys_metadata` read, and a store that cannot be read never turns a packaged
154-
* lock's `ITEM_LOCKED` into a 503.
223+
* {@link resolveItemLock}, reading the `overlay` layer only when it can bind
224+
* for some shipping package: when no package ships the item, or one of them
225+
* ships an artifact that declares no lock. The answer is the same; what this
226+
* saves is the `sys_metadata` read when every shipping package's own lock
227+
* binds. The write doors use it, so such a packaged lock is answered without a
228+
* store read, and a store that cannot be read never turns its `ITEM_LOCKED`
229+
* into a 503.
155230
*
156231
* A reader's failure propagates: each caller owns its #5532 / #5706
157232
* discrimination.
158233
*/
159234
export async function resolveItemLockLazily(read: {
160-
readonly [L in ItemLockLayer]: () => unknown | Promise<unknown>;
235+
readonly artifact: () => readonly unknown[] | Promise<readonly unknown[]>;
236+
readonly overlay: () => unknown | Promise<unknown>;
161237
}): Promise<ItemLock> {
162-
const layers = Object.fromEntries(ITEM_LOCK_LAYERS.map((layer) => [layer, undefined])) as Record<ItemLockLayer, unknown>;
163-
for (const layer of ITEM_LOCK_LAYERS) {
164-
layers[layer] = await read[layer]();
165-
const answer = resolveItemLock(layers);
166-
if (answer.layer !== undefined) return answer;
167-
}
168-
return UNLOCKED;
238+
const artifact = await read.artifact();
239+
const everyPackageBinds = artifact.length > 0
240+
&& artifact.every((document) => extractProtection(document).lock !== 'none');
241+
if (everyPackageBinds) return resolveItemLock({ artifact, overlay: undefined });
242+
return resolveItemLock({ artifact, overlay: await read.overlay() });
169243
}
170244

171245
// ── [#21761] The overlay layer, selected from the item's address ─────────────
@@ -313,20 +387,60 @@ function strictestRowLockDocument(rows: readonly StoredOverlayRow[], packageId:
313387
return family;
314388
}
315389

390+
// ── [#21803] The artifact layer, selected from the item's address ────────────
391+
392+
/**
393+
* Every artifact the installed code packages registered under the addressed
394+
* item's name: one per package that ships it, whichever package that is. Each
395+
* caller reads its own registry; the protocol's reader is
396+
* `ObjectStackProtocolImplementation.shippedArtifactsOf`.
397+
*/
398+
export type ShippedArtifactReader = () => readonly unknown[];
399+
400+
/**
401+
* [#21803] THE artifact layer's selection. What the `artifact` layer of
402+
* {@link resolveItemLock} gets for `address`: every artifact `artifactsOf`
403+
* reads, each once, the address's own package's first, then the others by
404+
* package id, so the order (and with it the prose of the answer) does not
405+
* depend on registration order. See this module's header for the rule.
406+
*
407+
* Every caller that enforces or reports an item's lock passes its address
408+
* here: the `_lock` gate, both item reads, the list and the diagnostics tile.
409+
* `packageId` orders the layer; it never decides which packages are in it.
410+
*/
411+
export function resolveArtifactLockLayer(address: ItemAddress, artifactsOf: ShippedArtifactReader): readonly unknown[] {
412+
const shipped = [...new Set(artifactsOf())].filter((artifact) => artifact !== undefined && artifact !== null);
413+
const packageOf = (artifact: unknown): string => {
414+
const id = (artifact as { _packageId?: unknown })._packageId;
415+
return typeof id === 'string' ? id : '';
416+
};
417+
const rank = (artifact: unknown): number =>
418+
address.packageId !== undefined && packageOf(artifact) === address.packageId ? 0 : 1;
419+
// `sort` is stable: one package's two entries keep the reader's order.
420+
return shipped.sort((a, b) => rank(a) - rank(b) || packageOf(a).localeCompare(packageOf(b)));
421+
}
422+
316423
/**
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.
424+
* [#21761, #21803] `document` carrying the lock family of `itemLock`, the
425+
* one item-lock resolution's answer: `_lock` and the prose the answer carries,
426+
* and no `_lock*` key it does not. So a served body states exactly the lock the
427+
* envelope reports, whichever document it was served from: a row the address
428+
* prefers for content while other rows in scope bind ([#21761]), the address's
429+
* own package's artifact while another package's lock binds ([#21803]), or a
430+
* row served by content precedence from a scope the lock is not read from (a
431+
* family the answer does not carry is removed). Returns `document` itself when
432+
* nothing changes, and a copy otherwise.
325433
*/
326-
export function withOverlayLockFamily(document: unknown, layers: ItemLockLayers): unknown {
434+
export function withItemLockFamily(document: unknown, itemLock: ItemLock): unknown {
327435
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>;
436+
const family: Record<string, unknown> = itemLock.lock === 'none'
437+
? {}
438+
: {
439+
_lock: itemLock.lock,
440+
_lockReason: itemLock.lockReason,
441+
_lockDocsUrl: itemLock.lockDocsUrl,
442+
_lockSource: itemLock.lockSource,
443+
};
330444
const current = document as Record<string, unknown>;
331445
if (LOCK_FAMILY_KEYS.every((key) => current[key] === family[key])) return document;
332446
const out: Record<string, unknown> = { ...current };

0 commit comments

Comments
 (0)