Skip to content

Commit 4413da7

Browse files
committed
fix(metadata-protocol): the item lock is the strictest among the installed packages that ship the name
The _lock gate looked the packaged artifact up with no package (the first package registered) while both reads, the list and the diagnostics tile looked it up with the request's package. Every caller now takes the artifact layer from one selection (resolveArtifactLockLayer over shippedArtifactsOf), and the resolution reads the layers once per shipping package and binds the strictest answer, so the door and the reads agree under every registration order and the door never answers looser than it did under any. The served body carries the resolution's lock family (withItemLockFamily), and no _lock key when nothing binds. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4d5713 commit 4413da7

2 files changed

Lines changed: 360 additions & 99 deletions

File tree

‎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)