Skip to content

Commit c55d300

Browse files
refactor(metadata-protocol): the two overlay-write closes ask one fix-can-be-taken predicate (#29874)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ897VUcPShHgSHgmfYBAj4Gx)_ Fixes #29397 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:两个覆盖层写入关闭判定(`namedBaseCloseSubject`、`packagelessCloseSubject`)里逐步重复的「修复能否走通」四步(配对 id、ensure 门、基包版本、D2 门放行声明体)收成一个共享判定 `overlayPairFixOf` + `overlayPairFixAdmits`,各自只保留本该不同的那部分。 - 为什么改:两份副本逐步相同,改其中一份时另一份会悄悄漂移;合同评审 S6 要求合并。 - 风险与回滚:纯搬移,拒绝文案、状态码、调用顺序都不变,两个关闭的既有测试原样通过;回滚即撤销本 PR。 - 先看:`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts`(新判定与具名关闭)、`packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts`(无包关闭,行读取仍夹在两半之间)、`packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts`(补的三条控制用例)。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.named-base-overlay-write.test.ts`,预期 31/31 全绿,含三条新的「某一步失败则写入保持开放」控制用例。 - 需要你做的:无,席位落地。 ## Why The contract review of #29270 (record 6577747588, should-fix S6) found that the two overlay-write closes judge "can the fix the refusal names be taken" with the same four steps, written twice. A change to one copy could drift from the other with every pin still green. This PR keeps one copy. ## What changes - **One predicate, two halves** (`protocol-packaged-base-refusal.ts`, the module that owns the override write gate and both refusal sentences): - `overlayPairFixOf(host, base)`: the registry half. The base has a pair id, the ensure door would answer or create the pair (`customizationPairEnsureRefusal`), and the base records a `manifest.version`. It answers `{ base, pair, basedOn }` or `null`. - `overlayPairFixAdmits(host, fix, { type, name, body, member })`: the gate half. The override write gate admits the declared body (`overrides: { package: base, basedOn }`) into the pair. - It has two halves because the package-less close reads the item's stored rows between them, and only for a name the registry has not already left open. Folding the row read in front would change the call order and which failure wins (a store-read failure before a registry `null`). - **`namedBaseCloseSubject`** keeps only its own steps: a named package, a body carrying `overrides` stays the D2 gate's, the named package among the shippers, and the declared body from the save (no row read). - **`packagelessCloseSubject`** keeps only its own steps: exactly one shipper, the item's row read, and `classifyPackagelessRow`. - **Unchanged:** `closedOverlayWriteClauses`, both refusal sentences, `protocol.ts` (no line touched; its two host methods and their eight call sites are as on `main`), and the return shape `{ base, pair }`. The new functions are internal: `src/index.ts` names its exports, and neither is added there. - **Changeset:** `@objectstack/metadata-protocol` patch (see Acceptance notes for why it is not `skip-changeset`). ### Measured first, on `main` at 42ff86f Every step of the two predicates, common or intended-different (line numbers are `main`'s): | Step | `namedBaseCloseSubject` (`protocol-packaged-base-refusal.ts`) | `packagelessCloseSubject` (`protocol-packageless-overlay-migration.ts`) | Verdict | |:---|:---|:---|:---| | Which write | `:394-395` a non-empty package | `:729` no package | intended-different | | `overrides` in the body | `:399` left to the D2 gate | (classified at `:764`, `foreign_declaration`) | intended-different (both docblocks say so) | | Shippers | `:400` the named one among them | `:731-733` exactly one | intended-different (the card) | | Pair id | `:401-403` | `:734-736` | common | | Registry present | `:404-405` | `:737-738` | common | | Ensure door | `:406` | `:739` | common | | Base version | `:407-408` reads `registry.getPackage(base)` | `:740-741` `baseVersionOf` re-reads `engine.registry` | common. Same value: `ObjectQL#registry` is a getter over one never-reassigned field, and `getPackage` was already proved a function at the step above | | Row read and classification | none | `:743-768` | intended-different (the card) | | Member of the declared body | `:411-415` keyed on `isPlainRecord(item)` | `:754-756` keyed on `item !== undefined` | spelled differently, same answer for every JSON body: for a non-record body both read the canonical member. Left in each close, not folded, so the fold stays move-only | | Declared body and gate | `:416-423` | `:770-778` | common | No step differs by accident, so the card's stop condition did not fire. ## Tests - **No existing pin was edited.** `protocol.named-base-overlay-write.test.ts` gained fixtures (three packages and two shipped dashboards that no existing case addresses) and one new top-level `describe`. The package-less close's pins (`packages/objectql/src/protocol-packageless-overlay-migration.test.ts`, `environment-overlay-email-locale-restart.test.ts`, `environment-overlay-five-types.test.ts`) were not touched. - **Step coverage before this PR:** | Step | named close | package-less close | |:---|:---|:---| | pair id | none | `no_pair` case | | ensure door | `a read-only base whose pair cannot be ensured` | `an unensurable pair` cases | | base version | none | none | | gate refuses the declared body | none | the `email_template` unshipped-locale reset | - **Three new pins, all through the named close, all green on `main` before the fold** (commit 0dc436c on 42ff86f), and each shown able to fail: a write naming a read-only base whose id forms no pair, a base that records no version while its pair is installed, and an `email_template` at a locale the base does not ship. Each stays open, as on `main`. One `basedOn` pin covers both closes now, because the step lives once. ## Acceptance notes - **The version step is not separately observable today.** Delete only the `basedOn` check and the no-version pin stays green: the gate then sees `overrides` with no `basedOn`, which `OverrideDeclarationSchema` rejects, so the gate judges the body as an undeclared same name in the pair and refuses it. The pin still pins the outcome: a fallback version (`?? '0.0.0'`) reds it (Verification, ablation 4). The check is kept as it was. - **Changeset, not `skip-changeset`.** No published behaviour changes, but the shipped `dist/` of `@objectstack/metadata-protocol` does (the build carries the new functions). AGENTS.md keeps `skip-changeset` for a diff that publishes nothing, so this is a `patch`. The order allowed `skip-changeset` if no behaviour changed; AGENTS.md wins. - The predicate guard `registry present with a getPackage function` is a type guard reached only by an engine without a registry. It is not one of the card's four steps and is not pinned. That is unchanged. ## Verification <details><summary>Unit tests at ba4aaf1 (the head, main merged in at 3a856f6, after #29797's 5f99582): @objectstack/metadata-protocol typecheck and full suite, and the package-less close's three objectql files against the rebuilt dist</summary> ``` pnpm --workspace-concurrency=2 --filter '@objectstack/metadata-protocol...' build VERDICT command-exit 0 (dist/index.js carries overlayPairFixOf) pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.named-base-overlay-write.test.ts Test Files 1 passed (1) Tests 31 passed (31) (25 of this PR, with #29797's six; the three new controls among them) VERDICT command-exit 0 pnpm --filter @objectstack/metadata-protocol typecheck && pnpm --filter @objectstack/metadata-protocol test Test Files 311 passed | 4 skipped (315) Tests 22320 passed | 26 skipped (22346) VERDICT command-exit 0 pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-packageless-overlay-migration.test.ts src/environment-overlay-email-locale-restart.test.ts src/environment-overlay-five-types.test.ts Test Files 3 passed (3) Tests 105 passed (105) VERDICT command-exit 0 ``` Earlier heads: at e1c8a10 the suite read 22314/22314 and the objectql files 105/105; at 408cc35, 22308/22308 and 105/105. </details> <details><summary>New pins on main first: 25/25 at 0dc436c (the pins on 42ff86f, before the fold)</summary> ``` ✓ CONTROLS: every step the fix takes, failing, leaves the write naming the base open (ADR-0146 D1, D11) > 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 ✓ … > a read-only base that records no version, its pair installed, keeps the write naming it: there is no `basedOn` to declare ✓ … > 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 Test Files 1 passed (1) Tests 25 passed (25) ``` </details> <details><summary>Ablation on main: four mutations of namedBaseCloseSubject through scripts/ablation-replace.mjs, each landed on disk and was restored to the HEAD blob</summary> The file was `protocol-packaged-base-refusal.ts`. The blob was 410bf52 before, and it was 410bf52 after every restore, equal to HEAD. 1. `if (!pairId.ok) return null;` replaced with a no-op: the no-pair-id pin goes red, 1 failed | 24 passed. 2. The `basedOn` check deleted: 25 passed. The step is masked by the gate (see Acceptance notes). 3. The gate's `refuse` return deleted: the unshipped-locale pin goes red, 1 failed | 24 passed. 4. `basedOn` given the fallback `?? '0.0.0'`: the no-version pin goes red, 1 failed | 24 passed. </details> <details><summary>Derived gates at e1c8a10: 59 derived, 58 run green, 1 NOT MEASURED; pnpm lint exit 0. Re-run at ba4aaf1: the ratchet and text families, all exit 0</summary> At ba4aaf1 (the merge brings no change to this PR's four files; `git diff --stat origin/main HEAD` still reads 4 files, +177 / -53), a declared narrowing to the ratchet and text families: `check:type-check-coverage`, `check:type-check-debt`, `check:durability-log-level`, `check:doc-authoring`, `check:closed-ceremony-text`, `check:nul-bytes`, `check:issue-citations`, `check-empty-changeset.mjs --base origin/main`, and eslint (`--no-inline-config`) on the three changed source files: every one exit 0. CI runs the full set on this head. The full union, at e1c8a10: `node scripts/pm/dispatch-gates.mjs --commands` (change set: the four files above, vs merge base on `origin/main`), each run with its exit code captured before any pipe, then `--ran`: ``` Run reconciliation — 59 derived, 58 run, 1 NOT-MEASURED, 0 UNRUN. NOT-MEASURED · DERIVED (1): - node scripts/check-plugin-teardown-shape.mjs --self-test [exit 3 — PREREQUISITE NOT MET] ✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 58 run, 1 NOT-MEASURED pnpm lint :: exit 0 ``` NOT MEASURED, reason: that self-test's fixture is pinned to a commit outside this shallow clone (it asks for `git fetch --unshallow`). CI runs it on a full checkout. Every other derived family exited 0, including `check:durability-log-level`, `check:doc-authoring`, `check:closed-ceremony-text`, `check:type-check-coverage` and `check:type-check-debt`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZz2XXsYuaJz9sPuGU --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZz2XXsYuaJz9sPuGU)_ <!-- signed: domain:server#2 · os-warren · session_01JQwxbZz2XXsYuaJz9sPuGU --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d74667c commit c55d300

4 files changed

Lines changed: 177 additions & 53 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
refactor(metadata-protocol): the two overlay-write closes ask one "the fix can be taken" predicate
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,82 @@ export function namedBaseOverlayWriteRefusal(
339339
return closedOverlayWriteError(message);
340340
}
341341

342+
// ─────────────────────────────────────────────────────────────────────────
343+
// ADR-0146 D1, D11 — "the fix the close names can be taken": one predicate
344+
// ─────────────────────────────────────────────────────────────────────────
345+
346+
/** The pair an overlay-write close sends the write into, and the `basedOn` its declaration records. */
347+
export interface OverlayPairFix {
348+
readonly base: string;
349+
readonly pair: string;
350+
readonly basedOn: string;
351+
}
352+
353+
/**
354+
* [ADR-0146 D1, D11] The registry half of whether the fix both overlay-write
355+
* closes name — write it into `base`'s pair, declaring the override — can be
356+
* taken, or `null` when it cannot: read from the steps that write takes.
357+
*
358+
* - the base has a pair id (`customizationPackageId`; an id ending in
359+
* `.custom` has none);
360+
* - the ensure door would answer or create the pair
361+
* ({@link customizationPairEnsureRefusal});
362+
* - the base records a version (`basedOn`, its `manifest.version`, the value
363+
* the ceremony records too).
364+
*
365+
* The other half is {@link overlayPairFixAdmits}, the override write gate on
366+
* the declared body. Two halves of one predicate so that a close may read its
367+
* own subject between them — the package-less close reads the item's stored
368+
* rows only for a name the registry has not already left open — and ⛔ each
369+
* close keeps only its own difference: which base it judges, and which body.
370+
* {@link namedBaseCloseSubject} and `packagelessCloseSubject`
371+
* (`protocol-packageless-overlay-migration.ts`) ask both. Registry reads only.
372+
*/
373+
export function overlayPairFixOf(
374+
host: { readonly engine: ObjectStackProtocolImplementation['engine'] },
375+
base: string,
376+
): OverlayPairFix | null {
377+
const pairId = customizationPackageId(base);
378+
if (!pairId.ok) return null; // no pair to name (the census's `no_pair`)
379+
const pair = pairId.value;
380+
const registry = (host.engine as { registry?: PairRegistry } | undefined)?.registry;
381+
if (!registry || typeof registry.getPackage !== 'function') return null;
382+
if (customizationPairEnsureRefusal(host.engine, registry, base, pair) !== undefined) return null; // the ensure door would refuse
383+
const basedOn = registry.getPackage(base)?.manifest?.version;
384+
if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare
385+
return { base, pair, basedOn };
386+
}
387+
388+
/**
389+
* [ADR-0146 D1, D2, D11] The gate half of {@link overlayPairFixOf}: whether
390+
* the override write gate admits the body the fix writes into the pair — the
391+
* close's `body` (a save's, or the stored row's), else the item's bare
392+
* identity at `member`, declaring `overrides: { package: base, basedOn }`. An
393+
* `email_template` member at a locale the base does not ship is refused
394+
* there, so its write stays open.
395+
*/
396+
export function overlayPairFixAdmits(
397+
host: { readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict },
398+
fix: OverlayPairFix,
399+
subject: {
400+
/** The singular type. */
401+
readonly type: string;
402+
readonly name: string;
403+
readonly body: Record<string, unknown> | undefined;
404+
/** The bundle member, as `OVERLAY_MEMBER_COLUMN` holds it. */
405+
readonly member: string | null | undefined;
406+
},
407+
): boolean {
408+
const { type, name, body, member } = subject;
409+
const disc = member === undefined ? undefined : overlayMemberField(type);
410+
const declaredBody = {
411+
...(body ?? { name, ...(disc && member !== null ? { [disc]: member } : {}) }),
412+
overrides: { package: fix.base, basedOn: fix.basedOn },
413+
};
414+
const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody });
415+
return verdict.verdict !== 'refuse';
416+
}
417+
342418
/** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */
343419
export interface NamedBaseCloseHost {
344420
readonly engine: ObjectStackProtocolImplementation['engine'];
@@ -367,16 +443,15 @@ export interface NamedBaseWriteSubject {
367443
* overlay types — the package-less close's reading of "packaged"
368444
* (`overlayBaseShippers`, the reading `os migrate packageless-overlays`
369445
* classifies by), with the named package among the shippers — and where the
370-
* fix the refusal names can be taken, read from the steps that write takes:
446+
* fix the refusal names can be taken: {@link overlayPairFixOf} and
447+
* {@link overlayPairFixAdmits}, the predicate the package-less close asks too
448+
* (a pair id, the ensure door, a base version, the override write gate
449+
* admitting the declared body — an `email_template` member at a locale the
450+
* base does not ship stays open, as the package-less one does).
371451
*
372-
* - the base has a pair id (`customizationPackageId`);
373-
* - the ensure door would answer or create the pair
374-
* ({@link customizationPairEnsureRefusal});
375-
* - the base records a version (`basedOn`, its `manifest.version`, the value
376-
* the ceremony records too);
377-
* - the override write gate admits the declared body into the pair (an
378-
* `email_template` member at a locale the base does not ship is refused
379-
* there, so its write stays open, as the package-less one does).
452+
* This close's own difference: it reads no row (the declared body is the
453+
* save's, else the item's identity), and the named package may be one of
454+
* several shippers.
380455
*
381456
* A body carrying `overrides` is not judged here: the D2 gate answers a
382457
* declaration outside the pair, the schema gate a malformed one, as before.
@@ -398,29 +473,17 @@ export function namedBaseCloseSubject(
398473
// a malformed one (`422 INVALID_METADATA`).
399474
if (isPlainRecord(request.item) && 'overrides' in request.item) return null;
400475
if (!this.overlayBaseShippers(request.type, request.name).includes(base)) return null;
401-
const pairId = customizationPackageId(base);
402-
if (!pairId.ok) return null; // no pair to name
403-
const pair = pairId.value;
404-
const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry;
405-
if (!registry || typeof registry.getPackage !== 'function') return null;
406-
if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null;
407-
const basedOn = registry.getPackage(base)?.manifest?.version;
408-
if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare
476+
const fix = overlayPairFixOf(this, base);
477+
if (fix === null) return null;
409478

410479
// The gate's verdict on the declared body, written into the pair.
411480
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
412481
const written = isPlainRecord(request.item) ? request.item : undefined;
413482
const member = written !== undefined && request.member === undefined
414483
? overlayMemberOfBody(type, written)
415484
: overlayMemberValue(type, request.member);
416-
const disc = member === undefined ? undefined : overlayMemberField(type);
417-
const declaredBody = {
418-
...(written ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }),
419-
overrides: { package: base, basedOn },
420-
};
421-
const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody });
422-
if (verdict.verdict === 'refuse') return null;
423-
return { base, pair };
485+
if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body: written, member })) return null;
486+
return { base, pair: fix.pair };
424487
}
425488

426489
// ─────────────────────────────────────────────────────────────────────────

‎packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts‎

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,15 @@ import {
131131
} from '@objectstack/spec/kernel';
132132
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
133133
import { storedRowDocument } from './item-lock.js';
134-
import { OVERLAY_MEMBER_COLUMN, overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
134+
import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
135135
import type { ObjectStackProtocolImplementation } from './protocol.js';
136-
import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
137-
import { overrideWriteMemberOf, type OverrideWriteSubject, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
136+
import {
137+
overlayPairFixAdmits,
138+
overlayPairFixOf,
139+
overrideWriteMemberOf,
140+
type OverrideWriteSubject,
141+
type OverrideWriteVerdict,
142+
} from './protocol-packaged-base-refusal.js';
138143
import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js';
139144
import { isServedOverrideRowOf } from './served-override.js';
140145
import { SysMetadataRepository } from './sys-metadata-repository.js';
@@ -710,14 +715,15 @@ export interface PackagelessWriteSubject {
710715
* read-only package ships). `shadowed` applies to a stored row only: a
711716
* first write has nothing in the pair to be shadowed by;
712717
* - its move must be able to succeed, read from the steps the ceremony
713-
* takes: the ensure door would ensure the pair
714-
* ({@link customizationPairEnsureRefusal}), the base records a version
715-
* (`basedOn`), and the override write gate admits the declared body into
716-
* the pair. An `email_template` member at a locale the base does not ship
717-
* is refused there (#28777) and stays open: the ceremony moves such a row
718-
* as the pair's own member, declaring nothing (#28904), and the close
719-
* reaches only the declared-override moves, so its package-less write is
720-
* the one it had before the close.
718+
* takes: `overlayPairFixOf` and `overlayPairFixAdmits`
719+
* (`protocol-packaged-base-refusal.ts`), the predicate the close of the
720+
* write naming the base asks too — the ensure door would ensure the pair,
721+
* the base records a version (`basedOn`), and the override write gate
722+
* admits the declared body into the pair. An `email_template` member at a
723+
* locale the base does not ship is refused there (#28777) and stays open:
724+
* the ceremony moves such a row as the pair's own member, declaring
725+
* nothing (#28904), and the close reaches only the declared-override
726+
* moves, so its package-less write is the one it had before the close.
721727
*
722728
* Registry reads first; the one store read (the item's rows) only for a name
723729
* the registry has not already left open.
@@ -731,14 +737,9 @@ export async function packagelessCloseSubject(
731737
const shippers = this.overlayBaseShippers(request.type, request.name);
732738
if (shippers.length !== 1) return null; // environment metadata, or `multi_shipped`
733739
const base = shippers[0]!;
734-
const pairId = customizationPackageId(base);
735-
if (!pairId.ok) return null; // `no_pair`
736-
const pair = pairId.value;
737-
const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry;
738-
if (!registry || typeof registry.getPackage !== 'function') return null;
739-
if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; // step 1 fails
740-
const basedOn = baseVersionOf(this, base);
741-
if (basedOn === undefined) return null; // no `basedOn` to record
740+
// `no_pair`, a pair step 1 cannot ensure, or no `basedOn` to record.
741+
const fix = overlayPairFixOf(this, base);
742+
if (fix === null) return null;
742743

743744
// The item's stored rows, both spellings, every package: the census's read.
744745
const spellings = [...new Set([type, SINGULAR_TO_PLURAL[type] ?? type])];
@@ -768,12 +769,6 @@ export async function packagelessCloseSubject(
768769
if (classified.disposition !== 'movable') return null;
769770

770771
// Step 2's gate: the declared body, into the pair.
771-
const disc = member === undefined ? undefined : overlayMemberField(type);
772-
const declaredBody = {
773-
...(body ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }),
774-
overrides: { package: base, basedOn },
775-
};
776-
const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody });
777-
if (verdict.verdict === 'refuse') return null;
778-
return { base, pair };
772+
if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body, member })) return null;
773+
return { base, pair: fix.pair };
779774
}

‎packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,20 @@ const BASE_VERSION = '3.2.0';
5252
const NO_PAIR_BASE = 'com.legacy.app';
5353
/** A writable runtime package: no manifest booted it. */
5454
const WRITABLE = 'com.acme.workspace';
55+
/** A read-only code package whose id forms no pair: an id ending in `.custom` (ADR-0146 D3). */
56+
const NO_PAIR_ID_BASE = 'com.vendor.custom';
57+
/** A read-only code package that records no version, with its pair already installed. */
58+
const NO_VERSION_BASE = 'com.nover.app';
59+
const NO_VERSION_PAIR = 'com.nover.app.custom';
5560

5661
const MANIFESTS: Record<string, Record<string, unknown>> = {
5762
[BASE]: { dependencies: {}, version: BASE_VERSION, namespace: 'acme' },
5863
[PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0', namespace: 'acme_custom' },
5964
[NO_PAIR_BASE]: { dependencies: {}, version: '1.0.0' },
6065
[WRITABLE]: { dependencies: {} },
66+
[NO_PAIR_ID_BASE]: { dependencies: {}, version: '1.0.0', namespace: 'vendor' },
67+
[NO_VERSION_BASE]: { dependencies: {}, namespace: 'nover' },
68+
[NO_VERSION_PAIR]: { dependencies: { [NO_VERSION_BASE]: '*' }, version: '0.1.0', namespace: 'nover_custom' },
6169
};
6270

6371
/** One item of each overlay type the base ships, and a body a write carries. */
@@ -78,6 +86,8 @@ const VIEW = ITEMS[0]!;
7886
const SHIPPED = [
7987
...ITEMS.map((i) => ({ type: i.type, name: i.name, pkg: BASE })),
8088
{ type: 'dashboard', name: 'legacy_board', pkg: NO_PAIR_BASE },
89+
{ type: 'dashboard', name: 'vendor_board', pkg: NO_PAIR_ID_BASE },
90+
{ type: 'dashboard', name: 'nover_board', pkg: NO_VERSION_BASE },
8191
];
8292
const PACKAGED = 'Packaged';
8393

@@ -109,7 +119,7 @@ function makeProtocol() {
109119
const packageRecord = (id: string) => (MANIFESTS[id] ? { manifest: { id, ...MANIFESTS[id] } } : undefined);
110120
const engine: any = {
111121
// A booted code package is read-only (`isWritablePackage` reads `manifests` first).
112-
manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}]]),
122+
manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}], [NO_PAIR_ID_BASE, {}], [NO_VERSION_BASE, {}]]),
113123
async find(t: string, q?: { where?: Record<string, unknown>; limit?: number }) {
114124
const rows = table(t).filter((r) => matches(r, q?.where));
115125
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
@@ -377,6 +387,52 @@ describe('CONTROLS: the writes the close leaves open, as on main', () => {
377387
* the commit CREATED is reverted by removing the row, which writes nothing and
378388
* stays open.
379389
*/
390+
/**
391+
* Each step of "the fix the refusal names can be taken" (the pair id, the
392+
* ensure door, the base's version, the override write gate admitting the
393+
* declared body) leaves the write naming the base open where it fails: the
394+
* refusal would name a door the author cannot take. The ensure door's step is
395+
* the CONTROL above (`a read-only base whose pair cannot be ensured`); a
396+
* shipped member's closed save is the five-type case at the top.
397+
*/
398+
describe('CONTROLS: every step the fix takes, failing, leaves the write naming the base open (ADR-0146 D1, D11)', () => {
399+
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 () => {
400+
const { protocol, rows } = makeProtocol();
401+
402+
const saved = await protocol.saveMetaItem({
403+
type: 'dashboard', name: 'vendor_board', item: { name: 'vendor_board', label: 'Vendor', widgets: [] }, packageId: NO_PAIR_ID_BASE,
404+
});
405+
406+
expect(saved).toMatchObject({ success: true });
407+
expect(rows('dashboard', 'vendor_board')).toEqual([{ package_id: NO_PAIR_ID_BASE, state: 'active', label: 'Vendor' }]);
408+
});
409+
410+
it('a read-only base that records no version, its pair installed, keeps the write naming it: there is no `basedOn` to declare', async () => {
411+
// The ensure door answers an installed pair without reading the base's
412+
// version, so this is the version step's own case.
413+
const { protocol, rows } = makeProtocol();
414+
415+
const saved = await protocol.saveMetaItem({
416+
type: 'dashboard', name: 'nover_board', item: { name: 'nover_board', label: 'Nover', widgets: [] }, packageId: NO_VERSION_BASE,
417+
});
418+
419+
expect(saved).toMatchObject({ success: true });
420+
expect(rows('dashboard', 'nover_board')).toEqual([{ package_id: NO_VERSION_BASE, state: 'active', label: 'Nover' }]);
421+
});
422+
423+
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 () => {
424+
const { protocol, rows } = makeProtocol();
425+
const email = ITEMS.find((i) => i.type === 'email_template')!;
426+
427+
const saved = await protocol.saveMetaItem({
428+
type: email.type, name: email.name, item: { ...email.body('Bienvenue'), locale: 'fr-FR' }, packageId: BASE,
429+
});
430+
431+
expect(saved).toMatchObject({ success: true });
432+
expect(rows(email.type, email.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Bienvenue' }]);
433+
});
434+
});
435+
380436
describe('the commit doors: restoring a row bound to the base is closed, removing one stays open (#29390)', () => {
381437
/** Each commit a second apart, so the package rollback's "strictly newer" reads an order. */
382438
let clock = Date.parse('2026-11-02T00:00:00.000Z');

0 commit comments

Comments
 (0)