@@ -39,6 +39,8 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
3939import { isOriginGatedType , managedItemSealedSentence , regimeCDeclaredSaveSentence } from './packaged-base-regime.js' ;
4040import { SysMetadataRepository } from './sys-metadata-repository.js' ;
4141import { 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' ;
4244import {
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