From ef126bd6a92fb698c58b21c91f4738caada31a4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:49:55 +0000 Subject: [PATCH 1/5] fix(metadata-protocol,cli): list a package-less view container cannot_declare, never attempt it The packageless-overlays census listed a package-less view container row movable, and every --apply failed it: the move's declared save adds overrides, which the container schema refuses as an unrecognized key. The census now asks the save door's own schema whether the body can carry the override declaration (overrideDeclarationRefusedByShape) and lists a row that cannot as cannot_declare, with the way out in the CLI; the move never attempts it. The package-less close and the close of a write naming the base read the same predicate, so neither closes a write whose fix cannot be taken. Claude-Session: https://claude.ai/code/session_01Utp6CbC5BpV41NMLrJMt8Q Co-authored-by: Claude --- ...261-packageless-overlays-view-container.md | 13 +++ .../packageless-overlays.integration.test.ts | 52 +++++++++++ .../commands/migrate/packageless-overlays.ts | 11 +++ .../src/protocol-packaged-base-refusal.ts | 50 +++++++++++ .../protocol-packageless-overlay-migration.ts | 27 +++++- ...ocol-packageless-overlay-migration.test.ts | 86 +++++++++++++++++++ 6 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 .changeset/30261-packageless-overlays-view-container.md diff --git a/.changeset/30261-packageless-overlays-view-container.md b/.changeset/30261-packageless-overlays-view-container.md new file mode 100644 index 00000000000..79c4629bf80 --- /dev/null +++ b/.changeset/30261-packageless-overlays-view-container.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/cli': patch +--- + +fix(metadata-protocol,cli): `os migrate packageless-overlays` lists a package-less view container `cannot_declare` instead of `movable`, and `--apply` no longer attempts (and fails) it on every run + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`@objectstack/metadata-protocol`: the census gains the disposition `cannot_declare`** (`PackagelessOverlayDisposition`, reachable through the exported `PackagelessOverlayCensus`). A row lands there when the schema the save door judges its body by refuses an `overrides` key on it, so the declared save the move makes could never land. Of the five overlay types the ceremony moves, that is a view container (`list` / `listViews` / `form` / `formViews`): only a single view declares an override. Before, such a row was listed `movable` and every `--apply` reported it `failed` with `Unrecognized key(s) on this view container: overrides`, leaving the row where it was. Now `migratePackagelessOverlays` does not attempt it: no outcome, no failure, no pair ensured for it. +- **The two closes read the same answer.** The package-less write close (`packagelessCloseSubject`) closes only what the ceremony moves, so a container's package-less save and delete stay open, as they were before the close: they are the one door such a row keeps. The close of a write naming the read-only base (`overlayPairFixAdmits`, the "the fix can be taken" predicate both closes ask) no longer names the pair as the fix for a container body, because that fix (a declared save into the pair) cannot be taken; that write keeps the answer it had before the close. +- **`@objectstack/cli`: `os migrate packageless-overlays`** prints the way out for a `cannot_declare` row under "left as they are": save each of the container's views into the pair as a single view named `.` (one the base ships declaring `overrides: { package, basedOn }`, a new one declaring nothing), then delete the container row with a delete naming no package (naming the base, for a row bound to it). In `--json`, the row carries `disposition: "cannot_declare"` and `outcome: "left"`, and `--apply` exits 0 for it. diff --git a/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts b/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts index af09e6bbdc5..fda880ab11b 100644 --- a/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts +++ b/packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts @@ -102,9 +102,27 @@ const BOUND_SEED: SeedRow[] = [ { type: 'view', name: ENV_VIEW, state: 'active', body: viewBody(ENV_VIEW, STORED_COLUMNS) }, ]; +/** + * [#30261] A database whose customization of the package's view container (a + * list view the package does not ship added beside its default list) was + * stored as a package-less CONTAINER row, beside the environment row (control). + */ +const CONTAINER = 'm28413_task'; +const CONTAINER_SEED: SeedRow[] = [ + { + type: 'view', name: CONTAINER, state: 'active', body: { + object: 'm28413_task', + list: { label: 'All', type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: STORED_COLUMNS.map((field) => ({ field })) }, + listViews: { mine: { label: 'Mine', type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: DRAFT_COLUMNS.map((field) => ({ field })) } }, + }, + }, + { type: 'view', name: ENV_VIEW, state: 'active', body: viewBody(ENV_VIEW, STORED_COLUMNS) }, +]; + let dir: string; let templateDb: string; let boundTemplateDb: string; +let containerTemplateDb: string; const savedCwd = process.cwd(); const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID'] as const; @@ -250,8 +268,11 @@ beforeAll(async () => { await seed.shutdown(); boundTemplateDb = join(dir, 'bound-template.db'); copyFileSync(templateDb, boundTemplateDb); + containerTemplateDb = join(dir, 'container-template.db'); + copyFileSync(templateDb, containerTemplateDb); await insertRows(templateDb, SEED); await insertRows(boundTemplateDb, BOUND_SEED); + await insertRows(containerTemplateDb, CONTAINER_SEED); }, CASE_TIMEOUT_MS); afterAll(() => { @@ -384,3 +405,34 @@ describe('os migrate packageless-overlays: a row bound to the read-only package' expect(again.payload.rows).toEqual([]); }, CASE_TIMEOUT_MS * 3); }); + +/** + * [#30261] A package-less view CONTAINER row of the package's view: its shape + * refuses the `overrides` key, so the declared save `--apply` makes could + * never land. The preview lists it `cannot_declare` (left), and `--apply` + * attempts nothing: no failure, exit 0, the row and the database as they were. + * Before, the preview listed it movable and every `--apply` failed it. + */ +describe('os migrate packageless-overlays: a package-less view container', () => { + it('the preview lists it `cannot_declare`, left; --apply attempts nothing, fails nothing, and leaves the row', async () => { + const db = caseDb(containerTemplateDb); + const before = await storedRows(db); + const preview = await runJson(['--database-url', `file:${db}`, '--json']); + expect(preview.payload.error, JSON.stringify(preview.payload).slice(0, 600)).toBeUndefined(); + expect(preview.exitCode).toBe(0); + expect(preview.payload.rows.map((r: any) => `${r.type}/${r.name}/${r.state} ${r.disposition} ${r.base} → ${r.pair} ${r.outcome}`)).toEqual([ + `view/${CONTAINER}/active cannot_declare ${PKG} → ${PAIR} left`, + ]); + expect(preview.payload.movable).toBe(0); + expect(preview.payload.left).toBe(1); + + const { payload, exitCode } = await runJson(['--apply', '--yes', '--database-url', `file:${db}`, '--json']); + expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined(); + expect(exitCode).toBe(0); + expect(payload.failed).toBe(0); + expect(payload.moved).toBe(0); + expect(payload.rows.map((r: any) => `${r.name} ${r.disposition} ${r.outcome}`)).toEqual([`${CONTAINER} cannot_declare left`]); + expect(await storedRows(db)).toEqual(before); + expect(await storedPackages(db)).not.toContain(PAIR); + }, CASE_TIMEOUT_MS * 2); +}); diff --git a/packages/cli/src/commands/migrate/packageless-overlays.ts b/packages/cli/src/commands/migrate/packageless-overlays.ts index b4c3ccbc527..41066b4666e 100644 --- a/packages/cli/src/commands/migrate/packageless-overlays.ts +++ b/packages/cli/src/commands/migrate/packageless-overlays.ts @@ -63,6 +63,17 @@ function reasonOf(row: CensusRow): string { + '(served then at that package\'s address, ?package=).'; case 'no_pair': return `'${row.base}' has no paired .custom package id, so there is nowhere to move it. Left as it is.`; + case 'cannot_declare': + // [#30261] Its shape refuses the `overrides` key, so the move's declared save can never land. + return (row.type === 'view' + ? 'it is a view container (list/listViews/form/formViews), and only a single view can declare an override, ' + : 'its shape cannot carry an `overrides` declaration, ') + + 'so it cannot be moved as one. Left as it is: ' + + (row.type === 'view' + ? `save each of its views into '${row.pair}' as a single view named . (?package=${row.pair}) — ` + + `one '${row.base}' ships declaring overrides: { package: '${row.base}', basedOn }, a new one declaring nothing — ` + : `save its content into '${row.pair}' in a shape that declares overrides: { package: '${row.base}', basedOn } — `) + + `then delete this row with a delete naming ${row.boundToBase ? `'${row.base}'` : 'no package'}.`; case 'foreign_declaration': return `its body already carries an \`overrides\` key that does not name '${row.base}'. Left as it is: ` + (row.boundToBase diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 7c958aa1395..76a60793bb5 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -46,6 +46,7 @@ import { 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 { resolveOverlaySchema } from './protocol-meta-type-schema.js'; import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; import { customizationBasePackageId, @@ -419,10 +420,59 @@ export function overlayPairFixAdmits( ...(body ?? { name, ...(disc && member !== null ? { [disc]: member } : {}) }), overrides: { package: fix.base, basedOn: fix.basedOn }, }; + // [#30261] The save door's schema half: a body whose shape cannot carry + // the declaration (a view container) is refused there, whatever the gate + // answers. A bare identity has no shape to judge. + if (body !== undefined && overrideDeclarationRefusedByShape(type, body)) return false; const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody }); return verdict.verdict !== 'refuse'; } +/** + * [#30261, ADR-0146 D2, D11] Whether the schema the save door judges `body` + * by (`resolveOverlaySchema`, the door's own resolver) refuses an `overrides` + * key on it as unrecognized: the body's SHAPE cannot carry the override + * declaration, so no save declaring it lands, whatever the override write gate + * answers. + * + * Measured over the five overlay types the ceremony moves, one shape answers + * `true`: the view CONTAINER (`ViewSchema`, the `container` member of + * `ViewMetadataSchema`). A single view, a list or form overlay, a dashboard, a + * report, a translation and an email template each spread + * `OverrideDeclarationFields`. Read from the parse, not from a list of shapes, + * so a member that gains or loses the key is answered by its own schema. + * + * Only the refusal of the KEY is read: an `unrecognized_keys` issue naming + * `overrides` at the body's root, or at the root inside a union's branch + * issues (the view union keeps the branch the body claims and mutes the + * others, `focusClaimedBranch`). Every other finding on the body is the save + * door's to answer, as before, and answers `false` here. The body's own + * `overrides` value, if any, is replaced by a well-formed probe: the key + * alone decides. + */ +export function overrideDeclarationRefusedByShape(type: string, body: Record): boolean { + const schema = resolveOverlaySchema(type, body); + if (!schema) return false; + const parsed = schema.safeParse({ ...body, overrides: OVERRIDES_KEY_PROBE }); + return !parsed.success && parsed.error.issues.some(namesOverridesAtRoot); +} + +/** A well-formed declaration: the probe asks whether the KEY is carried, so its value raises nothing. */ +const OVERRIDES_KEY_PROBE = Object.freeze({ package: 'com.example.base', basedOn: '1.0.0' }); + +/** An `unrecognized_keys` issue naming `overrides` at the parsed body's root, here or in a union branch's issues. */ +function namesOverridesAtRoot(issue: unknown): boolean { + const candidate = issue as { code?: unknown; path?: unknown; keys?: unknown; errors?: unknown } | null; + if (candidate?.code === 'unrecognized_keys') { + return Array.isArray(candidate.path) && candidate.path.length === 0 + && Array.isArray(candidate.keys) && candidate.keys.includes('overrides'); + } + if (candidate?.code === 'invalid_union' && Array.isArray(candidate.errors)) { + return candidate.errors.some((branch) => Array.isArray(branch) && branch.some(namesOverridesAtRoot)); + } + return false; +} + /** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */ export interface NamedBaseCloseHost { readonly engine: ObjectStackProtocolImplementation['engine']; diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index 8f4617e19f4..b312e9791e7 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -34,6 +34,14 @@ * "Ships" counts read-only code packages only, as the write gate does: a * writable package's item is edited in place (ADR-0070), and the pair itself * is writable. + * - `cannot_declare` (#30261) — the body's shape cannot carry the override + * declaration: the schema the save door judges it by refuses an `overrides` + * key on it ({@link overrideDeclarationRefusedByShape}). Of the five overlay + * types, that is a view CONTAINER (`list` / `listViews` / `form` / + * `formViews`): only a single view declares an override. Listed with the + * way out — each of its views saved into the pair as a single view, then + * the container deleted naming no package — and never attempted, so no run + * fails it. * - `foreign_declaration` — the body already carries an `overrides` key that * does not name the base: listed, never rewritten. * - `shadowed` — the pair already holds a row of that item, in EITHER state @@ -136,6 +144,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { overlayPairFixAdmits, overlayPairFixOf, + overrideDeclarationRefusedByShape, overrideWriteMemberOf, type OverrideWriteSubject, type OverrideWriteVerdict, @@ -165,6 +174,7 @@ export type PackagelessOverlayDisposition = | 'movable' | 'multi_shipped' | 'no_pair' + | 'cannot_declare' | 'foreign_declaration' | 'shadowed'; @@ -357,8 +367,14 @@ function addedMemberOf( * {@link listPackagelessOverlays} lists by it, and the close of the * package-less write ({@link packagelessCloseSubject}) refuses only a row it * answers `movable` — ⛔ never a second copy. + * + * `type` is the canonical singular type: `cannot_declare` reads the schema the + * save door judges the body by. It is asked before `foreign_declaration` and + * `shadowed`, whose ways out end in this ceremony moving the row; with no body + * (a write of an unstored item that carries none) there is no shape to judge. */ export function classifyPackagelessRow( + type: string, shippers: readonly string[], body: Record | undefined, shadowOf: (pair: string) => unknown, @@ -368,6 +384,7 @@ export function classifyPackagelessRow( const pairId = customizationPackageId(base); if (!pairId.ok) return { base, disposition: 'no_pair' }; const pair = pairId.value; + if (body !== undefined && overrideDeclarationRefusedByShape(type, body)) return { base, pair, disposition: 'cannot_declare' }; const declared = (body as { overrides?: unknown } | undefined)?.overrides; if (declared !== undefined && (declared === null || typeof declared !== 'object' || (declared as { package?: unknown }).package !== base)) { @@ -439,7 +456,7 @@ export async function listPackagelessOverlays( }; const body = bodyOf(row); // A row bound to the base names its base: the binding is its one shipper. - const classified = classifyPackagelessRow(binding !== undefined ? [binding] : shippers, body, + const classified = classifyPackagelessRow(type, binding !== undefined ? [binding] : shippers, body, (pair) => shadowOf(pair, type, name, member, state)); const addedMember = classified.disposition === 'movable' && classified.base !== undefined ? addedMemberOf(this, type, name, classified.base, body) @@ -719,10 +736,12 @@ export interface PackagelessWriteSubject { * member and state, or — when there is none — the row a save would store * (its body), or none at all (a delete or rollback of an unstored item); * - it is classified by the census's own {@link classifyPackagelessRow}: - * only `movable` is closed (`multi_shipped`, `no_pair`, + * only `movable` is closed (`multi_shipped`, `no_pair`, `cannot_declare`, * `foreign_declaration` and `shadowed` stay open, and so does a name no * read-only package ships). `shadowed` applies to a stored row only: a - * first write has nothing in the pair to be shadowed by; + * first write has nothing in the pair to be shadowed by. `cannot_declare` + * (a view container, #30261) has no declared write into the pair at all, + * so its package-less write is the one door it keeps; * - its move must be able to succeed, read from the steps the ceremony * takes: `overlayPairFixOf` and `overlayPairFixAdmits` * (`protocol-packaged-base-refusal.ts`), the predicate the close of the @@ -771,7 +790,7 @@ export async function packagelessCloseSubject( const writtenBody = request.item !== null && typeof request.item === 'object' && !Array.isArray(request.item) ? (request.item as Record) : undefined; const body = stored !== undefined ? storedBody : writtenBody; - const classified = classifyPackagelessRow(shippers, body, (p) => (stored === undefined + const classified = classifyPackagelessRow(type, shippers, body, (p) => (stored === undefined ? undefined : ofItem.find((row) => row.package_id === p && stateOf(row) === request.state) ?? ofItem.find((row) => row.package_id === p))); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 81b06e631dc..5f20e0995b1 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -826,3 +826,89 @@ describe('[#30000] the registry follows the move at once: it holds what a restar expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); }); }); + +describe('[#30261] a view container cannot carry the declaration: the preview says so, and --apply never attempts it', () => { + const CONTAINER = 'showcase_task'; + const MINE = 'showcase_task.mine'; + const listOf = (columns: readonly string[], label: string) => ({ + label, type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: columns.map((field) => ({ field })), + }); + /** A container a Setup customization stored: the base's default list re-columned, plus a list view the base does not ship. */ + const containerBody = (columns: readonly string[]) => ({ + object: 'showcase_task', + list: listOf(columns, 'All'), + listViews: { mine: listOf(columns, 'Mine') }, + }); + const storeContainer = (engine: ObjectQL, columns: readonly string[], packageId: string | null = null, state = 'active') => + engine.insert('sys_metadata', { + type: 'view', name: CONTAINER, organization_id: null, package_id: packageId, + metadata: JSON.stringify(containerBody(columns)), state, version: 1, + }, { context: { isSystem: true } }); + + it('the census lists the package-less container `cannot_declare`, naming its base and pair; --apply leaves it and reports no failure; a re-run lists it the same', async () => { + const { engine, protocol, rows } = await boot(); + await storeContainer(engine, UNBOUND_COLUMNS); + await storeContainer(engine, EDITED_COLUMNS, null, 'draft'); + + const listedCensus = await census(protocol); + expect(dispositions(listedCensus)).toEqual([`${CONTAINER}@active cannot_declare`, `${CONTAINER}@draft cannot_declare`]); + expect(listedCensus.rows.map((r: any) => [r.base, r.pair])).toEqual([[BASE, PAIR], [BASE, PAIR]]); + + const result = await migrate(protocol); + expect(result.outcomes).toEqual([]); + expect(result.bases).toEqual([]); + expect(rows(CONTAINER).slice().sort()).toEqual([[null, 'active'], [null, 'draft']]); + // Nothing was attempted, so no pair was ensured for it. + expect((engine.registry as any).getPackage(PAIR)).toBeUndefined(); + + const again = await migrate(protocol); + expect(dispositions(again)).toEqual([`${CONTAINER}@active cannot_declare`, `${CONTAINER}@draft cannot_declare`]); + expect(again.outcomes).toEqual([]); + }); + + it('beside it, a single view of the same base still moves as the declared override', async () => { + const { engine, protocol, rows } = await boot(); + await storeContainer(engine, UNBOUND_COLUMNS); + await storeRow(engine, VIEW, 'showcase_task', UNBOUND_COLUMNS); + expect(dispositions(await census(protocol))).toEqual([`${CONTAINER}@active cannot_declare`, `${VIEW}@active movable`]); + + const result = await migrate(protocol); + expect(result.outcomes.map((o: any) => o.outcome)).toEqual(['moved']); + expect(result.bases).toEqual([{ base: BASE, pair: PAIR, pairCreated: true, moved: 1, declarationsCompleted: 0, failed: 0, shadowed: 0 }]); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + expect(rows(CONTAINER)).toEqual([[null, 'active']]); + }); + + it('the close leaves its package-less write open: the ceremony cannot move it, so its save and DELETE naming no package succeed', async () => { + const { engine, protocol, rows } = await boot(); + await storeContainer(engine, UNBOUND_COLUMNS); + expect(await refusalOf(protocol.saveMetaItem({ type: 'view', name: CONTAINER, item: containerBody(EDITED_COLUMNS) } as any))).toBeUndefined(); + expect(rows(CONTAINER)).toEqual([[null, 'active']]); + expect(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: CONTAINER } as any))).toBeUndefined(); + expect(rows(CONTAINER)).toEqual([]); + // …and a first package-less save of it, with nothing stored yet. + expect(await refusalOf(protocol.saveMetaItem({ type: 'view', name: CONTAINER, item: containerBody(EDITED_COLUMNS) } as any))).toBeUndefined(); + expect(rows(CONTAINER)).toEqual([[null, 'active']]); + }); + + it('the way out the preview names holds: each view saved into the pair as a single view, then the container deleted naming no package, leaves nothing to list', async () => { + const { engine, protocol, rows } = await boot(); + await storeContainer(engine, UNBOUND_COLUMNS); + await (protocol as any).ensureCustomizationPackage({ basePackageId: BASE }); + // The base ships `showcase_task.default`: the pair declares the override of it. + await protocol.saveMetaItem({ + type: 'view', name: VIEW, packageId: PAIR, + item: { ...listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, + } as any); + // The base ships no `showcase_task.mine`: the pair holds it as its own view, declaring nothing. + await protocol.saveMetaItem({ type: 'view', name: MINE, packageId: PAIR, item: { ...listBody(MINE, 'showcase_task', UNBOUND_COLUMNS), label: 'Mine' } } as any); + expect(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: CONTAINER } as any))).toBeUndefined(); + + expect(rows(CONTAINER)).toEqual([]); + expect(rows(VIEW)).toEqual([[PAIR, 'active']]); + expect(rows(MINE)).toEqual([[PAIR, 'active']]); + const after = await census(protocol); + expect(after.rows).toEqual([]); + expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); + }); +}); From efbca752e135d28538cbcc1be2d1af9e46ef5746 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:52:50 +0000 Subject: [PATCH 2/5] test(metadata-protocol): pin the shapes of the five overlay types that cannot carry the override declaration Also counts a union's refusal only when every branch still diagnosing the body names the key, so a view body claiming no member is not answered true. Claude-Session: https://claude.ai/code/session_01Utp6CbC5BpV41NMLrJMt8Q Co-authored-by: Claude --- .../src/protocol-packaged-base-refusal.ts | 35 +++++++--- ...rotocol.override-declaration-shape.test.ts | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.override-declaration-shape.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 76a60793bb5..4a2a3ba3f9a 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -443,12 +443,15 @@ export function overlayPairFixAdmits( * so a member that gains or loses the key is answered by its own schema. * * Only the refusal of the KEY is read: an `unrecognized_keys` issue naming - * `overrides` at the body's root, or at the root inside a union's branch - * issues (the view union keeps the branch the body claims and mutes the - * others, `focusClaimedBranch`). Every other finding on the body is the save - * door's to answer, as before, and answers `false` here. The body's own - * `overrides` value, if any, is replaced by a well-formed probe: the key - * alone decides. + * `overrides` at the body's root. Inside a union it must be named by EVERY + * branch still diagnosing the body — the view union keeps the branch the body + * claims and mutes the others to a kind mismatch at the root + * (`focusClaimedBranch`), which is dropped here as every consumer of a failed + * union drops it — so a body that claims no branch, refused by the container + * member and by a member that carries the key, is not answered `true`. Every + * other finding on the body is the save door's to answer, as before, and + * answers `false` here. The body's own `overrides` value, if any, is replaced + * by a well-formed probe: the key alone decides. */ export function overrideDeclarationRefusedByShape(type: string, body: Record): boolean { const schema = resolveOverlaySchema(type, body); @@ -460,15 +463,25 @@ export function overrideDeclarationRefusedByShape(type: string, body: Record Array.isArray(issue?.path) && issue.path.length === 0; + +/** + * An `unrecognized_keys` issue naming `overrides` at the parsed body's root; + * for a union, named by every branch whose issues are not a bare kind + * mismatch at the root (a muted branch, or a member of another kind). + */ function namesOverridesAtRoot(issue: unknown): boolean { - const candidate = issue as { code?: unknown; path?: unknown; keys?: unknown; errors?: unknown } | null; + const candidate = issue as ParseIssue; if (candidate?.code === 'unrecognized_keys') { - return Array.isArray(candidate.path) && candidate.path.length === 0 - && Array.isArray(candidate.keys) && candidate.keys.includes('overrides'); + return atRoot(candidate) && Array.isArray(candidate.keys) && candidate.keys.includes('overrides'); } if (candidate?.code === 'invalid_union' && Array.isArray(candidate.errors)) { - return candidate.errors.some((branch) => Array.isArray(branch) && branch.some(namesOverridesAtRoot)); + const diagnosing = candidate.errors.filter((branch): branch is unknown[] => Array.isArray(branch) + && !branch.every((i) => (i as ParseIssue)?.code === 'invalid_type' && atRoot(i as ParseIssue))); + return diagnosing.length > 0 && diagnosing.every((branch) => branch.some(namesOverridesAtRoot)); } return false; } diff --git a/packages/metadata-protocol/src/protocol.override-declaration-shape.test.ts b/packages/metadata-protocol/src/protocol.override-declaration-shape.test.ts new file mode 100644 index 00000000000..d4645706d59 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.override-declaration-shape.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30261, ADR-0146 D2, D11] `overrideDeclarationRefusedByShape` — whether the + * schema the save door judges a body by refuses an `overrides` key on it, so + * that no save declaring the override can land. + * + * The census of `os migrate packageless-overlays` lists such a row + * `cannot_declare` (never `movable`), and the two overlay-write closes leave + * its write open (`overlayPairFixAdmits`). Pinned over the five overlay types + * the ceremony moves, one well-formed body per shape: the view CONTAINER is the + * one shape that answers `true`, in each of its spellings (`list`, + * `listViews`, `form`, `formViews`); every other shape spreads + * `OverrideDeclarationFields` and answers `false`. So does a `view` body that + * claims no member of the union (refused by the container member and by a + * member that carries the key alike), and a type the door validates with no + * schema. + */ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { overrideDeclarationRefusedByShape } from './protocol-packaged-base-refusal.js'; + +const grid = (columns: readonly string[]) => ({ + type: 'grid', data: { provider: 'object', object: 'task' }, columns: columns.map((field) => ({ field })), +}); + +/** One well-formed body per shape of the five overlay types, and whether its shape refuses the key. */ +const SHAPES: ReadonlyArray<{ shape: string; type: string; body: Record; refused: boolean }> = [ + { shape: 'view container (list)', type: 'view', refused: true, body: { object: 'task', list: { label: 'All', ...grid(['title']) } } }, + { shape: 'view container (listViews)', type: 'view', refused: true, body: { object: 'task', listViews: { mine: { label: 'Mine', ...grid(['title']) } } } }, + { shape: 'view container (form)', type: 'view', refused: true, body: { object: 'task', form: { type: 'simple', fields: ['title'] } } }, + { shape: 'view container (formViews)', type: 'view', refused: true, body: { object: 'task', formViews: { quick: { type: 'simple', fields: ['title'] } } } }, + { shape: 'single view', type: 'view', refused: false, body: { name: 'task.all', object: 'task', viewKind: 'list', label: 'All', config: grid(['title']) } }, + { shape: 'list overlay', type: 'view', refused: false, body: { name: 'task.all', label: 'All', object: 'task', viewKind: 'list', columns: [{ field: 'title', label: 'Title' }] } }, + { shape: 'dashboard', type: 'dashboard', refused: false, body: { name: 'crm_board', label: 'Board', widgets: [] } }, + { shape: 'report', type: 'report', refused: false, body: { name: 'crm_report', label: 'Report', type: 'summary', dataset: 'crm_metrics', rows: ['month'], values: ['amount_sum'] } }, + { shape: 'translation', type: 'translation', refused: false, body: { name: 'crm_fr', label: 'French', locale: 'fr', messages: { save: 'Enregistrer' } } }, + { shape: 'email template', type: 'email_template', refused: false, body: { name: 'crm_welcome', label: 'Welcome', subject: 'Welcome', bodyHtml: '

Hi

' } }, +]; + +describe('[#30261] overrideDeclarationRefusedByShape: the shapes of the five overlay types that cannot carry the declaration', () => { + it('the fixture covers every overlay type the ceremony moves', () => { + const overlayTypes = DEFAULT_METADATA_TYPE_REGISTRY.filter((entry) => entry.allowEnvironmentOverlay).map((entry) => entry.type).sort(); + expect([...new Set(SHAPES.map((s) => s.type))].sort()).toEqual(overlayTypes); + }); + + for (const { shape, type, body, refused } of SHAPES) { + it(`${shape}: ${refused ? 'refuses' : 'carries'} the \`overrides\` key`, () => { + expect(overrideDeclarationRefusedByShape(type, body)).toBe(refused); + // The answer is the key's, not the body's value: a body already + // carrying a declaration is answered the same way. + expect(overrideDeclarationRefusedByShape(type, { ...body, overrides: { package: 'com.acme.crm' } })).toBe(refused); + }); + } + + it('a view body that claims no member of the union is not answered `true` (the container member alone names the key)', () => { + expect(overrideDeclarationRefusedByShape('view', { name: 'task.all', label: 'All' })).toBe(false); + }); + + it('another finding on a container body does not hide the key: a container with an unknown key of its own still answers `true`', () => { + expect(overrideDeclarationRefusedByShape('view', { object: 'task', list: { label: 'All', ...grid(['title']) }, colour: 'red' })).toBe(true); + }); + + it('a type the save door validates with no schema answers `false`', () => { + expect(overrideDeclarationRefusedByShape('function', { name: 'f' })).toBe(false); + }); +}); From 17adf261bc978d7ec3432d8ea6d87c603dec3dba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:55:07 +0000 Subject: [PATCH 3/5] test(objectql): the close of a write naming the base leaves a view container's write open Claude-Session: https://claude.ai/code/session_01Utp6CbC5BpV41NMLrJMt8Q Co-authored-by: Claude --- .../protocol-packageless-overlay-migration.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 5f20e0995b1..128f648e6d9 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -891,6 +891,18 @@ describe('[#30261] a view container cannot carry the declaration: the preview sa expect(rows(CONTAINER)).toEqual([[null, 'active']]); }); + it('the close of a write naming the read-only base leaves it too: its fix, a declared save into the pair, cannot be taken', async () => { + const { protocol, rows } = await boot(); + const named = await refusalOf(protocol.saveMetaItem({ type: 'view', name: CONTAINER, packageId: BASE, item: containerBody(EDITED_COLUMNS) } as any)); + expect(named).toBeUndefined(); + expect(rows(CONTAINER)).toEqual([[BASE, 'active']]); + // …and the row it stored is the census's, listed with the same way out, bound to the base. + const listedCensus = await census(protocol); + expect(listedCensus.rows.map((r: any) => `${r.name}@${r.state} ${r.disposition} ${r.boundToBase}`)).toEqual([`${CONTAINER}@active cannot_declare true`]); + expect(await refusalOf(protocol.deleteMetaItem({ type: 'view', name: CONTAINER, packageId: BASE } as any))).toBeUndefined(); + expect(rows(CONTAINER)).toEqual([]); + }); + it('the way out the preview names holds: each view saved into the pair as a single view, then the container deleted naming no package, leaves nothing to list', async () => { const { engine, protocol, rows } = await boot(); await storeContainer(engine, UNBOUND_COLUMNS); From 6ec21c33ed5e1f5f9da113fe9a25a34f9a8e8d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:57:27 +0000 Subject: [PATCH 4/5] docs: a view container is listed and left by the packageless-overlays ceremony, and keeps its package-less write Claude-Session: https://claude.ai/code/session_01Utp6CbC5BpV41NMLrJMt8Q Co-authored-by: Claude --- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/deployment/cli.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 1ae0423260a..a1ef0ede285 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -173,7 +173,7 @@ document **whole** for every read that serves the base's name > **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried such a row to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. -> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)). A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. +> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)). A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view. | Type | `allowEnvironmentOverlay` | Rationale | | :--- | :---: | :--- | diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index b99739e4e02..2c442af703b 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1317,7 +1317,7 @@ where the data lives. | `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent | | `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run | | `os migrate security-catalog-overlays` | List the environment-wide permission-set and position rows (the legacy plurals `permissions` / `positions` included) stored over a name a configured package holds — the rows a v18 cold boot refuses — each with the package that holds its name; `--apply` deletes them, one history tombstone per row. Run it before the first v18 boot, with the flags and environment the deployment boots with: the platform security plugin's sets are held only where `os serve` composes it (an auth secret set, and the `auth` tier on), and the step takes `serve`'s `--preset` and `--dev` with `serve`'s meaning. Adopts nothing; exits 1 while it lists a row | -| `os migrate packageless-overlays` | List the package-less view, dashboard, report, translation and email-template rows stored over a name exactly one installed package ships — the customizations Setup saved before ADR-0146 D11 closed that write — and with `--apply` move each into that package's paired `.custom` (created when absent) as a declared override, `basedOn` the base's installed version; the package-less row is deleted only after the moved one reads back as served. A name no package ships is environment metadata and is not touched; a name several packages ship, or one the pair already holds a row of, is listed and left with the reason. Takes `serve`'s `--preset` and `--dev`. Idempotent: a second run moves nothing. Run it once after upgrading | +| `os migrate packageless-overlays` | List the package-less view, dashboard, report, translation and email-template rows stored over a name exactly one installed package ships — the customizations Setup saved before ADR-0146 D11 closed that write — and with `--apply` move each into that package's paired `.custom` (created when absent) as a declared override, `basedOn` the base's installed version; the package-less row is deleted only after the moved one reads back as served. A name no package ships is environment metadata and is not touched; a name several packages ship, or one the pair already holds a row of, is listed and left with the reason. So is a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so it is listed `cannot_declare` and never attempted, with the way out — save each of its views into the pair as a single view, then delete the container row. Takes `serve`'s `--preset` and `--dev`. Idempotent: a second run moves nothing. Run it once after upgrading | | `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes no row and no schema; run it before the boot-time tenancy repair, which overwrites part of the evidence | **The boot itself writes no row and no schema you did not ask for.** Each of these commands boots From 5cbefbb3f0ba9ecf6e40eefca9b63f4c993aaa67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 08:36:23 +0000 Subject: [PATCH 5/5] chore(changeset): declare Metadata-protocol semantic for the reopened view-container write; metadata-protocol minor Claude-Session: https://claude.ai/code/session_01Utp6CbC5BpV41NMLrJMt8Q Co-authored-by: Claude --- .changeset/30261-packageless-overlays-view-container.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/30261-packageless-overlays-view-container.md b/.changeset/30261-packageless-overlays-view-container.md index 79c4629bf80..bc87e5b3ddb 100644 --- a/.changeset/30261-packageless-overlays-view-container.md +++ b/.changeset/30261-packageless-overlays-view-container.md @@ -1,13 +1,14 @@ --- -'@objectstack/metadata-protocol': patch +'@objectstack/metadata-protocol': minor '@objectstack/cli': patch --- fix(metadata-protocol,cli): `os migrate packageless-overlays` lists a package-less view container `cannot_declare` instead of `movable`, and `--apply` no longer attempts (and fails) it on every run Clause-②: yes (widening) -Metadata-protocol: none +Metadata-protocol: semantic - **`@objectstack/metadata-protocol`: the census gains the disposition `cannot_declare`** (`PackagelessOverlayDisposition`, reachable through the exported `PackagelessOverlayCensus`). A row lands there when the schema the save door judges its body by refuses an `overrides` key on it, so the declared save the move makes could never land. Of the five overlay types the ceremony moves, that is a view container (`list` / `listViews` / `form` / `formViews`): only a single view declares an override. Before, such a row was listed `movable` and every `--apply` reported it `failed` with `Unrecognized key(s) on this view container: overrides`, leaving the row where it was. Now `migratePackagelessOverlays` does not attempt it: no outcome, no failure, no pair ensured for it. +- **A view container's write onto a packaged name is accepted again.** `PUT /api/v1/meta/view/` with a container body (`list` / `listViews` / `form` / `formViews`) over a name one installed read-only package ships, naming no package or naming that base with `?package=`, and the matching `DELETE`, answered `403 NOT_OVERRIDABLE` with a fix (a declared save into the pair) that the container schema refuses. They now succeed, as they did before the ADR-0146 D11 closes; every other shape of the five overlay types is closed exactly as before. - **The two closes read the same answer.** The package-less write close (`packagelessCloseSubject`) closes only what the ceremony moves, so a container's package-less save and delete stay open, as they were before the close: they are the one door such a row keeps. The close of a write naming the read-only base (`overlayPairFixAdmits`, the "the fix can be taken" predicate both closes ask) no longer names the pair as the fix for a container body, because that fix (a declared save into the pair) cannot be taken; that write keeps the answer it had before the close. - **`@objectstack/cli`: `os migrate packageless-overlays`** prints the way out for a `cannot_declare` row under "left as they are": save each of the container's views into the pair as a single view named `.` (one the base ships declaring `overrides: { package, basedOn }`, a new one declaring nothing), then delete the container row with a delete naming no package (naming the base, for a row bound to it). In `--json`, the row carries `disposition: "cannot_declare"` and `outcome: "left"`, and `--apply` exits 0 for it.