From 8c98b5162f96a52f419ae813c402b2fb92325a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:56:08 +0000 Subject: [PATCH 01/13] wip(metadata-protocol): close the package-less write onto a packaged item Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX Co-Authored-By: Claude --- .../src/protocol-packaged-base-refusal.ts | 66 +++++ .../protocol-packageless-overlay-migration.ts | 72 +----- packages/metadata-protocol/src/protocol.ts | 229 +++++++++++------- 3 files changed, 210 insertions(+), 157 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 2943bd077b6..265c7c8c5e2 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -57,6 +57,7 @@ import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spe */ export interface PackagedBaseRefusalHost { isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; + isOverlayableManagedItem: ObjectStackProtocolImplementation['isOverlayableManagedItem']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner']; @@ -206,6 +207,71 @@ 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 body of `ObjectStackProtocolImplementation#packagelessOverlayWriteRefusal`; + * the method's docblock there is its contract. + * + * [ADR-0146 D11] The closure of the package-less write onto a packaged item: + * a write naming no package (`packageId` absent, `null` or empty) onto an item + * a code package ships, of a type whose registry entry opens an overlay + * channel ({@link PackagedBaseRefusalHost.isOverlayableManagedItem}: the five + * `allowEnvironmentOverlay` types), is refused `403 NOT_OVERRIDABLE` — the + * code and status the sealed write already answers, so no door gains a code. + * Every other type's packaged item is sealed already + * ({@link refusePackagedBaseOverride}), so after this no package-less write + * reaches a packaged item at all. + * + * The sentence names the fix the writer has: the base's paired `.custom` + * (`?package=.custom`), where the customization is a declared override, + * and `os migrate packageless-overlays`, the ceremony that moves a + * package-less row stored before the close there. A row that ceremony has not + * moved yet is still served (the read path is not this function's); only its + * package-less write is refused, by this same sentence. + * + * `null` when the write names a package, or the item is no packaged item of an + * overlay type: a name no package ships is environment metadata and keeps its + * package-less write (ADR-0146 D11, "is not touched"). + */ +export function packagelessOverlayWriteRefusal( + this: Pick, + request: { type: string; name: string; packageId?: string | null }, + operation: PackagelessWriteOperation, +): Error | null { + if (typeof request.packageId === 'string' && request.packageId.length > 0) return null; + if (!this.isOverlayableManagedItem(request.type, request.name)) return null; + const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const item = `'${type}/${request.name}'`; + const owner = this.packagedArtifactOwner(request); + const pairId = typeof owner === 'string' ? customizationPackageId(owner) : undefined; + const pair = pairId?.ok ? `'${pairId.value}'` : 'the paired .custom package of the package that ships it'; + const query = pairId?.ok ? ` (?package=${pairId.value})` : ''; + const shipped = typeof owner === 'string' ? `ships in '${owner}'` : 'ships in an installed package'; + const declared = typeof owner === 'string' ? `overrides: { package: '${owner}', basedOn }` : 'overrides: { package, basedOn }'; + const ceremony = `A package-less customization stored before this release is still served: run \`os migrate packageless-overlays\` to move it into ${pair}`; + const fix = operation === 'delete' + ? `Remove it from ${pair}${query}. ${ceremony} first.` + : operation === 'rollback' + ? `${ceremony}, then roll it back there.` + : operation === 'publish' + ? `Publish it into ${pair}${query}, declaring ${declared}. ${ceremony} first.` + : `Save it into ${pair}${query}, declaring ${declared}. ${ceremony} first.`; + const what = operation === 'rollback' ? 'a rollback of its package-less row' : 'a write naming no package'; + const err = new Error( + `${item} ${shipped}, and ${what} onto a packaged item is closed: a customization of an installed package ` + + `lives in its paired .custom package (ADR-0146 D11). ${fix}`, + ); + (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..f9963839dfe 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -88,7 +88,7 @@ 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 } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; import { shippingPackageIds } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; @@ -524,73 +524,3 @@ export async function migratePackagelessOverlays( .sort((a, b) => a.base.localeCompare(b.base)), }; } - -// ───────────────────────────────────────────────────────────────────────── -// 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) -// ───────────────────────────────────────────────────────────────────────── - -/** The class members {@link packagelessPairLanding} reads. */ -export interface PackagelessPairLandingHost { - readonly overlayTypes: ReadonlySet; - readonly packagelessAddress: ObjectStackProtocolImplementation['packagelessAddress']; - readonly packagelessDraftAddress: ObjectStackProtocolImplementation['packagelessDraftAddress']; - readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows']; -} - -/** - * 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. - * - * 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. - */ -export async function packagelessPairLanding( - this: PackagelessPairLandingHost, - request: { type: string; name: string; mode?: string }, - member: string | undefined, -): Promise<{ pair: string; base: string; declaration: Record } | undefined> { - 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) } }; - } - return undefined; -} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d545e9bc6e1..f6b287c8f50 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -182,16 +182,15 @@ 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 { 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, type PackagelessOverlayCensus, type PackagelessOverlayMigration, } from './protocol-packageless-overlay-migration.js'; @@ -401,9 +400,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, @@ -9873,38 +9873,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}. @@ -16248,6 +16216,20 @@ export class ObjectStackProtocolImplementation implements && !ObjectStackProtocolImplementation.registryAllowsOverlay(type); } + /** + * [ADR-0146 D11] The other half of {@link isSealedManagedItem}: an item a + * managed package ships ({@link isArtifactBacked}) of a type whose registry + * entry opens an overlay channel ({@link registryAllowsOverlay}: the five + * `allowEnvironmentOverlay` types). The flag no longer opens a write onto + * such an item that names no package + * ({@link packagelessOverlayWriteRefusal}); it stays the predicate the + * migration reads and the read of a row not migrated yet honours. + */ + private isOverlayableManagedItem(type: string, name: string): boolean { + return this.isArtifactBacked(type, name) + && ObjectStackProtocolImplementation.registryAllowsOverlay(type); + } + /** * [#7743] Is `(field, '.')` a field a code package ships? * @@ -17092,6 +17074,46 @@ export class ObjectStackProtocolImplementation implements return refusePackagedBaseOverride.call(host, request); } + /** + * [ADR-0146 D11] The package-less write onto a packaged item, closed: the + * refusal of a save, a publish, a delete (either state) or a rollback that + * names no package, of an item a managed package ships, on a type whose + * registry entry opens an overlay channel ({@link isOverlayableManagedItem}). + * `null` when the write is not refused on that ground: it names a package + * (`?package=.custom` stays open, as does every other package's own + * door), or no package ships the name (environment metadata). + * + * Every other type's packaged item is sealed already + * ({@link refusePackagedBaseOverride}, {@link refusePackagedBaseRemoval}), + * so with this no write naming no package reaches a packaged item. The + * refusal names the base's paired `.custom` and `os migrate + * packageless-overlays`, the ceremony that moves a package-less row stored + * before the close into that pair; such a row stays served until then, and + * its package-less write is refused by the same sentence. + * + * Asked by {@link saveMetaItem} and {@link publishMetaItem} (authoring + * writes only: the server-stated rewrites name the row they rewrite), + * {@link deleteMetaItem}, and {@link rollbackMetaItem} (whose address is the + * restored row's own binding: package-less when that row is). Registry + * reads only, so it reads no store row ahead of the ADR-0010 lock gate. + * The body is `packagelessOverlayWriteRefusal` in + * `protocol-packaged-base-refusal.ts`. + * + * Not the read envelope's `editable` / `deletable` ({@link packagedBaseRefusal} + * keeps its answer): those describe the item, which the pair's write still + * changes. + */ + private packagelessOverlayWriteRefusal( + request: { type: string; name: string; packageId?: string | null }, + operation: PackagelessWriteOperation, + ): Error | null { + const host: ThisParameterType = { + isOverlayableManagedItem: (...a) => this.isOverlayableManagedItem(...a), + packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a), + }; + return packagelessOverlayWriteRefusal.call(host, request, operation); + } + /** * [ADR-0146 D2, ruling A on #28390] The override write gate's verdict on * one item written into one package: `admit` for the base's paired @@ -21145,32 +21167,31 @@ export class ObjectStackProtocolImplementation implements // or status changes (a view container's name collision keeps its // 400, a lock its ITEM_LOCKED). // - // [#28413] Until #28576 closes the package-less write: a package-less - // authoring save whose address is a pair row declaring the override of - // its base (a row `os migrate packageless-overlays` moved there) is the - // write into the pair it is — so Setup's save of a migrated item, which - // sends the item as it read it, is not refused. Carrying the - // declaration, it is judged here as the write into that pair; carrying - // none, the late re-judgement below keeps the row's `overrides` block - // (see `packagelessPairLanding`). The landing is read ONLY where the - // save would otherwise be refused, so no admitted save, and no refusal - // ranked before these, reads one row more (#5706's lock gate stays the - // first reader). - let overrideVerdict = this.overrideWriteVerdict(request); - if ( - overrideVerdict.verdict === 'refuse' - && overrideVerdict.rule === 'declared_outside_pair' - && this.isPackagelessAuthoringSave(request) - ) { - const landing = await this.packagelessPairLanding( - request, itemDiscriminator(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.item), - ); - if (landing !== undefined) overrideVerdict = this.overrideWriteVerdict({ ...request, packageId: landing.pair }); + // [ADR-0146 D11] …and AHEAD of it, the package-less write onto a + // packaged item, closed: an authoring save naming no package, of an + // item a code package ships, on one of the five overlay types, is + // refused naming the base's paired `.custom` and `os migrate + // packageless-overlays` ({@link packagelessOverlayWriteRefusal}). Ahead + // of the gate so the writer reads that fix, not the gate's "a row + // bound to no package carries `overrides`… rename" for the body Setup + // sends as it read it. Every other type's packaged item is sealed + // further down ({@link refusePackagedBaseOverride}). Registry reads + // only: #5706's lock gate stays the first store reader. The + // server-stated rewrites (`source` set, `package-duplicate`) name the + // row they rewrite and are not asked. + // + // This retires the migration window's allowance (a package-less save + // landing on a pair row the ceremony moved kept that row's + // declaration): such a save is this refusal now. + if (request.source === undefined && request.writeFace !== 'package-duplicate') { + const closed = this.packagelessOverlayWriteRefusal(request, 'save'); + if (closed) throw closed; } + const overrideVerdict = this.overrideWriteVerdict(request); if (overrideVerdict.verdict === 'refuse' && overrideVerdict.rule !== 'undeclared_same_name') { throw overrideWriteRefusalError(overrideVerdict); } - let declaredOverride = overrideVerdict.verdict === 'admit'; + const declaredOverride = overrideVerdict.verdict === 'admit'; if (request.source === undefined && request.writeFace !== 'package-duplicate') { const authored = await this.tenantAuthoredWriteRefusal({ type: request.type, @@ -22378,31 +22399,6 @@ export class ObjectStackProtocolImplementation implements lateOverrideVerdict = this.overrideWriteVerdict({ ...request, packageId: inheritedPackage }); } } - // [#28413] …and a package-less save carrying no declaration whose - // address is a pair row declaring the override (the early gate's - // comment above) keeps that row's `overrides` block: the body as - // persisted is the declared override it replaces, judged again. - if ( - lateOverrideVerdict.verdict === 'refuse' - && lateOverrideVerdict.rule === 'undeclared_same_name' - && namedNoPackage - // (its address may have named the pair on the request since: `namedNoPackage` is what it named) - && this.isPackagelessAuthoringSave({ ...request, packageId: null }) - && !('overrides' in (request.item as Record)) - ) { - const landing = await this.packagelessPairLanding( - request, itemDiscriminator(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.item), - ); - if (landing !== undefined) { - const declared = { ...request, item: { ...(request.item as Record), overrides: landing.declaration } }; - const verdict = this.overrideWriteVerdict({ ...declared, packageId: landing.pair }); - if (verdict.verdict === 'admit') { - request = declared; - lateOverrideVerdict = verdict; - declaredOverride = true; - } - } - } if (lateOverrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(lateOverrideVerdict); const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type; const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name); @@ -23560,6 +23556,14 @@ export class ObjectStackProtocolImplementation implements // residue drafts the refusal (rather than a promotion) is the ruled // direction, and `deleteMetaItem` stays open to clear them. this.refuseUngrammaticalMetaItemName(request); + // [ADR-0146 D11] A publish naming no package of a packaged item of an + // overlay type writes the active row a package-less save writes, and + // is closed with it ({@link packagelessOverlayWriteRefusal}): publish + // the draft by naming its package (`?package=.custom`). + { + const closed = this.packagelessOverlayWriteRefusal(request, 'publish'); + if (closed) throw closed; + } // [#8594] The refusal's own row is written HERE, by the route that owns // the (absent) transaction — see `promoteDraftForPublish`'s header. This // site has no transaction of its own, so recording it in the `catch` is @@ -27986,8 +27990,6 @@ export class ObjectStackProtocolImplementation implements // request was refused above. const repo = this.getOverlayRepo(); const artifactBacked = this.isArtifactBacked(singularType, request.name); - const intent: 'override-artifact' | 'runtime-only' = artifactBacked - ? 'override-artifact' : 'runtime-only'; const ref = { type: singularType, name: request.name, @@ -28006,6 +28008,42 @@ export class ObjectStackProtocolImplementation implements // already succeeded — the shape that ends in a `catch {}` swallowing a // real outage (#4867). const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, member); + // [ADR-0146 D11, D1] A rollback writes the row its binding names, so + // a rollback of a packaged item's package-less row (an overlay type's + // row the ceremony has not moved, or none stored, whose restore writes + // the package-unbound row) is the package-less write, closed + // ({@link packagelessOverlayWriteRefusal}): rollback, like source save + // and reset, is offered on the writable package — the base's paired + // `.custom`, where `os migrate packageless-overlays` moves the row. + { + const closed = this.packagelessOverlayWriteRefusal( + { type: singularType, name: request.name, packageId: rollbackPackageId }, 'rollback', + ); + if (closed) throw closed; + } + // [ADR-0146 D2] A rollback of the base's paired `.custom` row is the + // pair's declared override, as its save is: it edits no managed item, + // so the store is told a runtime-only write — never judged an artifact + // override from `isArtifactBacked` alone, which seals it on a type with + // no overlay channel (a page, a flow). The restored body is judged by + // the override write gate as that save is ({@link overrideWriteVerdict} + // into the pair): a version that declares no override is refused as + // the save is, with nothing written. + const owner = artifactBacked ? this.packagedArtifactOwner({ type: singularType, name: request.name }) : undefined; + const pairRowRollback = owner !== undefined && isCustomizationPairOf(rollbackPackageId, owner); + const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !pairRowRollback + ? 'override-artifact' : 'runtime-only'; + const writeRestored = this.restoredBodyWriter(singularType, request.name, rollbackPackageId, 'protocol.rollbackMetaItem'); + const deriveRestoredBody = pairRowRollback + ? async (historyBody: unknown): Promise => { + const body = await writeRestored(historyBody); + const verdict = this.overrideWriteVerdict({ + type: singularType, name: request.name, packageId: rollbackPackageId, item: body, + }); + if (verdict.verdict === 'refuse') throw overrideWriteRefusalError(verdict); + return body; + } + : writeRestored; try { const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. @@ -28019,7 +28057,8 @@ export class ObjectStackProtocolImplementation implements // rethrown below with nothing written; then [#20790] R2 — a rollback past the // credential move keeps the write-only channel's current // credential and stores none. See {@link restoredBodyWriter}. - deriveRestoredBody: this.restoredBodyWriter(singularType, request.name, rollbackPackageId, 'protocol.rollbackMetaItem'), + // [ADR-0146 D2] …and, for the pair's row, the override write gate. + deriveRestoredBody, // [#22877] The member whose lineage `toVersion` is taken from. ...memberOption(member), }); @@ -28608,6 +28647,24 @@ export class ObjectStackProtocolImplementation implements } } + // [ADR-0146 D11] The package-less removal of a packaged item's active + // row, closed with the package-less write + // ({@link packagelessOverlayWriteRefusal}): a delete naming no package, + // of an item a code package ships, on one of the five overlay types + // (Setup's "Reset overlay"). Its customization lives in the base's + // paired `.custom`, and `?package=.custom` removes it there. On + // every topology, as the save door's is, and ahead of the `_lock` + // gate's store read. + // + // ⛔ Not a draft discard: it changes nothing any read serves, and it is + // how a package-less draft stored before the close is cleared (the + // draft cannot be published package-less any more), including by the + // discard-all walk, which names the package each draft is listed under. + if (request.state !== 'draft') { + const closed = this.packagelessOverlayWriteRefusal(request, 'delete'); + if (closed) throw closed; + } + // ADR-0010 L3 — lock blocks delete. [#21694] On EVERY topology, ranked // below the package removal door exactly as `saveMetaItem` ranks the // save gate below its package door (see the note there): on an From ab5ba9e9984b7fc83dedaef5c63e55129689bfc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:01:08 +0000 Subject: [PATCH 02/13] wip: close tests, five-types re-judged Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX Co-Authored-By: Claude --- .../src/protocol-packaged-base-refusal.ts | 13 +- .../environment-overlay-five-types.test.ts | 58 +++++-- ...ocol-packageless-overlay-migration.test.ts | 143 ++++++++++++++---- 3 files changed, 165 insertions(+), 49 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 265c7c8c5e2..596dee5d09d 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -250,18 +250,17 @@ export function packagelessOverlayWriteRefusal( const item = `'${type}/${request.name}'`; const owner = this.packagedArtifactOwner(request); const pairId = typeof owner === 'string' ? customizationPackageId(owner) : undefined; - const pair = pairId?.ok ? `'${pairId.value}'` : 'the paired .custom package of the package that ships it'; + const pair = pairId?.ok ? `'${pairId.value}'` : "the base's paired .custom package"; const query = pairId?.ok ? ` (?package=${pairId.value})` : ''; const shipped = typeof owner === 'string' ? `ships in '${owner}'` : 'ships in an installed package'; const declared = typeof owner === 'string' ? `overrides: { package: '${owner}', basedOn }` : 'overrides: { package, basedOn }'; - const ceremony = `A package-less customization stored before this release is still served: run \`os migrate packageless-overlays\` to move it into ${pair}`; + const ceremony = 'A package-less customization stored before this release is still served; run ' + + `\`os migrate packageless-overlays\` first to move it into ${pair}.`; const fix = operation === 'delete' - ? `Remove it from ${pair}${query}. ${ceremony} first.` + ? `Remove it from ${pair}${query}. ${ceremony}` : operation === 'rollback' - ? `${ceremony}, then roll it back there.` - : operation === 'publish' - ? `Publish it into ${pair}${query}, declaring ${declared}. ${ceremony} first.` - : `Save it into ${pair}${query}, declaring ${declared}. ${ceremony} first.`; + ? `Its customization belongs in ${pair}: run \`os migrate packageless-overlays\` to move the row there, then roll it back.` + : `${operation === 'publish' ? 'Publish' : 'Save'} it into ${pair}${query}, declaring ${declared}. ${ceremony}`; const what = operation === 'rollback' ? 'a rollback of its package-less row' : 'a write naming no package'; const err = new Error( `${item} ${shipped}, and ${what} onto a packaged item is closed: a customization of an installed package ` diff --git a/packages/objectql/src/environment-overlay-five-types.test.ts b/packages/objectql/src/environment-overlay-five-types.test.ts index 68cae4ebbe9..2bf736fbb38 100644 --- a/packages/objectql/src/environment-overlay-five-types.test.ts +++ b/packages/objectql/src/environment-overlay-five-types.test.ts @@ -1,10 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The five overlay-enabled metadata types keep their ENVIRONMENT overlay: a - * packaged item of each can be overlaid environment-wide, the overlay is stored - * with no organization, it is what the item's read then serves, and - * `GET /meta/types` tells Studio the type is writable. + * The five overlay-enabled metadata types keep their ENVIRONMENT customization: + * a packaged item of each is customized environment-wide, the customization is + * stored with no organization, it is what the item's read then serves, and + * `GET /meta/types` still carries the type's flag. + * + * ## Re-judged for the migrated state (#28576, ADR-0146 D11) + * + * Since D11 closed the package-less write, the flag no longer opens a write + * door: a save naming no package over the packaged item is refused + * `403 NOT_OVERRIDABLE`, naming the base's paired `.custom` and the + * `os migrate packageless-overlays` ceremony, and stores nothing. The + * customization is the same save naming the pair (`?package=.custom`), + * declaring the override. The flag stays the predicate the migration reads + * and the read of a row not migrated yet honours, so leg 2 below (a stored + * package-less row is served) is unchanged. * * ## Why this file exists (#22340, stage S1) * @@ -21,15 +32,17 @@ * * ## What is pinned, per type and on both topologies * - * 1. the write — `saveMetaItem` over the packaged item is accepted, stores - * exactly one row, the row names no organization, and the item's read + * 1. the write — `saveMetaItem` naming no package over the packaged item is + * refused naming the pair, and stores nothing; the same save into the + * pair, declaring the override, is accepted, stores exactly one row, + * bound to the pair, the row names no organization, and the item's read * serves the body that was written; * 2. the read of a stored overlay — a stored environment-wide row is served * by `getMetaItem`. The row is inserted through the engine, never a write * door, so this leg asks the read alone (the engine, not the driver, so * the served read's cache sees the write as it sees every production one): a row stored before a * flag change keeps being served whatever the flag then says; - * 3. the wire — `getMetaTypes` advertises the flag `true` for the type, from + * 3. the wire — `getMetaTypes` carries the flag `true` for the type, from * the registry and not from the `OS_METADATA_WRITABLE` hatch. * * And the identity: the registry types whose flag is `true` are exactly these @@ -85,7 +98,10 @@ import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; -const PKG = 'com.acme.overlay_catalog'; +const PKG = 'com.acme.overlaycatalog'; +/** The base's paired customization package (ADR-0146 D3), where its customization lives. */ +const PAIR = 'com.acme.overlaycatalog.custom'; +const PKG_VERSION = '1.0.0'; const PACKAGED_LABEL = 'Packaged'; const OVERLAY_LABEL = 'Environment overlay'; @@ -135,6 +151,7 @@ function overlayCatalogManifest(): Record { return { id: PKG, name: 'overlay_catalog', + version: PKG_VERSION, // [#24668] The object the view and the dataset bind to, shipped by the // package: the overlay save door refuses a list view bound to an object // that exists nowhere, and `probe_obj` was declared by nothing. @@ -333,7 +350,7 @@ afterEach(() => { resetEnvWritableMetadataTypes(); }); -describe('the five overlay-enabled types keep their environment overlay of a packaged item', () => { +describe('the five overlay-enabled types keep their environment customization of a packaged item, in its pair (ADR-0146 D11)', () => { it('the registry opens an overlay on exactly these five types', () => { const flagged = DEFAULT_METADATA_TYPE_REGISTRY .filter((entry) => entry.allowEnvironmentOverlay === true) @@ -343,19 +360,32 @@ describe('the five overlay-enabled types keep their environment overlay of a pac }); for (const topology of TOPOLOGIES) { - it.each(OVERLAY_ENABLED)(`${topology}: a save over the packaged %s is accepted, stored with no organization, and served back`, async (type) => { + it.each(OVERLAY_ENABLED)(`${topology}: a package-less save over the packaged %s is refused naming its pair; the same save into the pair is accepted, stored with no organization, and served back`, async (type) => { const { engine, protocol, rowsOf } = await boot(topology); const { name, body } = ITEMS[type]; - // Only an overlay if a package ships the item it lands on. + // Only a customization if a package ships the item it lands on. expect(engine.registry.getArtifactItem(type, name)?._packageId).toBe(PKG); - const result: any = await protocol + const refused: any = await protocol .saveMetaItem({ type, name, item: body(OVERLAY_LABEL) }) - .catch((e: any) => ({ refused: { code: e?.code, status: e?.status } })); + .then(() => undefined, (e: any) => ({ code: e?.code, status: e?.status, message: String(e?.message ?? '') })); + expect(refused).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refused.message).toContain(`'${PAIR}'`); + expect(refused.message).toContain('os migrate packageless-overlays'); + expect(rowsOf(type, name)).toHaveLength(0); + + await (protocol as any).ensureCustomizationPackage({ basePackageId: PKG }); + const result: any = await protocol + .saveMetaItem({ + type, name, packageId: PAIR, + item: { ...body(OVERLAY_LABEL), overrides: { package: PKG, basedOn: PKG_VERSION } }, + }) + .catch((e: any) => ({ refused: { code: e?.code, status: e?.status, message: e?.message } })); expect(result).toMatchObject({ success: true }); const rows = rowsOf(type, name); expect(rows).toHaveLength(1); + expect(rows[0].package_id).toBe(PAIR); // NULL is how the repository writes "no organization" today; a // tenant-less row with no such column reads the same here. expect(rows[0].organization_id ?? null).toBeNull(); @@ -378,7 +408,7 @@ describe('the five overlay-enabled types keep their environment overlay of a pac }); } - it.each(OVERLAY_ENABLED)('GET /meta/types advertises %s as overlay-writable, from the registry', async (type) => { + it.each(OVERLAY_ENABLED)('GET /meta/types carries the overlay flag on %s, from the registry (the migration\'s and the unmigrated read\'s predicate)', async (type) => { const { protocol } = await boot('environment-scoped'); const result: any = await protocol.getMetaTypes(); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index a38d38a0c43..6cff748fe0e 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -4,9 +4,8 @@ * [#28413, ADR-0146 D11, N-16a] A package-less overlay row of a name a code * package X ships moves into X's paired `.custom` as a declared override — * `listPackagelessOverlays` / `migratePackagelessOverlays`, the protocol half - * of `os migrate packageless-overlays` — and, until #28576 closes the - * package-less write, a package-less save landing on the moved row keeps its - * declaration. + * of `os migrate packageless-overlays` — and, after it, the package-less + * write onto a packaged item closed (#28576, ADR-0146 D11). * * Pinned through the real protocol over the real `ObjectQL` engine and an * in-memory driver (the harness of `protocol-custom-override-effective-read.test.ts`): @@ -18,10 +17,13 @@ * - LEFT ALONE: a name no package ships, a name two packages ship, and a row * the pair already overrides (`shadowed`), each untouched; * - a stored pair declaration with no `basedOn` gains it (#28644's reading); - * - THE SAVE (Wanted 4): after the move, a package-less draft save and publish - * land on the pair's row with or without `overrides` in the body, and the - * item stays the declared override; a package-less DELETE removes the pair's - * row and X's document is served again; + * - THE CLOSE (#28576): after the move, a package-less save (draft or + * publish, with or without `overrides`), publish, DELETE and rollback are + * refused `403 NOT_OVERRIDABLE` naming the pair and the ceremony, and the + * pair's row is untouched; the same save and DELETE naming the pair + * succeed; a name no package ships keeps its package-less write; an + * unmigrated row is served, and its package-less write is refused; a + * rollback of the pair's declared row is the declared override (a page); * - ADOPT-ORPHANS (Wanted 5): adopting into a writable package skips the * shipped-name row and rebinds an environment one. * @@ -361,7 +363,7 @@ describe('[#28413] the census and the move', () => { /** * The state the move leaves, laid down without the ceremony (so these cases - * read the save door alone): the pair ensured through its door, and the + * read the write doors alone): the pair ensured through its door, and the * item's row bound to it, declaring the override. */ async function movedState(engine: ObjectQL, protocol: ObjectStackProtocolImplementation): Promise { @@ -369,46 +371,131 @@ async function movedState(engine: ObjectQL, protocol: ObjectStackProtocolImpleme await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS, PAIR, 'active', { overrides: { package: BASE, basedOn: '1.2.0' } }); } -describe('[#28413] until #28576: a package-less save of a moved item keeps its declaration', () => { +/** A write's refusal as an HTTP boundary relays it, or `undefined` when it was not refused. */ +const refusalOf = (write: Promise) => write.then( + () => undefined, + (e: any) => ({ code: e?.code, status: e?.status, message: String(e?.message ?? '') }), +); + +/** The close's refusal: the sealed write's code and status, naming the pair and the ceremony (ADR-0146 D11). */ +const expectClosed = (refusal: { code?: unknown; status?: unknown; message: string } | undefined) => { + expect(refusal).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refusal!.message).toContain(`'${PAIR}'`); + expect(refusal!.message).toContain('os migrate packageless-overlays'); +}; + +/** + * A package-less row of `VIEW` as an older release's door stored it: through + * the repository itself (the row and its history), versions 1 and 2, with no + * protocol door in front — the close refuses that write through every door. + */ +async function legacyRowWithHistory(protocol: ObjectStackProtocolImplementation): Promise { + const repo = (protocol as any).getOverlayRepo(); + const ref = { type: 'view', name: VIEW, org: 'env' }; + const opts = { actor: null, source: 'older-release', intent: 'override-artifact', packageId: null } as const; + const first = await repo.put(ref, listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS), { ...opts, parentVersion: null }); + await repo.put(ref, listBody(VIEW, 'showcase_task', EDITED_COLUMNS), { ...opts, parentVersion: first.version }); +} + +describe('[#28576] after the close: a package-less write onto a packaged item is refused, naming the pair', () => { for (const withOverrides of [false, true]) { - it(`a package-less draft save and publish ${withOverrides ? 'carrying' : 'without'} \`overrides\` land on the pair's row, still the declared override`, async () => { + it(`a migrated item: a package-less draft save and publish-mode save ${withOverrides ? 'carrying' : 'without'} \`overrides\` are refused, and the pair's row is untouched`, async () => { const { engine, protocol, rows, rowOf } = await boot(); await movedState(engine, protocol); // Setup's form save: the item as the editor read it, naming no package. const served = await read(protocol); const body: Record = { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS) }; if (withOverrides) body.overrides = served.overrides; - await protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any); - await protocol.publishMetaItem({ type: 'view', name: VIEW } as any); - expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); - expect(JSON.parse(String(rowOf(VIEW, PAIR)!.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); - expect(await fiveReads(protocol)).toEqual([EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS]); + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any))); + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body } as any))); + // `packageId: null` names no package too, as the request contract states. + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, packageId: null } as any))); - // …and a publish-mode save naming no package, the other spelling of the same write. - await protocol.saveMetaItem({ type: 'view', name: VIEW, item: { ...listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS) } } as any); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); - expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); }); } - it('a package-less DELETE removes the pair\'s row and X\'s document is served again', async () => { + it('a migrated item: a package-less DELETE (Setup\'s reset) and a package-less publish are refused, and nothing moves', async () => { const { engine, protocol, rows } = await boot(); await movedState(engine, protocol); - await protocol.deleteMetaItem({ type: 'view', name: VIEW } as any); + expectClosed(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + + // A draft in the pair, published naming no package: refused, the draft stays a draft. + await protocol.saveMetaItem({ + type: 'view', name: VIEW, packageId: PAIR, mode: 'draft', + item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, + } as any); + expectClosed(await refusalOf(protocol.publishMetaItem({ type: 'view', name: VIEW } as any))); + expect(rows(VIEW).sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); + expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + // …and naming the pair publishes it. + await protocol.publishMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any); + expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS); + }); + + it('a migrated item: a save and a DELETE naming ?package=.custom succeed', async () => { + const { engine, protocol, rows, rowOf } = await boot(); + await movedState(engine, protocol); + await protocol.saveMetaItem({ + type: 'view', name: VIEW, packageId: PAIR, + item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, + } as any); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(await fiveReads(protocol)).toEqual([EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS]); + + await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any); expect(rows(VIEW)).toEqual([]); expect(await fiveReads(protocol)).toEqual([BASE_COLUMNS, BASE_COLUMNS, BASE_COLUMNS, BASE_COLUMNS, BASE_COLUMNS]); }); - it('CONTROL: a package-less save carrying `overrides` over an unmoved package-less row is still refused (the pair is not its address)', async () => { - const { engine, protocol } = await boot(); - await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS); - await expect(protocol.saveMetaItem({ - type: 'view', name: VIEW, mode: 'draft', - item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, - } as any)).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + it('CONTROL: a package-less save and DELETE of a name no package ships (environment metadata) still succeed', async () => { + const { protocol, rows } = await boot(); + await protocol.saveMetaItem({ type: 'view', name: ENV_VIEW, item: listBody(ENV_VIEW, 'showcase_task', UNBOUND_COLUMNS) } as any); + expect(rows(ENV_VIEW)).toEqual([[null, 'active']]); + await protocol.deleteMetaItem({ type: 'view', name: ENV_VIEW } as any); + expect(rows(ENV_VIEW)).toEqual([]); + }); + + it('an unmigrated row is served, and its package-less save, DELETE and rollback are refused naming the ceremony', async () => { + const { protocol, rows, rowOf } = await boot(); + await legacyRowWithHistory(protocol); + const before = rowOf(VIEW, null); + expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS); + expect(columnsOf((await layered(protocol)).effective)).toEqual(EDITED_COLUMNS); + + expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(VIEW, 'showcase_task', BASE_COLUMNS) } as any))); + expectClosed(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: VIEW } as any))); + expectClosed(await refusalOf((protocol as any).rollbackMetaItem({ type: 'view', name: VIEW, toVersion: 1 }))); + + expect(rows(VIEW)).toEqual([[null, 'active']]); + expect(rowOf(VIEW, null)).toEqual(before); + expect(columnsOf(await read(protocol))).toEqual(EDITED_COLUMNS); + // The ceremony still moves it, and the item is the pair's then. + expect((await migrate(protocol)).outcomes.map((o: any) => o.outcome)).toEqual(['moved']); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + }); + + it('a rollback of the pair\'s own declared row is the pair\'s declared override, never an artifact override (a page)', async () => { + const { engine, protocol, stores } = await boot(); + engine.registerApp({ id: BASE, name: 'showcase', version: '1.2.0', namespace: 'showcase', pages: [pageBody(PAGE, 'Home')] } as any); + await (protocol as any).ensureCustomizationPackage({ basePackageId: BASE }); + const declared = (label: string) => ({ ...pageBody(PAGE, label), overrides: { package: BASE, basedOn: '1.2.0' } }); + await protocol.saveMetaItem({ type: 'page', name: PAGE, packageId: PAIR, item: declared('Edited home') } as any); + await protocol.saveMetaItem({ type: 'page', name: PAGE, packageId: PAIR, item: declared('Second edit') } as any); + const pageRow = () => Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === 'page' && r.name === PAGE); + + const rolled = await refusalOf((protocol as any).rollbackMetaItem({ type: 'page', name: PAGE, toVersion: 1 })); + expect(rolled).toBeUndefined(); + expect(pageRow().map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); + const body = JSON.parse(String(pageRow()[0]!.metadata)); + expect(body.label).toBe('Edited home'); + expect(body.overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); }); }); From b083959dab44e0512b03011d046488b035cebc55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:02:13 +0000 Subject: [PATCH 03/13] wip: re-judge packageless-save-address code-package block Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX Co-Authored-By: Claude --- .../protocol.packageless-save-address.test.ts | 39 +++++++++++-------- .../environment-overlay-five-types.test.ts | 1 + 2 files changed, 23 insertions(+), 17 deletions(-) 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..d62ec9b8c3c 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). @@ -419,21 +420,26 @@ 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'); + }; + + 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) => 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 +447,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) => 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/objectql/src/environment-overlay-five-types.test.ts b/packages/objectql/src/environment-overlay-five-types.test.ts index 2bf736fbb38..d1f2e942929 100644 --- a/packages/objectql/src/environment-overlay-five-types.test.ts +++ b/packages/objectql/src/environment-overlay-five-types.test.ts @@ -152,6 +152,7 @@ function overlayCatalogManifest(): Record { id: PKG, name: 'overlay_catalog', version: PKG_VERSION, + namespace: 'overlaycat', // [#24668] The object the view and the dataset bind to, shipped by the // package: the overlay save door refuses a list view bound to an object // that exists nowhere, and `probe_obj` was declared by nothing. From 8948d3cda280b35519e13c7fb2f1ba721e656da6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:14:51 +0000 Subject: [PATCH 04/13] 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 --- .../src/protocol-environment-rows.ts | 18 ++++++ .../src/protocol-packaged-base-refusal.ts | 60 +++++++++++-------- .../protocol-packageless-overlay-migration.ts | 6 +- ...protocol.package-door-before-gates.test.ts | 8 ++- .../protocol.packaged-base-refusal.test.ts | 9 ++- .../protocol.packageless-save-address.test.ts | 4 +- packages/metadata-protocol/src/protocol.ts | 37 +++++++----- 7 files changed, 91 insertions(+), 51 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts index e4cc31f8920..e70da00c8be 100644 --- a/packages/metadata-protocol/src/protocol-environment-rows.ts +++ b/packages/metadata-protocol/src/protocol-environment-rows.ts @@ -239,6 +239,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 596dee5d09d..f32eb9a0569 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -57,7 +57,7 @@ import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spe */ export interface PackagedBaseRefusalHost { isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; - isOverlayableManagedItem: ObjectStackProtocolImplementation['isOverlayableManagedItem']; + overlayBaseShippers: ObjectStackProtocolImplementation['overlayBaseShippers']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner']; @@ -220,13 +220,12 @@ export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollbac * * [ADR-0146 D11] The closure of the package-less write onto a packaged item: * a write naming no package (`packageId` absent, `null` or empty) onto an item - * a code package ships, of a type whose registry entry opens an overlay - * channel ({@link PackagedBaseRefusalHost.isOverlayableManagedItem}: the five - * `allowEnvironmentOverlay` types), is refused `403 NOT_OVERRIDABLE` — the - * code and status the sealed write already answers, so no door gains a code. - * Every other type's packaged item is sealed already - * ({@link refusePackagedBaseOverride}), so after this no package-less write - * reaches a packaged item at all. + * of one of the five overlay types that a read-only code package ships + * ({@link PackagedBaseRefusalHost.overlayBaseShippers}, the reading + * `os migrate packageless-overlays` classifies by), is refused + * `403 NOT_OVERRIDABLE` — the code and status the sealed write already + * answers, so no door gains a code. Every other type's packaged item is + * sealed already ({@link refusePackagedBaseOverride}). * * The sentence names the fix the writer has: the base's paired `.custom` * (`?package=.custom`), where the customization is a declared override, @@ -235,32 +234,45 @@ export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollbac * moved yet is still served (the read path is not this function's); only its * package-less write is refused, by this same sentence. * - * `null` when the write names a package, or the item is no packaged item of an - * overlay type: a name no package ships is environment metadata and keeps its - * package-less write (ADR-0146 D11, "is not touched"). + * `null` when the write names a package; when no read-only code package ships + * the name (environment metadata keeps its package-less write, ADR-0146 D11 + * "is not touched"); and when no base that ships it has a pair at all + * (`customizationPackageId` refuses every one of their ids): the ceremony + * lists such a row `no_pair` and cannot move it, and D11 closes only what it + * has migrated — closing it would leave that customization no write door. */ export function packagelessOverlayWriteRefusal( - this: Pick, + this: Pick, request: { type: string; name: string; packageId?: string | null }, operation: PackagelessWriteOperation, ): Error | null { if (typeof request.packageId === 'string' && request.packageId.length > 0) return null; - if (!this.isOverlayableManagedItem(request.type, request.name)) return null; + const shippers = this.overlayBaseShippers(request.type, request.name); + const pairs = shippers.flatMap((base) => { + const derived = customizationPackageId(base); + return derived.ok ? [{ base, pair: derived.value }] : []; + }); + if (pairs.length === 0) return null; const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; const item = `'${type}/${request.name}'`; - const owner = this.packagedArtifactOwner(request); - const pairId = typeof owner === 'string' ? customizationPackageId(owner) : undefined; - const pair = pairId?.ok ? `'${pairId.value}'` : "the base's paired .custom package"; - const query = pairId?.ok ? ` (?package=${pairId.value})` : ''; - const shipped = typeof owner === 'string' ? `ships in '${owner}'` : 'ships in an installed package'; - const declared = typeof owner === 'string' ? `overrides: { package: '${owner}', basedOn }` : 'overrides: { package, basedOn }'; - const ceremony = 'A package-less customization stored before this release is still served; run ' - + `\`os migrate packageless-overlays\` first to move it into ${pair}.`; + const shipped = `ships in ${shippers.map((id) => `'${id}'`).join(' and ')}`; + const sole = pairs.length === 1 ? pairs[0]! : undefined; + // One base: its pair, by name. Several: the pair of the base the writer + // customizes, each named — never guessed (the ceremony lists such a row). + const into = sole + ? `'${sole.pair}' (?package=${sole.pair})` + : `the pair of the package it customizes (${pairs.map((p) => `'${p.pair}'`).join(' or ')}, as ?package=)`; + const declared = sole ? `overrides: { package: '${sole.base}', basedOn }` : 'overrides: { package: , basedOn }'; + const ceremony = sole + ? 'A package-less customization stored before this release is still served; run ' + + `\`os migrate packageless-overlays\` first to move it into '${sole.pair}'.` + : 'A package-less customization stored before this release is still served; run ' + + '`os migrate packageless-overlays` first: it lists a name several packages ship for you to place.'; const fix = operation === 'delete' - ? `Remove it from ${pair}${query}. ${ceremony}` + ? `Remove it from ${into}. ${ceremony}` : operation === 'rollback' - ? `Its customization belongs in ${pair}: run \`os migrate packageless-overlays\` to move the row there, then roll it back.` - : `${operation === 'publish' ? 'Publish' : 'Save'} it into ${pair}${query}, declaring ${declared}. ${ceremony}`; + ? `Its customization belongs in ${into}: run \`os migrate packageless-overlays\` to move the row there, then roll it back.` + : `${operation === 'publish' ? 'Publish' : 'Save'} it into ${into}, declaring ${declared}. ${ceremony}`; const what = operation === 'rollback' ? 'a rollback of its package-less row' : 'a write naming no package'; const err = new Error( `${item} ${shipped}, and ${what} onto a packaged item is closed: a customization of an installed package ` diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index f9963839dfe..b40061979f1 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -90,7 +90,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared import { storedRowDocument } from './item-lock.js'; import { OVERLAY_MEMBER_COLUMN } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; -import { shippingPackageIds } from './protocol-environment-rows.js'; +import { readOnlyShippingPackages } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; @@ -298,9 +298,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; 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..5afc256afda 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 @@ -285,12 +285,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..e6c31ad58a0 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -669,10 +669,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 d62ec9b8c3c..d7ec6cf6e4a 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -432,7 +432,7 @@ describe('an item a CODE package ships: the package-less write is closed (ADR-01 const name = 'showcase_task_grid'; const { protocol, rows } = makeProtocol([packagedView(name)]); - const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }).then(() => undefined, (e) => e); + const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Overlaid') }).then(() => undefined, (e: unknown) => e); closed(refused); expect(rows('view', name)).toEqual([]); @@ -450,7 +450,7 @@ describe('an item a CODE package ships: the package-less write is closed (ADR-01 // 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 refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e) => e); + const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e: unknown) => e); closed(refused); expect(rows('view', name).map((r) => r.package_id ?? null)).toEqual([CODE_PACKAGE]); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f6b287c8f50..d1a51c7be4d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -469,7 +469,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 { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, soleShippingPackage } from './protocol-environment-rows.js'; +import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage, readOnlyShippingPackages, 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. @@ -16217,17 +16217,21 @@ export class ObjectStackProtocolImplementation implements } /** - * [ADR-0146 D11] The other half of {@link isSealedManagedItem}: an item a - * managed package ships ({@link isArtifactBacked}) of a type whose registry - * entry opens an overlay channel ({@link registryAllowsOverlay}: the five - * `allowEnvironmentOverlay` types). The flag no longer opens a write onto - * such an item that names no package - * ({@link packagelessOverlayWriteRefusal}); it stays the predicate the - * migration reads and the read of a row not migrated yet honours. + * [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 isOverlayableManagedItem(type: string, name: string): boolean { - return this.isArtifactBacked(type, name) - && ObjectStackProtocolImplementation.registryAllowsOverlay(type); + private overlayBaseShippers(type: string, name: string): string[] { + if (!ObjectStackProtocolImplementation.registryAllowsOverlay(type)) return []; + return readOnlyShippingPackages(this.shippedArtifactsOf(type, name), (id) => this.isWritablePackage(id)); } /** @@ -17077,11 +17081,13 @@ export class ObjectStackProtocolImplementation implements /** * [ADR-0146 D11] The package-less write onto a packaged item, closed: the * refusal of a save, a publish, a delete (either state) or a rollback that - * names no package, of an item a managed package ships, on a type whose - * registry entry opens an overlay channel ({@link isOverlayableManagedItem}). + * names no package, of an item of one of the five overlay types a + * read-only code package ships ({@link overlayBaseShippers}). * `null` when the write is not refused on that ground: it names a package * (`?package=.custom` stays open, as does every other package's own - * door), or no package ships the name (environment metadata). + * door), no read-only package ships the name (environment metadata), or no + * base that ships it can form a pair (the ceremony's `no_pair`: it cannot + * move that row, so D11 does not close its write). * * Every other type's packaged item is sealed already * ({@link refusePackagedBaseOverride}, {@link refusePackagedBaseRemoval}), @@ -17108,8 +17114,7 @@ export class ObjectStackProtocolImplementation implements operation: PackagelessWriteOperation, ): Error | null { const host: ThisParameterType = { - isOverlayableManagedItem: (...a) => this.isOverlayableManagedItem(...a), - packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a), + overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), }; return packagelessOverlayWriteRefusal.call(host, request, operation); } From e307d87d48f258c6280b739bdf02af3a9646c134 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:19:38 +0000 Subject: [PATCH 05/13] wip: ledger entry, changeset, skills line, console link Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX Co-Authored-By: Claude --- .../28576-packageless-overlay-write-closed.md | 44 ++++ .../18.packageless-overlay-write-closed.ts | 43 ++++ skills/objectstack-platform/SKILL.md | 2 +- ...Page.packagelessWriteClosed-28576.test.tsx | 200 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 58 ++--- .../StudioReadOnlyLink.overlay-27356.test.tsx | 34 ++- 6 files changed, 317 insertions(+), 64 deletions(-) create mode 100644 .changeset/28576-packageless-overlay-write-closed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts create mode 100644 ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx diff --git a/.changeset/28576-packageless-overlay-write-closed.md b/.changeset/28576-packageless-overlay-write-closed.md new file mode 100644 index 00000000000..9375ef6ff5d --- /dev/null +++ b/.changeset/28576-packageless-overlay-write-closed.md @@ -0,0 +1,44 @@ +--- +'@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, naming the base's `.custom` pair and `os migrate packageless-overlays` + +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 an installed read-only package ships, which they accepted before as a package-unbound Setup row. Nothing is widened. + +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; it stays the predicate the migration ceremony and the read of an unmigrated row 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 until migrated and that D11 refuses new ones. The same paragraph drops "the base's is unchanged" after the export sentence, which "replaces the base's document whole" already implies, to stay under the bundle's token ceiling. + + + +**BREAKING** write narrowing at the metadata doors (`@objectstack/metadata-protocol`), shipped as `minor` during the launch window. + +**Run the ceremony before you upgrade.** On each deployment with Setup customizations of a package's items, run `os migrate packageless-overlays` (preview), then `os migrate packageless-overlays --apply`, on the release before this one (#28413 shipped the command). If you upgrade first, nothing breaks at the upgrade: every customization is still served as before. But each of them becomes read-only in Setup until the command moves it, because its package-less save, reset, publish and rollback are refused. + +### 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 an installed read-only package ships. 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`. Setup's form save and reset show that message as sent. The refusal comes before every gate that reads a stored row, 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); a write of a name whose only shipping packages have ids no `.custom` pair can be formed from (the ceremony lists those rows `no_pair` and cannot move them, so their write is not closed); and discarding a draft. +- **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/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts b/packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts new file mode 100644 index 00000000000..858ff7c014b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0146 D11 — a runtime narrowing at the metadata write doors with no +// authorable key: a write naming no package onto an item an installed package +// ships is refused. Registered because an upgrading deployment can carry the +// package-less Setup rows those writes made, and the operator step that moves +// them into the base's `.custom` pair is to run BEFORE the upgrade (the +// migration prescription the dispatch order puts on this change's note). +export const entry: SemanticMigration = { + id: 'packageless-overlay-write-closed', + surface: + 'a metadata write that names no package (PUT, DELETE, publish or rollback under ' + + '/api/v1/meta// with no ?package=, and Setup\'s form save and reset, which send ' + + 'none) onto a view, dashboard, report, translation or email template that an installed ' + + 'read-only package ships, and the package-less sys_metadata rows such writes stored', + replacement: + 'before upgrading, run `os migrate packageless-overlays` (preview) and then ' + + '`os migrate packageless-overlays --apply` on each deployment with Setup customizations: it ' + + 'moves each package-less row of a name one installed package ships into that package\'s ' + + 'paired `.custom`, declaring `overrides: { package: , basedOn: }`. From then on write the customization into the pair: ' + + '`PUT /api/v1/meta//?package=.custom` with the `overrides` declaration in ' + + 'the body, and `DELETE /api/v1/meta//?package=.custom` to restore the ' + + 'package original', + reason: + 'A customization of an installed package lives in its paired `.custom` package, where it is ' + + 'a declared override that is exported with the pair and judged against the base\'s version ' + + '(ADR-0146 D1, D2, D11). The package-less write made a row of no package instead, which no ' + + 'export carries. Rows already stored that way are still served unchanged, so nothing breaks ' + + 'at the upgrade itself, but each package-less save, publish, delete or rollback of them is ' + + 'refused 403 NOT_OVERRIDABLE naming the pair and this command, so a deployment that skipped ' + + 'the step finds its Setup customizations read-only. No conversion applies: the rows are ' + + 'stored data, not authored source, and the move goes through the save door. A name several ' + + 'packages ship, or one whose package id forms no pair, is listed by the command and left ' + + 'for the operator. ADR-0146, ADR-0087.', + acceptanceCriteria: + 'On the deployment\'s database and configuration, `os migrate packageless-overlays` reports ' + + 'nothing movable (exit 0). Each customization is then served exactly as before, and a save ' + + 'of it with `?package=.custom` and its `overrides` declaration succeeds where the same ' + + 'save naming no package answers 403 NOT_OVERRIDABLE.', +}; diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index bc3d05b51b6..658273e491a 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -131,7 +131,7 @@ to a new protocol major — never to silence a mismatch. **`manifest.namespace` (ADR-0048):** Optional, but **enforced once set**. When a package declares `manifest.namespace: 'crm'`, every `object.name` must start with `crm_` or `defineStack` errors (`validateNamespacePrefix` in `@objectstack/spec`); the legacy `__` double-underscore form is rejected, and `sys_`-prefixed names are platform-reserved and exempt. The namespace is also a package-ownership key — installing two packages that both claim `crm` fails with `NamespaceConflictError` (downgrade to a warning with `OS_METADATA_COLLISION=warn`). `os lint` additionally emits a non-fatal `naming/namespace-prefix` warning for bare-named UI/automation items (app, page, dashboard, flow, action, report, dataset) when a namespace is set. -**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template or translation (page, app, dataset, doc, book, mapping, flow and action wait for their doors), never judges `basedOn` and ignores any other package's declaration. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a package-unbound Setup row still wins over the base's until D11. An undeclared pair row or a dependent's row is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`; the base's is unchanged. +**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template or translation (page, app, dataset, doc, book, mapping, flow and action wait for their doors), never judges `basedOn` and ignores any other package's declaration. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a pre-D11 package-unbound Setup row wins until migrated; D11 refuses new ones. An undeclared pair row or a dependent's row is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`. --- diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx new file mode 100644 index 00000000000..9835febce95 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.packagelessWriteClosed-28576.test.tsx @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28576 (ADR-0146 D11) — Setup's item page over a PACKAGED item + * shows the server's refusal of its package-less write as sent. + * + * The server closed the package-less write onto an item a code package ships: + * a save or a reset naming no package is refused `403 NOT_OVERRIDABLE`, and + * the sentence names the base's paired `.custom` package and the + * `os migrate packageless-overlays` ceremony — the two things the + * administrator can do. Setup's overlay page stays until its route retires + * (Studio is the editor), so what it owes is to relay that sentence whole, in + * the server's words: its error line leads with the first sentence and keeps + * the full text behind "More" (objectui#13332). + * + * Pinned over the REAL page and a REAL `MetadataClient`, whose transport is an + * in-memory server answering the `/meta` doors as the framework does after + * the close: the packaged item's layers, and a `403` for each write that names + * no package. Both the Save (a `PUT` with no `package`) and the Reset (a + * `DELETE` with no `package`) are asserted to have reached the server with no + * `package` key, so the refusal read back is the one that write was given. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { z } from 'zod'; +import { ComponentRegistry } from '@object-ui/core'; +import { DocSchema } from '@objectstack/spec/system'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('sonner', () => ({ toast: toasts })); + +const NAME = 'shipped_guide'; +const BASE = 'com.acme.handbook'; +const PAIR = `${BASE}.custom`; + +const doc = (content: string) => ({ name: NAME, label: NAME, content, group: 'reference' }); + +/** The server's sentences, worded as the framework words them; read back verbatim below. */ +const SAVE_CLOSED = + `'doc/${NAME}' ships in '${BASE}', and a write naming no package onto a packaged item is closed: a customization ` + + `of an installed package lives in its paired .custom package (ADR-0146 D11). Save it into '${PAIR}' ` + + `(?package=${PAIR}), declaring overrides: { package: '${BASE}', basedOn }. A package-less customization stored ` + + `before this release is still served; run \`os migrate packageless-overlays\` first to move it into '${PAIR}'.`; +const RESET_CLOSED = + `'doc/${NAME}' ships in '${BASE}', and a write naming no package onto a packaged item is closed: a customization ` + + `of an installed package lives in its paired .custom package (ADR-0146 D11). Remove it from '${PAIR}' ` + + `(?package=${PAIR}). A package-less customization stored before this release is still served; run ` + + `\`os migrate packageless-overlays\` first to move it into '${PAIR}'.`; + +const server = { + /** The packaged item's stored package-less row, as an unmigrated deployment holds it. */ + overlay: doc('# Customized') as Record, + writes: [] as Array<{ method: string; query: URLSearchParams }>, +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input), 'http://console.test'); + const q = url.searchParams; + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (!meta) return json(200, []); + const seg = meta[1].split('/').map(decodeURIComponent); + if (seg[0] === '_drafts') return json(200, { drafts: [] }); + if (seg.length === 1) return json(200, { items: [] }); + if (seg.length === 3 && seg[2] === 'layers') { + const code = { ...doc('# Shipped'), _packageId: BASE, _provenance: 'package' }; + return json(200, { + code, overlay: server.overlay, overlayScope: 'env', effective: server.overlay, + provenance: 'package', packageId: BASE, editable: true, deletable: true, resettable: true, lock: 'none', + }); + } + if (seg.length === 3 && seg[2] === 'references') return json(200, []); + if (seg.length === 2 && (method === 'PUT' || method === 'DELETE')) { + server.writes.push({ method, query: new URLSearchParams(q) }); + // After the close: a write naming no package onto this packaged item is refused. + if (!q.get('package')) { + return json(403, { error: method === 'PUT' ? SAVE_CLOSED : RESET_CLOSED, code: 'NOT_OVERRIDABLE' }); + } + return json(200, { success: true }); + } + if (seg.length === 2) { + if (q.get('state') === 'draft') return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } }); + return json(200, { type: seg[0], name: seg[1], item: server.overlay }); + } + return json(200, {}); +} + +const client = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client.current, + useMetadataTypes: () => ({ + loading: false, + entries: [ + // An overlay-able type as the registry serves the five: the page opens + // the packaged item for editing on this flag (objectui#12141). + { type: 'doc', label: 'Documentation', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true, schema: DOC_JSON_SCHEMA }, + ], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { registerBuiltinPreviews } from './previews'; + +const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record; +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +registerDefaultMetadataSchemas(); +registerBuiltinPreviews(); + +const STUB_NS = 'packageless-write-closed-28576'; +function StubMarkdown({ schema }: { schema: { content?: string } }) { + return
{schema.content}
; +} +beforeAll(() => { + ComponentRegistry.register('markdown', StubMarkdown as never, { namespace: STUB_NS }); +}); +afterAll(() => { + ComponentRegistry.unregister('markdown', STUB_NS); +}); + +beforeEach(() => { + server.overlay = doc('# Customized'); + server.writes = []; + for (const fn of Object.values(toasts)) fn.mockClear(); + window.localStorage.setItem('metadata-admin:autosave', '0'); +}); +afterEach(() => { + cleanup(); + window.localStorage.removeItem('metadata-admin:autosave'); + window.history.replaceState(null, '', '/'); +}); + +async function settle() { + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function open() { + const path = `/metadata/doc/${NAME}`; + window.history.replaceState(null, '', path); + render( + + + } /> + list} /> + + , + ); + const source = await screen.findByRole('textbox', { name: 'Markdown source' }, { timeout: 8000 }); + await settle(); + return source; +} + +/** The page's error line, opened whole: the server's sentence as sent. */ +async function refusalShown(): Promise { + fireEvent.click(await screen.findByTestId('refusal-more')); + return screen.getByTestId('refusal-detail').textContent ?? ''; +} + +describe('Setup shows the server\'s refusal of a package-less write onto a packaged item as sent (objectstack#28576)', () => { + it('Save: the PUT names no package, and the refusal is the page\'s error line, word for word', async () => { + const source = await open(); + fireEvent.change(source, { target: { value: '# Customized again' } }); + fireEvent.click(screen.getByRole('button', { name: /Save \(⌘S\)/ })); + + await waitFor(() => expect(server.writes.filter((w) => w.method === 'PUT')).toHaveLength(1)); + await settle(); + expect(server.writes[0]!.query.has('package')).toBe(false); + expect(await refusalShown()).toBe(SAVE_CLOSED); + expect(toasts.success).not.toHaveBeenCalled(); + }); + + it('Reset: the DELETE names no package, and the refusal is the page\'s error line, word for word; the page stays', async () => { + await open(); + fireEvent.click(screen.getByTestId('reset-or-delete-button')); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action')); + + await waitFor(() => expect(server.writes.filter((w) => w.method === 'DELETE')).toHaveLength(1)); + await settle(); + expect([...server.writes[0]!.query.keys()]).toEqual([]); + expect(await refusalShown()).toBe(RESET_CLOSED); + expect(screen.queryByTestId('list-view')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 149b9b902a3..1a914967b77 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -175,7 +175,6 @@ import { Clock, MousePointerClick, CornerDownRight, - Layers, ArrowUpRight, FileQuestion, PackageX, @@ -873,15 +872,14 @@ const PACKAGE_LIST_TOAST_ID = 'studio-package-list'; * chip, and this mark at the right of the open item's header, in every * pillar. The pillar's read-only sentence (objectui#11781) is its title. * - * It offers an environment overlay only where one exists: when the open - * item's metadata type declares `allowEnvironmentOverlay` in the served - * registry (ADR-0005), it is a link to that item's Setup page, where the - * overlay is edited. Any other item, no open item, or a registry not yet - * read draws a plain "Read-only" mark that links nowhere. - * - * objectstack#28562 (ADR-0146 D2) — nor does a view, dashboard or report - * (`isStudioCustomizeType`): Studio customizes those itself, into the base's - * paired customization package, so Setup's overlay page is not their editor. + * objectstack#28576 (ADR-0146 D11) — a plain "Read-only" mark that links + * nowhere, for every item of a read-only package. It used to link an item of + * an `allowEnvironmentOverlay` type to its Setup page ("Customize with an + * overlay"), whose save names no package; the server refuses that write onto + * a packaged item now, and a customization lives in the base's paired + * `.custom` package instead (Studio customizes a view, dashboard or report + * there itself, objectstack#28562). So the mark offers no overlay, whatever + * the type's registry flag says. `item` stays in the props the pillars pass. * * objectui#12707 — at a tab row's end, the mark folds to its glyph when the * row cannot hold it whole (`useStudioTabRowFold`), rather than being cut @@ -889,7 +887,6 @@ const PACKAGE_LIST_TOAST_ID = 'studio-package-list'; * and its title stays its tooltip. */ export function StudioReadOnlyLink({ - item, title, locale, }: { @@ -898,50 +895,23 @@ export function StudioReadOnlyLink({ title: string; locale: string; }): React.ReactElement { - const client = useMetadataClient(); - const { entries } = useMetadataTypes(client); - const overlayable = - item !== null && - !isStudioCustomizeType(item.type) && - entries.some((e) => e.type === item.type && e.allowEnvironmentOverlay === true); - const ref = React.useRef(null); - const label = t(overlayable ? 'engine.studio.pkg.readonlyLink' : 'engine.studio.pkg.readonly', locale); + const ref = React.useRef(null); + const label = t('engine.studio.pkg.readonly', locale); const folded = useStudioTabRowFold(ref, label); const className = cn( 'inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md bg-amber-400/15 text-xs font-medium text-amber-700 dark:text-amber-300', - overlayable && 'hover:bg-amber-400/25', folded ? 'w-7 justify-center' : 'px-2.5', ); - const body = ( - <> - {overlayable ? ( -