From 0dc436c3b0ffed0cb4485883f9c4633b75774a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:21:17 +0000 Subject: [PATCH 1/3] test(metadata-protocol): pin each fix-can-be-taken step of the named-base close where it was untested Claude-Session: https://claude.ai/code/session_01JQwxbZz2XXsYuaJz9sPuGU Co-authored-by: Claude --- .../protocol.named-base-overlay-write.test.ts | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts index aa17477e781..82bd4534f76 100644 --- a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts +++ b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts @@ -52,12 +52,20 @@ const BASE_VERSION = '3.2.0'; const NO_PAIR_BASE = 'com.legacy.app'; /** A writable runtime package: no manifest booted it. */ const WRITABLE = 'com.acme.workspace'; +/** A read-only code package whose id forms no pair: an id ending in `.custom` (ADR-0146 D3). */ +const NO_PAIR_ID_BASE = 'com.vendor.custom'; +/** A read-only code package that records no version, with its pair already installed. */ +const NO_VERSION_BASE = 'com.nover.app'; +const NO_VERSION_PAIR = 'com.nover.app.custom'; const MANIFESTS: Record> = { [BASE]: { dependencies: {}, version: BASE_VERSION, namespace: 'acme' }, [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0', namespace: 'acme_custom' }, [NO_PAIR_BASE]: { dependencies: {}, version: '1.0.0' }, [WRITABLE]: { dependencies: {} }, + [NO_PAIR_ID_BASE]: { dependencies: {}, version: '1.0.0', namespace: 'vendor' }, + [NO_VERSION_BASE]: { dependencies: {}, namespace: 'nover' }, + [NO_VERSION_PAIR]: { dependencies: { [NO_VERSION_BASE]: '*' }, version: '0.1.0', namespace: 'nover_custom' }, }; /** One item of each overlay type the base ships, and a body a write carries. */ @@ -78,6 +86,8 @@ const VIEW = ITEMS[0]!; const SHIPPED = [ ...ITEMS.map((i) => ({ type: i.type, name: i.name, pkg: BASE })), { type: 'dashboard', name: 'legacy_board', pkg: NO_PAIR_BASE }, + { type: 'dashboard', name: 'vendor_board', pkg: NO_PAIR_ID_BASE }, + { type: 'dashboard', name: 'nover_board', pkg: NO_VERSION_BASE }, ]; const PACKAGED = 'Packaged'; @@ -109,7 +119,7 @@ function makeProtocol() { const packageRecord = (id: string) => (MANIFESTS[id] ? { manifest: { id, ...MANIFESTS[id] } } : undefined); const engine: any = { // A booted code package is read-only (`isWritablePackage` reads `manifests` first). - manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}]]), + manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}], [NO_PAIR_ID_BASE, {}], [NO_VERSION_BASE, {}]]), async find(t: string, q?: { where?: Record; limit?: number }) { const rows = table(t).filter((r) => matches(r, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; @@ -377,6 +387,50 @@ describe('CONTROLS: the writes the close leaves open, as on main', () => { * the commit CREATED is reverted by removing the row, which writes nothing and * stays open. */ +/** + * Each step of "the fix the refusal names can be taken" (the pair id, the + * ensure door, the base's version, the override write gate admitting the + * declared body) leaves the write naming the base open where it fails: the + * refusal would name a door the author cannot take. The ensure door's step is + * the CONTROL above (`a read-only base whose pair cannot be ensured`); a + * shipped member's closed save is the five-type case at the top. + */ +describe('CONTROLS: every step the fix takes, failing, leaves the write naming the base open (ADR-0146 D1, D11)', () => { + it('a read-only base whose id forms no pair (an id ending in `.custom`) keeps the write naming it: there is no pair to name', async () => { + const { protocol, rows } = makeProtocol(); + + const saved = await protocol.saveMetaItem({ + type: 'dashboard', name: 'vendor_board', item: { name: 'vendor_board', label: 'Vendor', widgets: [] }, packageId: NO_PAIR_ID_BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows('dashboard', 'vendor_board')).toEqual([{ package_id: NO_PAIR_ID_BASE, state: 'active', label: 'Vendor' }]); + }); + + it('a read-only base that records no version, its pair installed, keeps the write naming it: there is no `basedOn` to declare', async () => { + const { protocol, rows } = makeProtocol(); + + const saved = await protocol.saveMetaItem({ + type: 'dashboard', name: 'nover_board', item: { name: 'nover_board', label: 'Nover', widgets: [] }, packageId: NO_VERSION_BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows('dashboard', 'nover_board')).toEqual([{ package_id: NO_VERSION_BASE, state: 'active', label: 'Nover' }]); + }); + + it('an email template at a locale the base does not ship keeps the write naming the base: the override write gate refuses its declared body', async () => { + const { protocol, rows } = makeProtocol(); + const email = ITEMS.find((i) => i.type === 'email_template')!; + + const saved = await protocol.saveMetaItem({ + type: email.type, name: email.name, item: { ...email.body('Bienvenue'), locale: 'fr-FR' }, packageId: BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows(email.type, email.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Bienvenue' }]); + }); +}); + describe('the commit doors: restoring a row bound to the base is closed, removing one stays open (#29390)', () => { /** Each commit a second apart, so the package rollback's "strictly newer" reads an order. */ let clock = Date.parse('2026-11-02T00:00:00.000Z'); From 408cc35c554d848817d79dff0c2049678bb21014 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:28:01 +0000 Subject: [PATCH 2/3] refactor(metadata-protocol): fold the two overlay-write closes' fix-can-be-taken steps into one predicate namedBaseCloseSubject and packagelessCloseSubject repeated the same four steps (the pair id, the ensure door, the base's version, the override write gate on the declared body). overlayPairFixOf / overlayPairFixAdmits now hold them once in protocol-packaged-base-refusal.ts; each close keeps only its own difference (which base, which body, the package-less close's row read between the two halves). Move-only: no refusal and no call order changes. Claude-Session: https://claude.ai/code/session_01JQwxbZz2XXsYuaJz9sPuGU Co-authored-by: Claude --- .../src/protocol-packaged-base-refusal.ts | 113 ++++++++++++++---- .../protocol-packageless-overlay-migration.ts | 49 ++++---- .../protocol.named-base-overlay-write.test.ts | 2 + 3 files changed, 112 insertions(+), 52 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 410bf525f92..5956fab0741 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -339,6 +339,82 @@ export function namedBaseOverlayWriteRefusal( return closedOverlayWriteError(message); } +// ───────────────────────────────────────────────────────────────────────── +// ADR-0146 D1, D11 — "the fix the close names can be taken": one predicate +// ───────────────────────────────────────────────────────────────────────── + +/** The pair an overlay-write close sends the write into, and the `basedOn` its declaration records. */ +export interface OverlayPairFix { + readonly base: string; + readonly pair: string; + readonly basedOn: string; +} + +/** + * [ADR-0146 D1, D11] The registry half of whether the fix both overlay-write + * closes name — write it into `base`'s pair, declaring the override — can be + * taken, or `null` when it cannot: read from the steps that write takes. + * + * - the base has a pair id (`customizationPackageId`; an id ending in + * `.custom` has none); + * - the ensure door would answer or create the pair + * ({@link customizationPairEnsureRefusal}); + * - the base records a version (`basedOn`, its `manifest.version`, the value + * the ceremony records too). + * + * The other half is {@link overlayPairFixAdmits}, the override write gate on + * the declared body. Two halves of one predicate so that a close may read its + * own subject between them — the package-less close reads the item's stored + * rows only for a name the registry has not already left open — and ⛔ each + * close keeps only its own difference: which base it judges, and which body. + * {@link namedBaseCloseSubject} and `packagelessCloseSubject` + * (`protocol-packageless-overlay-migration.ts`) ask both. Registry reads only. + */ +export function overlayPairFixOf( + host: { readonly engine: ObjectStackProtocolImplementation['engine'] }, + base: string, +): OverlayPairFix | null { + const pairId = customizationPackageId(base); + if (!pairId.ok) return null; // no pair to name (the census's `no_pair`) + const pair = pairId.value; + const registry = (host.engine as { registry?: PairRegistry } | undefined)?.registry; + if (!registry || typeof registry.getPackage !== 'function') return null; + if (customizationPairEnsureRefusal(host.engine, registry, base, pair) !== undefined) return null; // the ensure door would refuse + const basedOn = registry.getPackage(base)?.manifest?.version; + if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare + return { base, pair, basedOn }; +} + +/** + * [ADR-0146 D1, D2, D11] The gate half of {@link overlayPairFixOf}: whether + * the override write gate admits the body the fix writes into the pair — the + * close's `body` (a save's, or the stored row's), else the item's bare + * identity at `member`, declaring `overrides: { package: base, basedOn }`. An + * `email_template` member at a locale the base does not ship is refused + * there, so its write stays open. + */ +export function overlayPairFixAdmits( + host: { readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict }, + fix: OverlayPairFix, + subject: { + /** The singular type. */ + readonly type: string; + readonly name: string; + readonly body: Record | undefined; + /** The bundle member, as `OVERLAY_MEMBER_COLUMN` holds it. */ + readonly member: string | null | undefined; + }, +): boolean { + const { type, name, body, member } = subject; + const disc = member === undefined ? undefined : overlayMemberField(type); + const declaredBody = { + ...(body ?? { name, ...(disc && member !== null ? { [disc]: member } : {}) }), + overrides: { package: fix.base, basedOn: fix.basedOn }, + }; + const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody }); + return verdict.verdict !== 'refuse'; +} + /** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */ export interface NamedBaseCloseHost { readonly engine: ObjectStackProtocolImplementation['engine']; @@ -367,16 +443,15 @@ export interface NamedBaseWriteSubject { * overlay types — the package-less close's reading of "packaged" * (`overlayBaseShippers`, the reading `os migrate packageless-overlays` * classifies by), with the named package among the shippers — and where the - * fix the refusal names can be taken, read from the steps that write takes: + * fix the refusal names can be taken: {@link overlayPairFixOf} and + * {@link overlayPairFixAdmits}, the predicate the package-less close asks too + * (a pair id, the ensure door, a base version, the override write gate + * admitting the declared body — an `email_template` member at a locale the + * base does not ship stays open, as the package-less one does). * - * - the base has a pair id (`customizationPackageId`); - * - the ensure door would answer or create the pair - * ({@link customizationPairEnsureRefusal}); - * - the base records a version (`basedOn`, its `manifest.version`, the value - * the ceremony records too); - * - the override write gate admits the declared body into the pair (an - * `email_template` member at a locale the base does not ship is refused - * there, so its write stays open, as the package-less one does). + * This close's own difference: it reads no row (the declared body is the + * save's, else the item's identity), and the named package may be one of + * several shippers. * * A body carrying `overrides` is not judged here: the D2 gate answers a * declaration outside the pair, the schema gate a malformed one, as before. @@ -398,14 +473,8 @@ export function namedBaseCloseSubject( // a malformed one (`422 INVALID_METADATA`). if (isPlainRecord(request.item) && 'overrides' in request.item) return null; if (!this.overlayBaseShippers(request.type, request.name).includes(base)) return null; - const pairId = customizationPackageId(base); - if (!pairId.ok) return null; // no pair to name - const pair = pairId.value; - const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry; - if (!registry || typeof registry.getPackage !== 'function') return null; - if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; - const basedOn = registry.getPackage(base)?.manifest?.version; - if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare + const fix = overlayPairFixOf(this, base); + if (fix === null) return null; // The gate's verdict on the declared body, written into the pair. const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; @@ -413,14 +482,8 @@ export function namedBaseCloseSubject( const member = written !== undefined && request.member === undefined ? overlayMemberOfBody(type, written) : overlayMemberValue(type, request.member); - const disc = member === undefined ? undefined : overlayMemberField(type); - const declaredBody = { - ...(written ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }), - overrides: { package: base, basedOn }, - }; - const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody }); - if (verdict.verdict === 'refuse') return null; - return { base, pair }; + if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body: written, member })) return null; + return { base, pair: fix.pair }; } // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index c95c95c7e6c..6cf4c7f8b28 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -131,10 +131,15 @@ import { } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; -import { OVERLAY_MEMBER_COLUMN, overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; +import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; -import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js'; -import { overrideWriteMemberOf, type OverrideWriteSubject, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; +import { + overlayPairFixAdmits, + overlayPairFixOf, + overrideWriteMemberOf, + type OverrideWriteSubject, + type OverrideWriteVerdict, +} from './protocol-packaged-base-refusal.js'; import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; @@ -710,14 +715,15 @@ export interface PackagelessWriteSubject { * read-only package ships). `shadowed` applies to a stored row only: a * first write has nothing in the pair to be shadowed by; * - its move must be able to succeed, read from the steps the ceremony - * takes: the ensure door would ensure the pair - * ({@link customizationPairEnsureRefusal}), the base records a version - * (`basedOn`), and the override write gate admits the declared body into - * the pair. An `email_template` member at a locale the base does not ship - * is refused there (#28777) and stays open: the ceremony moves such a row - * as the pair's own member, declaring nothing (#28904), and the close - * reaches only the declared-override moves, so its package-less write is - * the one it had before the close. + * takes: `overlayPairFixOf` and `overlayPairFixAdmits` + * (`protocol-packaged-base-refusal.ts`), the predicate the close of the + * write naming the base asks too — the ensure door would ensure the pair, + * the base records a version (`basedOn`), and the override write gate + * admits the declared body into the pair. An `email_template` member at a + * locale the base does not ship is refused there (#28777) and stays open: + * the ceremony moves such a row as the pair's own member, declaring + * nothing (#28904), and the close reaches only the declared-override + * moves, so its package-less write is the one it had before the close. * * Registry reads first; the one store read (the item's rows) only for a name * the registry has not already left open. @@ -731,14 +737,9 @@ export async function packagelessCloseSubject( const shippers = this.overlayBaseShippers(request.type, request.name); if (shippers.length !== 1) return null; // environment metadata, or `multi_shipped` const base = shippers[0]!; - const pairId = customizationPackageId(base); - if (!pairId.ok) return null; // `no_pair` - const pair = pairId.value; - const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry; - if (!registry || typeof registry.getPackage !== 'function') return null; - if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; // step 1 fails - const basedOn = baseVersionOf(this, base); - if (basedOn === undefined) return null; // no `basedOn` to record + // `no_pair`, a pair step 1 cannot ensure, or no `basedOn` to record. + const fix = overlayPairFixOf(this, base); + if (fix === null) return null; // The item's stored rows, both spellings, every package: the census's read. const spellings = [...new Set([type, SINGULAR_TO_PLURAL[type] ?? type])]; @@ -768,12 +769,6 @@ export async function packagelessCloseSubject( if (classified.disposition !== 'movable') return null; // Step 2's gate: the declared body, into the pair. - const disc = member === undefined ? undefined : overlayMemberField(type); - const declaredBody = { - ...(body ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }), - overrides: { package: base, basedOn }, - }; - const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody }); - if (verdict.verdict === 'refuse') return null; - return { base, pair }; + if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body, member })) return null; + return { base, pair: fix.pair }; } diff --git a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts index 82bd4534f76..40f1dca6e6c 100644 --- a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts +++ b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts @@ -408,6 +408,8 @@ describe('CONTROLS: every step the fix takes, failing, leaves the write naming t }); it('a read-only base that records no version, its pair installed, keeps the write naming it: there is no `basedOn` to declare', async () => { + // The ensure door answers an installed pair without reading the base's + // version, so this is the version step's own case. const { protocol, rows } = makeProtocol(); const saved = await protocol.saveMetaItem({ From f5eb71d9c176d93a6506815ad9458f223b052e0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:35:19 +0000 Subject: [PATCH 3/3] chore(changeset): patch for the overlay-write closes' one fix predicate Claude-Session: https://claude.ai/code/session_01JQwxbZz2XXsYuaJz9sPuGU Co-authored-by: Claude --- .changeset/29397-overlay-pair-fix-one-predicate.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/29397-overlay-pair-fix-one-predicate.md diff --git a/.changeset/29397-overlay-pair-fix-one-predicate.md b/.changeset/29397-overlay-pair-fix-one-predicate.md new file mode 100644 index 00000000000..be6b3182a46 --- /dev/null +++ b/.changeset/29397-overlay-pair-fix-one-predicate.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): the two overlay-write closes ask one "the fix can be taken" predicate + +Clause-②: no +Metadata-protocol: none + +The close of a write naming the read-only base itself and the close of the package-less write each judged, step for step, whether the fix their refusal names can be taken: the base has a pair id, the ensure door would answer or create the pair, the base records a version, and the override write gate admits the declared body into the pair. Those four steps now live once (`overlayPairFixOf` and `overlayPairFixAdmits`, internal to the package), and each close keeps only its own difference: the named close reads no row and accepts the named package among several shippers; the package-less close requires exactly one shipper and classifies the stored row. No refusal, status, code or sentence changes, and no write that was open or closed before changes side.