diff --git a/.changeset/28576-packageless-overlay-write-closed.md b/.changeset/28576-packageless-overlay-write-closed.md
new file mode 100644
index 00000000000..d795b3d7ca1
--- /dev/null
+++ b/.changeset/28576-packageless-overlay-write-closed.md
@@ -0,0 +1,52 @@
+---
+'@objectstack/metadata-protocol': minor
+'@objectstack/spec': patch
+'@objectstack/skills': patch
+'@object-ui/app-shell': patch
+---
+
+fix(metadata-protocol)!: a write naming no package onto an installed package's view, dashboard, report, translation or email template is refused where `os migrate packageless-overlays` can move its row, naming the base's `.custom` pair and the command
+
+Clause-②: yes (narrowing) — the metadata save, publish, delete and rollback doors refuse a write that names no package onto an item of the five overlay types that one installed read-only package ships, where the ceremony would classify the row (or the first write's body) `movable` and its move can succeed; they accepted it before as a package-unbound Setup row. Every row the ceremony lists and does not move keeps that write. One write is widened: a rollback of the `.custom` pair's own declared row of an item its base ships (#28644 item 1) is judged as the pair's declared override, where the store sealed it as an artifact override before, on every type the pair can override (a page or flow pair row included).
+
+Metadata-protocol: narrowed (the `allowEnvironmentOverlay` flag of `view`, `dashboard`, `report`, `translation` and `email_template` no longer opens a package-less write onto a packaged item the ceremony can move; it stays the predicate the migration ceremony, the read of an unmigrated row and every row the ceremony leaves use)
+
+Skills-docs: updated here — `skills/objectstack-platform/SKILL.md`'s ADR-0146 paragraph said "a package-unbound Setup row still wins over the base's until D11"; it now says such a row from before D11 wins over the base's until `os migrate packageless-overlays` moves it, and that its package-less save, reset and rollback are refused once the command can move it. To stay under the bundle's token ceiling, the same paragraph drops "the base's is unchanged" after the export sentence (which "replaces the base's document whole" already implies) and the aside that the other overridable types wait for their doors (the sentence already names the six types the read honours today).
+
+
+
+**BREAKING** write narrowing at the metadata doors (`@objectstack/metadata-protocol`), shipped as `minor` during the launch window.
+
+**Upgrade, then run the ceremony.** The command ships in this release. On each deployment with Setup customizations of a package's items, upgrade, run `os migrate packageless-overlays` (preview) with the flags and environment the deployment boots with (`--preset` and `--dev` mean what they mean to `os serve`), then `os migrate packageless-overlays --apply`. Nothing breaks at the upgrade: between the upgrade and `--apply`, every customization is served as before, and the package-less save, reset, publish and rollback of one the command can move are refused, naming the command. Done when the preview lists no movable row.
+
+The preview lists five dispositions; only `movable` rows are moved, and every other row keeps its package-less write:
+
+- `movable`: `--apply` moves it into the pair. A move that ends `failed` keeps the row as it was; fix what the error names and re-run. When the error names the pair (it cannot be ensured, or it refuses the row) or the base's missing version, the row also keeps its package-less write and may be left.
+- `multi_shipped` (several packages ship the name): delete it naming no package, then save it into the pair of the package it customizes if it is still wanted (the pair's override is then served at that base's address, `?package=`; a read naming no package is not pinned to it while several packages ship the name).
+- `no_pair` (the base's id forms no pair): nothing to do.
+- `foreign_declaration` (its `overrides` names another package): remove the key with a save naming no package, then re-run: the move declares the right override. (A package-less save that keeps a corrected `overrides` is refused by the override write gate.)
+- `shadowed` (the pair already holds the item; the pair's row is served when it declares the override, otherwise this row still is): nothing to do to keep the pair's declared override; or restore the package original (`DELETE …?package=.custom`) and re-run to move this one.
+
+### Migration: FROM → TO
+
+FROM, a customization of the installed package `com.acme.crm`'s view written with no package:
+
+```http
+PUT /api/v1/meta/view/account_list
+{ "name": "account_list", "label": "My accounts" }
+```
+
+TO, the same customization written into the base's paired `.custom`, declaring what it overrides:
+
+```http
+PUT /api/v1/meta/view/account_list?package=com.acme.crm.custom
+{ "name": "account_list", "label": "My accounts", "overrides": { "package": "com.acme.crm", "basedOn": "1.4.0" } }
+```
+
+To restore the package original, FROM `DELETE /api/v1/meta/view/account_list` TO `DELETE /api/v1/meta/view/account_list?package=com.acme.crm.custom`. `basedOn` is the base's installed `manifest.version`. The pair is created by `os migrate packageless-overlays --apply`, or by Studio's Customize.
+
+- **Now refused.** A save (draft or publish mode), a publish, a delete of the active item, or a rollback whose restored row is package-less, with no `?package=` (or an empty one), of a `view`, `dashboard`, `report`, `translation` or `email_template` that exactly one installed read-only package ships, where the item's stored package-less row in that state (or, with none, the saved body) is one the ceremony classifies `movable`, the pair can be ensured (the base has a namespace and a SemVer version), and the override write gate admits that row into the pair. This covers an unmigrated `movable` row, and the first package-less write of an item the pair already holds (a moved one). Each answers `403 NOT_OVERRIDABLE`, writes nothing, and its message names the item, the shipping package, the pair (`?package=.custom`), the `overrides` declaration and `os migrate packageless-overlays`; a rollback's message says to save a version from before the move into the pair, declaring it. The metadata item page shows that message as sent when its URL holds no `?package=`. The refusal comes before every gate that reads a stored row's lock, so it is the same with or without `overrides` in the body.
+- **Still open.** Every write naming `?package=.custom` (the pair's save, publish, delete and rollback, judged by the override write gate as before); a rollback of the pair's own declared row, which is judged as the pair's override; a write of a name no installed package ships (environment metadata); and every package-less write the ceremony would not move, as before this release: a name several read-only packages ship (`multi_shipped`); a base whose id forms no pair (`no_pair`); a base whose pair cannot be ensured (no namespace, or a version that is not SemVer); a stored package-less row whose `overrides` names another package (`foreign_declaration`) or beside the pair's row of the item (`shadowed`; its package-less writes are addressed to the pair's row as before, so its package-less delete restores the package original); and an `email_template` locale the base does not ship (the pair refuses it, #28777; the ceremony's move of it is #28904). Discarding a draft stays open.
+- **Unchanged.** A package-less row stored before this release is still served exactly as before, by every read. The ceremony still moves it: it writes into the pair, never package-less.
+- **Studio.** The read-only item notice no longer links to the package-less Setup overlay (`@object-ui/app-shell`).
+- **Removed with it.** #28413's interim allowance, by which a package-less save that landed on a moved row kept that row's `overrides` block, is gone: such a save is refused like any other.
diff --git a/packages/cli/src/commands/migrate/packageless-overlays.ts b/packages/cli/src/commands/migrate/packageless-overlays.ts
index 485dea35dcb..8e227be0d8e 100644
--- a/packages/cli/src/commands/migrate/packageless-overlays.ts
+++ b/packages/cli/src/commands/migrate/packageless-overlays.ts
@@ -58,17 +58,23 @@ function reasonOf(row: CensusRow): string {
switch (row.disposition) {
case 'multi_shipped':
return `several packages ship this name (${row.shippers.join(', ')}), so it overlays none of them for certain. `
- + 'Left as it is: move it by hand into the pair of the package it customizes, or delete it.';
+ + 'Left as it is: delete it (a delete naming no package removes it while no package holds a row of '
+ + 'this name), then, to keep the customization, save it into the pair of the package it customizes '
+ + '(served then at that package\'s address, ?package=).';
case 'no_pair':
return `'${row.base}' has no paired .custom package id, so there is nowhere to move it. Left as it is.`;
case 'foreign_declaration':
return `its body already carries an \`overrides\` key that does not name '${row.base}'. Left as it is: `
- + 'correct or remove the key, then re-run.';
+ + 'remove the key with a save naming no package, then re-run: the move declares the right override.';
case 'shadowed':
- return `'${row.pair}' already ${row.shadowDeclares ? 'overrides this item (a customization made in Studio)' : 'holds a row of this name'}, `
- + 'and that row is the one served — this row is never merged into it. Two ways out: keep the Studio '
- + `customization and delete this package-less row (id ${row.id}), or use "Restore package original" in `
- + `Studio to delete '${row.pair}''s row and re-run this command to move this one.`;
+ return (row.shadowDeclares
+ ? `'${row.pair}' already overrides this item (a customization made in Studio), and that row is the one `
+ + 'served — this row is never merged into it. Left as it is: to keep the Studio customization, nothing '
+ + 'is owed (this row is not served while that one stands); to make this row the customization instead, '
+ : `'${row.pair}' already holds a row of this name that declares no override, so this row is still the one `
+ + 'served, and the ceremony does not move it beside that row. Left as it is: to have it moved, ')
+ + `use "Restore package original" in Studio to delete '${row.pair}''s row, then re-run this command to `
+ + 'move this one.';
default:
return '';
}
diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts
index c2b66fee61f..26b5c0a4607 100644
--- a/packages/metadata-protocol/src/migrations/overlay-index.ts
+++ b/packages/metadata-protocol/src/migrations/overlay-index.ts
@@ -501,6 +501,11 @@ function overlayDiscriminatorOf(type: string): { field: string; canonical: strin
return ITEM_KEY_DISCRIMINATORS[PLURAL_TO_SINGULAR[type] ?? type];
}
+/** The body field that names `type`'s bundle member (`email_template`: `locale`), or `undefined` for a type with none. */
+export function overlayMemberField(type: string): string | undefined {
+ return overlayDiscriminatorOf(type)?.field;
+}
+
/**
* The value {@link OVERLAY_MEMBER_COLUMN} holds for the member a caller names.
*
diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts
index 5032552e303..7dd1df75227 100644
--- a/packages/metadata-protocol/src/protocol-customization-package.ts
+++ b/packages/metadata-protocol/src/protocol-customization-package.ts
@@ -128,7 +128,7 @@ export interface CustomizationPackageHost {
}
/** The registry slice the door and the predicate read. */
-interface PairRegistry {
+export interface PairRegistry {
getPackage(id: string): PairPackageRow | undefined;
getNamespaceOwners?(namespace: string): string[];
}
@@ -289,34 +289,41 @@ export async function ensureCustomizationPackage(
}
}
-/** One judgement of the registry, and the create when the pair is absent. */
-async function ensurePairOnce(
- this: CustomizationPackageHost,
+/**
+ * [ADR-0146 D11, #28576] Why the ensure door would refuse to ensure `baseId`'s
+ * pair on this registry now, or `undefined` when it would answer the existing
+ * pair or create it. ONE reading of the door's registry-decidable refusals:
+ * {@link ensurePairOnce} throws the error this answers, and the close of the
+ * package-less write (`packagelessOverlayWriteRefusal`) leaves a name's
+ * package-less write open where it answers one — `os migrate
+ * packageless-overlays` could not move such a row (its step 1 fails). Pure
+ * over the registry; it writes nothing.
+ */
+export function customizationPairEnsureRefusal(
+ engine: ObjectStackProtocolImplementation['engine'],
registry: PairRegistry,
baseId: string,
pairId: string,
-): Promise {
+): Error | undefined {
const base = registry.getPackage(baseId);
if (!base) {
- throw refusal(404, `Package '${baseId}' not found: a paired customization package is ensured for an installed `
+ return refusal(404, `Package '${baseId}' not found: a paired customization package is ensured for an installed `
+ 'base.');
}
// ADR-0070 D2's one predicate. A writable base is the customer's own and
// takes their changes directly; the pair exists for a base the customer
// cannot author into.
- if (isWritablePackage(this.engine, baseId)) {
- throw refusal(409, `Package '${baseId}' is writable: customize it directly. A paired customization package `
+ if (isWritablePackage(engine, baseId)) {
+ return refusal(409, `Package '${baseId}' is writable: customize it directly. A paired customization package `
+ `('${pairId}') holds the changes to a read-only base — one provided by code or an installed app `
+ '(ADR-0146 D3).');
}
const namespace = customizationNamespace(base.manifest?.namespace);
if (!namespace.ok) {
- throw refusal(409, `Cannot ensure '${pairId}': ${noPairNamespaceClause(baseId, base.manifest?.namespace, namespace.reason)} `
+ return refusal(409, `Cannot ensure '${pairId}': ${noPairNamespaceClause(baseId, base.manifest?.namespace, namespace.reason)} `
+ '(ADR-0146 D3).');
}
-
- // Idempotent: an existing pair is returned unchanged, or refused naming
- // what it lacks — ⛔ never adopted, never rewritten.
+ // An existing pair is returned unchanged, or refused naming what it lacks.
const existing = registry.getPackage(pairId);
if (existing) {
const lacks: string[] = [];
@@ -326,24 +333,16 @@ async function ensurePairOnce(
}
const declaredRange = declaredRangeOn(existing.manifest, baseId);
if (declaredRange === undefined) lacks.push(`a dependency on '${baseId}' in manifest.dependencies`);
- if (lacks.length > 0 || declaredRange === undefined) {
- throw refusal(409, `Package '${pairId}' is installed but is not the paired customization package of `
+ if (lacks.length > 0) {
+ return refusal(409, `Package '${pairId}' is installed but is not the paired customization package of `
+ `'${baseId}': it lacks ${lacks.join(', and ')}. It is not adopted or rewritten — delete it, or `
+ 'install a manifest over it that declares both, then ensure the pair again (ADR-0146 D3).');
}
- return {
- basePackageId: baseId,
- packageId: pairId,
- namespace: namespace.value,
- dependencyRange: declaredRange,
- created: false,
- writable: isWritablePackage(this.engine, pairId),
- };
+ return undefined;
}
-
const range = customizationDependencyRange(base.manifest?.version);
if (!range.ok) {
- throw refusal(409, `Cannot ensure '${pairId}': package '${baseId}' is at version `
+ return refusal(409, `Cannot ensure '${pairId}': package '${baseId}' is at version `
+ `'${String(base.manifest?.version)}', which is not a SemVer 2.0.0 version, so the pair's dependency `
+ 'range — the major of the base\'s version — cannot be declared (ADR-0146 D3).');
}
@@ -352,12 +351,44 @@ async function ensurePairOnce(
// would read.
const owner = registry.getNamespaceOwners?.(namespace.value)?.find((id) => id !== pairId);
if (owner !== undefined) {
- throw refusal(422, `Cannot ensure '${pairId}': its namespace '${namespace.value}' is already owned by package `
+ return refusal(422, `Cannot ensure '${pairId}': its namespace '${namespace.value}' is already owned by package `
+ `'${owner}'. The pair's namespace is the base's followed by '_custom' and has no alternative; uninstall `
+ `'${owner}' or move it off that namespace, then ensure the pair again (ADR-0146 D3).`,
'NAMESPACE_CONFLICT');
}
+ return undefined;
+}
+/** One judgement of the registry, and the create when the pair is absent. */
+async function ensurePairOnce(
+ this: CustomizationPackageHost,
+ registry: PairRegistry,
+ baseId: string,
+ pairId: string,
+): Promise {
+ // Every refusal the registry decides, read once ({@link customizationPairEnsureRefusal}).
+ const refused = customizationPairEnsureRefusal(this.engine, registry, baseId, pairId);
+ if (refused) throw refused;
+ const base = registry.getPackage(baseId)!;
+ const namespace = customizationNamespace(base.manifest?.namespace);
+ if (!namespace.ok) throw new Error('unreachable: the namespace was judged above');
+
+ // Idempotent: an existing pair is returned unchanged — ⛔ never adopted,
+ // never rewritten (its refusal is above).
+ const existing = registry.getPackage(pairId);
+ if (existing) {
+ return {
+ basePackageId: baseId,
+ packageId: pairId,
+ namespace: namespace.value,
+ dependencyRange: declaredRangeOn(existing.manifest, baseId)!,
+ created: false,
+ writable: isWritablePackage(this.engine, pairId),
+ };
+ }
+
+ const range = customizationDependencyRange(base.manifest?.version);
+ if (!range.ok) throw new Error('unreachable: the version was judged above');
const baseName = typeof base.manifest?.name === 'string' && base.manifest.name !== '' ? base.manifest.name : baseId;
const manifest = {
id: pairId,
diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts
index 343ebc5f831..5c30656f05b 100644
--- a/packages/metadata-protocol/src/protocol-environment-rows.ts
+++ b/packages/metadata-protocol/src/protocol-environment-rows.ts
@@ -241,6 +241,24 @@ export function shippingPackageIds(shipped: readonly unknown[]): ReadonlySet boolean,
+): string[] {
+ return [...shippingPackageIds(shipped)].filter((id) => !isWritablePackage(id)).sort();
+}
+
/**
* [#28409, ADR-0146 D8 items 1–2] The one code package that ships a name, or
* `undefined` when none or several do: the case in which
diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
index 7cb9b02e329..e1ee7c89799 100644
--- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
+++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
@@ -208,6 +208,58 @@ function sealedDeclareOrRenameClause(type: string, basePackageId: unknown, write
return `Declare an override from '${pair.value}' or rename it to customize it`;
}
+// ─────────────────────────────────────────────────────────────────────────
+// ADR-0146 D11 — the package-less write onto a packaged item is closed
+// ─────────────────────────────────────────────────────────────────────────
+
+/** The write a {@link packagelessOverlayWriteRefusal} judges: the four package-less writes ADR-0146 D11 closes. */
+export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollback';
+
+/**
+ * The refusal `ObjectStackProtocolImplementation#packagelessOverlayWriteRefusal`
+ * throws; the method's docblock there is its contract.
+ *
+ * [ADR-0146 D11] A write naming no package (`packageId` absent, `null` or
+ * empty) onto an item of one of the five overlay types that one read-only code
+ * package ships, whose row `os migrate packageless-overlays` would move into
+ * the base's pair (`packagelessCloseSubject`, the ceremony's own reading), is
+ * refused `403 NOT_OVERRIDABLE` — the code and status the sealed write already
+ * answers, so no door gains a code. Every row the ceremony leaves keeps its
+ * package-less write, so the sentence's two doors are always open: the pair
+ * (`?package=.custom`), where the customization is a declared override,
+ * and the ceremony, which moves a row stored before the close there. Such a
+ * row is still served until it is moved (the read path is not this
+ * function's); only its package-less write is refused.
+ *
+ * The fix first, then the rule and the ceremony: the REST door bounds a
+ * refusal's text (`CLIENT_MESSAGE_MAX`, 500 characters, `@objectstack/types`),
+ * and the metadata item page shows that text as sent.
+ */
+export function packagelessOverlayWriteRefusal(
+ request: { type: string; name: string },
+ closed: { base: string; pair: string },
+ operation: PackagelessWriteOperation,
+): Error {
+ const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
+ const item = `'${type}/${request.name}'`;
+ const shipped = `ships in '${closed.base}'`;
+ const pair = `'${closed.pair}' (?package=${closed.pair})`;
+ const declared = `overrides: { package: '${closed.base}', basedOn }`;
+ const rule = 'A write naming no package is closed where `os migrate packageless-overlays` can move its row '
+ + `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`;
+ const message = operation === 'rollback'
+ ? `${item} ${shipped}, and a rollback of its package-less row is closed (ADR-0146 D11): run `
+ + `\`os migrate packageless-overlays\` to move the row into ${pair}; to restore a version from before `
+ + `the move, save that version's body there, declaring ${declared}.`
+ : operation === 'delete'
+ ? `${item} ${shipped}: remove its customization from ${pair}. ${rule}`
+ : `${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}. ${rule}`;
+ const err = new Error(message);
+ (err as any).code = 'NOT_OVERRIDABLE';
+ (err as any).status = 403;
+ return err;
+}
+
// ─────────────────────────────────────────────────────────────────────────
// ADR-0146 D2 — the override write gate (ruling A on decision card #28390)
// ─────────────────────────────────────────────────────────────────────────
diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts
index 083d33d4f6f..517eb068aa7 100644
--- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts
+++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts
@@ -88,9 +88,11 @@ 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, overlayMemberValue } from './migrations/overlay-index.js';
+import { OVERLAY_MEMBER_COLUMN, overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
import type { ObjectStackProtocolImplementation } from './protocol.js';
-import { shippingPackageIds } from './protocol-environment-rows.js';
+import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
+import type { OverrideWriteSubject, OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
+import { readOnlyShippingPackages } from './protocol-environment-rows.js';
import { isServedOverrideRowOf } from './served-override.js';
import { SysMetadataRepository } from './sys-metadata-repository.js';
@@ -258,6 +260,37 @@ function baseVersionOf(host: Pick, ba
return typeof version === 'string' && version !== '' ? version : undefined;
}
+/**
+ * [#28576] How the census classifies ONE package-less row of a name some
+ * read-only code package ships (module header, "The population"), from the
+ * shippers ({@link readOnlyShippingPackages}), the row's body and the pair's
+ * row of the same item, in either state (`shadowOf`). The one classification:
+ * {@link listPackagelessOverlays} lists by it, and the close of the
+ * package-less write ({@link packagelessCloseSubject}) refuses only a row it
+ * answers `movable` — ⛔ never a second copy.
+ */
+export function classifyPackagelessRow(
+ shippers: readonly string[],
+ body: Record | undefined,
+ shadowOf: (pair: string) => unknown,
+): Pick {
+ if (shippers.length > 1) return { disposition: 'multi_shipped' };
+ const base = shippers[0]!;
+ const pairId = customizationPackageId(base);
+ if (!pairId.ok) return { base, disposition: 'no_pair' };
+ const pair = pairId.value;
+ const declared = (body as { overrides?: unknown } | undefined)?.overrides;
+ if (declared !== undefined && (declared === null || typeof declared !== 'object'
+ || (declared as { package?: unknown }).package !== base)) {
+ return { base, pair, disposition: 'foreign_declaration' };
+ }
+ const shadow = shadowOf(pair);
+ if (shadow !== undefined) {
+ return { base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base) };
+ }
+ return { base, pair, disposition: 'movable' };
+}
+
/**
* The census (module header, "The population"): every package-less overlay
* row classified, and every pair row whose declaration lacks `basedOn`.
@@ -298,9 +331,7 @@ export async function listPackagelessOverlays(
// The read-only code packages that ship the name: a writable package's
// item is edited in place (ADR-0070) and has no pair (ADR-0146 D3), and
// the pair itself is writable — the write gate's own narrowing.
- const shippers = [...shippingPackageIds(this.shippedArtifactsOf(type, name))]
- .filter((id) => !this.isWritablePackage(id))
- .sort();
+ const shippers = readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id));
if (shippers.length === 0) {
environmentRows += 1;
continue;
@@ -314,29 +345,8 @@ export async function listPackagelessOverlays(
...(member !== undefined ? { member } : {}),
shippers,
};
- if (shippers.length > 1) {
- listed.push({ ...common, disposition: 'multi_shipped' });
- continue;
- }
- const base = shippers[0]!;
- const pairId = customizationPackageId(base);
- if (!pairId.ok) {
- listed.push({ ...common, base, disposition: 'no_pair' });
- continue;
- }
- const pair = pairId.value;
- const declared = (bodyOf(row) as { overrides?: unknown } | undefined)?.overrides;
- if (declared !== undefined && (declared === null || typeof declared !== 'object'
- || (declared as { package?: unknown }).package !== base)) {
- listed.push({ ...common, base, pair, disposition: 'foreign_declaration' });
- continue;
- }
- const shadow = shadowOf(pair, type, name, member, state);
- if (shadow !== undefined) {
- listed.push({ ...common, base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base) });
- continue;
- }
- listed.push({ ...common, base, pair, disposition: 'movable' });
+ const classified = classifyPackagelessRow(shippers, bodyOf(row), (pair) => shadowOf(pair, type, name, member, state));
+ listed.push({ ...common, ...classified });
}
// Every type a declaration applies to, not only the five the move acts on.
@@ -526,71 +536,112 @@ export async function migratePackagelessOverlays(
}
// ─────────────────────────────────────────────────────────────────────────
-// Until #28576 closes the package-less write: a package-less save landing on
-// a migrated row keeps that row's declaration (N-16a, Wanted 4)
+// [#28576] The close reads this ceremony: only what it would move is closed
// ─────────────────────────────────────────────────────────────────────────
-/** The class members {@link packagelessPairLanding} reads. */
-export interface PackagelessPairLandingHost {
- readonly overlayTypes: ReadonlySet;
- readonly packagelessAddress: ObjectStackProtocolImplementation['packagelessAddress'];
- readonly packagelessDraftAddress: ObjectStackProtocolImplementation['packagelessDraftAddress'];
+/** The class members {@link packagelessCloseSubject} reads, typed as the class declares them. */
+export interface PackagelessCloseHost {
+ readonly engine: ObjectStackProtocolImplementation['engine'];
readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows'];
+ readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned'];
+ /** The read-only code packages that ship a name of an overlay type ([] for any other type). */
+ readonly overlayBaseShippers: (type: string, name: string) => string[];
+ /** The override write gate, as the ceremony's step 2 meets it through the save door. */
+ readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict;
+}
+
+/** The package-less write {@link packagelessCloseSubject} judges, and the row it would write. */
+export interface PackagelessWriteSubject {
+ readonly type: string;
+ readonly name: string;
+ /** The package the write names; a non-empty string means the write is not package-less. */
+ readonly packageId?: string | null;
+ /** The row's state: a draft save or a publish's draft is `draft`, everything else `active`. */
+ readonly state: 'active' | 'draft';
+ /** The bundle member as a caller names it (absent: the canonical one). */
+ readonly member?: string;
+ /** A save's body: the row a first write of the name would store. */
+ readonly item?: unknown;
}
/**
- * The declared override a package-less save of an overlay type lands on: when
- * the save's own address — {@link ObjectStackProtocolImplementation.packagelessAddress}
- * for a publish-mode save, {@link ObjectStackProtocolImplementation.packagelessDraftAddress}'s
- * `into` for a draft — is a base's paired `.custom`, and that pair's row
- * of the item (the draft's own row first for a draft save, then the active
- * row) is the served override of the base ({@link isServedOverrideRowOf}),
- * answers the pair, the base and the row's `overrides` block. Otherwise
- * `undefined`: nothing changes for the save.
+ * [ADR-0146 D11, #28576 ruling 2] The base and pair a package-less write is
+ * closed onto, or `null` when it stays open. A package-less write is closed
+ * exactly where `os migrate packageless-overlays` would move its row and the
+ * move can succeed — so every customization the close reaches has a door
+ * after the upgrade (the ceremony, then `?package=.custom`), and every
+ * one the ceremony leaves keeps the package-less write it has on `main`:
*
- * Why the save asks it. After `os migrate packageless-overlays` moves Setup's
- * customization into the pair, Setup still saves and resets it naming no
- * package, and the package-less address lands that save on the pair's row
- * (sole binding). Setup's form sends the item as it read it — its `overrides`
- * key included, or not, depending on the editor — and a pair row saved without
- * its declaration is refused by the override write gate as an undeclared same
- * name (ADR-0146 D2, N-09). So the save keeps the row's declaration when the
- * body carries none, and judges a body that carries one as a write into the
- * pair, which is where it lands. #28576 closes the package-less write; this
- * goes with it.
+ * - the row the write addresses is the stored package-less row of that item,
+ * member and state, or — when there is none — the row a save would store
+ * (its body), or none at all (a delete or rollback of an unstored item);
+ * - it is classified by the census's own {@link classifyPackagelessRow}:
+ * only `movable` is closed (`multi_shipped`, `no_pair`,
+ * `foreign_declaration` and `shadowed` stay open, and so does a name no
+ * 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: #28904).
+ *
+ * Registry reads first; the one store read (the item's rows) only for a name
+ * the registry has not already left open.
*/
-export async function packagelessPairLanding(
- this: PackagelessPairLandingHost,
- request: { type: string; name: string; mode?: string },
- member: string | undefined,
-): Promise<{ pair: string; base: string; declaration: Record } | undefined> {
+export async function packagelessCloseSubject(
+ this: PackagelessCloseHost,
+ request: PackagelessWriteSubject,
+): Promise<{ base: string; pair: string } | null> {
+ if (typeof request.packageId === 'string' && request.packageId.length > 0) return null;
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
- if (!this.overlayTypes.has(type)) return undefined;
- const draft = request.mode === 'draft';
- let into: string | null;
- if (draft) {
- const address = await this.packagelessDraftAddress(type, request.name, member);
- if (address.kind === 'ambiguous') return undefined;
- into = address.into;
- } else {
- const address = await this.packagelessAddress(type, request.name, member, 'active');
- into = address.candidates.length === 1 ? address.packageId : null;
- }
- const base = typeof into === 'string' ? customizationBasePackageId(into) : null;
- if (base === null || into === null) return undefined;
- const rows = await this.findEnvironmentRows('sys_metadata', {
- where: { type, name: request.name, package_id: into },
- context: SYSTEM,
- });
- // The member as the row stores it: `null` (absent here) for the canonical one.
- const storedMember = overlayMemberValue(type, member) ?? undefined;
- const at = (state: 'active' | 'draft') =>
- (rows ?? []).find((row) => row?.state === state && memberOf(row) === storedMember);
- for (const row of draft ? [at('draft'), at('active')] : [at('active')]) {
- if (row === undefined || !isServedOverrideRowOf(row, base)) continue;
- const declaration = (bodyOf(row) as { overrides?: unknown } | undefined)?.overrides;
- if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) continue;
- return { pair: into, base, declaration: { ...(declaration as Record) } };
+ 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
+
+ // The item's stored rows, both spellings, every package: the census's read.
+ const spellings = [...new Set([type, SINGULAR_TO_PLURAL[type] ?? type])];
+ let rows: any[] = [];
+ try {
+ rows = (await this.findEnvironmentRows('sys_metadata', {
+ where: { type: { $in: spellings }, name: request.name },
+ context: SYSTEM,
+ })) ?? [];
+ } catch (error) {
+ this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
- return undefined;
+ const member = request.item !== undefined && request.member === undefined
+ ? overlayMemberOfBody(type, request.item)
+ : overlayMemberValue(type, request.member);
+ const sameMember = (row: any) => member === undefined || ((row?.[OVERLAY_MEMBER_COLUMN] ?? null) === member);
+ const ofItem = rows.filter((row) => sameMember(row) && stateOf(row) !== undefined);
+ const stored = ofItem.find((row) => isUnbound(row) && stateOf(row) === request.state);
+ const storedBody = stored === undefined ? undefined : bodyOf(stored);
+ const writtenBody = request.item !== null && typeof request.item === 'object' && !Array.isArray(request.item)
+ ? (request.item as Record) : undefined;
+ const body = stored !== undefined ? storedBody : writtenBody;
+ const classified = classifyPackagelessRow(shippers, body, (p) => (stored === undefined
+ ? undefined
+ : ofItem.find((row) => row.package_id === p && stateOf(row) === request.state)
+ ?? ofItem.find((row) => row.package_id === p)));
+ 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 };
}
diff --git a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts
index a6226a4d961..dbfe1d183c4 100644
--- a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts
+++ b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts
@@ -50,7 +50,12 @@ import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
* declares no dependencies, as an authored base does.
*/
const INSTALLED_PACKAGES: ReadonlySet = new Set(['com.example.pkg']);
-const installedPackage = (id: string) => (INSTALLED_PACKAGES.has(id) ? { manifest: { id } } : undefined);
+// [#28576] With a namespace and a SemVer version, so a base this file ships
+// from could have its pair ensured: the close of the package-less write
+// applies to its items (the ceremony could move their rows).
+const installedPackage = (id: string) => (INSTALLED_PACKAGES.has(id)
+ ? { manifest: { id, namespace: 'pkg', version: '1.0.0' } }
+ : undefined);
const PACKAGE_ID = 'com.example.pkg';
@@ -285,12 +290,16 @@ describe('[#22220] controls: the gates still judge what the door does not refuse
}
});
- it('a packaged item of a type that allows overlays (view) with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => {
+ 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 () => {
+ // Was a control (422 INVALID_METADATA): the overlay type's package-less
+ // write reached the gates. That write onto a packaged item is closed
+ // now, so the door answers first, whatever the body.
for (const [kernel, environmentId] of KERNELS) {
const result = await probe(environmentId, {
type: 'view', name: 'pkg_view', item: specRefused({ name: 'pkg_view', label: 'Pkg' }),
});
- expect(result.envelope, kernel).toEqual(INVALID_METADATA);
+ expect(result.envelope, kernel).toEqual(NOT_OVERRIDABLE);
+ expect(result.gateReached, kernel).toBe(false);
expect(result.persistedRows, kernel).toBe(0);
}
});
diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
index e8795858102..14ea8513ca4 100644
--- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
+++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
@@ -491,9 +491,12 @@ describe('the override write gate — only the base\'s paired .custom declares a
const PARTNER = 'com.acme.partner';
const OTHER_PAIR = 'com.zeta.custom';
const UNRELATED = 'com.other';
- const MANIFESTS: Record; version: string }> = {
- [BASE]: { dependencies: {}, version: '3.2.0' },
- [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0' },
+ // [#28576] The base and its pair declare the namespaces the ensure door
+ // reads, so the pair can be ensured and the package-less write the close
+ // judges is one `os migrate packageless-overlays` could move.
+ const MANIFESTS: Record; version: string; namespace?: string }> = {
+ [BASE]: { dependencies: {}, version: '3.2.0', namespace: 'acme' },
+ [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0', namespace: 'acme_custom' },
[PARTNER]: { dependencies: { [BASE]: '^3' }, version: '1.0.0' },
// A pair of ANOTHER base that also depends on this one: the former
// "second declarer" — not the base's pair, so it may not declare.
@@ -669,10 +672,13 @@ describe('the override write gate — only the base\'s paired .custom declares a
expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: UNRELATED, state: 'active' }]);
});
- it('control: a package-less overlay row is not this gate\'s question (the migration card closes it)', async () => {
+ 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 () => {
const { protocol, stored } = gateProtocol();
- await protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') });
- expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: null, state: 'active' }]);
+ const e = await thrown(protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') }));
+ expect(shape(e)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
+ expect(String((e as Error).message)).toContain(`'${PAIR}' (?package=${PAIR})`);
+ expect(String((e as Error).message)).toContain('os migrate packageless-overlays');
+ expect(stored()).toEqual([]);
});
// ── the admit ────────────────────────────────────────────────────────────
diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts
index 52cd0e78f8e..02f7e1e9001 100644
--- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts
+++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts
@@ -35,9 +35,10 @@
* - CONTROL: a package-less row minted beside a package's row before the fix
* stays as it is, and a server-stated rewrite of that row (the
* stored-metadata migration passes `packageId: null`) still writes it;
- * - an item a code package ships, with no stored row: the package-less save
- * writes the package-unbound overlay and the read serves it, as before; with
- * an overlay row already bound to the code package, it writes that row;
+ * - [#28576, ADR-0146 D11] an item a code package ships: the package-less save
+ * is refused naming the base's pair and the migration ceremony, with no
+ * stored row or with an overlay row bound to the code package, and writes
+ * nothing;
* - the draft path: a package-less draft save still inherits the package
* (its own suite, `sys-metadata-repository.draft-package-inherit.test.ts`,
* holds the rest).
@@ -158,9 +159,13 @@ function makeProtocol(artifacts: Array<{ type: string; item: Record (id === INSTALLED_READ_ONLY
? { manifest: { id, scope: 'cloud' } }
- : [WRITABLE, OTHER_WRITABLE, 'com.qa.thirdapp', CODE_PACKAGE].includes(id) ? { manifest: { id } } : undefined),
+ : id === CODE_PACKAGE ? { manifest: { id, namespace: 'showcase', version: '0.1.0' } }
+ : [WRITABLE, OTHER_WRITABLE, 'com.qa.thirdapp'].includes(id) ? { manifest: { id } } : undefined),
isPackageDisabled: () => false,
applyNavContributions: (app: unknown) => app,
},
@@ -419,21 +424,30 @@ describe('CONTROL: what the resolution leaves alone', () => {
});
});
-describe('an item a CODE package ships', () => {
- it('with no stored row: the package-less save writes the package-unbound overlay, and the read serves it', async () => {
+describe('an item a CODE package ships: the package-less write is closed (ADR-0146 D11)', () => {
+ const PAIR = `${CODE_PACKAGE}.custom`;
+ const closed = (e: unknown) => {
+ expect(resolveThrownHttpError(e)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' });
+ expect(String((e as Error).message)).toContain(`'${PAIR}'`);
+ expect(String((e as Error).message)).toContain('os migrate packageless-overlays');
+ // The REST door cuts a refusal's text at 500 characters
+ // (`CLIENT_MESSAGE_MAX`, `@objectstack/types`), and Setup shows it as
+ // sent: the whole sentence fits, the ceremony included.
+ expect(String((e as Error).message).length).toBeLessThan(500);
+ };
+
+ it('with no stored row: the package-less save is refused naming the pair, and nothing is written', async () => {
const name = 'showcase_task_grid';
const { protocol, rows } = makeProtocol([packagedView(name)]);
- const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') });
+ const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }).then(() => undefined, (e: unknown) => e);
- expect(saved.message).toBe(
- `Saved customization overlay (env-wide, state=active) — type=view, name=${name} [seq=${saved.seq}]`,
- );
- expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([null]);
- expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Overlaid');
+ closed(refused);
+ expect(rows('view', name)).toEqual([]);
+ expect((await protocol.getMetaItem({ type: 'view', name })).item.label).not.toBe('Overlaid');
});
- it('with an overlay row bound to the code package: the package-less save writes that row', async () => {
+ it('with an overlay row bound to the code package: the package-less save is refused, and that row is untouched', async () => {
const name = 'showcase_task_grid';
// [#26708] The code package ships the object its view binds: a save
// naming the (installed) package is judged against its closure.
@@ -441,15 +455,14 @@ describe('an item a CODE package ships', () => {
packagedView(name),
{ type: 'object', item: { name: 'task', _packageId: CODE_PACKAGE, fields: { name: { type: 'text' } } } },
]);
- // The overlay a save naming the package writes (ADR-0005: an overlay of
- // a code-shipped item names the package it customizes).
+ // A save naming the package names a package: not the write D11 closes.
await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Named'), packageId: CODE_PACKAGE });
- const saved = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') });
+ const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e: unknown) => e);
- expect(saved.message).toContain(`package=${CODE_PACKAGE}`);
+ closed(refused);
expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([CODE_PACKAGE]);
- expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Unnamed');
+ expect((await protocol.getMetaItem({ type: 'view', name })).item.label).toBe('Named');
});
});
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 9c54267af1e..063d48a6fd1 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -183,18 +183,19 @@ import { deriveNamespaceFromPackageId } from '@objectstack/spec/kernel';
// [#28409, ADR-0146 D3 / D8 item 2] The pair's id, the one spelling N-08 owns,
// and the one predicate that says a stored row is the served override of a
// base's name.
-import { customizationPackageId } from '@objectstack/spec/kernel';
+import { customizationPackageId, isCustomizationPairOf } from '@objectstack/spec/kernel';
import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js';
import { bindStoredRow } from './stored-row-binding.js';
import { incomingTakesStoredFlowSlot } from './stored-flow-slot.js';
import { listServedOverrides, type ServedOverrideEntry } from './protocol-served-overrides.js';
// [#28413, ADR-0146 D11] The package-less overlay migration (`os migrate
-// packageless-overlays`), and the save's landing on a migrated row.
+// packageless-overlays`).
import {
listPackagelessOverlays,
migratePackagelessOverlays,
- packagelessPairLanding,
+ packagelessCloseSubject,
type PackagelessOverlayCensus,
+ type PackagelessWriteSubject,
type PackagelessOverlayMigration,
} from './protocol-packageless-overlay-migration.js';
// [#19417] The package-id declaration and its remedy text, imported to be
@@ -403,9 +404,10 @@ import {
originGatedRemovalRefusal,
overrideWriteRefusalError,
overrideWriteVerdict,
+ packagelessOverlayWriteRefusal,
refusePackagedBaseOverride,
} from './protocol-packaged-base-refusal.js';
-import type { OverrideWriteSubject, OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
+import type { OverrideWriteSubject, OverrideWriteVerdict, PackagelessWriteOperation } from './protocol-packaged-base-refusal.js';
// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge.
import {
extensionFieldSuffixRefusalError,
@@ -480,7 +482,7 @@ import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js';
// [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it.
import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js';
// [#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.
-import { baseAddressServesMember, findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, shippingPackageIds, soleShippingPackage } from './protocol-environment-rows.js';
+import { baseAddressServesMember, findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, readOnlyShippingPackages, shippingPackageIds, soleShippingPackage } from './protocol-environment-rows.js';
// [#25467 cut 35] The stored rows of a collection, folded over the registered ones.
import { foldStoredCollection } from './protocol-stored-collection-fold.js';
// [#25467 cut 35] The environment-wide package uninstall verb.
@@ -10059,38 +10061,6 @@ export class ObjectStackProtocolImplementation implements
return migratePackagelessOverlays.call(host, request);
}
- /**
- * [#28413] Whether a save is a package-less AUTHORING save — the one
- * {@link packagelessPairLanding} may find a pair row for: it names no
- * package, is no runtime-source write or package duplicate, and carries a
- * body object.
- */
- private isPackagelessAuthoringSave(request: SaveMetaItemRequest): boolean {
- return request.packageId == null
- && request.source === undefined
- && request.writeFace !== 'package-duplicate'
- && request.item !== null && typeof request.item === 'object' && !Array.isArray(request.item);
- }
-
- /**
- * [#28413] The declared override a package-less save of an overlay type
- * lands on, or `undefined` — see `packagelessPairLanding` in
- * `protocol-packageless-overlay-migration.ts`. Asked by
- * {@link saveMetaItem} before the override write gate.
- */
- private packagelessPairLanding(
- request: { type: string; name: string; mode?: string },
- member: string | undefined,
- ): Promise<{ pair: string; base: string; declaration: Record } | undefined> {
- const host: ThisParameterType = {
- overlayTypes: ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES,
- packagelessAddress: (...a) => this.packagelessAddress(...a),
- packagelessDraftAddress: (...a) => this.packagelessDraftAddress(...a),
- findEnvironmentRows: (...a) => this.findEnvironmentRows(...a),
- };
- return packagelessPairLanding.call(host, request, member);
- }
-
/**
* The one body behind {@link getMetaItems},
* {@link getMetaItemsForExecution} and {@link getMetaItemsPerPackage}.
@@ -16536,6 +16506,24 @@ export class ObjectStackProtocolImplementation implements
&& !ObjectStackProtocolImplementation.registryAllowsOverlay(type);
}
+ /**
+ * [ADR-0146 D11] The bases of an item of one of the five overlay types
+ * ({@link registryAllowsOverlay}: the `allowEnvironmentOverlay` types): the
+ * READ-ONLY code packages that ship the name ({@link shippedArtifactsOf}
+ * less {@link isWritablePackage}), sorted — `[]` for every other type, and
+ * for a name no read-only package ships. The reading
+ * `os migrate packageless-overlays` classifies a package-less row by
+ * (`readOnlyShippingPackages`), so the close
+ * ({@link packagelessOverlayWriteRefusal}) and the ceremony agree on what
+ * is a packaged item. The flag no longer opens a package-less write onto
+ * one; it stays the predicate the migration reads and the read of a row not
+ * migrated yet honours.
+ */
+ private overlayBaseShippers(type: string, name: string): string[] {
+ if (!ObjectStackProtocolImplementation.registryAllowsOverlay(type)) return [];
+ return readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id));
+ }
+
/**
* [#7743] Is `(field, '