Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/29397-overlay-pair-fix-one-predicate.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 88 additions & 25 deletions packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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'];
Expand Down Expand Up @@ -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.
Expand All @@ -398,29 +473,17 @@ 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;
const written = isPlainRecord(request.item) ? request.item : undefined;
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 };
}

// ─────────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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])];
Expand Down Expand Up @@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>> = {
[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. */
Expand All @@ -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';

Expand Down Expand Up @@ -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<string, unknown>; limit?: number }) {
const rows = table(t).filter((r) => matches(r, q?.where));
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
Expand Down Expand Up @@ -377,6 +387,52 @@ 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 () => {
// 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({
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');
Expand Down
Loading