Skip to content
38 changes: 38 additions & 0 deletions .changeset/28905-named-base-overlay-write-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a save, publish or rollback naming an installed read-only package itself onto its view, dashboard, report, translation or email template is refused, naming the base's `.custom` pair

Clause-②: yes (narrowing) — the metadata save (draft and active), publish (per item and `publish-drafts`) and rollback doors refuse a write that names an installed read-only package (`?package=<base>`) onto an item of the five overlay types that package ships, where its customization can be written into the base's paired `<base>.custom` instead (the pair can be ensured, the base records a version, and the override write gate admits the declared body there); they accepted it before as an undeclared row bound to the base. A delete naming the base stays open.

Metadata-protocol: narrowed (the `allowEnvironmentOverlay` flag of `view`, `dashboard`, `report`, `translation` and `email_template` no longer opens a write naming the read-only base itself onto an item it ships; the customization is the pair's declared override, as ADR-0146 D1 states)

Skills-docs: none needed — `skills/objectstack-platform/SKILL.md`'s ADR-0146 paragraph already places every customization of an installed base in its paired `.custom`, declaring `overrides`, and names `?package=<base>` only as a read that serves it; nothing it says becomes false.

<!-- adr-0087: not-required (already-registered packageless-overlay-write-closed) That entry, unreleased and shipping in the same release, already prescribes this change's TO for every customization of an installed package: write it into the base's paired `<base>.custom` with `PUT /api/v1/meta/<type>/<name>?package=<base>.custom` and the `overrides` declaration (its replacement), because the customization lives there (its reason, ADR-0146 D1, D2, D11). This change closes the second address that bypassed the pair, the read-only base's own, onto the same TO. No stored row is rewritten or moved: rows bound to the base stay served, so `objectstack migrate meta` has nothing new to do. The entry's surface names the package-less write only; widening its text is a `packages/spec` edit this change does not make. -->

**BREAKING** write narrowing at the metadata doors (`@objectstack/metadata-protocol`), shipped as `minor` during the launch window.

### Migration: FROM → TO

FROM, a customization of the installed package `com.acme.crm`'s view written naming the package itself:

```http
PUT /api/v1/meta/view/account_list?package=com.acme.crm
{ "name": "account_list", "label": "My accounts" }
```

TO, the same customization written into the base's paired `.custom`, declaring what it overrides (as for a write naming no package since `packageless-overlay-write-closed`):

```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" } }
```

A draft is saved and published there too (`?mode=draft`, then the publish naming `com.acme.crm.custom`). To restore a version of a row bound to the base, save that version's body into the pair, declaring it. `basedOn` is the base's installed `manifest.version`. The pair is created by Studio's Customize, or by `os migrate packageless-overlays --apply`.

- **Now refused.** An authoring save (draft or publish mode) or a per-item publish naming `?package=<base>`, `POST /api/v1/packages/<base>/publish-drafts` holding such a draft (the batch is all-or-nothing, so none of its drafts is published), and a rollback of a row bound to the base, of a `view`, `dashboard`, `report`, `translation` or `email_template` that the installed read-only package `<base>` ships, where the pair can be ensured (the base has a namespace and a SemVer version) and the override write gate admits the declared body into it. Each answers `403 NOT_OVERRIDABLE`, writes nothing, and its message names the item, the base, the pair (`?package=<base>.custom`) and the `overrides` declaration — the fix the package-less refusal names, in the same words.
- **Still open.** A delete naming `?package=<base>`, and its draft discard: removing a row bound to the base is the one way to clear one stored before this release (`os migrate packageless-overlays` moves package-less rows only, so it never lists one). Every write naming the pair, a writable package or no package (the package-less close is unchanged). A body carrying `overrides` keeps the override write gate's refusal (only the pair declares one); a name the base does not ship keeps ADR-0070 D1's `422 WRITABLE_PACKAGE_REQUIRED`. And every write the pair could not take: a base whose pair cannot be ensured, a base with no version, and an `email_template` locale the base does not ship. The server's own rewrites of a stored row (`migrate-stored`, package duplication) are not authoring writes and are unchanged.
- **Unchanged.** A row bound to the base stored before this release is still served at the base's address and by the read naming no package, until the pair declares the override (the pair's declared override is served over it) or a delete naming the base removes it.
- **`os migrate organization-ownership --apply`.** Its carry of an organization's public-form withdrawal onto a view a read-only package ships saved that view naming the package; that save is now refused, so the ceremony stops with a blocker quoting this refusal. Save the withdrawal (`sharing.enabled: false`) into the pair as above, then re-run.
180 changes: 170 additions & 10 deletions packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js';
import { SysMetadataRepository } from './sys-metadata-repository.js';
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
import {
customizationBasePackageId,
customizationPackageId,
Expand Down Expand Up @@ -104,10 +106,14 @@ export function refusePackagedBaseOverride(
// • Below the registry limb — this whole branch is guarded
// by {@link isSealedManagedItem}, whose type half is the
// registry's `allowEnvironmentOverlay` alone, so a regime-O type
// never reaches the door. That is ADR-0005: an overlay of
// a code-shipped item ALWAYS names the read-only package
// it customizes, and a door one limb higher would close
// the overlay model outright. Pinned.
// never reaches the door. That was ADR-0005: an overlay of
// a code-shipped item ALWAYS named the read-only package
// it customizes, and a door one limb higher would have closed
// the overlay model outright. Pinned. [#28905, ADR-0146 D1]
// Such an overlay now lands in the base's paired `.custom`:
// an authoring write naming the read-only base of one of the
// five types is refused by its own close
// ({@link namedBaseCloseSubject}), never by this door.
// • [ADR-0131 D6] No hatch limb. `OS_METADATA_WRITABLE` used
// to take an artifact-backed write past this branch, down
// to the repository door, where a package-less write landed
Expand Down Expand Up @@ -240,11 +246,7 @@ export function packagelessOverlayWriteRefusal(
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 { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed);
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'
Expand All @@ -253,13 +255,171 @@ export function packagelessOverlayWriteRefusal(
+ `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}`;
: `${writeInto(operation)} ${rule}`;
return closedOverlayWriteError(message);
}

/**
* The clauses both closes of an overlay write onto a packaged item spell
* ({@link packagelessOverlayWriteRefusal}, {@link namedBaseOverlayWriteRefusal}):
* the item, the base that ships it, the pair (with its `?package=` address),
* the declaration, and the fix — write it into the pair, declaring the
* override. ONE spelling: a writer refused at either address reads the same
* fix, and only the rule clause that follows it says which address is closed.
*/
function closedOverlayWriteClauses(
request: { type: string; name: string },
closed: { base: string; pair: string },
) {
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 writeInto = (operation: 'save' | 'publish') =>
`${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}.`;
return { item, shipped, pair, declared, writeInto };
}

/** `403 NOT_OVERRIDABLE`, the code and status the sealed write already answers, so neither close gains a code. */
function closedOverlayWriteError(message: string): Error {
const err = new Error(message);
(err as any).code = 'NOT_OVERRIDABLE';
(err as any).status = 403;
return err;
}

// ─────────────────────────────────────────────────────────────────────────
// ADR-0146 D1 / D11 — the write naming the read-only base itself is closed
// ─────────────────────────────────────────────────────────────────────────

/**
* The writes {@link namedBaseOverlayWriteRefusal} judges: the three that land
* a row bound to the base. ⛔ Not `delete`: removing a row bound to the base
* writes nothing, and it is the one way to clear such a row stored before the
* close — `os migrate packageless-overlays` reads package-less rows only, so
* it never moves one. The removal serves the base's own document again, or
* the pair's declared override where one exists (it is served over the row).
*/
export type NamedBaseWriteOperation = Exclude<PackagelessWriteOperation, 'delete'>;

/**
* The refusal `ObjectStackProtocolImplementation#namedBaseOverlayWriteRefusal`
* throws; the method's docblock there is its contract.
*
* [#28905, ADR-0146 D1, D11] An authoring write that names the read-only code
* package itself (`?package=<base>`) onto an item of one of the five overlay
* types that package ships stored the customization as a row bound to the
* base, with no declaration: served at the base's address in place of its
* document, outside the pair D1 puts every customization in, and not seen by
* `os migrate packageless-overlays`. It is refused with the fix the
* package-less close names ({@link closedOverlayWriteClauses}): write it into
* the pair, declaring the override. A row stored that way before the close is
* still served until the pair declares the override (the declared override is
* served over it) or a delete naming the base removes it — both open.
*
* Fix first, under the REST door's 500-character bound (`CLIENT_MESSAGE_MAX`,
* `@objectstack/types`), as the package-less sentence.
*/
export function namedBaseOverlayWriteRefusal(
request: { type: string; name: string },
closed: { base: string; pair: string },
operation: NamedBaseWriteOperation,
): Error {
const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed);
const served = 'is served until the pair declares the override, or a delete naming the base removes it.';
const message = operation === 'rollback'
? `${item} ${shipped}, and a rollback of its row bound to the read-only base is closed (ADR-0146 D1, D11): `
+ `save the version's body into ${pair}, declaring ${declared}. The row ${served}`
: `${writeInto(operation)} The read-only base itself takes no customization (ADR-0146 D1, D11): `
+ `a row bound to it from before ${served}`;
return closedOverlayWriteError(message);
}

/** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */
export interface NamedBaseCloseHost {
readonly engine: ObjectStackProtocolImplementation['engine'];
/** 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 declared write into the pair meets it at the save door. */
readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict;
}

/** The write naming a package that {@link namedBaseCloseSubject} judges. */
export interface NamedBaseWriteSubject {
readonly type: string;
readonly name: string;
/** The package the write names; only a non-empty one is judged here (none is #28576's close). */
readonly packageId?: string | null;
/** The bundle member as the caller names it; absent, the body's member, else the canonical one. */
readonly member?: string;
/** A save's body. */
readonly item?: unknown;
}

/**
* [#28905, ADR-0146 D1, D11] The base and pair a write naming a package is
* closed onto, or `null` when it stays open. Closed exactly where the write
* names a READ-ONLY code package that ships the item, on one of the five
* overlay types — the package-less close's reading of "packaged"
* (`overlayBaseShippers`, the reading `os migrate packageless-overlays`
* classifies by), with the named package among the shippers — and where the
* fix the refusal names can be taken, read from the steps that write takes:
*
* - the base has a pair id (`customizationPackageId`);
* - the ensure door would answer or create the pair
* ({@link customizationPairEnsureRefusal});
* - the base records a version (`basedOn`, its `manifest.version`, the value
* the ceremony records too);
* - 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, so its write stays open, as the package-less one does).
*
* A body carrying `overrides` is not judged here: the D2 gate answers a
* declaration outside the pair, the schema gate a malformed one, as before.
*
* So every write this closes has a door, the pair; every other one keeps the
* write it has on `main`. A writable package, a package that does not ship the
* name (ADR-0070 D1's `WRITABLE_PACKAGE_REQUIRED` answers a new item in a
* read-only one) and every other type are not this close's. Registry reads
* only: it reads no row.
*/
export function namedBaseCloseSubject(
this: NamedBaseCloseHost,
request: NamedBaseWriteSubject,
): { base: string; pair: string } | null {
const base = typeof request.packageId === 'string' && request.packageId.length > 0 ? request.packageId : undefined;
if (base === undefined) return null; // the package-less write: #28576's close
// A body carrying `overrides` keeps its answer: the D2 gate refuses a
// declaration outside the pair (`declared_outside_pair`), the schema gate
// a malformed one (`422 INVALID_METADATA`).
if (isPlainRecord(request.item) && 'overrides' in request.item) return null;
if (!this.overlayBaseShippers(request.type, request.name).includes(base)) return null;
const pairId = customizationPackageId(base);
if (!pairId.ok) return null; // no pair to name
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;
const basedOn = registry.getPackage(base)?.manifest?.version;
if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare

// The gate's verdict on the declared body, written into the pair.
const type = PLURAL_TO_SINGULAR[request.type] ?? request.type;
const written = isPlainRecord(request.item) ? request.item : undefined;
const member = written !== undefined && request.member === undefined
? overlayMemberOfBody(type, written)
: overlayMemberValue(type, request.member);
const disc = member === undefined ? undefined : overlayMemberField(type);
const declaredBody = {
...(written ?? { 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 };
}

// ─────────────────────────────────────────────────────────────────────────
// ADR-0146 D2 — the override write gate (ruling A on decision card #28390)
// ─────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading