Skip to content

Commit 8948d3c

Browse files
committed
wip: close reads the ceremony's shippers; no_pair stays open; re-judge two door pins
Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b083959 commit 8948d3c

7 files changed

Lines changed: 91 additions & 51 deletions

‎packages/metadata-protocol/src/protocol-environment-rows.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,24 @@ export function shippingPackageIds(shipped: readonly unknown[]): ReadonlySet<str
239239
return shippers;
240240
}
241241

242+
/**
243+
* [ADR-0146 D11] The READ-ONLY code packages that ship a name, sorted: the
244+
* {@link shippingPackageIds} of `shipped` less every package
245+
* `isWritablePackage` answers for — a writable package's item is edited in
246+
* place (ADR-0070) and has no pair (ADR-0146 D3), and the pair itself is
247+
* writable. ONE reading for both halves of D11: the bases
248+
* `os migrate packageless-overlays` classifies a package-less row against,
249+
* and the bases whose package-less write the doors close
250+
* (`packagelessOverlayWriteRefusal`), so the close never reaches a name the
251+
* ceremony would call environment metadata, nor misses one it would move.
252+
*/
253+
export function readOnlyShippingPackages(
254+
shipped: readonly unknown[],
255+
isWritablePackage: (packageId: string) => boolean,
256+
): string[] {
257+
return [...shippingPackageIds(shipped)].filter((id) => !isWritablePackage(id)).sort();
258+
}
259+
242260
/**
243261
* [#28409, ADR-0146 D8 items 1–2] The one code package that ships a name, or
244262
* `undefined` when none or several do: the case in which

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

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spe
5757
*/
5858
export interface PackagedBaseRefusalHost {
5959
isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem'];
60-
isOverlayableManagedItem: ObjectStackProtocolImplementation['isOverlayableManagedItem'];
60+
overlayBaseShippers: ObjectStackProtocolImplementation['overlayBaseShippers'];
6161
isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage'];
6262
packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal'];
6363
packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner'];
@@ -220,13 +220,12 @@ export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollbac
220220
*
221221
* [ADR-0146 D11] The closure of the package-less write onto a packaged item:
222222
* a write naming no package (`packageId` absent, `null` or empty) onto an item
223-
* a code package ships, of a type whose registry entry opens an overlay
224-
* channel ({@link PackagedBaseRefusalHost.isOverlayableManagedItem}: the five
225-
* `allowEnvironmentOverlay` types), is refused `403 NOT_OVERRIDABLE` — the
226-
* code and status the sealed write already answers, so no door gains a code.
227-
* Every other type's packaged item is sealed already
228-
* ({@link refusePackagedBaseOverride}), so after this no package-less write
229-
* reaches a packaged item at all.
223+
* of one of the five overlay types that a read-only code package ships
224+
* ({@link PackagedBaseRefusalHost.overlayBaseShippers}, the reading
225+
* `os migrate packageless-overlays` classifies by), is refused
226+
* `403 NOT_OVERRIDABLE` — the code and status the sealed write already
227+
* answers, so no door gains a code. Every other type's packaged item is
228+
* sealed already ({@link refusePackagedBaseOverride}).
230229
*
231230
* The sentence names the fix the writer has: the base's paired `.custom`
232231
* (`?package=<base>.custom`), where the customization is a declared override,
@@ -235,32 +234,45 @@ export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollbac
235234
* moved yet is still served (the read path is not this function's); only its
236235
* package-less write is refused, by this same sentence.
237236
*
238-
* `null` when the write names a package, or the item is no packaged item of an
239-
* overlay type: a name no package ships is environment metadata and keeps its
240-
* package-less write (ADR-0146 D11, "is not touched").
237+
* `null` when the write names a package; when no read-only code package ships
238+
* the name (environment metadata keeps its package-less write, ADR-0146 D11
239+
* "is not touched"); and when no base that ships it has a pair at all
240+
* (`customizationPackageId` refuses every one of their ids): the ceremony
241+
* lists such a row `no_pair` and cannot move it, and D11 closes only what it
242+
* has migrated — closing it would leave that customization no write door.
241243
*/
242244
export function packagelessOverlayWriteRefusal(
243-
this: Pick<PackagedBaseRefusalHost, 'isOverlayableManagedItem' | 'packagedArtifactOwner'>,
245+
this: Pick<PackagedBaseRefusalHost, 'overlayBaseShippers'>,
244246
request: { type: string; name: string; packageId?: string | null },
245247
operation: PackagelessWriteOperation,
246248
): Error | null {
247249
if (typeof request.packageId === 'string' && request.packageId.length > 0) return null;
248-
if (!this.isOverlayableManagedItem(request.type, request.name)) return null;
250+
const shippers = this.overlayBaseShippers(request.type, request.name);
251+
const pairs = shippers.flatMap((base) => {
252+
const derived = customizationPackageId(base);
253+
return derived.ok ? [{ base, pair: derived.value }] : [];
254+
});
255+
if (pairs.length === 0) return null;
249256
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
250257
const item = `'${type}/${request.name}'`;
251-
const owner = this.packagedArtifactOwner(request);
252-
const pairId = typeof owner === 'string' ? customizationPackageId(owner) : undefined;
253-
const pair = pairId?.ok ? `'${pairId.value}'` : "the base's paired .custom package";
254-
const query = pairId?.ok ? ` (?package=${pairId.value})` : '';
255-
const shipped = typeof owner === 'string' ? `ships in '${owner}'` : 'ships in an installed package';
256-
const declared = typeof owner === 'string' ? `overrides: { package: '${owner}', basedOn }` : 'overrides: { package, basedOn }';
257-
const ceremony = 'A package-less customization stored before this release is still served; run '
258-
+ `\`os migrate packageless-overlays\` first to move it into ${pair}.`;
258+
const shipped = `ships in ${shippers.map((id) => `'${id}'`).join(' and ')}`;
259+
const sole = pairs.length === 1 ? pairs[0]! : undefined;
260+
// One base: its pair, by name. Several: the pair of the base the writer
261+
// customizes, each named — never guessed (the ceremony lists such a row).
262+
const into = sole
263+
? `'${sole.pair}' (?package=${sole.pair})`
264+
: `the pair of the package it customizes (${pairs.map((p) => `'${p.pair}'`).join(' or ')}, as ?package=<that pair>)`;
265+
const declared = sole ? `overrides: { package: '${sole.base}', basedOn }` : 'overrides: { package: <that base>, basedOn }';
266+
const ceremony = sole
267+
? 'A package-less customization stored before this release is still served; run '
268+
+ `\`os migrate packageless-overlays\` first to move it into '${sole.pair}'.`
269+
: 'A package-less customization stored before this release is still served; run '
270+
+ '`os migrate packageless-overlays` first: it lists a name several packages ship for you to place.';
259271
const fix = operation === 'delete'
260-
? `Remove it from ${pair}${query}. ${ceremony}`
272+
? `Remove it from ${into}. ${ceremony}`
261273
: operation === 'rollback'
262-
? `Its customization belongs in ${pair}: run \`os migrate packageless-overlays\` to move the row there, then roll it back.`
263-
: `${operation === 'publish' ? 'Publish' : 'Save'} it into ${pair}${query}, declaring ${declared}. ${ceremony}`;
274+
? `Its customization belongs in ${into}: run \`os migrate packageless-overlays\` to move the row there, then roll it back.`
275+
: `${operation === 'publish' ? 'Publish' : 'Save'} it into ${into}, declaring ${declared}. ${ceremony}`;
264276
const what = operation === 'rollback' ? 'a rollback of its package-less row' : 'a write naming no package';
265277
const err = new Error(
266278
`${item} ${shipped}, and ${what} onto a packaged item is closed: a customization of an installed package `

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared
9090
import { storedRowDocument } from './item-lock.js';
9191
import { OVERLAY_MEMBER_COLUMN } from './migrations/overlay-index.js';
9292
import type { ObjectStackProtocolImplementation } from './protocol.js';
93-
import { shippingPackageIds } from './protocol-environment-rows.js';
93+
import { readOnlyShippingPackages } from './protocol-environment-rows.js';
9494
import { isServedOverrideRowOf } from './served-override.js';
9595
import { SysMetadataRepository } from './sys-metadata-repository.js';
9696

@@ -298,9 +298,7 @@ export async function listPackagelessOverlays(
298298
// The read-only code packages that ship the name: a writable package's
299299
// item is edited in place (ADR-0070) and has no pair (ADR-0146 D3), and
300300
// the pair itself is writable — the write gate's own narrowing.
301-
const shippers = [...shippingPackageIds(this.shippedArtifactsOf(type, name))]
302-
.filter((id) => !this.isWritablePackage(id))
303-
.sort();
301+
const shippers = readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id));
304302
if (shippers.length === 0) {
305303
environmentRows += 1;
306304
continue;

‎packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,16 @@ describe('[#22220] controls: the gates still judge what the door does not refuse
285285
}
286286
});
287287

288-
it('a packaged item of a type that allows overlays (view) with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => {
288+
it('[#28576, ADR-0146 D11] a packaged item of a type that allows overlays (view), saved naming no package, with a body the spec parse refuses → 403 NOT_OVERRIDABLE (both kernels)', async () => {
289+
// Was a control (422 INVALID_METADATA): the overlay type's package-less
290+
// write reached the gates. That write onto a packaged item is closed
291+
// now, so the door answers first, whatever the body.
289292
for (const [kernel, environmentId] of KERNELS) {
290293
const result = await probe(environmentId, {
291294
type: 'view', name: 'pkg_view', item: specRefused({ name: 'pkg_view', label: 'Pkg' }),
292295
});
293-
expect(result.envelope, kernel).toEqual(INVALID_METADATA);
296+
expect(result.envelope, kernel).toEqual(NOT_OVERRIDABLE);
297+
expect(result.gateReached, kernel).toBe(false);
294298
expect(result.persistedRows, kernel).toBe(0);
295299
}
296300
});

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -669,10 +669,13 @@ describe('the override write gate — only the base\'s paired .custom declares a
669669
expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: UNRELATED, state: 'active' }]);
670670
});
671671

672-
it('control: a package-less overlay row is not this gate\'s question (the migration card closes it)', async () => {
672+
it('[#28576, ADR-0146 D11] a package-less save is not this gate\'s question: the close refuses it first, naming the pair, and nothing is stored', async () => {
673673
const { protocol, stored } = gateProtocol();
674-
await protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') });
675-
expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: null, state: 'active' }]);
674+
const e = await thrown(protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') }));
675+
expect(shape(e)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
676+
expect(String((e as Error).message)).toContain(`'${PAIR}' (?package=${PAIR})`);
677+
expect(String((e as Error).message)).toContain('os migrate packageless-overlays');
678+
expect(stored()).toEqual([]);
676679
});
677680

678681
// ── the admit ────────────────────────────────────────────────────────────

‎packages/metadata-protocol/src/protocol.packageless-save-address.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ describe('an item a CODE package ships: the package-less write is closed (ADR-01
432432
const name = 'showcase_task_grid';
433433
const { protocol, rows } = makeProtocol([packagedView(name)]);
434434

435-
const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }).then(() => undefined, (e) => e);
435+
const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }).then(() => undefined, (e: unknown) => e);
436436

437437
closed(refused);
438438
expect(rows('view', name)).toEqual([]);
@@ -450,7 +450,7 @@ describe('an item a CODE package ships: the package-less write is closed (ADR-01
450450
// A save naming the package names a package: not the write D11 closes.
451451
await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Named'), packageId: CODE_PACKAGE });
452452

453-
const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e) => e);
453+
const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e: unknown) => e);
454454

455455
closed(refused);
456456
expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([CODE_PACKAGE]);

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js';
469469
// [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it.
470470
import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js';
471471
// [#25467 cut 35] The environment's own `sys_metadata` rows: the family read, the one-row read, the served row and the package-less address.
472-
import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, soleShippingPackage } from './protocol-environment-rows.js';
472+
import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, readOnlyShippingPackages, soleShippingPackage } from './protocol-environment-rows.js';
473473
// [#25467 cut 35] The stored rows of a collection, folded over the registered ones.
474474
import { foldStoredCollection } from './protocol-stored-collection-fold.js';
475475
// [#25467 cut 35] The environment-wide package uninstall verb.
@@ -16217,17 +16217,21 @@ export class ObjectStackProtocolImplementation implements
1621716217
}
1621816218

1621916219
/**
16220-
* [ADR-0146 D11] The other half of {@link isSealedManagedItem}: an item a
16221-
* managed package ships ({@link isArtifactBacked}) of a type whose registry
16222-
* entry opens an overlay channel ({@link registryAllowsOverlay}: the five
16223-
* `allowEnvironmentOverlay` types). The flag no longer opens a write onto
16224-
* such an item that names no package
16225-
* ({@link packagelessOverlayWriteRefusal}); it stays the predicate the
16226-
* migration reads and the read of a row not migrated yet honours.
16220+
* [ADR-0146 D11] The bases of an item of one of the five overlay types
16221+
* ({@link registryAllowsOverlay}: the `allowEnvironmentOverlay` types): the
16222+
* READ-ONLY code packages that ship the name ({@link shippedArtifactsOf}
16223+
* less {@link isWritablePackage}), sorted — `[]` for every other type, and
16224+
* for a name no read-only package ships. The reading
16225+
* `os migrate packageless-overlays` classifies a package-less row by
16226+
* (`readOnlyShippingPackages`), so the close
16227+
* ({@link packagelessOverlayWriteRefusal}) and the ceremony agree on what
16228+
* is a packaged item. The flag no longer opens a package-less write onto
16229+
* one; it stays the predicate the migration reads and the read of a row not
16230+
* migrated yet honours.
1622716231
*/
16228-
private isOverlayableManagedItem(type: string, name: string): boolean {
16229-
return this.isArtifactBacked(type, name)
16230-
&& ObjectStackProtocolImplementation.registryAllowsOverlay(type);
16232+
private overlayBaseShippers(type: string, name: string): string[] {
16233+
if (!ObjectStackProtocolImplementation.registryAllowsOverlay(type)) return [];
16234+
return readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id));
1623116235
}
1623216236

1623316237
/**
@@ -17077,11 +17081,13 @@ export class ObjectStackProtocolImplementation implements
1707717081
/**
1707817082
* [ADR-0146 D11] The package-less write onto a packaged item, closed: the
1707917083
* refusal of a save, a publish, a delete (either state) or a rollback that
17080-
* names no package, of an item a managed package ships, on a type whose
17081-
* registry entry opens an overlay channel ({@link isOverlayableManagedItem}).
17084+
* names no package, of an item of one of the five overlay types a
17085+
* read-only code package ships ({@link overlayBaseShippers}).
1708217086
* `null` when the write is not refused on that ground: it names a package
1708317087
* (`?package=<base>.custom` stays open, as does every other package's own
17084-
* door), or no package ships the name (environment metadata).
17088+
* door), no read-only package ships the name (environment metadata), or no
17089+
* base that ships it can form a pair (the ceremony's `no_pair`: it cannot
17090+
* move that row, so D11 does not close its write).
1708517091
*
1708617092
* Every other type's packaged item is sealed already
1708717093
* ({@link refusePackagedBaseOverride}, {@link refusePackagedBaseRemoval}),
@@ -17108,8 +17114,7 @@ export class ObjectStackProtocolImplementation implements
1710817114
operation: PackagelessWriteOperation,
1710917115
): Error | null {
1711017116
const host: ThisParameterType<typeof packagelessOverlayWriteRefusal> = {
17111-
isOverlayableManagedItem: (...a) => this.isOverlayableManagedItem(...a),
17112-
packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a),
17117+
overlayBaseShippers: (...a) => this.overlayBaseShippers(...a),
1711317118
};
1711417119
return packagelessOverlayWriteRefusal.call(host, request, operation);
1711517120
}

0 commit comments

Comments
 (0)