From 4413da74c6d5426070b37dd11bb5e4894699fa1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:36:05 +0000 Subject: [PATCH 1/8] 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 --- packages/metadata-protocol/src/item-lock.ts | 184 ++++++++++--- packages/metadata-protocol/src/protocol.ts | 275 +++++++++++++++----- 2 files changed, 360 insertions(+), 99 deletions(-) diff --git a/packages/metadata-protocol/src/item-lock.ts b/packages/metadata-protocol/src/item-lock.ts index 27679523471..2e06934a9c1 100644 --- a/packages/metadata-protocol/src/item-lock.ts +++ b/packages/metadata-protocol/src/item-lock.ts @@ -11,8 +11,10 @@ * - Both item reads' protection envelope (`getMetaItem`, * `getMetaItemLayered`, through `servedLockState`) and the per-type * `locked` count of `getMetaDiagnostics` call {@link resolveItemLock}. - * - The lock family a served body carries (`mergeArtifactProtection`) calls - * it too, so a body never states a lock the envelope does not report. + * - The lock family a served body carries is that resolution's answer + * ({@link withItemLockFamily}, through `mergeArtifactProtection` and the + * layered read's `effective`), so a body never states a lock the envelope + * does not report. * * Before this module the reads took their lock from two derivations of their * own, and neither was the door's: @@ -76,8 +78,54 @@ * across the rows in scope. The served document is still the row the * address prefers, and its lock family is the binding layer's. * - * ⛔ Not a policy of its own. Which artifact a caller hands the `artifact` - * layer is still the caller's lookup. + * ## [#21803] The artifact layer is every installed package that ships the name + * + * The `artifact` layer is not an artifact a caller picks either. Two installed + * code packages may ship one `(type, name)` (ADR-0048 §3.4), and before this + * rule the `_lock` gate looked the artifact up with no package (the first + * package registered) while both reads looked it up with the request's + * package (prefer-local). One item had two lock answers, and which one the + * door gave depended on registration order: with package B shipping + * `_lock: 'full'` and package A shipping no lock, a read naming A reported + * `'none'` while the door refused (B registered first), and a read naming B + * reported `'full'` while the door admitted (A registered first). + * + * Every caller now hands {@link resolveArtifactLockLayer} the item's address + * and a reader of every artifact the installed packages ship under the name, + * and gets the layer: those artifacts, the address's own package first. The + * resolution then reads the layers once per shipping package and takes the + * strictest answer: + * + * - **Per package, the rule above.** Each package's artifact over the + * overlay layer, first binding layer wins, so a package's answer is + * exactly what the door gave when that package's artifact was the one it + * looked up. + * - **The lock is the strictest of those answers** ({@link strictestLock}). + * So the lock is never looser than the door's answer under ANY + * registration order, and it no longer depends on one. Taking the + * strictest artifact alone, and then the first binding layer, would not + * hold that: with A shipping no lock, B shipping `'no-delete'` and the + * stored row declaring `'no-overlay'`, the door refused the save when A was + * registered first (A's artifact does not bind, the row does), and B's + * `'no-delete'` binding as the whole artifact layer would admit it. + * - **Prose** is the answer of the first package, in the layer's order, + * whose answer is the strictest one: the address's own package, then the + * others by package id, so it does not depend on registration order + * either. When no single package's answer is the strictest (one refuses + * the write, another the delete), the lock carries no prose and no layer. + * - **Content stays prefer-local** (ADR-0048). The served document and its + * provenance are still the address's own package's artifact. + * + * With one package shipping the name, or none, the answer is exactly what it + * was: one per-package answer is the strictest of one. + * + * ## The served body carries the resolution's answer + * + * {@link withItemLockFamily} writes the answer's lock family onto a served + * body and removes any `_lock*` key the answer does not carry. A body is often + * not the document a layer binds from (the address's own package's artifact, + * a row served by content precedence outside the lock's scope), so a family + * left in place could state a lock the envelope does not report. */ import { MetadataLockSchema, @@ -91,8 +139,10 @@ import { /** * The layers an item's lock can come from, in precedence order: * - * - `artifact`: the item a code package's loader registered (ADR-0010 §3.3, - * "an overlay cannot loosen a packaged lock"); + * - `artifact`: the items the code packages' loaders registered (ADR-0010 + * §3.3, "an overlay cannot loosen a packaged lock"), one per installed + * package that ships the name, as {@link resolveArtifactLockLayer} selects + * them from the item's address; * - `overlay`: the stored `sys_metadata` rows in the item's scope (ADR-0005), * as {@link resolveOverlayLockLayer} selects them from the item's address. */ @@ -101,8 +151,18 @@ export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const); /** One of {@link ITEM_LOCK_LAYERS}. */ export type ItemLockLayer = (typeof ITEM_LOCK_LAYERS)[number]; -/** Per layer, the document that layer contributes, or `undefined` when it has none. */ -export type ItemLockLayers = { readonly [L in ItemLockLayer]: unknown }; +/** + * Per layer, what that layer contributes: + * + * - `artifact`: [#21803] the artifact of every installed package that ships + * the item, in {@link resolveArtifactLockLayer}'s order (empty when none + * does); + * - `overlay`: the overlay layer's document, or `undefined` when it has none. + */ +export type ItemLockLayers = { + readonly artifact: readonly unknown[]; + readonly overlay: unknown; +} & { readonly [L in ItemLockLayer]: unknown }; /** The resolution's answer. */ export interface ItemLock { @@ -127,12 +187,25 @@ const UNLOCKED: ItemLock = Object.freeze({ }); /** - * Resolve the item's lock from the documents its layers contribute. - * See this module's header for the rule. + * Resolve the item's lock from what its layers contribute. See this module's + * header for the rule: per shipping package, the first layer whose declared + * `_lock` is not `'none'` binds; [#21803] the item's lock is the strictest of + * those answers. */ export function resolveItemLock(layers: ItemLockLayers): ItemLock { + const shipped: readonly unknown[] = layers.artifact.length > 0 ? layers.artifact : [undefined]; + const perPackage = shipped.map((artifact) => firstBindingLayer({ artifact, overlay: layers.overlay })); + const lock = strictestLock(perPackage.map((answer) => answer.lock)); + if (lock === 'none') return UNLOCKED; + // The first package, in the layer's order, whose answer is the strictest. + // None is when the strictest joins two answers that refuse different verbs. + return perPackage.find((answer) => answer.lock === lock) ?? { ...UNLOCKED, lock }; +} + +/** The rule for ONE package's artifact over the overlay layer: the first binding layer. */ +function firstBindingLayer(documents: { readonly [L in ItemLockLayer]: unknown }): ItemLock { for (const layer of ITEM_LOCK_LAYERS) { - const declared = extractProtection(layers[layer]); + const declared = extractProtection(documents[layer]); if (declared.lock !== 'none') { return { lock: declared.lock, @@ -147,25 +220,26 @@ export function resolveItemLock(layers: ItemLockLayers): ItemLock { } /** - * {@link resolveItemLock}, reading each layer only when no layer above it - * binds. The answer is the same; what this saves is the reads below a binding - * layer. The write doors use it, so a packaged lock is answered without a - * `sys_metadata` read, and a store that cannot be read never turns a packaged - * lock's `ITEM_LOCKED` into a 503. + * {@link resolveItemLock}, reading the `overlay` layer only when it can bind + * for some shipping package: when no package ships the item, or one of them + * ships an artifact that declares no lock. The answer is the same; what this + * saves is the `sys_metadata` read when every shipping package's own lock + * binds. The write doors use it, so such a packaged lock is answered without a + * store read, and a store that cannot be read never turns its `ITEM_LOCKED` + * into a 503. * * A reader's failure propagates: each caller owns its #5532 / #5706 * discrimination. */ export async function resolveItemLockLazily(read: { - readonly [L in ItemLockLayer]: () => unknown | Promise; + readonly artifact: () => readonly unknown[] | Promise; + readonly overlay: () => unknown | Promise; }): Promise { - const layers = Object.fromEntries(ITEM_LOCK_LAYERS.map((layer) => [layer, undefined])) as Record; - for (const layer of ITEM_LOCK_LAYERS) { - layers[layer] = await read[layer](); - const answer = resolveItemLock(layers); - if (answer.layer !== undefined) return answer; - } - return UNLOCKED; + const artifact = await read.artifact(); + const everyPackageBinds = artifact.length > 0 + && artifact.every((document) => extractProtection(document).lock !== 'none'); + if (everyPackageBinds) return resolveItemLock({ artifact, overlay: undefined }); + return resolveItemLock({ artifact, overlay: await read.overlay() }); } // ── [#21761] The overlay layer, selected from the item's address ───────────── @@ -313,20 +387,60 @@ function strictestRowLockDocument(rows: readonly StoredOverlayRow[], packageId: return family; } +// ── [#21803] The artifact layer, selected from the item's address ──────────── + +/** + * Every artifact the installed code packages registered under the addressed + * item's name: one per package that ships it, whichever package that is. Each + * caller reads its own registry; the protocol's reader is + * `ObjectStackProtocolImplementation.shippedArtifactsOf`. + */ +export type ShippedArtifactReader = () => readonly unknown[]; + +/** + * [#21803] THE artifact layer's selection. What the `artifact` layer of + * {@link resolveItemLock} gets for `address`: every artifact `artifactsOf` + * reads, each once, the address's own package's first, then the others by + * package id, so the order (and with it the prose of the answer) does not + * depend on registration order. See this module's header for the rule. + * + * Every caller that enforces or reports an item's lock passes its address + * here: the `_lock` gate, both item reads, the list and the diagnostics tile. + * `packageId` orders the layer; it never decides which packages are in it. + */ +export function resolveArtifactLockLayer(address: ItemAddress, artifactsOf: ShippedArtifactReader): readonly unknown[] { + const shipped = [...new Set(artifactsOf())].filter((artifact) => artifact !== undefined && artifact !== null); + const packageOf = (artifact: unknown): string => { + const id = (artifact as { _packageId?: unknown })._packageId; + return typeof id === 'string' ? id : ''; + }; + const rank = (artifact: unknown): number => + address.packageId !== undefined && packageOf(artifact) === address.packageId ? 0 : 1; + // `sort` is stable: one package's two entries keep the reader's order. + return shipped.sort((a, b) => rank(a) - rank(b) || packageOf(a).localeCompare(packageOf(b))); +} + /** - * [#21761] `document` with the lock family of the `overlay` layer - * ({@link resolveOverlayLockLayer}) when that layer binds, so a served body - * states the lock the envelope reports even when the rows in scope that bind - * are not the row the body was served from. A key that layer's document does - * not declare is removed. Returns `document` itself when the overlay layer does - * not bind or nothing changes (the binding row is the served row), and a copy - * otherwise. The `artifact` layer's family is `mergeArtifactProtection`'s to - * put, as before. + * [#21761, #21803] `document` carrying the lock family of `itemLock`, the + * one item-lock resolution's answer: `_lock` and the prose the answer carries, + * and no `_lock*` key it does not. So a served body states exactly the lock the + * envelope reports, whichever document it was served from: a row the address + * prefers for content while other rows in scope bind ([#21761]), the address's + * own package's artifact while another package's lock binds ([#21803]), or a + * row served by content precedence from a scope the lock is not read from (a + * family the answer does not carry is removed). Returns `document` itself when + * nothing changes, and a copy otherwise. */ -export function withOverlayLockFamily(document: unknown, layers: ItemLockLayers): unknown { +export function withItemLockFamily(document: unknown, itemLock: ItemLock): unknown { if (!document || typeof document !== 'object' || Array.isArray(document)) return document; - if (resolveItemLock(layers).layer !== 'overlay') return document; - const family = layers.overlay as Record; + const family: Record = itemLock.lock === 'none' + ? {} + : { + _lock: itemLock.lock, + _lockReason: itemLock.lockReason, + _lockDocsUrl: itemLock.lockDocsUrl, + _lockSource: itemLock.lockSource, + }; const current = document as Record; if (LOCK_FAMILY_KEYS.every((key) => current[key] === family[key])) return document; const out: Record = { ...current }; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c3958342651..a2fbca4791f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -46,11 +46,12 @@ import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js' import { SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js'; import { packagedBaseRegimeSentence } from './packaged-base-regime.js'; import { + resolveArtifactLockLayer, resolveItemLock, resolveItemLockLazily, resolveOverlayLockLayer, storedRowDocument, - withOverlayLockFamily, + withItemLockFamily, type ItemAddress, type ItemLock, type StoredOverlayRow, @@ -1740,27 +1741,30 @@ function viewIdentityPatch(overlay: Record, baseline: unknown): * stored row's `'full'` from the served body and from the read envelope while * the write door, which skips `'none'`, still refused the save. * - * [#21761] A caller that resolved the item's `overlay` layer from its address - * ({@link resolveOverlayLockLayer}) passes it as `lockLayers.overlay`. When that - * layer binds, the body carries ITS lock family: the family of the strictest - * row in scope, which need not be the row the body was served from (content - * stays prefer-local, ADR-0048). Without `lockLayers` the item's own body - * stands in for the overlay layer, as before. + * [#21761, #21803] A read that resolved the item's lock from its address + * ({@link resolveArtifactLockLayer} and {@link resolveOverlayLockLayer} into + * {@link resolveItemLock}) passes that answer as `itemLock`, and the body + * carries ITS lock family ({@link withItemLockFamily}): the binding row's or + * the binding package's, which need not be the document the body was served + * from, and no `_lock*` key at all when nothing binds. `artifactItem` then + * contributes the provenance fields only: content, and with it provenance, + * stays prefer-local (ADR-0048). Without `itemLock` the item's own body + * stands in for the overlay layer over `artifactItem` alone, as before: the + * registry's hydration and a previewed draft, neither of which is a read's + * lock answer. */ function mergeArtifactProtection( item: unknown, artifactItem: unknown, - lockLayers?: { readonly overlay: unknown }, + itemLock?: ItemLock, ): unknown { if (item === undefined || item === null) return item; - if (lockLayers !== undefined) { - item = withOverlayLockFamily(item, { artifact: artifactItem, overlay: lockLayers.overlay }); - } + if (itemLock !== undefined) item = withItemLockFamily(item, itemLock); if (artifactItem === undefined || artifactItem === null) return item; const a = artifactItem as Record; if (typeof a !== 'object') return item; const out: Record = { ...(item as Record) }; - if (resolveItemLock({ artifact: a, overlay: item }).layer === 'artifact') { + if (itemLock === undefined && resolveItemLock({ artifact: [a], overlay: item }).layer === 'artifact') { if (a._lock !== undefined) out._lock = a._lock; if (a._lockReason !== undefined) out._lockReason = a._lockReason; if (a._lockDocsUrl !== undefined) out._lockDocsUrl = a._lockDocsUrl; @@ -7945,6 +7949,7 @@ export class ObjectStackProtocolImplementation implements : []; const pkgSet = new Set(); let lockedCount = 0; + let shippingPackageIds: ReadonlySet | undefined; for (const item of items) { scannedItems += 1; const pkg = (item?._packageId ?? null) as string | null; @@ -7955,14 +7960,19 @@ export class ObjectStackProtocolImplementation implements // its item envelope, so the per-type tile counts it too. The // derivation reads the registry only — no store read per item. // [#21738] Its lock is the one item-lock resolution - // ({@link resolveItemLock}) over the item's artifact — the - // lookup the list's own merge made (ADR-0048 package scope) — - // and the document the list serves for it. + // ({@link resolveItemLock}) over the item's artifact layer — + // [#21803] every installed package that ships the name, from + // the address the list's own merge used (ADR-0048 package + // scope) — and the document the list serves for it. const itemName = typeof item?.name === 'string' ? item.name : ''; + shippingPackageIds ??= this.artifactPackageIds(t); const itemLock = resolveItemLock({ - artifact: this.lookupArtifactItem( - t, itemName, request.packageId ?? (item?._packageId as string | undefined), - ), + artifact: this.artifactLockLayerAt({ + type: t, + name: itemName, + organizationId: request.organizationId, + packageId: request.packageId ?? (item?._packageId as string | undefined), + }, shippingPackageIds), overlay: item, }); const served = this.servedLockState(t, itemName, item, this.isArtifactBacked(t, itemName), itemLock); @@ -9220,7 +9230,9 @@ export class ObjectStackProtocolImplementation implements // layer, so the body and the directory tile ({@link getMetaDiagnostics} // counts these bodies) state the lock the item's envelope reports. A // previewed draft keeps its own family: it is the pending edit, not - // the item the doors gate. + // the item the doors gate. [#21803] The artifact layer likewise: every + // installed package that ships the item's name + // ({@link artifactLockLayerAt}), the packages read once for the type. const otherType = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; const lockRowsByName = new Map(); for (const row of lockRows) { @@ -9229,33 +9241,39 @@ export class ObjectStackProtocolImplementation implements if (list) list.push(row); else lockRowsByName.set(name, [row]); } + let shippingPackageIds: ReadonlySet | undefined; const governed: any[] = []; for (const it of items as any[]) { const itemName = (it as any)?.name; const itemPackageId = packageId ?? ((it as any)?._packageId as string | undefined); // ADR-0048 — scope the artifact lookup to THIS item's owning // package so a same-name collision grafts each item's own - // protection envelope, not the first-registered package's. + // provenance envelope, not the first-registered package's. // (`requested` packageId, when the whole list is scoped, // takes priority; else the item's own `_packageId`.) const a = this.lookupArtifactItem(request.type, itemName, itemPackageId); - const lockLayers = typeof itemName === 'string' && (it as any)?._draft !== true - ? { - overlay: await resolveOverlayLockLayer({ - type: request.type, - name: itemName, - organizationId: orgId, - packageId: itemPackageId, - }, (organizationId, spelling) => (lockRowsByName.get(itemName) ?? []).filter((row) => - (row.organization_id ?? null) === organizationId - && row.type === (spelling === 'canonical' ? request.type : otherType)), { otherSpelling: true }), - } - : undefined; + let itemLock: ItemLock | undefined; + if (typeof itemName === 'string' && (it as any)?._draft !== true) { + const address: ItemAddress = { + type: request.type, + name: itemName, + organizationId: orgId, + packageId: itemPackageId, + }; + shippingPackageIds ??= this.artifactPackageIds(request.type); + itemLock = resolveItemLock({ + artifact: this.artifactLockLayerAt(address, shippingPackageIds), + overlay: await resolveOverlayLockLayer(address, (organizationId, spelling) => + (lockRowsByName.get(itemName) ?? []).filter((row) => + (row.organization_id ?? null) === organizationId + && row.type === (spelling === 'canonical' ? request.type : otherType)), { otherSpelling: true }), + }); + } // [#4513] Same governance as the single-item read — the list // is the other exit a client reads field metadata from, and // an overlay row wins over the (already-governed) registry // entry in the merge above, so it carries the same lie. - governed.push(this.governServedObject(request.type, mergeArtifactProtection(it, a, lockLayers))); + governed.push(this.governServedObject(request.type, mergeArtifactProtection(it, a, itemLock))); } return { type: request.type, @@ -10051,17 +10069,30 @@ export class ObjectStackProtocolImplementation implements // declaration; we must consult the in-memory artifact registry // directly and let its protection envelope override. // ADR-0048 — scope the artifact lookup to the requested package so a - // same-name collision grafts the OWNING package's protection envelope - // (`_packageId`/`_lock`), not whichever package registered first. + // same-name collision grafts the OWNING package's provenance envelope + // (`_packageId`), not whichever package registered first. const artifactItem = this.lookupArtifactItem(request.type, request.name, request.packageId); - // [#21761] …and the body carries the binding layer's lock family, - // the `overlay` layer's included, so it states the lock the envelope - // below reports. + // [#21738] The lock is the one item-lock resolution's, over the layers + // this read resolved from its address: [#21803] every installed + // package that ships the name ({@link artifactLockLayerAt}), never the + // one artifact the content above came from, and [#21761] the rows in + // scope. + const itemLock = resolveItemLock({ + artifact: this.artifactLockLayerAt({ + type: request.type, + name: request.name, + organizationId: orgId, + packageId: request.packageId, + }), + overlay: overlayLockLayer, + }); + // [#21761, #21803] …and the body carries that answer's lock family, so + // it states the lock the envelope below reports. let decorated = decorateMetadataItem( request.type, this.governServedObject( request.type, - mergeArtifactProtection(item, artifactItem, { overlay: overlayLockLayer }), + mergeArtifactProtection(item, artifactItem, itemLock), ), ); // ADR-0047 — list views additionally get reference-integrity @@ -10101,9 +10132,8 @@ export class ObjectStackProtocolImplementation implements // ADR-0010 — surface lock/provenance flags so Studio can render // the correct affordances without a second round trip. [#21670] They // report the write doors' verdicts — see {@link servedLockState}. - // [#21738] The lock is the one item-lock resolution's, over the layers - // this read resolved — never the served document's `_lock`. - const itemLock = resolveItemLock({ artifact: artifactItem, overlay: overlayLockLayer }); + // [#21738] The lock is the one item-lock resolution's (above), over the + // layers this read resolved — never the served document's `_lock`. const artifactBacked = this.isArtifactBacked(request.type, request.name); const lockState = this.servedLockState(request.type, request.name, decorated, artifactBacked, itemLock); return { @@ -10533,15 +10563,32 @@ export class ObjectStackProtocolImplementation implements : overlay !== null && !this.isShippedFlowName(request.type, request.name) ? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay) : code; - // [#21761] `effective` is what {@link getMetaItem} would return, and - // that read's body carries the `overlay` layer's lock family when that - // layer binds (the strictest row in scope, which need not be the row - // `overlay` reports). So this one does too. `code` and `overlay` stay - // the layers as shipped and as stored. - const lockArtifact = this.lookupArtifactItem(request.type, request.name, request.packageId); - const effective: unknown | null = withOverlayLockFamily( + // [#21738] The lock is the one item-lock resolution's — the + // `getMetaItem` call over [#21803] every installed package that ships + // the name ({@link artifactLockLayerAt}) and [#21761] the rows in + // scope for its address — never `code ?? overlay`, which took the code + // layer's (absent) lock over a stored row's `_lock`. A row-less name a + // stored container expands contributes no `overlay` layer: the + // container's row is not this name's row, and the `_lock` gate does not + // read it. The provenance fields still come from the layer the + // response reports first. + const itemLock = resolveItemLock({ + artifact: this.artifactLockLayerAt({ + type: request.type, + name: request.name, + organizationId: orgId, + packageId: request.packageId, + }), + overlay: overlayLockLayer, + }); + // [#21761, #21803] `effective` is what {@link getMetaItem} would + // return, and that read's body carries the resolution's lock family + // (the binding row's or the binding package's, which need not be the + // layer `effective` was taken from). So this one does too. `code` and + // `overlay` stay the layers as shipped and as stored. + const effective: unknown | null = withItemLockFamily( this.governServedObject(request.type, effectiveBase), - { artifact: lockArtifact, overlay: overlayLockLayer }, + itemLock, ); const _diagnostics = @@ -10552,18 +10599,6 @@ export class ObjectStackProtocolImplementation implements // ADR-0010 — surface lock/provenance flags so the Studio editor // can render the correct affordances without a second round trip. const artifactBacked = this.isArtifactBacked(request.type, request.name); - // [#21738] The lock is the one item-lock resolution's — the - // `getMetaItem` call over this read's artifact lookup and [#21761] the - // rows in scope for its address — never `code ?? overlay`, which took the code - // layer's (absent) lock over a stored row's `_lock`. A row-less name a - // stored container expands contributes no `overlay` layer: the - // container's row is not this name's row, and the `_lock` gate does not - // read it. The provenance fields still come from the layer the - // response reports first. - const itemLock = resolveItemLock({ - artifact: lockArtifact, - overlay: overlayLockLayer, - }); // [#21670] …joined with the locked-packaged-base verdict the write // doors answer — the same derivation `getMetaItem` publishes. const lockState = this.servedLockState( @@ -16438,6 +16473,96 @@ export class ObjectStackProtocolImplementation implements return item; } + /** + * [#21803, ADR-0010 §3.3, ADR-0048 §3.4] The item-lock resolution's + * `artifact` layer ({@link resolveItemLock}) for the item at `address`: + * {@link resolveArtifactLockLayer}, the one selection, over + * {@link shippedArtifactsOf}. The `_lock` gate ({@link getEffectiveLock}), + * {@link getMetaItem}, {@link getMetaItemLayered}, the list + * ({@link readFlattenedMetaItems}) and the diagnostics tile + * ({@link getMetaDiagnostics}) all take the artifact layer here, so no + * caller picks the artifact its lock comes from. Content does not come from + * here: each of them still serves the address's own package's artifact + * ({@link lookupArtifactItem} with the request's package). + * + * `packageIds` is {@link artifactPackageIds} for the type, computed once + * by a caller that asks about many items of one type. + */ + private artifactLockLayerAt(address: ItemAddress, packageIds?: ReadonlySet): readonly unknown[] { + return resolveArtifactLockLayer(address, () => this.shippedArtifactsOf(address.type, address.name, packageIds)); + } + + /** + * [#21803] Every artifact an installed code package registered under + * `(type, name)`: what the registry's artifact-only lookup + * ({@link lookupArtifactItem}) answers for each package that could ship + * it, kept when it is that package's own (`_packageId` equal to the + * package asked for, the prefer-local hit), plus what it answers with no + * package at all. + * + * The registry has no enumeration of its own for this, so the reader asks + * its existing lookups per package ({@link artifactPackageIds} names + * them). The package-less lookup is always in the set. It is the one the + * `_lock` gate made before #21803 (the first package registered), so the + * resolution over this set never answers looser than the gate did under + * any registration order; with no composite entry at all it is also the + * only way to reach an artifact registered under the plain key. + * + * An `object` has one owner (`SchemaRegistry.registerObject` refuses a + * second code package's claim on the name, ADR-0029 D3), and its artifact + * lookup reads the owner's layer whatever package is asked for, so the + * owner is the whole set. + */ + private shippedArtifactsOf(type: string, name: string, packageIds?: ReadonlySet): unknown[] { + const shipped: unknown[] = []; + const add = (artifact: unknown): void => { + if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact); + }; + add(this.lookupArtifactItem(type, name)); + if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped; + for (const packageId of packageIds ?? this.artifactPackageIds(type)) { + const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined; + if (own?._packageId === packageId) add(own); + } + return shipped; + } + + /** + * [#21803] Every package that can ship an item of `type`: the package of + * every entry the registry lists for the type (both spellings), and every + * installed package. The listing hides a DISABLED package's entries, and + * the artifact lookup does not (the `_lock` gate bound a disabled + * package's artifact before #21803 when it was registered first), so the + * installed packages are read too: a disabled package is still installed. + * A registry double without either listing contributes nothing here, and + * {@link shippedArtifactsOf} then answers the package-less lookup alone. + */ + private artifactPackageIds(type: string): Set { + const registry = (this.engine as any)?.registry; + const ids = new Set(); + if (!registry) return ids; + const addId = (id: unknown): void => { + if (typeof id === 'string' && id !== '' && id !== 'sys_metadata') ids.add(id); + }; + if (typeof registry.listItems === 'function') { + for (const spelling of new Set([PLURAL_TO_SINGULAR[type] ?? type, type])) { + const listed: unknown = registry.listItems(spelling); + if (!Array.isArray(listed)) continue; + for (const entry of listed) addId((entry as { _packageId?: unknown } | null | undefined)?._packageId); + } + } + if (typeof registry.getAllPackages === 'function') { + const installed: unknown = registry.getAllPackages(); + if (Array.isArray(installed)) { + for (const record of installed) { + const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined; + addId(r?.manifest?.id ?? r?.id); + } + } + } + return ids; + } + /** * True when `packageId` is a **writable base** — a DB-backed package an * org or the AI may author *new* metadata into (ADR-0070 D2). @@ -16512,6 +16637,20 @@ export class ObjectStackProtocolImplementation implements * Canonical spelling only (#4432, the one declared difference, stated on * {@link overlayLockLayerAt}). * + * ## [#21803] The artifact limb and the reads take every shipping package + * + * The artifact limb used to look the artifact up with no package, so with + * two installed packages shipping one name (ADR-0048 §3.4) it bound the + * first package registered, while a read naming a package reported that + * package's artifact. Now the limb hands {@link artifactLockLayerAt} the + * write's address and the reads hand it theirs; the resolution reads the + * layers once per shipping package and binds the strictest answer + * ({@link resolveItemLock}). The package-less artifact this limb bound + * before is always one of them, so no write it refused under some + * registration order is admitted under any. The overlay rows are read only + * when a shipping package's own artifact declares no lock, or none ships + * the name ({@link resolveItemLockLazily}). + * * `'none'` is a VERDICT, not a default: both callers turn it into * "allow". So it is returned only when the absence of a lock was * actually established. When the overlay row cannot be read this @@ -16591,12 +16730,20 @@ export class ObjectStackProtocolImplementation implements // [#9009] ONE key for BOTH limbs — see this method's header. const canonicalType = canonicalMetaType(type); // [#21738] The one item-lock resolution decides; the two readers below - // only gather its layers, the second only when the first does not bind. + // only gather its layers, the second only when it can bind. const resolved = await resolveItemLockLazily({ // 1. Artifact. `lookupArtifactItem` is shadow-immune: a // sys_metadata overlay row hydrated into the registry's plain // key cannot mask the packaged artifact's `_lock` envelope. - artifact: () => this.lookupArtifactItem(canonicalType, name), + // [#21803] Every installed package that ships the name, through + // the reads' own selection, from the same address — see this + // method's header. + artifact: () => this.artifactLockLayerAt({ + type: canonicalType, + name, + organizationId: organizationId ?? undefined, + packageId, + }), overlay: () => this.readLockGateOverlayLayer(canonicalType, name, organizationId, packageId), }); return { lock: resolved.lock, lockReason: resolved.lockReason, lockSource: resolved.layer }; From 76ee199989c75216179cb1a12d4347c49ccd35ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:41:32 +0000 Subject: [PATCH 2/8] fix(metadata-protocol): a registry listing that throws contributes no shipping packages The artifact layer's package ids are best-effort context on a metadata-only host whose partial registry cannot list; the package-less lookup still answers, never looser than the gate before this change. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 34 +++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a2fbca4791f..4f074052df5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16534,31 +16534,37 @@ export class ObjectStackProtocolImplementation implements * the artifact lookup does not (the `_lock` gate bound a disabled * package's artifact before #21803 when it was registered first), so the * installed packages are read too: a disabled package is still installed. - * A registry double without either listing contributes nothing here, and - * {@link shippedArtifactsOf} then answers the package-less lookup alone. + * A registry without either listing, or whose listing throws (a + * metadata-only host's partial registry: listing there is best-effort + * context, never the reason a write fails), contributes nothing here, and + * {@link shippedArtifactsOf} then answers with the package-less lookup and + * whatever could be listed: never looser than the `_lock` gate before + * #21803, which asked that lookup alone. */ private artifactPackageIds(type: string): Set { const registry = (this.engine as any)?.registry; const ids = new Set(); if (!registry) return ids; - const addId = (id: unknown): void => { - if (typeof id === 'string' && id !== '' && id !== 'sys_metadata') ids.add(id); + const addIds = (read: () => unknown, idOf: (entry: any) => unknown): void => { + let listed: unknown; + try { + listed = read(); + } catch { + return; // See this method's header: the package-less lookup still answers. + } + if (!Array.isArray(listed)) return; + for (const entry of listed) { + const id = idOf(entry); + if (typeof id === 'string' && id !== '' && id !== 'sys_metadata') ids.add(id); + } }; if (typeof registry.listItems === 'function') { for (const spelling of new Set([PLURAL_TO_SINGULAR[type] ?? type, type])) { - const listed: unknown = registry.listItems(spelling); - if (!Array.isArray(listed)) continue; - for (const entry of listed) addId((entry as { _packageId?: unknown } | null | undefined)?._packageId); + addIds(() => registry.listItems(spelling), (entry) => entry?._packageId); } } if (typeof registry.getAllPackages === 'function') { - const installed: unknown = registry.getAllPackages(); - if (Array.isArray(installed)) { - for (const record of installed) { - const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined; - addId(r?.manifest?.id ?? r?.id); - } - } + addIds(() => registry.getAllPackages(), (record) => record?.manifest?.id ?? record?.id); } return ids; } From 12cb7fd662fd305c2a4c3263f6f243b18e22d157 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:49:52 +0000 Subject: [PATCH 3/8] test(metadata-protocol): the lock family's enumeration pin covers the artifact layer's package axis The generated pin becomes the family's enumeration: named positions (layer x topology / organization / package), slices that open them, and a completeness check that fails by name. PR #21801's 16 320 rows are kept, checked under their own titles. Every row also asserts the served body states the envelope's lock. Named pins: the card's case, the never-widening join, a disabled package, and the folded content-scope position. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../src/protocol.lock-one-resolution.test.ts | 664 +++++++++++++++--- 1 file changed, 555 insertions(+), 109 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts index 200d27ae180..4bb054c1623 100644 --- a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts @@ -61,15 +61,67 @@ * the gate asked with no package and could bind it, so leaving it out * would admit writes the gate refused under some row order. * + * ## [#21803] The family's enumeration pin: every position, by layer and axis + * + * This card is the family's sixth position: the `artifact` layer on the + * package axis. Two installed code packages may ship one `(type, name)` + * (ADR-0048 §3.4); the gate bound the first package registered while the reads + * bound the request's package. Now the `artifact` layer is every installed + * package that ships the name (`resolveArtifactLockLayer`), and the lock is the + * strictest of the per-package answers (`resolveItemLock`). + * + * The table is now the family's enumeration: one oracle and one completeness + * check over named POSITIONS, each a layer of the resolution × one of the + * family's axes (topology, organization, package). A position names the table + * axes that open it and the card that measured it. The check fails by name when + * a layer of `ITEM_LOCK_LAYERS` or a family axis has no position, when a + * position is opened by no slice of the table, or when an axis of the table + * belongs to no position. The rows are the union of SLICES, each slice a full + * product of its axes' values, so a position's axes vary together while the + * rest are held at the family product's values: + * + * - the family product (PR #21801's 16 320 rows, checked by title: no row of + * it is lost); + * - the artifact layer × package: another installed package ships the name, + * each lock level, crossed with the package's own artifact, the env-wide + * stored row's lock, every address and topology, each run under BOTH + * registration orders; + * - the stored rows × organization × package (5988387087 on #21803): the + * organization holds only another package's row, so the content a request + * naming the package is served (the env-wide row) and the lock's scope (the + * organization's rows) part. The served body states the envelope's lock. + * + * Every row also asserts that the served body (`getMetaItem`'s `item`, the + * layered read's `effective`) states the lock the envelope reports. + * + * 8. The card's measured case, named: package B ships `_lock: 'full'`, + * package A ships no lock. Under both registration orders, a read naming + * A, B or no package reports the lock the door enforces. + * 9. Never a widening, named: A ships no lock, B ships `'no-delete'`, the + * stored row declares `'no-overlay'`. The door refused the save when A was + * registered first and the delete when B was; both are refused under both + * orders now, and the reads say so. + * 10. A DISABLED package is still installed: its packaged lock binds. + * 11. The folded position, named: the body served from the env-wide row of + * the package carries no lock the organization's rows do not declare. + * * `@objectstack/objectql` cannot be imported here: it depends on this package. + * The real `SchemaRegistry`'s enumeration is pinned beside it + * (`packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`). */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MetadataLockSchema, evaluateLockForDelete, evaluateLockForWrite } from '@objectstack/spec/kernel'; +import { + MetadataLockSchema, + evaluateLockForDelete, + evaluateLockForWrite, + extractProtection, +} from '@objectstack/spec/kernel'; import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { ITEM_ADDRESS_FIELDS, ITEM_LOCK_LAYERS, + resolveArtifactLockLayer, resolveItemLock, resolveOverlayLockLayer, type ItemAddressField, @@ -79,6 +131,8 @@ import { const ENV_ID = 'env_1'; const ORG = 'org_a'; const PACKAGE_ID = 'com.example.pkg'; +/** [#21803] Another installed package, shipping the same name. */ +const OTHER_PACKAGE = 'com.example.other'; const NAME = 'v_lock'; type Lock = (typeof MetadataLockSchema.options)[number]; @@ -94,7 +148,7 @@ interface StoredRow { // ── The axes ───────────────────────────────────────────────────────────────── -/** What the loader registered for the item: nothing, an artifact with no `_lock`, or one declaring each level. */ +/** What a package's loader registered for the item: nothing, an artifact with no `_lock`, or one declaring each level. */ type ArtifactAxis = { artifact: 'absent' } | { artifact: 'no _lock' } | { artifact: 'declared'; lock: Lock }; const ARTIFACT_VALUES: readonly ArtifactAxis[] = [ { artifact: 'absent' }, @@ -112,7 +166,11 @@ const STORED_ROW_VALUES: readonly StoredRowAxis[] = [ (['canonical', 'other (residue)'] as const).map((spelling) => ({ row: 'stored' as const, scope, lock, spelling })))), ]; -/** [#21761] The row bound to the package (ADR-0048): none, or an env-wide canonical row declaring each level. */ +/** + * [#21761] The row bound to the package (ADR-0048): none, or an env-wide + * canonical row declaring each level. [#21803] The same shape serves the other + * package's row in the organization. + */ type PackageRowAxis = { row: 'none' } | { row: 'stored'; lock: Lock }; const PACKAGE_ROW_VALUES: readonly PackageRowAxis[] = [ { row: 'none' }, @@ -121,28 +179,56 @@ const PACKAGE_ROW_VALUES: readonly PackageRowAxis[] = [ const AXIS_VALUES = { artifact: ARTIFACT_VALUES, + /** [#21803] What ANOTHER installed package registered under the same name. */ + otherArtifact: ARTIFACT_VALUES, storedRow: STORED_ROW_VALUES, packageRow: PACKAGE_ROW_VALUES, + /** [#21803] Another package's org-scoped canonical row of the item. */ + otherPackageRow: PACKAGE_ROW_VALUES, requestScope: [undefined, ORG] as const, - requestPackage: [undefined, PACKAGE_ID] as const, + requestPackage: [undefined, PACKAGE_ID, OTHER_PACKAGE] as const, topology: [{ kernel: 'environment', environmentId: ENV_ID }, { kernel: 'host-config', environmentId: undefined }] as const, requestSpelling: ['view', 'views'] as const, operation: ['save', 'delete'] as const, }; type AxisName = keyof typeof AXIS_VALUES; +type AxisValue = (typeof AXIS_VALUES)[A][number]; + +/** The caller's own axes, which decide no layer: how the request spells the type, which verb. */ +const CALLER_AXES: readonly AxisName[] = ['requestSpelling', 'operation']; /** - * Per layer of the one resolution, the axes that decide the document the layer - * contributes. Keyed by `ItemLockLayer`, so a layer added to the resolution - * without an entry here fails the typecheck, and the completeness check below - * fails the run, naming it. + * [#21803] The axes the family has been measured on. A layer × one of these + * is a POSITION; every one has a row below. */ -const LAYER_AXES: { readonly [L in ItemLockLayer]: readonly AxisName[] } = { - artifact: ['artifact'], - overlay: ['storedRow', 'packageRow', 'requestScope', 'requestPackage'], -}; -/** The caller's own axes: which kernel, how the request spells the type, which verb. */ -const CALLER_AXES: readonly AxisName[] = ['topology', 'requestSpelling', 'operation']; +const FAMILY_AXES = ['topology', 'organization', 'package'] as const; +type FamilyAxis = (typeof FAMILY_AXES)[number]; + +/** + * [#21803] The family's positions: a layer of the one resolution × a family + * axis, the table axes that open it (every one of them varies in some slice), + * and the card that measured it. A layer added to `ITEM_LOCK_LAYERS`, or an + * axis added to `FAMILY_AXES`, with no position here fails the completeness + * check, naming the pair. + */ +const POSITIONS: ReadonlyArray<{ + readonly layer: ItemLockLayer; + readonly axis: FamilyAxis; + readonly opensWith: readonly AxisName[]; + readonly card: string; +}> = [ + { layer: 'artifact', axis: 'topology', opensWith: ['artifact', 'topology'], card: '#21694' }, + { layer: 'artifact', axis: 'organization', opensWith: ['artifact', 'requestScope'], card: '#21738' }, + { layer: 'artifact', axis: 'package', opensWith: ['artifact', 'otherArtifact', 'requestPackage'], card: '#21803' }, + { layer: 'overlay', axis: 'topology', opensWith: ['storedRow', 'topology'], card: '#21694' }, + { layer: 'overlay', axis: 'organization', opensWith: ['storedRow', 'requestScope'], card: '#21716' }, + { layer: 'overlay', axis: 'package', opensWith: ['storedRow', 'packageRow', 'requestPackage'], card: '#21761' }, + { + layer: 'overlay', axis: 'organization', + opensWith: ['packageRow', 'otherPackageRow', 'requestScope', 'requestPackage'], + card: '#21803 (5988387087: the content scope and the lock scope part)', + }, +]; /** * [#21761] Per field of the item's address (`ITEM_ADDRESS_FIELDS`), the axis @@ -160,37 +246,102 @@ const ADDRESS_AXES: { readonly [F in ItemAddressField]: AxisName | 'one item' } interface Row { artifact: ArtifactAxis; + otherArtifact: ArtifactAxis; storedRow: StoredRowAxis; packageRow: PackageRowAxis; - requestScope: string | undefined; - requestPackage: string | undefined; - topology: (typeof AXIS_VALUES.topology)[number]; - requestSpelling: (typeof AXIS_VALUES.requestSpelling)[number]; - operation: (typeof AXIS_VALUES.operation)[number]; + otherPackageRow: PackageRowAxis; + requestScope: AxisValue<'requestScope'>; + requestPackage: AxisValue<'requestPackage'>; + topology: AxisValue<'topology'>; + requestSpelling: AxisValue<'requestSpelling'>; + operation: AxisValue<'operation'>; } -const TABLE: Row[] = AXIS_VALUES.artifact.flatMap((artifact) => AXIS_VALUES.storedRow.flatMap((storedRow) => - AXIS_VALUES.packageRow.flatMap((packageRow) => AXIS_VALUES.requestScope.flatMap((requestScope) => - AXIS_VALUES.requestPackage.flatMap((requestPackage) => AXIS_VALUES.topology.flatMap((topology) => - AXIS_VALUES.requestSpelling.flatMap((requestSpelling) => AXIS_VALUES.operation.map((operation) => ({ - artifact, storedRow, packageRow, requestScope, requestPackage, topology, requestSpelling, operation, - }))))))))); +type SliceValues = { readonly [A in AxisName]: ReadonlyArray> }; + +const ABSENT: ArtifactAxis = ARTIFACT_VALUES[0]!; +const NO_ROW: PackageRowAxis = PACKAGE_ROW_VALUES[0]!; +const present = (v: ArtifactAxis): boolean => v.artifact !== 'absent'; +const storedOnly = (v: PackageRowAxis): boolean => v.row === 'stored'; + +/** + * [#21803] The table's slices. Each is a full product of its axes' values; an + * axis a slice does not open is held at the family product's value. + */ +const SLICES: ReadonlyArray<{ readonly name: string; readonly values: SliceValues }> = [ + { + name: 'the family product (PR #21801)', + values: { + ...AXIS_VALUES, + otherArtifact: [ABSENT], + otherPackageRow: [NO_ROW], + requestPackage: [undefined, PACKAGE_ID], + }, + }, + { + name: '[#21803] the artifact layer × package: another installed package ships the name', + values: { + ...AXIS_VALUES, + otherArtifact: ARTIFACT_VALUES.filter(present), + // The overlay layer at its locks, env-wide and canonical: every + // per-package answer the artifact layer can join with. + storedRow: STORED_ROW_VALUES.filter((v) => v.row === 'none' || (v.scope === 'env-wide' && v.spelling === 'canonical')), + packageRow: [NO_ROW], + otherPackageRow: [NO_ROW], + }, + }, + { + name: '[#21803] the stored rows × organization × package: the organization holds only another package\'s row', + values: { + ...AXIS_VALUES, + artifact: [ABSENT], + otherArtifact: [ABSENT], + storedRow: [STORED_ROW_VALUES[0]!], + otherPackageRow: PACKAGE_ROW_VALUES.filter(storedOnly), + requestSpelling: ['view'], + }, + }, +]; + +function productOf(values: SliceValues): Row[] { + return values.artifact.flatMap((artifact) => values.otherArtifact.flatMap((otherArtifact) => + values.storedRow.flatMap((storedRow) => values.packageRow.flatMap((packageRow) => + values.otherPackageRow.flatMap((otherPackageRow) => values.requestScope.flatMap((requestScope) => + values.requestPackage.flatMap((requestPackage) => values.topology.flatMap((topology) => + values.requestSpelling.flatMap((requestSpelling) => values.operation.map((operation) => ({ + artifact, otherArtifact, storedRow, packageRow, otherPackageRow, + requestScope, requestPackage, topology, requestSpelling, operation, + }))))))))))); +} + +const TABLE: Row[] = SLICES.flatMap((slice) => productOf(slice.values)); function describeArtifact(a: ArtifactAxis): string { return a.artifact === 'declared' ? `artifact _lock=${a.lock}` : `artifact ${a.artifact}`; } +function describeOtherArtifact(a: ArtifactAxis): string { + if (a.artifact === 'absent') return ''; + return a.artifact === 'declared' + ? `other package's artifact _lock=${a.lock}` + : `other package's artifact ${a.artifact}`; +} function describeRow(r: StoredRowAxis): string { return r.row === 'none' ? 'no stored row' : `${r.scope} row _lock=${r.lock} (${r.spelling} spelling)`; } function describePackageRow(r: PackageRowAxis): string { return r.row === 'none' ? '' : `env-wide package row _lock=${r.lock}`; } +function describeOtherPackageRow(r: PackageRowAxis): string { + return r.row === 'none' ? '' : `org-scoped other package's row _lock=${r.lock}`; +} function titleOf(row: Row): string { return [ `${row.topology.kernel} kernel`, describeArtifact(row.artifact), + describeOtherArtifact(row.otherArtifact), describeRow(row.storedRow), describePackageRow(row.packageRow), + describeOtherPackageRow(row.otherPackageRow), `request: ${row.requestScope ? `organization ${row.requestScope}` : 'no organization'}` + (row.requestPackage ? `, package ${row.requestPackage}` : ''), `/meta/${row.requestSpelling}`, @@ -208,6 +359,9 @@ function oracleRows(row: Row): OracleRow[] { rows.push({ scope: row.storedRow.scope, spelling: row.storedRow.spelling, lock: row.storedRow.lock }); } if (row.packageRow.row === 'stored') rows.push({ scope: 'env-wide', spelling: 'canonical', lock: row.packageRow.lock }); + if (row.otherPackageRow.row === 'stored') { + rows.push({ scope: 'org-scoped', spelling: 'canonical', lock: row.otherPackageRow.lock }); + } return rows; } @@ -224,16 +378,25 @@ function strictestOf(locks: readonly Lock[]): Lock { && (evaluateLockForDelete(state) !== null) === refusesDelete)!; } +const declaredLockOf = (a: ArtifactAxis): Lock => (a.artifact === 'declared' ? a.lock : 'none'); + /** - * The lock each layer declares for `side` — the reads, or the door. The - * overlay layer is the strictest lock among the rows in scope (ADR-0005: the - * organization's rows when it holds any, else the env-wide rows; any package). - * The door differs on exactly one input: it does not see a row stored under - * the other spelling, which the reads see only when no canonical row is in - * that scope. The request's package selects nothing here. + * What each layer declares for `side` — the reads, or the door. + * + * - `artifact`: [#21803] one lock per installed package that ships the name + * (the package's own, another package's), `'none'` for an artifact that + * declares no lock; no entry for a package that ships nothing. + * - `overlay`: the strictest lock among the rows in scope (ADR-0005: the + * organization's rows when it holds any, else the env-wide rows; any + * package). The door differs on exactly one input: it does not see a row + * stored under the other spelling, which the reads see only when no + * canonical row is in that scope. The request's package selects nothing. */ -const DECLARED: { readonly [L in ItemLockLayer]: (row: Row, side: 'reads' | 'door') => Lock } = { - artifact: (row) => (row.artifact.artifact === 'declared' ? row.artifact.lock : 'none'), +const DECLARED: { + readonly artifact: (row: Row) => Lock[]; + readonly overlay: (row: Row, side: 'reads' | 'door') => Lock; +} = { + artifact: (row) => [row.artifact, row.otherArtifact].filter(present).map(declaredLockOf), overlay: (row, side) => { const scopes: Array = row.requestScope === ORG ? ['org-scoped', 'env-wide'] : ['env-wide']; for (const scope of scopes) { @@ -246,13 +409,21 @@ const DECLARED: { readonly [L in ItemLockLayer]: (row: Row, side: 'reads' | 'doo }, }; -/** The rule: the first layer, in `ITEM_LOCK_LAYERS` order, whose declared lock is not `'none'` binds. */ +/** + * The rule: per installed package that ships the name (or once, when none + * does), the first layer in `ITEM_LOCK_LAYERS` order whose declared lock is not + * `'none'` binds; [#21803] the item's lock is the strictest of those answers. + */ function expectedLock(row: Row, side: 'reads' | 'door'): Lock { - for (const layer of ITEM_LOCK_LAYERS) { - const lock = DECLARED[layer](row, side); - if (lock !== 'none') return lock; - } - return 'none'; + const shipped = DECLARED.artifact(row); + const perPackage = (shipped.length > 0 ? shipped : ['none' as Lock]).map((artifactLock) => { + for (const layer of ITEM_LOCK_LAYERS) { + const lock = layer === 'artifact' ? artifactLock : DECLARED.overlay(row, side); + if (lock !== 'none') return lock; + } + return 'none' as Lock; + }); + return strictestOf(perPackage); } // ── The harness ────────────────────────────────────────────────────────────── @@ -278,44 +449,81 @@ function storedRow( } /** What a code package's loader registers: package-stamped, with the envelope `applyProtection` writes. */ -function packagedView(a: ArtifactAxis, name = NAME): Record | undefined { +function packagedView(a: ArtifactAxis, name = NAME, packageId = PACKAGE_ID): Record | undefined { if (a.artifact === 'absent') return undefined; return { name, - label: 'packaged', + label: `packaged by ${packageId}`, object: 'account', - _packageId: PACKAGE_ID, + _packageId: packageId, _provenance: 'package', ...(a.artifact === 'declared' - ? { _lock: a.lock, _lockReason: `Packaged lock (${a.lock}).`, _lockSource: 'package' } + ? { _lock: a.lock, _lockReason: `Packaged lock of ${packageId} (${a.lock}).`, _lockSource: 'package' } : {}), }; } /** - * The engine double: `find` / `findOne` over `sys_metadata` rows, a registry - * whose artifact lookup answers what the loader registered, and an `insert` - * that keeps nothing (the gate writes its denial row through it). + * [#21803] The registry double, after `SchemaRegistry`: each package's item + * under its composite key `:`, in REGISTRATION order (the Map + * iterates in it, as the registry's does). + * + * - `getArtifactItem` (`SchemaRegistry.getArtifactItem`): the asked package's + * own entry (prefer-local, ADR-0048), else the FIRST package registered + * that ships the name; + * - `getItem`: the same order, without the code-artifact test; + * - `listItems`: every entry, a disabled package's hidden; + * - `getAllPackages` / `getPackage` / `isPackageDisabled`: the packages the + * test installs (none by default: the family's rows install no package). */ -function harness(environmentId: string | undefined, rows: StoredRow[], artifact?: Record) { - const items: Record> = artifact ? { [String(artifact.name)]: artifact } : {}; - const registry = { - getArtifactItem(type: string, name: string) { - const hit = type === 'view' ? items[name] : undefined; - return hit && isCodeArtifactBody(hit) ? hit : undefined; +function registryDouble( + artifacts: ReadonlyArray>, + installed: ReadonlyArray<{ id: string; enabled: boolean }> = [], +) { + const entries = new Map>(); + for (const artifact of artifacts) entries.set(`${String(artifact._packageId)}:${String(artifact.name)}`, artifact); + const disabled = (id: unknown) => installed.some((p) => p.id === id && !p.enabled); + const ordered = (name: string, packageId?: string) => { + const local = packageId ? entries.get(`${packageId}:${name}`) : undefined; + const composites = [...entries].filter(([key]) => key.endsWith(`:${name}`)).map(([, item]) => item); + return local ? [local, ...composites] : composites; + }; + return { + getArtifactItem(type: string, name: string, packageId?: string) { + return type === 'view' ? ordered(name, packageId).find((item) => isCodeArtifactBody(item)) : undefined; }, - getItem(type: string, name: string) { - return type === 'view' ? items[name] : undefined; + getItem(type: string, name: string, packageId?: string) { + return type === 'view' ? ordered(name, packageId)[0] : undefined; }, listItems(type: string) { - return type === 'view' ? Object.values(items) : []; + return type === 'view' ? [...entries.values()].filter((item) => !disabled(item._packageId)) : []; }, + getAllPackages: () => installed.map((p) => ({ manifest: { id: p.id }, enabled: p.enabled })), + getPackage: (id: string) => { + const p = installed.find((candidate) => candidate.id === id); + return p ? { manifest: { id: p.id }, enabled: p.enabled } : undefined; + }, + isPackageDisabled: (id?: string) => disabled(id), getObject: () => undefined, registerObject: () => undefined, - getPackage: () => undefined, - isPackageDisabled: () => false, applyNavContributions: (app: unknown) => app, }; +} + +/** + * The engine double: `find` / `findOne` over `sys_metadata` rows, the registry + * double above over the packages' artifacts (one artifact, a list of them in + * registration order, or none), and an `insert` that keeps nothing (the gate + * writes its denial row through it). + */ +function harness( + environmentId: string | undefined, + rows: StoredRow[], + artifacts?: Record | ReadonlyArray>, + installed?: ReadonlyArray<{ id: string; enabled: boolean }>, +) { + const registered = artifacts === undefined ? [] : Array.isArray(artifacts) ? artifacts : [artifacts]; + const registry = registryDouble(registered as ReadonlyArray>, installed); const matching = (where: Record) => { for (const k of Object.keys(where)) { if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); @@ -362,9 +570,22 @@ function rowOrdersFor(row: Row): StoredRow[][] { if (row.packageRow.row === 'stored') { rows.push(storedRow('view', null, row.packageRow.lock, 'package row', NAME, PACKAGE_ID)); } + if (row.otherPackageRow.row === 'stored') { + rows.push(storedRow('view', ORG, row.otherPackageRow.lock, 'org row of the other package', NAME, OTHER_PACKAGE)); + } return rows.length < 2 ? [rows] : [rows, [...rows].reverse()]; } +/** + * [#21803] The row's artifacts, in each order the packages can be registered + * in: one order when at most one package ships the name, both when two do. + */ +function registrationOrdersFor(row: Row): Array>> { + const artifacts = [packagedView(row.artifact), packagedView(row.otherArtifact, NAME, OTHER_PACKAGE)] + .filter((a): a is Record => a !== undefined); + return artifacts.length < 2 ? [artifacts] : [artifacts, [...artifacts].reverse()]; +} + const settle = (run: Promise) => run.then(() => null, (e: unknown) => e); type Verdict = { refused: { code: unknown; status: unknown } } | 'admitted'; @@ -423,27 +644,42 @@ afterEach(() => vi.restoreAllMocks()); // ── 1. The generated pin ───────────────────────────────────────────────────── -describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaItem = getMetaItemLayered = the door', () => { - it('completeness: every layer the resolution reads has an axis here, and the resolution takes nothing else', () => { - const missing = ITEM_LOCK_LAYERS.filter((layer) => !Object.prototype.hasOwnProperty.call(LAYER_AXES, layer)); - expect(missing, 'resolution layers with no axis in this table').toEqual([]); - const stale = Object.keys(LAYER_AXES).filter((layer) => !(ITEM_LOCK_LAYERS as readonly string[]).includes(layer)); - expect(stale, 'axes for a layer the resolution no longer reads').toEqual([]); +describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from the resolution\'s inputs: getMetaItem = getMetaItemLayered = the door', () => { + it('completeness: every layer × family axis is a position, every position is opened by a slice, every axis belongs to a position, and the resolution takes nothing else', () => { + // Every layer the resolution reads, on every axis the family has, is a + // position here, by name. + const unpositioned = ITEM_LOCK_LAYERS.flatMap((layer) => FAMILY_AXES + .filter((axis) => !POSITIONS.some((p) => p.layer === layer && p.axis === axis)) + .map((axis) => `${layer} × ${axis}`)); + expect(unpositioned, 'layer × family axis with no position in this table').toEqual([]); + const stale = POSITIONS.filter((p) => !(ITEM_LOCK_LAYERS as readonly string[]).includes(p.layer)) + .map((p) => `${p.layer} × ${p.axis}`); + expect(stale, 'positions for a layer the resolution no longer reads').toEqual([]); + // Every position is OPENED by a slice: all its axes vary together there. + const unopened = POSITIONS.filter((p) => !SLICES.some((slice) => + p.opensWith.every((axis) => slice.values[axis].length > 1))) + .map((p) => `${p.layer} × ${p.axis} (${p.card}): ${p.opensWith.join(', ')}`); + expect(unopened, 'positions no slice of the table varies').toEqual([]); + // Every axis of the table belongs to a position or is the caller's own. + const used = new Set([...POSITIONS.flatMap((p) => p.opensWith), ...CALLER_AXES]); + const orphaned = (Object.keys(AXIS_VALUES) as AxisName[]).filter((axis) => !used.has(axis)); + expect(orphaned, 'axes that open no position').toEqual([]); + // The resolution takes its layers record, and each layer's selection + // takes the address and a reader (and the overlay's spelling option). expect(resolveItemLock.length, 'resolveItemLock takes exactly its layers record').toBe(1); - // Every axis is used, exactly once, and the table is their full product. - const used = [...Object.values(LAYER_AXES).flat(), ...CALLER_AXES]; - expect([...used].sort()).toEqual((Object.keys(AXIS_VALUES) as AxisName[]).sort()); - expect(new Set(used).size).toBe(used.length); - const product = (Object.keys(AXIS_VALUES) as AxisName[]) - .reduce((n, axis) => n * AXIS_VALUES[axis].length, 1); - expect(TABLE).toHaveLength(product); + expect(resolveOverlayLockLayer.length, 'resolveOverlayLockLayer takes (address, rowsIn, options)').toBe(3); + expect(resolveArtifactLockLayer.length, 'resolveArtifactLockLayer takes (address, artifactsOf)').toBe(2); + // The table is the union of its slices, each its full product. + const sizes = SLICES.map((slice) => (Object.keys(AXIS_VALUES) as AxisName[]) + .reduce((n, axis) => n * slice.values[axis].length, 1)); + expect(TABLE).toHaveLength(sizes.reduce((a, b) => a + b, 0)); expect(new Set(TABLE.map(titleOf)).size, 'row titles are unique').toBe(TABLE.length); // Every lock level is an axis value on both layers (read off the schema itself). expect(MetadataLockSchema.options).toEqual(['none', 'no-overlay', 'no-delete', 'full']); + expect(PACKAGE_ROW_VALUES.filter((v) => v.row === 'stored').map((v) => (v as { lock: Lock }).lock)) + .toEqual(MetadataLockSchema.options); // [#21761] Every field of the item's ADDRESS has an axis, by name, and - // each such axis is an input of the overlay layer (or the caller's - // spelling). The selection takes the address, a row reader and its - // spelling option, and nothing else. + // each such axis is an axis of a position (or the caller's spelling). const unaddressed = ITEM_ADDRESS_FIELDS.filter((field) => !Object.prototype.hasOwnProperty.call(ADDRESS_AXES, field)); expect(unaddressed, 'address fields with no axis in this table').toEqual([]); const staleAddress = Object.keys(ADDRESS_AXES).filter((field) => !(ITEM_ADDRESS_FIELDS as readonly string[]).includes(field)); @@ -451,12 +687,37 @@ describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaIte for (const field of ITEM_ADDRESS_FIELDS) { const axis = ADDRESS_AXES[field]; if (axis === 'one item') continue; - expect([...LAYER_AXES.overlay, ...CALLER_AXES], `address field ${field}'s axis ${axis}`).toContain(axis); + expect([...used], `address field ${field}'s axis ${axis}`).toContain(axis); } - expect(resolveOverlayLockLayer.length, 'resolveOverlayLockLayer takes (address, rowsIn, options)').toBe(3); - expect(AXIS_VALUES.requestPackage, 'packageId present and absent').toEqual([undefined, PACKAGE_ID]); - expect(PACKAGE_ROW_VALUES.filter((v) => v.row === 'stored').map((v) => (v as { lock: Lock }).lock)) - .toEqual(MetadataLockSchema.options); + expect(AXIS_VALUES.requestPackage, 'packageId absent, the package\'s own, another package').toEqual([undefined, PACKAGE_ID, OTHER_PACKAGE]); + }); + + it('PR #21801\'s generated product (16 320 rows) is a subset of this table, by its own titles: no row is lost', () => { + const titles = new Set(TABLE.map(titleOf)); + // PR #21801's axes and title, frozen here as they were: the product + // regenerated from them must be found in this table under the very + // titles it ran under. + const before: string[] = []; + for (const artifact of ARTIFACT_VALUES) for (const storedRowValue of STORED_ROW_VALUES) + for (const packageRow of PACKAGE_ROW_VALUES) for (const requestScope of [undefined, ORG]) + for (const requestPackage of [undefined, PACKAGE_ID]) for (const topology of AXIS_VALUES.topology) + for (const requestSpelling of ['view', 'views']) for (const operation of ['save', 'delete']) { + before.push([ + `${topology.kernel} kernel`, + artifact.artifact === 'declared' ? `artifact _lock=${artifact.lock}` : `artifact ${artifact.artifact}`, + storedRowValue.row === 'none' + ? 'no stored row' + : `${storedRowValue.scope} row _lock=${storedRowValue.lock} (${storedRowValue.spelling} spelling)`, + packageRow.row === 'none' ? '' : `env-wide package row _lock=${packageRow.lock}`, + `request: ${requestScope ? `organization ${requestScope}` : 'no organization'}` + + (requestPackage ? `, package ${requestPackage}` : ''), + `/meta/${requestSpelling}`, + operation, + ].filter((part) => part !== '').join(' · ')); + } + expect(before).toHaveLength(16320); + expect(new Set(before).size).toBe(16320); + expect(before.filter((t) => !titles.has(t))).toEqual([]); }); it('PR #21737\'s 64-row enumeration (topology × row scope × request scope × lock × operation) is a subset of this table', () => { @@ -466,9 +727,11 @@ describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaIte for (const requestScope of AXIS_VALUES.requestScope) for (const lock of MetadataLockSchema.options) for (const operation of AXIS_VALUES.operation) { folded.push(titleOf({ - artifact: { artifact: 'absent' }, + artifact: ABSENT, + otherArtifact: ABSENT, storedRow: { row: 'stored', scope, lock, spelling: 'canonical' }, - packageRow: { row: 'none' }, + packageRow: NO_ROW, + otherPackageRow: NO_ROW, requestScope, requestPackage: undefined, topology, requestSpelling: 'view', operation, @@ -481,34 +744,46 @@ describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaIte for (const row of TABLE) { const title = titleOf(row); it(title, async () => { - // [#21761] Under every order the store can return the rows in. + // [#21761] Under every order the store can return the rows in, and + // [#21803] every order the packages can be registered in. for (const [order, rows] of rowOrdersFor(row).entries()) { - const at = `${title} (row order ${order + 1})`; - const protocol = harness(row.topology.environmentId, rows, packagedView(row.artifact)); - const read = await envelope(protocol, row.requestSpelling, row.requestScope, NAME, row.requestPackage); - // Both reads report the declared rule's lock. - expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }, `${at}: the reads`) - .toEqual(flagsOf(expectedLock(row, 'reads'))); - // The door binds the rule's lock over the layers IT sees. - const verdict = await door( - protocol, row.requestSpelling, row.operation, row.requestScope, NAME, row.requestPackage, - ); - const doorAllows = row.operation === 'save' - ? evaluateLockForWrite(expectedLock(row, 'door')) === null - : evaluateLockForDelete(expectedLock(row, 'door')) === null; - expect(verdict, `${at}: the door`).toEqual(doorAllows ? 'admitted' : ITEM_LOCKED); - // …and the two agree: the door admits exactly when the envelope - // says it may. Except on the one declared difference - // (`overlayLockLayerAt`'s `otherSpelling`): a row stored under - // the other spelling is in the reads' scope and not the door's, - // which the two oracle assertions above already state row by - // row — so those rows flip, by name, the day the reads' - // fallback retires. - const residue = expectedLock(row, 'reads') !== expectedLock(row, 'door'); - if (!residue) { - const readAllows = row.operation === 'save' ? read.editable : read.deletable; - expect(verdict, `${at}: the door and the read envelope (lock ${read.lock}) disagree`) - .toEqual(readAllows ? 'admitted' : ITEM_LOCKED); + for (const [registration, artifacts] of registrationOrdersFor(row).entries()) { + const at = `${title} (row order ${order + 1}, registration order ${registration + 1})`; + const protocol = harness(row.topology.environmentId, rows, artifacts); + const read = await envelope(protocol, row.requestSpelling, row.requestScope, NAME, row.requestPackage); + // Both reads report the declared rule's lock. + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }, `${at}: the reads`) + .toEqual(flagsOf(expectedLock(row, 'reads'))); + // [#21803] …and a body they serve states it, no more. (A + // request can be served no body while a row in the lock's + // scope binds: content never serves another package's row.) + if (read.byName.item != null) { + expect(extractProtection(read.byName.item).lock, `${at}: getMetaItem's body`).toBe(read.lock); + } + if (read.layered.effective != null) { + expect(extractProtection(read.layered.effective).lock, `${at}: the layered read's effective`).toBe(read.lock); + } + // The door binds the rule's lock over the layers IT sees. + const verdict = await door( + protocol, row.requestSpelling, row.operation, row.requestScope, NAME, row.requestPackage, + ); + const doorAllows = row.operation === 'save' + ? evaluateLockForWrite(expectedLock(row, 'door')) === null + : evaluateLockForDelete(expectedLock(row, 'door')) === null; + expect(verdict, `${at}: the door`).toEqual(doorAllows ? 'admitted' : ITEM_LOCKED); + // …and the two agree: the door admits exactly when the envelope + // says it may. Except on the one declared difference + // (`overlayLockLayerAt`'s `otherSpelling`): a row stored under + // the other spelling is in the reads' scope and not the door's, + // which the two oracle assertions above already state row by + // row — so those rows flip, by name, the day the reads' + // fallback retires. + const residue = expectedLock(row, 'reads') !== expectedLock(row, 'door'); + if (!residue) { + const readAllows = row.operation === 'save' ? read.editable : read.deletable; + expect(verdict, `${at}: the door and the read envelope (lock ${read.lock}) disagree`) + .toEqual(readAllows ? 'admitted' : ITEM_LOCKED); + } } } }); @@ -533,7 +808,7 @@ describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaIte // binds over a looser package-less row (arrangement 1's shape), and a // package-less row binds over the package row's explicit 'none' // (arrangement 2's), with the package named and not. - for (const requestPackage of AXIS_VALUES.requestPackage) { + for (const requestPackage of [undefined, PACKAGE_ID]) { expect(locks.some((l) => l.row.requestPackage === requestPackage && l.row.artifact.artifact === 'absent' && l.row.storedRow.row === 'stored' && l.row.storedRow.lock === 'none' && l.row.packageRow.row === 'stored' && l.row.packageRow.lock === 'full' && l.door === 'full')).toBe(true); @@ -545,6 +820,28 @@ describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaIte expect(locks.some((l) => l.row.storedRow.row === 'stored' && l.row.storedRow.lock === 'no-overlay' && l.row.packageRow.row === 'stored' && l.row.packageRow.lock === 'no-delete' && l.row.artifact.artifact === 'absent' && l.door === 'full')).toBe(true); + // [#21803] The artifact layer's package axis reaches every direction, + // with each request shape: the other package's lock binds over the + // package's own unlocked artifact; the package's own lock binds over the + // other's; and a per-package answer from the OVERLAY (an unlocked + // artifact) joins one from the other package's ARTIFACT into a lock + // neither package gives alone. + for (const requestPackage of AXIS_VALUES.requestPackage) { + const at = (l: (typeof locks)[number]) => l.row.requestPackage === requestPackage && l.row.storedRow.row === 'none'; + expect(locks.some((l) => at(l) && l.row.artifact.artifact === 'no _lock' + && l.row.otherArtifact.artifact === 'declared' && l.row.otherArtifact.lock === 'full' && l.door === 'full')).toBe(true); + expect(locks.some((l) => at(l) && l.row.artifact.artifact === 'declared' && l.row.artifact.lock === 'full' + && l.row.otherArtifact.artifact === 'no _lock' && l.door === 'full')).toBe(true); + } + expect(locks.some((l) => l.row.artifact.artifact === 'no _lock' + && l.row.otherArtifact.artifact === 'declared' && l.row.otherArtifact.lock === 'no-delete' + && l.row.storedRow.row === 'stored' && l.row.storedRow.lock === 'no-overlay' && l.door === 'full')).toBe(true); + // [#21803] The folded position: the organization's only row is another + // package's, and the request names the package, whose env-wide row is + // what it is served, with a lock the organization's rows do not declare. + expect(locks.some((l) => l.row.otherPackageRow.row === 'stored' && l.row.otherPackageRow.lock === 'none' + && l.row.packageRow.row === 'stored' && l.row.packageRow.lock === 'full' + && l.row.requestScope === ORG && l.row.requestPackage === PACKAGE_ID && l.reads === 'none')).toBe(true); }); it('lit control: an org-scoped request is served the env-wide row and reads its lock; an org-scoped row is never served to a request naming none', async () => { @@ -558,8 +855,21 @@ describe('[#21738] pin 1 — generated from the resolution\'s inputs: getMetaIte expect(await door(other, 'view', 'save')).toBe('admitted'); expect(await door(other, 'view', 'delete')).toBe('admitted'); }); -}); + it('lit control: the registry double answers the first package registered when asked with no package, and the asked package\'s own when asked with one', () => { + const a = packagedView({ artifact: 'no _lock' })!; + const b = packagedView({ artifact: 'declared', lock: 'full' }, NAME, OTHER_PACKAGE)!; + for (const order of [[a, b], [b, a]]) { + const registry = registryDouble(order); + expect(registry.getArtifactItem('view', NAME)).toBe(order[0]); + expect(registry.getArtifactItem('view', NAME, PACKAGE_ID)).toBe(a); + expect(registry.getArtifactItem('view', NAME, OTHER_PACKAGE)).toBe(b); + } + const disabled = registryDouble([a, b], [{ id: OTHER_PACKAGE, enabled: false }]); + expect(disabled.listItems('view')).toEqual([a]); + expect(disabled.getArtifactItem('view', NAME, OTHER_PACKAGE)).toBe(b); + }); +}); // ── 2. Position 1, named ───────────────────────────────────────────────────── describe('[#21738] pin 2 — position 1: an artifact\'s explicit _lock: \'none\' does not override a stored env-wide \'full\'', () => { @@ -793,3 +1103,139 @@ describe('[#21761] pin 7 — a third package\'s row is in scope: no write the ga } }); }); + +// ── 8–11. [#21803] The artifact layer's package axis, named ───────────────── + +/** Package A ships the view with `a`, package B (another installed package) with `b`, in both registration orders. */ +function twoPackages(a: ArtifactAxis, b: ArtifactAxis, name: string) { + const ofA = () => packagedView(a, name, PACKAGE_ID)!; + const ofB = () => packagedView(b, name, OTHER_PACKAGE)!; + return [ + { order: 'package B registered first', artifacts: () => [ofB(), ofA()] }, + { order: 'package A registered first', artifacts: () => [ofA(), ofB()] }, + ]; +} + +const REQUESTS = [ + { request: 'naming package A', packageId: PACKAGE_ID }, + { request: 'naming package B', packageId: OTHER_PACKAGE }, + { request: 'naming no package', packageId: undefined }, +] as const; + +describe('[#21803] pin 8 — the card\'s case: B ships _lock \'full\', A ships no lock; every read reports the lock the door enforces', () => { + for (const { order, artifacts } of twoPackages({ artifact: 'no _lock' }, { artifact: 'declared', lock: 'full' }, 'v_art')) { + for (const { request, packageId } of REQUESTS) { + it(`${order} · request ${request}`, async () => { + const protocol = harness(ENV_ID, [], artifacts()); + const read = await envelope(protocol, 'view', undefined, 'v_art', packageId); + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) + .toEqual({ lock: 'full', editable: false, deletable: false }); + // The prose is B's: B's is the only package whose lock is the strictest. + expect(read.byName.lockReason).toBe(`Packaged lock of ${OTHER_PACKAGE} (full).`); + expect(read.layered.lockReason).toBe(`Packaged lock of ${OTHER_PACKAGE} (full).`); + // Content stays prefer-local (ADR-0048): a read naming A is + // served A's artifact, under A's provenance, carrying B's lock. + if (packageId !== undefined) { + expect({ served: read.byName.item?.label, packageId: read.byName.packageId }) + .toEqual({ served: `packaged by ${packageId}`, packageId }); + } + expect(read.byName.item?._lock).toBe('full'); + expect(read.layered.effective?._lock).toBe('full'); + // The door refuses both verbs, with the binding package's prose. + const err: any = await settle(protocol.saveMetaItem({ + type: 'view', name: 'v_art', item: { name: 'v_art', label: 'x', object: 'account' }, + ...(packageId ? { packageId } : {}), + })); + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status, lock: err.lock }).toEqual({ code: 'ITEM_LOCKED', status: 403, lock: 'full' }); + expect(err.lockReason).toBe(`Packaged lock of ${OTHER_PACKAGE} (full).`); + expect(await door(protocol, 'view', 'delete', undefined, 'v_art')).toEqual(ITEM_LOCKED); + }); + } + + it(`${order} · the list and the directory tile report the item's lock for every package's slot`, async () => { + const protocol = harness(ENV_ID, [], artifacts()); + for (const packageId of [undefined, PACKAGE_ID, OTHER_PACKAGE]) { + const listed: any = await protocol.getMetaItems({ type: 'view', ...(packageId ? { packageId } : {}) }); + const items = listed.items.filter((i: any) => i.name === 'v_art'); + expect(items.length, `list ${packageId ?? 'unscoped'}`).toBeGreaterThan(0); + for (const item of items) expect(item._lock, `list ${packageId ?? 'unscoped'}: ${item._packageId}`).toBe('full'); + const diag: any = await protocol.getMetaDiagnostics({ type: 'view', severity: 'warning', ...(packageId ? { packageId } : {}) } as any); + expect(diag.stats.view.locked, `tile ${packageId ?? 'unscoped'}`).toBe(diag.stats.view.count); + } + }); + } +}); + +describe('[#21803] pin 9 — never a widening: A ships no lock, B ships \'no-delete\', the stored row declares \'no-overlay\'', () => { + // Before #21803 the door refused the save when A was registered first (A's + // artifact does not bind, the row's 'no-overlay' does) and the delete when + // B was (B's 'no-delete' binds). Neither is admitted under either order now. + for (const { order, artifacts } of twoPackages({ artifact: 'no _lock' }, { artifact: 'declared', lock: 'no-delete' }, 'v_join')) { + for (const { request, packageId } of REQUESTS) { + it(`${order} · request ${request}: the reads say 'full' and the door refuses both verbs`, async () => { + const protocol = harness(ENV_ID, [storedRow('view', null, 'no-overlay', 'env-wide row', 'v_join')], artifacts()); + const read = await envelope(protocol, 'view', undefined, 'v_join', packageId); + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) + .toEqual({ lock: 'full', editable: false, deletable: false }); + // No single package's answer is 'full' (A's is the row's + // 'no-overlay', B's its own 'no-delete'), so no prose is + // borrowed from either. + expect(read.byName.lockReason).toBeUndefined(); + expect(read.byName.item?._lock).toBe('full'); + expect(await door(protocol, 'view', 'save', undefined, 'v_join', packageId)).toEqual(ITEM_LOCKED); + expect(await door(protocol, 'view', 'delete', undefined, 'v_join')).toEqual(ITEM_LOCKED); + }); + } + } +}); + +describe('[#21803] pin 10 — a disabled package is still installed: its packaged lock binds', () => { + for (const { order, artifacts } of twoPackages({ artifact: 'no _lock' }, { artifact: 'declared', lock: 'full' }, 'v_dis')) { + it(`${order}: B disabled, a read naming A and the door both say 'full'`, async () => { + const protocol = harness(ENV_ID, [], artifacts(), [{ id: OTHER_PACKAGE, enabled: false }]); + // The listing hides B's entry; the lock does not depend on it. + expect(((protocol as any).engine.registry.listItems('view') as any[]).map((i) => i._packageId)).toEqual([PACKAGE_ID]); + const read = await envelope(protocol, 'view', undefined, 'v_dis', PACKAGE_ID); + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) + .toEqual({ lock: 'full', editable: false, deletable: false }); + expect(await door(protocol, 'view', 'save', undefined, 'v_dis', PACKAGE_ID)).toEqual(ITEM_LOCKED); + expect(await door(protocol, 'view', 'delete', undefined, 'v_dis')).toEqual(ITEM_LOCKED); + }); + } +}); + +describe('[#21803] pin 11 — the folded position: a body served from outside the lock\'s scope states no lock the envelope does not report', () => { + // 5988387087: the organization holds only package B's row; an env-wide row + // of package A declares 'full'. A request naming A in the organization is + // served A's env-wide row (content: the organization holds no row of A and + // no package-less row), while the lock's scope is the organization's rows. + const rows = () => [ + storedRow('view', ORG, 'none', 'org row of the other package', 'v_scope', OTHER_PACKAGE), + storedRow('view', null, 'full', 'env-wide package row', 'v_scope', PACKAGE_ID), + ]; + + for (const reversed of [false, true]) { + it(`${reversed ? 'the env-wide row returned first' : 'the org row returned first'}: the envelope says 'none', and the served body carries no _lock`, async () => { + const protocol = harness(ENV_ID, reversed ? rows().reverse() : rows()); + const read = await envelope(protocol, 'view', ORG, 'v_scope', PACKAGE_ID); + expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) + .toEqual({ lock: 'none', editable: true, deletable: true }); + expect(read.byName.item?.label).toBe('env-wide package row'); + expect(Object.keys(read.byName.item ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); + expect(Object.keys(read.layered.effective ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); + // The layered read still reports the stored layer as stored. + expect(read.layered.overlay?._lock).toBe('full'); + // The door agrees with the envelope. + expect(await door(protocol, 'view', 'save', ORG, 'v_scope', PACKAGE_ID)).toBe('admitted'); + expect(await door(protocol, 'view', 'delete', ORG, 'v_scope')).toBe('admitted'); + }); + } + + it('lit control: the same rows read with no organization bind the env-wide row\'s \'full\'', async () => { + const protocol = harness(ENV_ID, rows()); + const read = await envelope(protocol, 'view', undefined, 'v_scope', PACKAGE_ID); + expect({ lock: read.lock, body: read.byName.item?._lock }).toEqual({ lock: 'full', body: 'full' }); + expect(await door(protocol, 'view', 'save', undefined, 'v_scope', PACKAGE_ID)).toEqual(ITEM_LOCKED); + }); +}); From e95bf1bb77ed53a5516eca6648647abbe0719ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:51:56 +0000 Subject: [PATCH 4/8] test(objectql): the item lock across two installed packages on the real SchemaRegistry The card's case, a disabled package and the never-widening join, under both registration orders, on the registry whose getArtifactItem / listItems / getAllPackages answers the metadata protocol's artifact layer is built from. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...rotocol-lock-artifact-package-axis.test.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 packages/objectql/src/protocol-lock-artifact-package-axis.test.ts diff --git a/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts b/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts new file mode 100644 index 00000000000..7aac3469569 --- /dev/null +++ b/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21803, ADR-0010 §3.3, ADR-0048 §3.4] The item lock on the REAL + * `SchemaRegistry`: when two installed code packages ship one `(type, name)`, + * the `_lock` gate and both reads take the lock from every package that ships + * it, the strictest per-package answer, whatever order the packages were + * registered in. + * + * The metadata protocol's own enumeration pin + * (`packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts`) + * runs on a registry double, because that package cannot import this one. The + * resolution's artifact layer depends on THIS registry's answers + * (`getArtifactItem` prefer-local and first-registered, `listItems` hiding a + * disabled package, `getAllPackages` naming every installed one), so the + * card's case is pinned here on the real thing: + * + * 1. Package B ships `_lock: 'full'`, package A ships no lock. Before #21803, + * with B registered first a read naming A said `'none'` while the door + * refused, and with A registered first a read naming B said `'full'` while + * the door admitted. Now every read and the door say `'full'`. + * 2. B is disabled: still installed, its lock still binds. + * 3. A ships no lock, B ships `'no-delete'`, the stored row declares + * `'no-overlay'`: the door refused the save under one order and the delete + * under the other, and refuses both under both orders now. + */ +import { describe, expect, it } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { SchemaRegistry } from './registry.js'; + +const A = 'com.example.a'; +const B = 'com.example.b'; + +type Lock = 'none' | 'no-overlay' | 'no-delete' | 'full'; + +interface Arrangement { + readonly name: string; + readonly locks: Readonly>; + readonly rowLock?: Lock; + readonly disabled?: readonly string[]; +} + +/** One protocol over a real registry with the packages registered in `order`, and the stored row, if any. */ +function protocolFor(arrangement: Arrangement, order: readonly string[]) { + const registry = new SchemaRegistry({ multiTenant: false, logLevel: 'silent' }); + for (const packageId of order) { + const lock = arrangement.locks[packageId]; + registry.registerItem('view', { + name: arrangement.name, + label: `view of ${packageId}`, + object: 'account', + viewKind: 'list', + ...(lock ? { protection: { lock, reason: `Packaged lock of ${packageId}.` } } : {}), + } as Record, 'name' as never, packageId); + } + for (const packageId of arrangement.disabled ?? []) { + registry.installPackage({ id: packageId, name: packageId, version: '1.0.0', type: 'app' } as never); + registry.disablePackage(packageId); + } + const rows = arrangement.rowLock === undefined ? [] : [{ + id: 'row_1', type: 'view', name: arrangement.name, organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify({ + name: arrangement.name, label: 'stored row', object: 'account', _provenance: 'org', _lock: arrangement.rowLock, + }), + }]; + const matching = (where: Record = {}) => + rows.filter((r) => Object.entries(where).every(([k, v]) => v === undefined || (r as Record)[k] === v)); + const engine = { + registry, + async find(table: string, opts?: { where?: Record }) { + return table === 'sys_metadata' ? matching(opts?.where) : []; + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + return table === 'sys_metadata' ? (matching(opts?.where)[0] ?? null) : null; + }, + async insert() { + return {}; + }, + }; + return new ObjectStackProtocolImplementation(engine as never, () => new Map(), 'env_1'); +} + +const ORDERS = [ + { order: 'package B registered first', packages: [B, A] }, + { order: 'package A registered first', packages: [A, B] }, +] as const; + +const REQUESTS = [ + { request: 'naming package A', packageId: A }, + { request: 'naming package B', packageId: B }, + { request: 'naming no package', packageId: undefined }, +] as const; + +const settle = (run: Promise) => run.then(() => null, (e: unknown) => e as any); + +async function readsAndDoor(protocol: ObjectStackProtocolImplementation, name: string, packageId: string | undefined) { + const scope = packageId ? { packageId } : {}; + const byName: any = await protocol.getMetaItem({ type: 'view', name, ...scope }); + const layered: any = await protocol.getMetaItemLayered({ type: 'view', name, ...scope }); + const save = await settle(protocol.saveMetaItem({ + type: 'view', name, item: { name, label: 'edited', object: 'account', viewKind: 'list' }, ...scope, + })); + const remove = await settle(protocol.deleteMetaItem({ type: 'view', name })); + return { + byName: { lock: byName.lock, editable: byName.editable, deletable: byName.deletable }, + layered: { lock: layered.lock, editable: layered.editable, deletable: layered.deletable }, + body: byName.item?._lock, + servedPackage: byName.item?._packageId, + save: save ? { code: save.code, status: save.status, lock: save.lock } : 'admitted', + delete: remove ? { code: remove.code, status: remove.status, lock: remove.lock } : 'admitted', + }; +} + +const LOCKED_FULL = { lock: 'full', editable: false, deletable: false }; +const REFUSED_FULL = { code: 'ITEM_LOCKED', status: 403, lock: 'full' }; + +describe('[#21803] two installed packages ship one view: the real SchemaRegistry, both registration orders', () => { + describe('1. B ships _lock \'full\', A ships no lock', () => { + const arrangement: Arrangement = { name: 'v_art', locks: { [A]: undefined, [B]: 'full' } }; + for (const { order, packages } of ORDERS) { + for (const { request, packageId } of REQUESTS) { + it(`${order} · request ${request}: both reads and the door say 'full'`, async () => { + const got = await readsAndDoor(protocolFor(arrangement, packages), arrangement.name, packageId); + expect(got.byName).toEqual(LOCKED_FULL); + expect(got.layered).toEqual(LOCKED_FULL); + expect(got.body).toBe('full'); + // Content stays prefer-local (ADR-0048). + if (packageId) expect(got.servedPackage).toBe(packageId); + expect(got.save).toEqual(REFUSED_FULL); + expect(got.delete).toEqual(REFUSED_FULL); + }); + } + } + }); + + describe('2. B is disabled: still installed, its lock still binds', () => { + const arrangement: Arrangement = { name: 'v_dis', locks: { [A]: undefined, [B]: 'full' }, disabled: [B] }; + for (const { order, packages } of ORDERS) { + it(`${order} · request naming package A`, async () => { + const protocol = protocolFor(arrangement, packages); + const registry = (protocol as any).engine.registry as SchemaRegistry; + expect(registry.isPackageDisabled(B)).toBe(true); + expect(registry.listItems<{ _packageId?: string }>('view').map((i) => i._packageId)).toEqual([A]); + const got = await readsAndDoor(protocol, arrangement.name, A); + expect(got.byName).toEqual(LOCKED_FULL); + expect(got.save).toEqual(REFUSED_FULL); + expect(got.delete).toEqual(REFUSED_FULL); + }); + } + }); + + describe('3. never a widening: A ships no lock, B ships \'no-delete\', the stored row declares \'no-overlay\'', () => { + const arrangement: Arrangement = { name: 'v_join', locks: { [A]: undefined, [B]: 'no-delete' }, rowLock: 'no-overlay' }; + for (const { order, packages } of ORDERS) { + for (const { request, packageId } of REQUESTS) { + it(`${order} · request ${request}: both verbs refused, and the reads say so`, async () => { + const got = await readsAndDoor(protocolFor(arrangement, packages), arrangement.name, packageId); + expect(got.byName).toEqual(LOCKED_FULL); + expect(got.layered).toEqual(LOCKED_FULL); + expect(got.save).toEqual(REFUSED_FULL); + expect(got.delete).toEqual(REFUSED_FULL); + }); + } + } + }); +}); From aa38fce9aff02afd21fe416ed3ddaa0f25fa2b7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:54:52 +0000 Subject: [PATCH 5/8] chore(changeset): the item lock across the installed packages that ship a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② no (narrowing), minor, with its ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../21803-artifact-lock-package-axis.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/21803-artifact-lock-package-axis.md diff --git a/.changeset/21803-artifact-lock-package-axis.md b/.changeset/21803-artifact-lock-package-axis.md new file mode 100644 index 00000000000..f0dc40338f0 --- /dev/null +++ b/.changeset/21803-artifact-lock-package-axis.md @@ -0,0 +1,23 @@ +--- +"@objectstack/metadata-protocol": minor +--- + +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) + +Clause-②: no (narrowing) + +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. + +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. + +**What moves for consumers.** The door now refuses where it used to depend on registration order: + +- 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; +- 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; +- a disabled package's packaged lock binds under both registration orders, where it bound only when that package was registered first. + +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. + +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. + + From 540769bb7860216033169a0818d5c5c865f7c1b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:25:13 +0000 Subject: [PATCH 6/8] chore(engine-double-contract): pin the real-registry lock test's findOne double Written by `check-engine-double-contract.mjs --write`. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 19c69f678a8..59ec541b327 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2111,6 +2111,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/objectql/src/protocol-lock-artifact-package-axis.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/objectql/src/protocol-meta-effective-schema.test.ts", "verb": "delete", From f38762577cef7abb9d1722b5b1e1daa8e8a19fe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:31:58 +0000 Subject: [PATCH 7/8] perf(metadata-protocol): probe only the packages that can ship the item's name The artifact layer's reader probed every package listed for the type and every installed package for every item, and a probe that misses scans the registry collection, so the list and the diagnostics tile paid items x packages x collection size. The listing is now indexed by name; a package is probed for every name only when the listing cannot attribute it (a disabled package, an entry without a name). Same set, same answers: the H4 census is cell-for-cell unchanged. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 116 +++++++++++++-------- 1 file changed, 73 insertions(+), 43 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4f074052df5..182c66778c7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1720,6 +1720,16 @@ function viewIdentityPatch(overlay: Record, baseline: unknown): return Object.keys(patch).length > 0 ? patch : null; } +/** + * [#21803] The packages that can ship an item of one type, read once from the + * registry's listings: per name, and the ones probed for every name. See + * `ObjectStackProtocolImplementation.shippingPackagesOf`. + */ +type ShippingPackages = { + readonly byName: ReadonlyMap>; + readonly anyName: ReadonlySet; +}; + /** * ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope * onto a returned item so artifact-level lock/packageId/provenance @@ -7949,7 +7959,7 @@ export class ObjectStackProtocolImplementation implements : []; const pkgSet = new Set(); let lockedCount = 0; - let shippingPackageIds: ReadonlySet | undefined; + let shipping: ShippingPackages | undefined; for (const item of items) { scannedItems += 1; const pkg = (item?._packageId ?? null) as string | null; @@ -7965,14 +7975,14 @@ export class ObjectStackProtocolImplementation implements // the address the list's own merge used (ADR-0048 package // scope) — and the document the list serves for it. const itemName = typeof item?.name === 'string' ? item.name : ''; - shippingPackageIds ??= this.artifactPackageIds(t); + shipping ??= this.shippingPackagesOf(t); const itemLock = resolveItemLock({ artifact: this.artifactLockLayerAt({ type: t, name: itemName, organizationId: request.organizationId, packageId: request.packageId ?? (item?._packageId as string | undefined), - }, shippingPackageIds), + }, shipping), overlay: item, }); const served = this.servedLockState(t, itemName, item, this.isArtifactBacked(t, itemName), itemLock); @@ -9241,7 +9251,7 @@ export class ObjectStackProtocolImplementation implements if (list) list.push(row); else lockRowsByName.set(name, [row]); } - let shippingPackageIds: ReadonlySet | undefined; + let shipping: ShippingPackages | undefined; const governed: any[] = []; for (const it of items as any[]) { const itemName = (it as any)?.name; @@ -9260,9 +9270,9 @@ export class ObjectStackProtocolImplementation implements organizationId: orgId, packageId: itemPackageId, }; - shippingPackageIds ??= this.artifactPackageIds(request.type); + shipping ??= this.shippingPackagesOf(request.type); itemLock = resolveItemLock({ - artifact: this.artifactLockLayerAt(address, shippingPackageIds), + artifact: this.artifactLockLayerAt(address, shipping), overlay: await resolveOverlayLockLayer(address, (organizationId, spelling) => (lockRowsByName.get(itemName) ?? []).filter((row) => (row.organization_id ?? null) === organizationId @@ -16485,42 +16495,43 @@ export class ObjectStackProtocolImplementation implements * here: each of them still serves the address's own package's artifact * ({@link lookupArtifactItem} with the request's package). * - * `packageIds` is {@link artifactPackageIds} for the type, computed once - * by a caller that asks about many items of one type. + * `shipping` is {@link shippingPackagesOf} for the type, read once by a + * caller that asks about many items of one type. */ - private artifactLockLayerAt(address: ItemAddress, packageIds?: ReadonlySet): readonly unknown[] { - return resolveArtifactLockLayer(address, () => this.shippedArtifactsOf(address.type, address.name, packageIds)); + private artifactLockLayerAt(address: ItemAddress, shipping?: ShippingPackages): readonly unknown[] { + return resolveArtifactLockLayer(address, () => this.shippedArtifactsOf(address.type, address.name, shipping)); } /** * [#21803] Every artifact an installed code package registered under * `(type, name)`: what the registry's artifact-only lookup - * ({@link lookupArtifactItem}) answers for each package that could ship - * it, kept when it is that package's own (`_packageId` equal to the - * package asked for, the prefer-local hit), plus what it answers with no - * package at all. + * ({@link lookupArtifactItem}) answers for each package that can ship the + * name ({@link shippingPackagesOf}), kept when it is that package's own + * (`_packageId` equal to the package asked for, the prefer-local hit), plus + * what it answers with no package at all. * * The registry has no enumeration of its own for this, so the reader asks - * its existing lookups per package ({@link artifactPackageIds} names - * them). The package-less lookup is always in the set. It is the one the - * `_lock` gate made before #21803 (the first package registered), so the - * resolution over this set never answers looser than the gate did under - * any registration order; with no composite entry at all it is also the - * only way to reach an artifact registered under the plain key. + * its existing lookups per package. The package-less lookup is always in + * the set. It is the one the `_lock` gate made before #21803 (the first + * package registered), so the resolution over this set never answers + * looser than the gate did under any registration order; with no composite + * entry at all it is also the only way to reach an artifact registered + * under the plain key. * * An `object` has one owner (`SchemaRegistry.registerObject` refuses a * second code package's claim on the name, ADR-0029 D3), and its artifact * lookup reads the owner's layer whatever package is asked for, so the * owner is the whole set. */ - private shippedArtifactsOf(type: string, name: string, packageIds?: ReadonlySet): unknown[] { + private shippedArtifactsOf(type: string, name: string, shipping?: ShippingPackages): unknown[] { const shipped: unknown[] = []; const add = (artifact: unknown): void => { if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact); }; add(this.lookupArtifactItem(type, name)); if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped; - for (const packageId of packageIds ?? this.artifactPackageIds(type)) { + const packages = shipping ?? this.shippingPackagesOf(type); + for (const packageId of new Set([...(packages.byName.get(name) ?? []), ...packages.anyName])) { const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined; if (own?._packageId === packageId) add(own); } @@ -16528,12 +16539,19 @@ export class ObjectStackProtocolImplementation implements } /** - * [#21803] Every package that can ship an item of `type`: the package of - * every entry the registry lists for the type (both spellings), and every - * installed package. The listing hides a DISABLED package's entries, and - * the artifact lookup does not (the `_lock` gate bound a disabled - * package's artifact before #21803 when it was registered first), so the - * installed packages are read too: a disabled package is still installed. + * [#21803] The packages that can ship an item of `type`, read off the + * registry's own listings: + * + * - `byName`: per name, the package of every entry the registry lists for + * the type under that name (both spellings); + * - `anyName`: the packages the listing cannot attribute to a name, probed + * for every name. The listing hides a DISABLED package's entries and the + * artifact lookup does not (the `_lock` gate bound a disabled package's + * artifact before #21803 when it was registered first), so every + * installed package the registry reports disabled is here (every + * installed package, when the registry cannot say which are disabled); so + * is the package of a listed entry that carries no `name`. + * * A registry without either listing, or whose listing throws (a * metadata-only host's partial registry: listing there is best-effort * context, never the reason a write fails), contributes nothing here, and @@ -16541,32 +16559,44 @@ export class ObjectStackProtocolImplementation implements * whatever could be listed: never looser than the `_lock` gate before * #21803, which asked that lookup alone. */ - private artifactPackageIds(type: string): Set { + private shippingPackagesOf(type: string): ShippingPackages { const registry = (this.engine as any)?.registry; - const ids = new Set(); - if (!registry) return ids; - const addIds = (read: () => unknown, idOf: (entry: any) => unknown): void => { - let listed: unknown; + const byName = new Map>(); + const anyName = new Set(); + if (!registry) return { byName, anyName }; + const isPackage = (id: unknown): id is string => typeof id === 'string' && id !== '' && id !== 'sys_metadata'; + const listing = (read: () => unknown): readonly unknown[] => { try { - listed = read(); + const listed = read(); + return Array.isArray(listed) ? listed : []; } catch { - return; // See this method's header: the package-less lookup still answers. - } - if (!Array.isArray(listed)) return; - for (const entry of listed) { - const id = idOf(entry); - if (typeof id === 'string' && id !== '' && id !== 'sys_metadata') ids.add(id); + return []; // See this method's header: the package-less lookup still answers. } }; if (typeof registry.listItems === 'function') { for (const spelling of new Set([PLURAL_TO_SINGULAR[type] ?? type, type])) { - addIds(() => registry.listItems(spelling), (entry) => entry?._packageId); + for (const entry of listing(() => registry.listItems(spelling))) { + const { _packageId: id, name } = (entry ?? {}) as { _packageId?: unknown; name?: unknown }; + if (!isPackage(id)) continue; + if (typeof name !== 'string') { + anyName.add(id); + continue; + } + const ids = byName.get(name); + if (ids) ids.add(id); + else byName.set(name, new Set([id])); + } } } if (typeof registry.getAllPackages === 'function') { - addIds(() => registry.getAllPackages(), (record) => record?.manifest?.id ?? record?.id); + const canTell = typeof registry.isPackageDisabled === 'function'; + for (const record of listing(() => registry.getAllPackages())) { + const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined; + const id = r?.manifest?.id ?? r?.id; + if (isPackage(id) && (!canTell || registry.isPackageDisabled(id))) anyName.add(id); + } } - return ids; + return { byName, anyName }; } /** From d1551fde71a93240fd98bc7e7eec6812dd9c9206 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:59:35 +0000 Subject: [PATCH 8/8] test(objectql): the real-registry lock test's find double holds the caller's bound check:objectql-double-limit: the bound is applied after the filter, by presence. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../src/protocol-lock-artifact-package-axis.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts b/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts index 7aac3469569..afd12ad93cc 100644 --- a/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts +++ b/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts @@ -68,8 +68,11 @@ function protocolFor(arrangement: Arrangement, order: readonly string[]) { rows.filter((r) => Object.entries(where).every(([k, v]) => v === undefined || (r as Record)[k] === v)); const engine = { registry, - async find(table: string, opts?: { where?: Record }) { - return table === 'sys_metadata' ? matching(opts?.where) : []; + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, async findOne(table: string, opts?: { where?: Record }) { // `check:engine-double-contract` — refuses what the real engine refuses.