From c12ff96ebc781d5e7b927b3318fc5c17e39fdb29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:07:33 +0000 Subject: [PATCH 1/5] fix(metadata-protocol): a write naming a read-only base itself is closed onto its pair An authoring save (draft or active), a publish (per-item and the package batch) or a rollback that names the read-only code package itself (`?package=`) onto an item of one of the five overlay types it ships landed a row bound to the base with no declaration: served at the base's address, outside the pair ADR-0146 D1 puts every customization in, and not seen by `os migrate packageless-overlays`. Each is now refused `403 NOT_OVERRIDABLE` with the package-less close's fix (the pair, its `?package=` address, the declaration), one spelling of it, under the 500-character bound. Closed only where that fix can be taken; a delete naming the base stays open (the one way to clear a row stored before). Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8 Co-authored-by: Claude --- .../src/protocol-packaged-base-refusal.ts | 180 ++++++++- .../protocol.named-base-overlay-write.test.ts | 364 ++++++++++++++++++ .../protocol.packageless-save-address.test.ts | 17 +- packages/metadata-protocol/src/protocol.ts | 107 ++++- .../environment-overlay-five-types.test.ts | 25 +- 5 files changed, 676 insertions(+), 17 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index e1ee7c89799..afbe987568e 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -39,6 +39,8 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js'; +import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js'; +import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; import { customizationBasePackageId, customizationPackageId, @@ -104,10 +106,14 @@ export function refusePackagedBaseOverride( // • Below the registry limb — this whole branch is guarded // by {@link isSealedManagedItem}, whose type half is the // registry's `allowEnvironmentOverlay` alone, so a regime-O type - // never reaches the door. That is ADR-0005: an overlay of - // a code-shipped item ALWAYS names the read-only package - // it customizes, and a door one limb higher would close - // the overlay model outright. Pinned. + // never reaches the door. That was ADR-0005: an overlay of + // a code-shipped item ALWAYS named the read-only package + // it customizes, and a door one limb higher would have closed + // the overlay model outright. Pinned. [#28905, ADR-0146 D1] + // Such an overlay now lands in the base's paired `.custom`: + // an authoring write naming the read-only base of one of the + // five types is refused by its own close + // ({@link namedBaseCloseSubject}), never by this door. // • [ADR-0131 D6] No hatch limb. `OS_METADATA_WRITABLE` used // to take an artifact-backed write past this branch, down // to the repository door, where a package-less write landed @@ -240,11 +246,7 @@ export function packagelessOverlayWriteRefusal( closed: { base: string; pair: string }, operation: PackagelessWriteOperation, ): Error { - const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; - const item = `'${type}/${request.name}'`; - const shipped = `ships in '${closed.base}'`; - const pair = `'${closed.pair}' (?package=${closed.pair})`; - const declared = `overrides: { package: '${closed.base}', basedOn }`; + const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed); const rule = 'A write naming no package is closed where `os migrate packageless-overlays` can move its row ' + `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`; const message = operation === 'rollback' @@ -253,13 +255,171 @@ export function packagelessOverlayWriteRefusal( + `the move, save that version's body there, declaring ${declared}.` : operation === 'delete' ? `${item} ${shipped}: remove its customization from ${pair}. ${rule}` - : `${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}. ${rule}`; + : `${writeInto(operation)} ${rule}`; + return closedOverlayWriteError(message); +} + +/** + * The clauses both closes of an overlay write onto a packaged item spell + * ({@link packagelessOverlayWriteRefusal}, {@link namedBaseOverlayWriteRefusal}): + * the item, the base that ships it, the pair (with its `?package=` address), + * the declaration, and the fix — write it into the pair, declaring the + * override. ONE spelling: a writer refused at either address reads the same + * fix, and only the rule clause that follows it says which address is closed. + */ +function closedOverlayWriteClauses( + request: { type: string; name: string }, + closed: { base: string; pair: string }, +) { + const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const item = `'${type}/${request.name}'`; + const shipped = `ships in '${closed.base}'`; + const pair = `'${closed.pair}' (?package=${closed.pair})`; + const declared = `overrides: { package: '${closed.base}', basedOn }`; + const writeInto = (operation: 'save' | 'publish') => + `${item} ${shipped}: ${operation === 'publish' ? 'publish' : 'save'} it into ${pair}, declaring ${declared}.`; + return { item, shipped, pair, declared, writeInto }; +} + +/** `403 NOT_OVERRIDABLE`, the code and status the sealed write already answers, so neither close gains a code. */ +function closedOverlayWriteError(message: string): Error { const err = new Error(message); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; return err; } +// ───────────────────────────────────────────────────────────────────────── +// ADR-0146 D1 / D11 — the write naming the read-only base itself is closed +// ───────────────────────────────────────────────────────────────────────── + +/** + * The writes {@link namedBaseOverlayWriteRefusal} judges: the three that land + * a row bound to the base. ⛔ Not `delete`: removing a row bound to the base + * writes nothing, and it is the one way to clear such a row stored before the + * close — `os migrate packageless-overlays` reads package-less rows only, so + * it never moves one. The removal serves the base's own document again, or + * the pair's declared override where one exists (it is served over the row). + */ +export type NamedBaseWriteOperation = Exclude; + +/** + * The refusal `ObjectStackProtocolImplementation#namedBaseOverlayWriteRefusal` + * throws; the method's docblock there is its contract. + * + * [#28905, ADR-0146 D1, D11] An authoring write that names the read-only code + * package itself (`?package=`) onto an item of one of the five overlay + * types that package ships stored the customization as a row bound to the + * base, with no declaration: served at the base's address in place of its + * document, outside the pair D1 puts every customization in, and not seen by + * `os migrate packageless-overlays`. It is refused with the fix the + * package-less close names ({@link closedOverlayWriteClauses}): write it into + * the pair, declaring the override. A row stored that way before the close is + * still served until the pair declares the override (the declared override is + * served over it) or a delete naming the base removes it — both open. + * + * Fix first, under the REST door's 500-character bound (`CLIENT_MESSAGE_MAX`, + * `@objectstack/types`), as the package-less sentence. + */ +export function namedBaseOverlayWriteRefusal( + request: { type: string; name: string }, + closed: { base: string; pair: string }, + operation: NamedBaseWriteOperation, +): Error { + const { item, shipped, pair, declared, writeInto } = closedOverlayWriteClauses(request, closed); + const served = 'is served until the pair declares the override, or a delete naming the base removes it.'; + const message = operation === 'rollback' + ? `${item} ${shipped}, and a rollback of its row bound to the read-only base is closed (ADR-0146 D1, D11): ` + + `save the version's body into ${pair}, declaring ${declared}. The row ${served}` + : `${writeInto(operation)} The read-only base itself takes no customization (ADR-0146 D1, D11): ` + + `a row bound to it from before ${served}`; + return closedOverlayWriteError(message); +} + +/** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */ +export interface NamedBaseCloseHost { + readonly engine: ObjectStackProtocolImplementation['engine']; + /** The read-only code packages that ship a name of an overlay type ([] for any other type). */ + readonly overlayBaseShippers: (type: string, name: string) => string[]; + /** The override write gate, as the declared write into the pair meets it at the save door. */ + readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict; +} + +/** The write naming a package that {@link namedBaseCloseSubject} judges. */ +export interface NamedBaseWriteSubject { + readonly type: string; + readonly name: string; + /** The package the write names; only a non-empty one is judged here (none is #28576's close). */ + readonly packageId?: string | null; + /** The bundle member as the caller names it; absent, the body's member, else the canonical one. */ + readonly member?: string; + /** A save's body. */ + readonly item?: unknown; +} + +/** + * [#28905, ADR-0146 D1, D11] The base and pair a write naming a package is + * closed onto, or `null` when it stays open. Closed exactly where the write + * names a READ-ONLY code package that ships the item, on one of the five + * overlay types — the package-less close's reading of "packaged" + * (`overlayBaseShippers`, the reading `os migrate packageless-overlays` + * classifies by), with the named package among the shippers — and where the + * fix the refusal names can be taken, read from the steps that write takes: + * + * - the base has a pair id (`customizationPackageId`); + * - the ensure door would answer or create the pair + * ({@link customizationPairEnsureRefusal}); + * - the base records a version (`basedOn`, its `manifest.version`, the value + * the ceremony records too); + * - the override write gate admits the declared body into the pair (an + * `email_template` member at a locale the base does not ship is refused + * there, so its write stays open, as the package-less one does). + * + * A body carrying `overrides` is not judged here: the D2 gate answers a + * declaration outside the pair, the schema gate a malformed one, as before. + * + * So every write this closes has a door, the pair; every other one keeps the + * write it has on `main`. A writable package, a package that does not ship the + * name (ADR-0070 D1's `WRITABLE_PACKAGE_REQUIRED` answers a new item in a + * read-only one) and every other type are not this close's. Registry reads + * only: it reads no row. + */ +export function namedBaseCloseSubject( + this: NamedBaseCloseHost, + request: NamedBaseWriteSubject, +): { base: string; pair: string } | null { + const base = typeof request.packageId === 'string' && request.packageId.length > 0 ? request.packageId : undefined; + if (base === undefined) return null; // the package-less write: #28576's close + // A body carrying `overrides` keeps its answer: the D2 gate refuses a + // declaration outside the pair (`declared_outside_pair`), the schema gate + // a malformed one (`422 INVALID_METADATA`). + if (isPlainRecord(request.item) && 'overrides' in request.item) return null; + if (!this.overlayBaseShippers(request.type, request.name).includes(base)) return null; + const pairId = customizationPackageId(base); + if (!pairId.ok) return null; // no pair to name + const pair = pairId.value; + const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry; + if (!registry || typeof registry.getPackage !== 'function') return null; + if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; + const basedOn = registry.getPackage(base)?.manifest?.version; + if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare + + // The gate's verdict on the declared body, written into the pair. + const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const written = isPlainRecord(request.item) ? request.item : undefined; + const member = written !== undefined && request.member === undefined + ? overlayMemberOfBody(type, written) + : overlayMemberValue(type, request.member); + const disc = member === undefined ? undefined : overlayMemberField(type); + const declaredBody = { + ...(written ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }), + overrides: { package: base, basedOn }, + }; + const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody }); + if (verdict.verdict === 'refuse') return null; + return { base, pair }; +} + // ───────────────────────────────────────────────────────────────────────── // ADR-0146 D2 — the override write gate (ruling A on decision card #28390) // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts new file mode 100644 index 00000000000..e174815e921 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts @@ -0,0 +1,364 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28905, ADR-0146 D1, D11] The write NAMING the read-only base itself + * (`?package=`) onto an item of one of the five overlay types it ships. + * + * D1 puts every customization of a read-only package in its paired + * `.custom`, and #28576 closed the write naming no package. The write + * naming the base was still accepted: a save (draft or active), a publish (the + * per-item one and the package batch) and a rollback each landed a row bound + * to the base with no declaration, served at the base's address in place of + * its document, outside the pair, and never seen by + * `os migrate packageless-overlays` (its census reads package-less rows). + * Measured through the doors below before the close. + * + * Pinned here, through the real doors over a stub engine: + * + * - each of the five types: the save naming the base, active and draft, is + * refused `403 NOT_OVERRIDABLE` with #28576's fix (the pair, its + * `?package=` address, the declaration), under the REST door's + * 500-character bound, and writes nothing; the read serves the base's own + * document; + * - the per-item publish and `publishPackageDrafts` of a draft bound to the + * base (stored before the close): refused with the publish sentence, the + * draft stays a draft and no active row is written; + * - the rollback of a row bound to the base: refused, the row unchanged; + * - CONTROLS, open as on `main`: a delete naming the base removes such a row + * (the one way to clear it: the ceremony moves package-less rows only), a + * draft discard naming it, the pair's declared save (served over a row + * bound to the base), a server-stated rewrite (`source` set), a writable + * package's same name, a name the base does not ship (ADR-0070 D1's own + * refusal), a base whose pair cannot be ensured, and a body carrying + * `overrides` (the D2 gate's sentence, unchanged). + * + * Refusals are asserted as the envelope an HTTP boundary answers with + * (`resolveThrownHttpError`). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { namedBaseOverlayWriteRefusal } from './protocol-packaged-base-refusal.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +/** A booted code package: read-only, with the namespace and version its pair is ensured from. */ +const BASE = 'com.acme.crm'; +const PAIR = 'com.acme.crm.custom'; +const BASE_VERSION = '3.2.0'; +/** A read-only code package with no namespace: its pair cannot be ensured (ADR-0146 D3). */ +const NO_PAIR_BASE = 'com.legacy.app'; +/** A writable runtime package: no manifest booted it. */ +const WRITABLE = 'com.acme.workspace'; + +const MANIFESTS: Record> = { + [BASE]: { dependencies: {}, version: BASE_VERSION, namespace: 'acme' }, + [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0', namespace: 'acme_custom' }, + [NO_PAIR_BASE]: { dependencies: {}, version: '1.0.0' }, + [WRITABLE]: { dependencies: {} }, +}; + +/** One item of each overlay type the base ships, and a body a write carries. */ +const ITEMS: Array<{ type: string; name: string; body: (label: string) => Record }> = [ + { + type: 'view', name: 'task.crm_list', + body: (label) => ({ name: 'task.crm_list', label, object: 'task', viewKind: 'list', columns: [{ field: 'title', label: 'Title' }] }), + }, + { type: 'dashboard', name: 'crm_board', body: (label) => ({ name: 'crm_board', label, widgets: [] }) }, + { + type: 'report', name: 'crm_report', + body: (label) => ({ name: 'crm_report', label, type: 'summary', dataset: 'crm_metrics', rows: ['month'], values: ['amount_sum'] }), + }, + { type: 'translation', name: 'crm_fr', body: (label) => ({ name: 'crm_fr', label, locale: 'fr', messages: { save: 'Enregistrer' } }) }, + { type: 'email_template', name: 'crm_welcome', body: (label) => ({ name: 'crm_welcome', label, subject: 'Welcome', bodyHtml: '

Hi

' }) }, +]; +const VIEW = ITEMS[0]!; +const SHIPPED = [ + ...ITEMS.map((i) => ({ type: i.type, name: i.name, pkg: BASE })), + { type: 'dashboard', name: 'legacy_board', pkg: NO_PAIR_BASE }, +]; +const PACKAGED = 'Packaged'; + +type Row = Record; + +function matches(row: Row, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + if (k === '$or') return (v as Array>).some((c) => matches(row, c)); + if (v !== null && typeof v === 'object' && Array.isArray((v as { $in?: unknown }).$in)) { + return (v as { $in: unknown[] }).$in.includes(row[k] ?? null); + } + if (v !== null && typeof v === 'object') throw new Error(`stub engine: unsupported operator on ${k}`); + return (row[k] ?? null) === v; + }); +} + +function makeProtocol() { + const tables = new Map(); + const table = (n: string) => { + if (!tables.has(n)) tables.set(n, []); + return tables.get(n)!; + }; + let nextId = 0; + const artifact = (type: string, name: string) => { + const hit = SHIPPED.find((i) => i.type === type && i.name === name); + return hit ? { ...ITEMS.find((i) => i.type === type && i.name === name)?.body(PACKAGED), name, label: PACKAGED, _packageId: hit.pkg, _provenance: 'package' } : undefined; + }; + const packageRecord = (id: string) => (MANIFESTS[id] ? { manifest: { id, ...MANIFESTS[id] } } : undefined); + const engine: any = { + // A booted code package is read-only (`isWritablePackage` reads `manifests` first). + manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}]]), + async find(t: string, q?: { where?: Record; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(t: string, q: { where: Record }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + nextId += 1; + const row = { id: `r_${nextId}`, ...data }; + table(t).push(row); + return { ...row }; + }, + async update(t: string, data: Row, q?: { where?: Record }) { + assertEngineUpdateDispatch(data, q); + const hit = table(t).filter((r) => matches(r, q?.where)); + for (const r of hit) Object.assign(r, data); + return { id: hit[0]?.id ?? null }; + }, + async delete(t: string, q?: { where?: Record }) { + assertEngineDeleteDispatch(q); + const keep = table(t).filter((r) => !matches(r, q?.where)); + const deleted = table(t).length - keep.length; + tables.set(t, keep); + return { deleted }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { return true; }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + listItems: (type: string) => (type === 'object' + ? [{ name: 'task', fields: { name: { type: 'text' }, title: { type: 'text' } } }] + : SHIPPED.filter((i) => i.type === type).map((i) => artifact(i.type, i.name))), + getItem: (type: string, name: string) => artifact(type, name), + getObject: () => undefined, + getPackage: packageRecord, + getAllPackages: () => Object.keys(MANIFESTS).map(packageRecord), + isPackageDisabled: () => false, + getArtifactItem: artifact, + applyNavContributions: (a: unknown) => a, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1') as any; + const rows = (type: string, name: string) => table('sys_metadata') + .filter((r) => r.type === type && r.name === name) + .map((r) => ({ package_id: r.package_id ?? null, state: r.state, label: JSON.parse(r.metadata).label })); + /** A draft bound to the base, as a save naming it left one before the close. */ + const seedBaseDraft = (type: string, name: string, body: Record) => { + nextId += 1; + table('sys_metadata').push({ + id: `seed_${nextId}`, type, name, organization_id: null, package_id: BASE, state: 'draft', + version: 1, metadata: JSON.stringify(body), checksum: `sha-${nextId}`, + }); + }; + /** + * A row bound to the base, as a save naming it left one before the close: + * written through the server-stated rewrite (`source` set), the one write + * naming the base the close does not judge, so its history is the door's. + */ + const storeBaseRow = (type: string, name: string, body: Record) => + protocol.saveMetaItem({ type, name, item: body, packageId: BASE, source: 'migrate-stored', force: true }); + return { protocol, rows, seedBaseDraft, storeBaseRow }; +} + +const thrown = (run: () => Promise) => run().then(() => undefined, (e: any) => e); + +/** The named-base close's envelope, its fix (#28576's, one spelling) and its bound. */ +function expectClosed(e: any, type: string, name: string, verb: 'save' | 'publish' | 'rollback') { + expect(e, 'expected the write naming the base to be refused').toBeInstanceOf(Error); + expect(resolveThrownHttpError(e)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' }); + expectClosedSentence(String(e.message), type, name, verb); +} + +function expectClosedSentence(message: string, type: string, name: string, verb: 'save' | 'publish' | 'rollback') { + expect(message).toContain(`'${type}/${name}' ships in '${BASE}'`); + expect(message).toContain(`'${PAIR}' (?package=${PAIR})`); + expect(message).toContain(`overrides: { package: '${BASE}', basedOn }`); + expect(message).toContain('ADR-0146 D1, D11'); + if (verb === 'rollback') expect(message).toContain('a rollback of its row bound to the read-only base is closed'); + else expect(message).toContain(`${verb} it into '${PAIR}'`); + // The REST door cuts a refusal's text at 500 characters + // (`CLIENT_MESSAGE_MAX`, `@objectstack/types`): the whole sentence fits. + expect(message.length).toBeLessThan(500); +} + +beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('a write naming the read-only base itself is closed: its customization lands in the pair (ADR-0146 D1, D11)', () => { + for (const { type, name, body } of ITEMS) { + it(`${type}: the save naming the base, active and draft, is refused naming the pair, and nothing is written`, async () => { + const { protocol, rows } = makeProtocol(); + + const active = await thrown(() => protocol.saveMetaItem({ type, name, item: body('NamedBase'), packageId: BASE })); + const draft = await thrown(() => protocol.saveMetaItem({ type, name, item: body('NamedBaseDraft'), packageId: BASE, mode: 'draft' })); + + expectClosed(active, type, name, 'save'); + expectClosed(draft, type, name, 'save'); + expect(rows(type, name)).toEqual([]); + expect((await protocol.getMetaItem({ type, name, packageId: BASE })).item.label).toBe(PACKAGED); + }); + } + + it('the per-item publish of a draft bound to the base is refused naming the pair; the draft stays a draft', async () => { + const { protocol, rows, seedBaseDraft } = makeProtocol(); + seedBaseDraft(VIEW.type, VIEW.name, VIEW.body('BaseDraft')); + + const refused = await thrown(() => protocol.publishMetaItem({ type: VIEW.type, name: VIEW.name, packageId: BASE })); + + expectClosed(refused, VIEW.type, VIEW.name, 'publish'); + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'draft', label: 'BaseDraft' }]); + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe(PACKAGED); + }); + + it('the package batch publish (`publishPackageDrafts`) of the base\'s drafts promotes none of them', async () => { + const { protocol, rows, seedBaseDraft } = makeProtocol(); + seedBaseDraft(VIEW.type, VIEW.name, VIEW.body('BaseDraft')); + + const result = await protocol.publishPackageDrafts({ packageId: BASE }); + + // The batch is all-or-nothing (ADR-0067 D2): refused, naming the item and the close. + expect(result).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, published: [] }); + expect(result.failed).toEqual([expect.objectContaining({ type: VIEW.type, name: VIEW.name, code: 'NOT_OVERRIDABLE' })]); + expectClosedSentence(result.failed[0].error, VIEW.type, VIEW.name, 'publish'); + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'draft', label: 'BaseDraft' }]); + }); + + it('the rollback of a row bound to the base is refused naming the pair; the row is unchanged', async () => { + const { protocol, rows, storeBaseRow } = makeProtocol(); + await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('First')); + await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('Second')); + + const refused = await thrown(() => protocol.rollbackMetaItem({ type: VIEW.type, name: VIEW.name, toVersion: 1, actor: 'admin' })); + + expectClosed(refused, VIEW.type, VIEW.name, 'rollback'); + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Second' }]); + }); + + it('a 30-character base id and a 40-character name keep each sentence under the 500-character bound', () => { + const closed = { base: 'com.objectstack.enterprise_crm', pair: 'com.objectstack.enterprise_crm.custom' }; + const request = { type: 'email_template', name: `crm_${'x'.repeat(36)}` }; + for (const op of ['save', 'publish', 'rollback'] as const) { + const named = namedBaseOverlayWriteRefusal(request, closed, op).message; + expect(named.length, `${op}: ${named}`).toBeLessThan(500); + } + }); +}); + +describe('CONTROLS: the writes the close leaves open, as on main', () => { + it('a delete naming the base removes a row bound to it (stored before the close); the read serves the base again', async () => { + const { protocol, rows, storeBaseRow } = makeProtocol(); + await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('StoredBefore')); + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('StoredBefore'); + + const receipt = await protocol.deleteMetaItem({ type: VIEW.type, name: VIEW.name, packageId: BASE }); + + expect(receipt).toMatchObject({ success: true }); + expect(rows(VIEW.type, VIEW.name)).toEqual([]); + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe(PACKAGED); + }); + + it('a draft discard naming the base removes a draft bound to it', async () => { + const { protocol, rows, seedBaseDraft } = makeProtocol(); + seedBaseDraft(VIEW.type, VIEW.name, VIEW.body('BaseDraft')); + + const receipt = await protocol.deleteMetaItem({ type: VIEW.type, name: VIEW.name, packageId: BASE, state: 'draft' }); + + expect(receipt).toMatchObject({ success: true }); + expect(rows(VIEW.type, VIEW.name)).toEqual([]); + }); + + it('the fix the refusal names is taken: the pair\'s declared save is accepted and served over a row bound to the base', async () => { + const { protocol, rows, storeBaseRow } = makeProtocol(); + await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('StoredBefore')); + + const saved = await protocol.saveMetaItem({ + type: VIEW.type, name: VIEW.name, packageId: PAIR, + item: { ...VIEW.body('PairDeclared'), overrides: { package: BASE, basedOn: BASE_VERSION } }, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows(VIEW.type, VIEW.name)).toEqual(expect.arrayContaining([ + { package_id: PAIR, state: 'active', label: 'PairDeclared' }, + { package_id: BASE, state: 'active', label: 'StoredBefore' }, + ])); + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('PairDeclared'); + expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name, packageId: BASE })).item.label).toBe('PairDeclared'); + }); + + it('a server-stated rewrite naming the base (`source` set) still writes the row it names', async () => { + const { protocol, rows, storeBaseRow } = makeProtocol(); + + await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('Rewritten')); + + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Rewritten' }]); + }); + + it('a writable package\'s save of the same name is not this close\'s', async () => { + const { protocol, rows } = makeProtocol(); + + const saved = await protocol.saveMetaItem({ type: VIEW.type, name: VIEW.name, item: VIEW.body('Workspace'), packageId: WRITABLE }); + + expect(saved).toMatchObject({ success: true }); + expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: WRITABLE, state: 'active', label: 'Workspace' }]); + }); + + it('a name the base does not ship keeps ADR-0070 D1\'s answer, not this close\'s', async () => { + const { protocol, rows } = makeProtocol(); + const name = 'task.not_shipped'; + + const e = await thrown(() => protocol.saveMetaItem({ type: 'view', name, item: { ...VIEW.body('New'), name }, packageId: BASE })); + + expect(resolveThrownHttpError(e)).not.toMatchObject({ code: 'NOT_OVERRIDABLE' }); + expect(resolveThrownHttpError(e)).toMatchObject({ code: 'WRITABLE_PACKAGE_REQUIRED' }); + expect(rows('view', name)).toEqual([]); + }); + + it('a read-only base whose pair cannot be ensured keeps the write naming it: the refusal would name no door', async () => { + const { protocol, rows } = makeProtocol(); + + const saved = await protocol.saveMetaItem({ + type: 'dashboard', name: 'legacy_board', item: { name: 'legacy_board', label: 'Legacy', widgets: [] }, packageId: NO_PAIR_BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows('dashboard', 'legacy_board')).toEqual([{ package_id: NO_PAIR_BASE, state: 'active', label: 'Legacy' }]); + }); + + it('a body carrying `overrides` keeps the D2 gate\'s sentence: only the pair declares one', async () => { + const { protocol, rows } = makeProtocol(); + + const e = await thrown(() => protocol.saveMetaItem({ + type: VIEW.type, name: VIEW.name, packageId: BASE, + item: { ...VIEW.body('Declared'), overrides: { package: BASE, basedOn: BASE_VERSION } }, + })); + + expect(resolveThrownHttpError(e)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(String(e.message)).toContain(`only '${PAIR}' may declare an override of '${BASE}'`); + expect(String(e.message)).not.toContain('ADR-0146 D1, D11'); + expect(rows(VIEW.type, VIEW.name)).toEqual([]); + }); +}); 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 02f7e1e9001..b61f6ab0b17 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -37,7 +37,8 @@ * stored-metadata migration passes `packageId: null`) still writes it; * - [#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 + * stored row or with an overlay row bound to the code package (stored + * before [#28905] closed the save naming the package too), 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`, @@ -451,12 +452,20 @@ describe('an item a CODE package ships: the package-less write is closed (ADR-01 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. - const { protocol, rows } = makeProtocol([ + const { protocol, rows, seedRow } = makeProtocol([ packagedView(name), { type: 'object', item: { name: 'task', _packageId: CODE_PACKAGE, fields: { name: { type: 'text' } } } }, ]); - // 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 }); + // [#28905, ADR-0146 D1] A save naming the read-only code package is + // closed too now, naming the same pair + // (`protocol.named-base-overlay-write.test.ts` holds that close), so + // the row bound to the package is one stored before it, as an older + // build left it. + const named = await refusalOf(() => protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Named'), packageId: CODE_PACKAGE })); + expect(resolveThrownHttpError(named)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(String(named.message)).toContain(`'${PAIR}'`); + expect(rows('view', name)).toEqual([]); + seedRow({ type: 'view', name, package_id: CODE_PACKAGE, metadata: JSON.stringify(viewBody(name, 'Named')), checksum: 'sha-named' }); const refused = await protocol.saveMetaItem({ type: 'view', name, item: viewBody(name, 'Unnamed') }).then(() => undefined, (e: unknown) => e); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0261038e739..08b97a3630d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -397,13 +397,21 @@ import { discardDraftInItsScope, listDrafts } from './protocol-drafts.js'; // [#25467 cut 13] The package door's save refusal and the origin-gated removal verdict. import { isPairRowRemoval, + namedBaseCloseSubject, + namedBaseOverlayWriteRefusal, originGatedRemovalRefusal, overrideWriteRefusalError, overrideWriteVerdict, packagelessOverlayWriteRefusal, refusePackagedBaseOverride, } from './protocol-packaged-base-refusal.js'; -import type { OverrideWriteSubject, OverrideWriteVerdict, PackagelessWriteOperation } from './protocol-packaged-base-refusal.js'; +import type { + NamedBaseWriteOperation, + NamedBaseWriteSubject, + OverrideWriteSubject, + OverrideWriteVerdict, + PackagelessWriteOperation, +} from './protocol-packaged-base-refusal.js'; // [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge. import { extensionFieldSuffixRefusalError, @@ -17285,7 +17293,9 @@ export class ObjectStackProtocolImplementation implements * * `null` — the write stays open, as on `main` — when it names a package * (`?package=.custom` stays open, as does every other package's own - * door); when no read-only package ships the name (environment metadata); + * door; [#28905] the write naming the read-only base itself is closed by + * {@link namedBaseOverlayWriteRefusal}, not here); when no read-only + * package ships the name (environment metadata); * and for every row the ceremony lists and does not move: a name several * packages ship (`multi_shipped`), a base with no pair id (`no_pair`), a * stored row whose `overrides` names another package @@ -17327,6 +17337,50 @@ export class ObjectStackProtocolImplementation implements return closed ? packagelessOverlayWriteRefusal(request, closed, operation) : null; } + /** + * [#28905, ADR-0146 D1, D11] The write NAMING the read-only base itself + * (`?package=`) onto an item of one of the five overlay types that + * base ships: the refusal of a save (draft or active), a publish (both + * {@link publishMetaItem} and {@link publishPackageDrafts}, through + * {@link promoteDraftForPublish}) or a rollback that would land a row bound + * to the base. D1 puts a read-only package's customizations in its paired + * `.custom`; such a row is served at the base's address with no + * declaration, outside the pair, and `os migrate packageless-overlays` + * never sees it (its census reads package-less rows only). Refused with + * the fix the package-less close names ({@link packagelessOverlayWriteRefusal}, + * one spelling): write it into the pair, declaring the override. + * + * Closed on the package-less close's reading of "packaged" — a READ-ONLY + * code package among {@link overlayBaseShippers} — and only where that fix + * can be taken (a pair id, the ensure door, a base version, the override + * write gate admitting the declared body): `namedBaseCloseSubject` in + * `protocol-packaged-base-refusal.ts`. `null` — the write stays open, as on + * `main` — for a writable package, a package that does not ship the name, + * a body carrying `overrides` (the D2 gate's), any other type, and a fix + * that could not be taken. + * + * ⛔ Not a delete: removing a row bound to the base writes nothing, and it + * is the only way to clear such a row stored before the close (the + * ceremony moves package-less rows only). A row stored before it stays + * served until the pair declares the override, which is served over it. + * + * Authoring writes only: the server-stated rewrites (`source` set, the + * `package-duplicate` write face) name the row they rewrite. Registry + * reads only. + */ + private namedBaseOverlayWriteRefusal( + request: NamedBaseWriteSubject, + operation: NamedBaseWriteOperation, + ): Error | null { + const host: ThisParameterType = { + engine: this.engine, + overlayBaseShippers: (...a) => this.overlayBaseShippers(...a), + overrideWriteVerdict: (...a) => this.overrideWriteVerdict(...a), + }; + const closed = namedBaseCloseSubject.call(host, request); + return closed ? namedBaseOverlayWriteRefusal(request, closed, operation) : null; + } + /** * [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 @@ -21574,6 +21628,13 @@ export class ObjectStackProtocolImplementation implements // 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. + // + // [#28905, ADR-0146 D1, D11] …and the save NAMING the read-only base + // itself (`?package=`, draft or active), of an item of one of the + // five overlay types it ships: refused with the same fix, the pair + // ({@link namedBaseOverlayWriteRefusal}; registry reads only). Before + // it, such a save landed an undeclared row bound to the base, served + // in place of the base's document and never seen by the ceremony. if (request.source === undefined && request.writeFace !== 'package-duplicate') { const closed = await this.packagelessOverlayWriteRefusal({ type: request.type, @@ -21583,6 +21644,13 @@ export class ObjectStackProtocolImplementation implements item: request.item, }, 'save'); if (closed) throw closed; + const namedBase = this.namedBaseOverlayWriteRefusal({ + type: request.type, + name: request.name, + packageId: request.packageId, + item: request.item, + }, 'save'); + if (namedBase) throw namedBase; } const overrideVerdict = this.overrideWriteVerdict(request); if (overrideVerdict.verdict === 'refuse' && overrideVerdict.rule !== 'undeclared_same_name') { @@ -22842,6 +22910,13 @@ export class ObjectStackProtocolImplementation implements // Left untouched (the binding survives): // • `override-artifact` writes — an org overlay OF a packaged item // must keep pointing at the package it customizes (ADR-0005). + // [#28905, ADR-0146 D1] Of the five overlay types, an authoring + // write naming the READ-ONLY base that ships the item no longer + // reaches here: {@link namedBaseOverlayWriteRefusal} refused it + // above, naming the pair. What still arrives is a writable + // package's overlay, a server-stated rewrite, a body carrying + // `overrides` (the D2 gate's) and a close whose fix could not be + // taken. // • a project-scoped base, or a bare ADR-0048 authoring-workspace // id — both are writable; `isWritablePackage` returns true. // A `null` packageId is still accepted here (legacy org-overlay @@ -24427,6 +24502,22 @@ export class ObjectStackProtocolImplementation implements ); if (nameCollision) throw nameCollision; } + // [#28905, ADR-0146 D1, D11] …and the promotion INTO the read-only + // base itself, of an item of one of the five overlay types it ships: + // the active row it writes is bound to the base, outside the pair D1 + // puts every customization in, as the save naming the base would be, + // and is refused with that save's sentence + // ({@link namedBaseOverlayWriteRefusal}) before `repo.promoteDraft` + // writes, so a batch publish aborts on it as on the refusals above. + // Judged on `promoteInto`, the package the active row is written + // under; the draft stays a draft, for a delete to discard. + if (draftForGate) { + const namedBase = this.namedBaseOverlayWriteRefusal({ + type: singularType, name: request.name, packageId: promoteInto, + item: draftForGate.body, ...memberOption(request.member), + }, 'publish'); + if (namedBase) throw namedBase; + } // [ADR-0146 D2] …and the override write gate, on the draft's body and // the package it is promoted into, with the save door's judge and // envelope: a draft stored before the gate (or under a package whose @@ -28563,12 +28654,24 @@ export class ObjectStackProtocolImplementation implements // ({@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. + // + // [#28905, ADR-0146 D1, D11] …and so is the rollback of a row bound to + // the read-only base itself (a save naming the base stored it before + // that save was closed): the restore writes the base-bound row again, + // so it is refused as that save is ({@link namedBaseOverlayWriteRefusal}), + // naming the pair a version's body is saved into. The row stays + // served; a delete naming the base removes it. { const closed = await this.packagelessOverlayWriteRefusal( { type: singularType, name: request.name, packageId: rollbackPackageId, state: 'active', ...memberOption(member) }, 'rollback', ); if (closed) throw closed; + const namedBase = this.namedBaseOverlayWriteRefusal( + { type: singularType, name: request.name, packageId: rollbackPackageId, ...memberOption(member) }, + 'rollback', + ); + if (namedBase) throw namedBase; } // [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, diff --git a/packages/objectql/src/environment-overlay-five-types.test.ts b/packages/objectql/src/environment-overlay-five-types.test.ts index d1f2e942929..cc42e9e511c 100644 --- a/packages/objectql/src/environment-overlay-five-types.test.ts +++ b/packages/objectql/src/environment-overlay-five-types.test.ts @@ -36,7 +36,9 @@ * 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; + * serves the body that was written; [#28905, ADR-0146 D1] and the save + * naming the read-only package itself, active and draft, is refused + * naming the same pair, and stores nothing; * 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 @@ -394,6 +396,27 @@ describe('the five overlay-enabled types keep their environment customization of expect(read?.item?.label).toBe(OVERLAY_LABEL); }); + // [#28905, ADR-0146 D1] …and the save NAMING the read-only base + // itself, active and draft: refused naming the same pair, and nothing + // is written, so the read keeps serving the package's item. + it.each(OVERLAY_ENABLED)(`${topology}: a save naming the read-only package itself over its %s is refused naming its pair, and nothing is written`, async (type) => { + const { protocol, rowsOf } = await boot(topology); + const { name, body } = ITEMS[type]; + + for (const mode of ['publish', 'draft'] as const) { + const refused: any = await protocol + .saveMetaItem({ type, name, packageId: PKG, mode, item: body(OVERLAY_LABEL) }) + .then(() => undefined, (e: any) => ({ code: e?.code, status: e?.status, message: String(e?.message ?? '') })); + expect(refused, mode).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refused.message).toContain(`'${PAIR}' (?package=${PAIR})`); + expect(refused.message).toContain(`overrides: { package: '${PKG}', basedOn }`); + expect(refused.message.length).toBeLessThan(500); + } + expect(rowsOf(type, name)).toHaveLength(0); + const read: any = await protocol.getMetaItem({ type, name, packageId: PKG }); + expect(read?.item?.label).toBe(PACKAGED_LABEL); + }); + it.each(OVERLAY_ENABLED)(`${topology}: a stored environment-wide overlay of the packaged %s is what its read serves`, async (type) => { const { protocol, storeEnvironmentRow } = await boot(topology); const { name, body } = ITEMS[type]; From efd41da794f87fb9dc4cef98eafa4fcbeed9970f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:09:54 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(changeset):=20the=20write=20naming=20a?= =?UTF-8?q?=20read-only=20base=20is=20closed,=20FROM=20=E2=86=92=20TO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8 Co-authored-by: Claude --- .../28905-named-base-overlay-write-closed.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .changeset/28905-named-base-overlay-write-closed.md diff --git a/.changeset/28905-named-base-overlay-write-closed.md b/.changeset/28905-named-base-overlay-write-closed.md new file mode 100644 index 00000000000..9fb98bd9527 --- /dev/null +++ b/.changeset/28905-named-base-overlay-write-closed.md @@ -0,0 +1,38 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a save, publish or rollback naming an installed read-only package itself onto its view, dashboard, report, translation or email template is refused, naming the base's `.custom` pair + +Clause-②: yes (narrowing) — the metadata save (draft and active), publish (per item and `publish-drafts`) and rollback doors refuse a write that names an installed read-only package (`?package=`) onto an item of the five overlay types that package ships, where its customization can be written into the base's paired `.custom` instead (the pair can be ensured, the base records a version, and the override write gate admits the declared body there); they accepted it before as an undeclared row bound to the base. A delete naming the base stays open. + +Metadata-protocol: narrowed (the `allowEnvironmentOverlay` flag of `view`, `dashboard`, `report`, `translation` and `email_template` no longer opens a write naming the read-only base itself onto an item it ships; the customization is the pair's declared override, as ADR-0146 D1 states) + +Skills-docs: none needed — `skills/objectstack-platform/SKILL.md`'s ADR-0146 paragraph already places every customization of an installed base in its paired `.custom`, declaring `overrides`, and names `?package=` only as a read that serves it; nothing it says becomes false. + + + +**BREAKING** write narrowing at the metadata doors (`@objectstack/metadata-protocol`), shipped as `minor` during the launch window. + +### Migration: FROM → TO + +FROM, a customization of the installed package `com.acme.crm`'s view written naming the package itself: + +```http +PUT /api/v1/meta/view/account_list?package=com.acme.crm +{ "name": "account_list", "label": "My accounts" } +``` + +TO, the same customization written into the base's paired `.custom`, declaring what it overrides (as for a write naming no package since `packageless-overlay-write-closed`): + +```http +PUT /api/v1/meta/view/account_list?package=com.acme.crm.custom +{ "name": "account_list", "label": "My accounts", "overrides": { "package": "com.acme.crm", "basedOn": "1.4.0" } } +``` + +A draft is saved and published there too (`?mode=draft`, then the publish naming `com.acme.crm.custom`). To restore a version of a row bound to the base, save that version's body into the pair, declaring it. `basedOn` is the base's installed `manifest.version`. The pair is created by Studio's Customize, or by `os migrate packageless-overlays --apply`. + +- **Now refused.** An authoring save (draft or publish mode) or a per-item publish naming `?package=`, `POST /api/v1/packages//publish-drafts` holding such a draft (the batch is all-or-nothing, so none of its drafts is published), and a rollback of a row bound to the base, of a `view`, `dashboard`, `report`, `translation` or `email_template` that the installed read-only package `` ships, where the pair can be ensured (the base has a namespace and a SemVer version) and the override write gate admits the declared body into it. Each answers `403 NOT_OVERRIDABLE`, writes nothing, and its message names the item, the base, the pair (`?package=.custom`) and the `overrides` declaration — the fix the package-less refusal names, in the same words. +- **Still open.** A delete naming `?package=`, and its draft discard: removing a row bound to the base is the one way to clear one stored before this release (`os migrate packageless-overlays` moves package-less rows only, so it never lists one). Every write naming the pair, a writable package or no package (the package-less close is unchanged). A body carrying `overrides` keeps the override write gate's refusal (only the pair declares one); a name the base does not ship keeps ADR-0070 D1's `422 WRITABLE_PACKAGE_REQUIRED`. And every write the pair could not take: a base whose pair cannot be ensured, a base with no version, and an `email_template` locale the base does not ship. The server's own rewrites of a stored row (`migrate-stored`, package duplication) are not authoring writes and are unchanged. +- **Unchanged.** A row bound to the base stored before this release is still served at the base's address and by the read naming no package, until the pair declares the override (the pair's declared override is served over it) or a delete naming the base removes it. +- **`os migrate organization-ownership --apply`.** Its carry of an organization's public-form withdrawal onto a view a read-only package ships saved that view naming the package; that save is now refused, so the ceremony stops with a blocker quoting this refusal. Save the withdrawal (`sharing.enabled: false`) into the pair as above, then re-run. From 3eda44ba0d286f47a5d13852e33f8fcf1eada215 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:43:18 +0000 Subject: [PATCH 3/5] chore(scripts): record the named-base close suite's engine doubles in the pinned ledger Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8 Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 0dd27312307..03c05f0866e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1276,6 +1276,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.named-base-stored-binding.test.ts", "verb": "delete", From 88f062f42c0e741a9baabce0dfa519a572b2074a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:00:28 +0000 Subject: [PATCH 4/5] test: the batch-door member pins draft into the pair; drop an unused binding The package-batch member cases drafted into the read-only package itself, which the save door now refuses; they draft into its pair, declared, and assert the same member semantics. Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8 Co-authored-by: Claude --- .../protocol.named-base-overlay-write.test.ts | 2 +- ...nment-overlay-email-locale-restart.test.ts | 35 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts index e174815e921..68ad160302b 100644 --- a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts +++ b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts @@ -310,7 +310,7 @@ describe('CONTROLS: the writes the close leaves open, as on main', () => { }); it('a server-stated rewrite naming the base (`source` set) still writes the row it names', async () => { - const { protocol, rows, storeBaseRow } = makeProtocol(); + const { rows, storeBaseRow } = makeProtocol(); await storeBaseRow(VIEW.type, VIEW.name, VIEW.body('Rewritten')); diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 3af91d25523..a4e4d9b571e 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -204,8 +204,9 @@ async function boot( * delete that names no package, and adds the declaration to a saved body of a * member the package ships (one it does not ship is the pair's own item) — * the member semantics pinned below are the pair's, row for row, as they were - * the package-less row's. A write that names a package (the drafts into - * `PKG` below) is passed through untouched. + * the package-less row's. A write that names a package (the drafts into the + * pair below, declared; [#28905] a write naming `PKG` itself is refused) is + * passed through untouched. */ const PAIR = 'com.acme.mail.custom'; const DECLARATION = { package: PKG, basedOn: '1.0.0' }; @@ -633,11 +634,17 @@ describe('[#26699] the layered read addresses ONE member by the selector', () => }); describe('the package-batch doors and the cluster fan-out carry the member (#22877, stage 2)', () => { - /** A zh-CN draft bound to the package, beside an en-US active overlay that has none. */ + /** + * A zh-CN draft bound to the package's pair, beside an en-US active + * overlay that has none. [#28905, ADR-0146 D1] A draft naming the + * read-only package itself is refused now, so the package-batch doors are + * asked of the pair, whose drafts declare the override; the member + * semantics pinned below are the same, row for row. + */ async function bootWithZhDraft(stores: Map>>) { const booted = await boot(stores); await booted.protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') }); - await booted.protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'DRAFT zh'), mode: 'draft', packageId: PKG }); + await booted.protocol.saveMetaItem({ type: T, name: NAME, item: { ...template('zh-CN', 'DRAFT zh'), overrides: DECLARATION }, mode: 'draft', packageId: PAIR }); return booted; } const draftRows = (stores: Map>>) => @@ -649,7 +656,7 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 const { protocol } = await bootWithZhDraft(stores); expect(draftRows(stores).map((r) => r.bundle_member)).toEqual(['zh-CN']); - const out: any = await protocol.publishPackageDrafts({ packageId: PKG }); + const out: any = await protocol.publishPackageDrafts({ packageId: PAIR }); expect(out, JSON.stringify(out)).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); expect(draftRows(stores)).toEqual([]); expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'DRAFT zh' }); @@ -658,7 +665,7 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 it('revertCommit: the revert of that publish removes the zh-CN row it created and leaves en-US', async () => { const stores = new Map>>(); const { protocol } = await bootWithZhDraft(stores); - const published: any = await protocol.publishPackageDrafts({ packageId: PKG }); + const published: any = await protocol.publishPackageDrafts({ packageId: PAIR }); expect(published.commitId, JSON.stringify(published)).toBeTruthy(); const reverted: any = await protocol.revertCommit({ commitId: published.commitId }); @@ -666,7 +673,7 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 expect(overlayRows(stores).map((r) => r.bundle_member ?? null)).toEqual([null]); expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); // [#23073] The timeline serves each item's member: the publish and its revert, zh-CN both. - const commits = await protocol.listCommits({ packageId: PKG }); + const commits = await protocol.listCommits({ packageId: PAIR }); expect(commits.map((c) => c.items.map((it) => [it.type, it.name, it.member]))).toEqual([ [[T, NAME, 'zh-CN']], [[T, NAME, 'zh-CN']], ]); @@ -675,23 +682,23 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 it('discardPackageDrafts: the zh-CN draft is discarded as zh-CN', async () => { const stores = new Map>>(); const { protocol } = await bootWithZhDraft(stores); - const out: any = await protocol.discardPackageDrafts({ packageId: PKG }); + const out: any = await protocol.discardPackageDrafts({ packageId: PAIR }); expect(out, JSON.stringify(out)).toMatchObject({ success: true, discardedCount: 1, failedCount: 0 }); expect(draftRows(stores)).toEqual([]); expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' }); - const listed = await protocol.listDrafts({ packageId: PKG }); + const listed = await protocol.listDrafts({ packageId: PAIR }); expect(listed.drafts).toEqual([]); }); it('[#23073] the draft list and the commit timeline tell two languages of one template apart, and each listed member addresses its own item by name', async () => { const stores = new Map>>(); const { protocol } = await boot(stores); - await protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'DRAFT en'), mode: 'draft', packageId: PKG }); - await protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'DRAFT zh'), mode: 'draft', packageId: PKG }); + await protocol.saveMetaItem({ type: T, name: NAME, item: { ...template('en-US', 'DRAFT en'), overrides: DECLARATION }, mode: 'draft', packageId: PAIR }); + await protocol.saveMetaItem({ type: T, name: NAME, item: { ...template('zh-CN', 'DRAFT zh'), overrides: DECLARATION }, mode: 'draft', packageId: PAIR }); const wording: Record = { 'en-US': 'DRAFT en', 'zh-CN': 'DRAFT zh' }; // Two drafts of one `type` and `name`, told apart by `member` alone. - const listed = await protocol.listDrafts({ packageId: PKG }); + const listed = await protocol.listDrafts({ packageId: PAIR }); expect(listed.drafts.map((d) => [d.type, d.name, d.member]).sort()).toEqual([[T, NAME, 'en-US'], [T, NAME, 'zh-CN']]); // The listed member round-trips through the by-name door: each reads its own draft. for (const d of listed.drafts) { @@ -699,9 +706,9 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 expect(read?.item?.subject).toBe(wording[d.member!]); } - const published: any = await protocol.publishPackageDrafts({ packageId: PKG }); + const published: any = await protocol.publishPackageDrafts({ packageId: PAIR }); expect(published, JSON.stringify(published)).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 }); - const [commit] = await protocol.listCommits({ packageId: PKG }); + const [commit] = await protocol.listCommits({ packageId: PAIR }); expect(commit!.items.map((it) => [it.type, it.name, it.member]).sort()).toEqual([[T, NAME, 'en-US'], [T, NAME, 'zh-CN']]); for (const it of commit!.items) { const read: any = await protocol.getMetaItem({ type: it.type, name: it.name, member: it.member }); From 29a58bb6fd89d046f877c8dbe7c07a4b082db677 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:23:37 +0000 Subject: [PATCH 5/5] test(dogfood): the showcase's customization commit is taken in its pair The control drafted into the read-only showcase itself, which the save door now refuses naming the pair. It asserts that refusal, then writes the same customization into the pair, declared, and pins its commit's revert through the pair's own path and through no other. Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8 Co-authored-by: Claude --- ...commit-revert-path-package.dogfood.test.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts b/packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts index d17837c3472..71f76137d2c 100644 --- a/packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts @@ -30,11 +30,20 @@ // reverting it through the showcase's own path removes the overlay. That is // the undo of an ADR-0131 D6 customization, which `POST /packages/:id/revert` // also answers for a read-only package (stored rows first), so the commit -// doors take no read-only guard; the last case pins it. +// doors take no read-only guard. +// +// [#28905, ADR-0146 D1] That draft naming the read-only showcase itself is +// refused now (`403 NOT_OVERRIDABLE`, naming its `.custom` pair), so a fresh +// boot can no longer give the showcase such a commit; one recorded before +// keeps its revert, as the commit doors still take no read-only guard. The +// last case pins the refusal, then the same customization written where D1 +// puts it, the showcase's pair: its commit reverts through the pair's own +// path, and through no other. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair } from './customization-pair.js'; const SHOWCASE = 'com.example.showcase'; const OVERLAY = 'showcase_task.default'; @@ -154,26 +163,33 @@ describe('dogfood: package commit revert and rollback act only on a commit of th expect(await isServed('cmtpb_first')).toBe(true); }); - it('control: a read-only package\'s overlay commit reverts through its own path, and through no other', async () => { - const saved = await call('PUT', `/meta/view/${OVERLAY}?mode=draft&package=${SHOWCASE}`, { + it('control: the showcase\'s customization commit, in its pair, reverts through the pair\'s own path, and through no other', async () => { + const view = { name: OVERLAY, object: 'showcase_task', viewKind: 'list', label: 'Overlay probe', config: { type: 'grid', columns: [{ field: 'title' }] }, - }); + }; + // [#28905] The draft naming the read-only showcase itself is closed, naming its pair. + const named = await call('PUT', `/meta/view/${OVERLAY}?mode=draft&package=${SHOWCASE}`, view); + expect(named.status, JSON.stringify(named.json)).toBe(403); + expect(JSON.stringify(named.json)).toContain(`?package=${SHOWCASE}.custom`); + + const pair = await ensurePair(stack, admin, SHOWCASE); + const saved = await call('PUT', inPair(`/meta/view/${OVERLAY}?mode=draft`, SHOWCASE), declaredOverride(view, SHOWCASE, '0.1.0')); expect(saved.status, JSON.stringify(saved.json)).toBe(200); - const published = await call('POST', `/packages/${SHOWCASE}/publish-drafts`, {}); + const published = await call('POST', `/packages/${pair}/publish-drafts`, {}); expect(published.status, JSON.stringify(published.json)).toBe(200); - const before = await commitsOf(SHOWCASE); + const before = await commitsOf(pair); expect(before[0]).toMatchObject({ operation: 'apply', id: published.json?.data?.commitId }); const cross = await call('POST', `/packages/${B}/commits/${before[0].id}/revert`, {}); expect(cross.status, JSON.stringify(cross.json)).toBe(404); - expect(await commitsOf(SHOWCASE)).toEqual(before); + expect(await commitsOf(pair)).toEqual(before); - const res = await call('POST', `/packages/${SHOWCASE}/commits/${before[0].id}/revert`, {}); + const res = await call('POST', `/packages/${pair}/commits/${before[0].id}/revert`, {}); expect(res.status, JSON.stringify(res.json)).toBe(200); expect(res.json?.data).toMatchObject({ success: true, revertedCount: 1, reverted: [{ type: 'view', name: OVERLAY, action: 'removed' }], }); - expect(await commitsOf(SHOWCASE)).toHaveLength(before.length + 1); + expect(await commitsOf(pair)).toHaveLength(before.length + 1); }); });