Skip to content

Commit c12ff96

Browse files
committed
fix(metadata-protocol): a write naming a read-only base itself is closed onto its pair
An authoring save (draft or active), a publish (per-item and the package batch) or a rollback that names the read-only code package itself (`?package=<base>`) onto an item of one of the five overlay types it ships landed a row bound to the base with no declaration: served at the base's address, outside the pair ADR-0146 D1 puts every customization in, and not seen by `os migrate packageless-overlays`. Each is now refused `403 NOT_OVERRIDABLE` with the package-less close's fix (the pair, its `?package=` address, the declaration), one spelling of it, under the 500-character bound. Closed only where that fix can be taken; a delete naming the base stays open (the one way to clear a row stored before). Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14208f2 commit c12ff96

5 files changed

Lines changed: 676 additions & 17 deletions

File tree

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

Lines changed: 170 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
3939
import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js';
4040
import { SysMetadataRepository } from './sys-metadata-repository.js';
4141
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
42+
import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
43+
import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
4244
import {
4345
customizationBasePackageId,
4446
customizationPackageId,
@@ -104,10 +106,14 @@ export function refusePackagedBaseOverride(
104106
// • Below the registry limb — this whole branch is guarded
105107
// by {@link isSealedManagedItem}, whose type half is the
106108
// registry's `allowEnvironmentOverlay` alone, so a regime-O type
107-
// never reaches the door. That is ADR-0005: an overlay of
108-
// a code-shipped item ALWAYS names the read-only package
109-
// it customizes, and a door one limb higher would close
110-
// the overlay model outright. Pinned.
109+
// never reaches the door. That was ADR-0005: an overlay of
110+
// a code-shipped item ALWAYS named the read-only package
111+
// it customizes, and a door one limb higher would have closed
112+
// the overlay model outright. Pinned. [#28905, ADR-0146 D1]
113+
// Such an overlay now lands in the base's paired `.custom`:
114+
// an authoring write naming the read-only base of one of the
115+
// five types is refused by its own close
116+
// ({@link namedBaseCloseSubject}), never by this door.
111117
// • [ADR-0131 D6] No hatch limb. `OS_METADATA_WRITABLE` used
112118
// to take an artifact-backed write past this branch, down
113119
// to the repository door, where a package-less write landed
@@ -240,11 +246,7 @@ export function packagelessOverlayWriteRefusal(
240246
closed: { base: string; pair: string },
241247
operation: PackagelessWriteOperation,
242248
): Error {
243-
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
244-
const item = `'${type}/${request.name}'`;
245-
const shipped = `ships in '${closed.base}'`;
246-
const pair = `'${closed.pair}' (?package=${closed.pair})`;
247-
const declared = `overrides: { package: '${closed.base}', basedOn }`;
249+
const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed);
248250
const rule = 'A write naming no package is closed where `os migrate packageless-overlays` can move its row '
249251
+ `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`;
250252
const message = operation === 'rollback'
@@ -253,13 +255,171 @@ export function packagelessOverlayWriteRefusal(
253255
+ `the move, save that version's body there, declaring ${declared}.`
254256
: operation === 'delete'
255257
? `${item} ${shipped}: remove its customization from ${pair}. ${rule}`
256-
: `${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}. ${rule}`;
258+
: `${writeInto(operation)} ${rule}`;
259+
return closedOverlayWriteError(message);
260+
}
261+
262+
/**
263+
* The clauses both closes of an overlay write onto a packaged item spell
264+
* ({@link packagelessOverlayWriteRefusal}, {@link namedBaseOverlayWriteRefusal}):
265+
* the item, the base that ships it, the pair (with its `?package=` address),
266+
* the declaration, and the fix — write it into the pair, declaring the
267+
* override. ONE spelling: a writer refused at either address reads the same
268+
* fix, and only the rule clause that follows it says which address is closed.
269+
*/
270+
function closedOverlayWriteClauses(
271+
request: { type: string; name: string },
272+
closed: { base: string; pair: string },
273+
) {
274+
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
275+
const item = `'${type}/${request.name}'`;
276+
const shipped = `ships in '${closed.base}'`;
277+
const pair = `'${closed.pair}' (?package=${closed.pair})`;
278+
const declared = `overrides: { package: '${closed.base}', basedOn }`;
279+
const writeInto = (operation: 'save' | 'publish') =>
280+
`${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}.`;
281+
return { item, shipped, pair, declared, writeInto };
282+
}
283+
284+
/** `403 NOT_OVERRIDABLE`, the code and status the sealed write already answers, so neither close gains a code. */
285+
function closedOverlayWriteError(message: string): Error {
257286
const err = new Error(message);
258287
(err as any).code = 'NOT_OVERRIDABLE';
259288
(err as any).status = 403;
260289
return err;
261290
}
262291

292+
// ─────────────────────────────────────────────────────────────────────────
293+
// ADR-0146 D1 / D11 — the write naming the read-only base itself is closed
294+
// ─────────────────────────────────────────────────────────────────────────
295+
296+
/**
297+
* The writes {@link namedBaseOverlayWriteRefusal} judges: the three that land
298+
* a row bound to the base. ⛔ Not `delete`: removing a row bound to the base
299+
* writes nothing, and it is the one way to clear such a row stored before the
300+
* close — `os migrate packageless-overlays` reads package-less rows only, so
301+
* it never moves one. The removal serves the base's own document again, or
302+
* the pair's declared override where one exists (it is served over the row).
303+
*/
304+
export type NamedBaseWriteOperation = Exclude<PackagelessWriteOperation, 'delete'>;
305+
306+
/**
307+
* The refusal `ObjectStackProtocolImplementation#namedBaseOverlayWriteRefusal`
308+
* throws; the method's docblock there is its contract.
309+
*
310+
* [#28905, ADR-0146 D1, D11] An authoring write that names the read-only code
311+
* package itself (`?package=<base>`) onto an item of one of the five overlay
312+
* types that package ships stored the customization as a row bound to the
313+
* base, with no declaration: served at the base's address in place of its
314+
* document, outside the pair D1 puts every customization in, and not seen by
315+
* `os migrate packageless-overlays`. It is refused with the fix the
316+
* package-less close names ({@link closedOverlayWriteClauses}): write it into
317+
* the pair, declaring the override. A row stored that way before the close is
318+
* still served until the pair declares the override (the declared override is
319+
* served over it) or a delete naming the base removes it — both open.
320+
*
321+
* Fix first, under the REST door's 500-character bound (`CLIENT_MESSAGE_MAX`,
322+
* `@objectstack/types`), as the package-less sentence.
323+
*/
324+
export function namedBaseOverlayWriteRefusal(
325+
request: { type: string; name: string },
326+
closed: { base: string; pair: string },
327+
operation: NamedBaseWriteOperation,
328+
): Error {
329+
const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed);
330+
const served = 'is served until the pair declares the override, or a delete naming the base removes it.';
331+
const message = operation === 'rollback'
332+
? `${item} ${shipped}, and a rollback of its row bound to the read-only base is closed (ADR-0146 D1, D11): `
333+
+ `save the version's body into ${pair}, declaring ${declared}. The row ${served}`
334+
: `${writeInto(operation)} The read-only base itself takes no customization (ADR-0146 D1, D11): `
335+
+ `a row bound to it from before ${served}`;
336+
return closedOverlayWriteError(message);
337+
}
338+
339+
/** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */
340+
export interface NamedBaseCloseHost {
341+
readonly engine: ObjectStackProtocolImplementation['engine'];
342+
/** The read-only code packages that ship a name of an overlay type ([] for any other type). */
343+
readonly overlayBaseShippers: (type: string, name: string) => string[];
344+
/** The override write gate, as the declared write into the pair meets it at the save door. */
345+
readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict;
346+
}
347+
348+
/** The write naming a package that {@link namedBaseCloseSubject} judges. */
349+
export interface NamedBaseWriteSubject {
350+
readonly type: string;
351+
readonly name: string;
352+
/** The package the write names; only a non-empty one is judged here (none is #28576's close). */
353+
readonly packageId?: string | null;
354+
/** The bundle member as the caller names it; absent, the body's member, else the canonical one. */
355+
readonly member?: string;
356+
/** A save's body. */
357+
readonly item?: unknown;
358+
}
359+
360+
/**
361+
* [#28905, ADR-0146 D1, D11] The base and pair a write naming a package is
362+
* closed onto, or `null` when it stays open. Closed exactly where the write
363+
* names a READ-ONLY code package that ships the item, on one of the five
364+
* overlay types — the package-less close's reading of "packaged"
365+
* (`overlayBaseShippers`, the reading `os migrate packageless-overlays`
366+
* classifies by), with the named package among the shippers — and where the
367+
* fix the refusal names can be taken, read from the steps that write takes:
368+
*
369+
* - the base has a pair id (`customizationPackageId`);
370+
* - the ensure door would answer or create the pair
371+
* ({@link customizationPairEnsureRefusal});
372+
* - the base records a version (`basedOn`, its `manifest.version`, the value
373+
* the ceremony records too);
374+
* - the override write gate admits the declared body into the pair (an
375+
* `email_template` member at a locale the base does not ship is refused
376+
* there, so its write stays open, as the package-less one does).
377+
*
378+
* A body carrying `overrides` is not judged here: the D2 gate answers a
379+
* declaration outside the pair, the schema gate a malformed one, as before.
380+
*
381+
* So every write this closes has a door, the pair; every other one keeps the
382+
* write it has on `main`. A writable package, a package that does not ship the
383+
* name (ADR-0070 D1's `WRITABLE_PACKAGE_REQUIRED` answers a new item in a
384+
* read-only one) and every other type are not this close's. Registry reads
385+
* only: it reads no row.
386+
*/
387+
export function namedBaseCloseSubject(
388+
this: NamedBaseCloseHost,
389+
request: NamedBaseWriteSubject,
390+
): { base: string; pair: string } | null {
391+
const base = typeof request.packageId === 'string' && request.packageId.length > 0 ? request.packageId : undefined;
392+
if (base === undefined) return null; // the package-less write: #28576's close
393+
// A body carrying `overrides` keeps its answer: the D2 gate refuses a
394+
// declaration outside the pair (`declared_outside_pair`), the schema gate
395+
// a malformed one (`422 INVALID_METADATA`).
396+
if (isPlainRecord(request.item) && 'overrides' in request.item) return null;
397+
if (!this.overlayBaseShippers(request.type, request.name).includes(base)) return null;
398+
const pairId = customizationPackageId(base);
399+
if (!pairId.ok) return null; // no pair to name
400+
const pair = pairId.value;
401+
const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry;
402+
if (!registry || typeof registry.getPackage !== 'function') return null;
403+
if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null;
404+
const basedOn = registry.getPackage(base)?.manifest?.version;
405+
if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare
406+
407+
// The gate's verdict on the declared body, written into the pair.
408+
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
409+
const written = isPlainRecord(request.item) ? request.item : undefined;
410+
const member = written !== undefined && request.member === undefined
411+
? overlayMemberOfBody(type, written)
412+
: overlayMemberValue(type, request.member);
413+
const disc = member === undefined ? undefined : overlayMemberField(type);
414+
const declaredBody = {
415+
...(written ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }),
416+
overrides: { package: base, basedOn },
417+
};
418+
const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody });
419+
if (verdict.verdict === 'refuse') return null;
420+
return { base, pair };
421+
}
422+
263423
// ─────────────────────────────────────────────────────────────────────────
264424
// ADR-0146 D2 — the override write gate (ruling A on decision card #28390)
265425
// ─────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)