Skip to content

Commit 8fad7b1

Browse files
fix(metadata-protocol,cli): os migrate packageless-overlays lists a package-less view container cannot_declare, and --apply never attempts it (#30380)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgVTHCxahQmaNucVGiWhnJ8X)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:`os migrate packageless-overlays` 的预览遇到无包的视图容器行(`list` / `listViews` / `form` / `formViews` 写在一起的那种),不再列成"可移动",而是列成 `cannot_declare` 并给出出路(把每个视图逐个存进配对包,再删掉容器行);`--apply` 不再尝试它,也不再每次报失败。 - 为什么改:容器的 schema 没有 `overrides` 键,迁移第二步的声明式保存必被拒,预览却说可移动,`--apply` 每次失败、行原样不动,运维无从下手;另外 main 上两处关门(无包写、写名指只读基包)把容器的写也关了,403 指向一个做不到的修法,连"删掉容器行"这条出路都被拒。 - 风险与回滚:判断取自 save 门自己的 schema(不另列类型清单),实测五种叠加类型里只有视图容器装不下 `overrides`;两处关门对容器恢复为关门前的行为(无包保存、删除可用),其余类型不变。回滚即撤本 PR,无数据迁移。 - 先看:`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts` 的 `overrideDeclarationRefusedByShape` 与 `overlayPairFixAdmits` 新增的一行;`protocol-packageless-overlay-migration.ts` 的 `classifyPackagelessRow`(新分支在 `no_pair` 之后、`foreign_declaration` 之前);`packages/cli/src/commands/migrate/packageless-overlays.ts` 的 `reasonOf`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/protocol-packageless-overlay-migration.test.ts -t 30261`(需先 build metadata-protocol),预期 5 条全绿;在 main 的字节上这 5 条全红。 - 需要你做的:无,席位落地。 Fixes #30261 Clause-②: yes (widening) — `PackagelessOverlayDisposition` (reachable through the exported `PackagelessOverlayCensus`) gains `cannot_declare`; the package-less write close and the close of a write naming the read-only base no longer refuse a view container's write, whose named fix cannot be taken Metadata-protocol: semantic — an author's package-less save or delete (and a save naming the read-only base, `?package=<base>`) of a view container (`list` / `listViews` / `form` / `formViews`) over a name one read-only package ships, refused `403 NOT_OVERRIDABLE` since the D11 close, succeeds again: the close names a fix (a declared save into the pair) a container cannot take. Every other shape is closed as before. `@objectstack/metadata-protocol` ships `minor`; `@objectstack/cli` `patch` (seat correction at 5cbefbb, the #30168 / PR #30322 precedent). Skills-docs: none — `skills/objectstack-platform/SKILL.md` line 128 refuses the package-less write "once movable as a declared override"; a container is never movable as one, so the sentence stays true, and no skill teaches writing a container over a packaged name. ## What changes The card's Expected: "The preview and `--apply` agree: a row listed `movable` moves, or a container row is listed with the reason it cannot move." This PR takes the second arm (the order's fork: make the preview honest, leave the container schema as it is; widening `ViewSchema` is a `packages/spec` design question, not a migration fix). **One predicate, from the save door's own schema.** `overrideDeclarationRefusedByShape(type, body)` (`protocol-packaged-base-refusal.ts`) parses the body with an `overrides` probe through `resolveOverlaySchema`, the resolver the save door validates with, and answers `true` only when the parse names `overrides` as an unrecognized key at the body's root. In a union it must be named by every branch still diagnosing the body (the view union mutes the branches the body does not claim to a root kind mismatch, `focusClaimedBranch`), so a view body that claims no member is not answered `true`. No list of shapes is kept: a member that gains or loses the key is answered by its schema. **Measured over the five overlay types the ceremony moves** (pinned in `protocol.override-declaration-shape.test.ts`, which also pins that its fixture covers every `allowEnvironmentOverlay` type): the view container answers `true` in all four spellings (`list`, `listViews`, `form`, `formViews`); a single view, a list overlay, a dashboard, a report, a translation and an email template each spread `OverrideDeclarationFields` and answer `false`. So the view container is the one container-shaped member with this mismatch. **The census.** `classifyPackagelessRow` takes the canonical type and returns `cannot_declare` (with `base` and `pair`) for such a body, after `multi_shipped` / `no_pair` and before `foreign_declaration` / `shadowed`, because those two dispositions' ways out end in this ceremony moving the row. `migratePackagelessOverlays` acts on `movable` rows only, so it never attempts the row: no outcome, no failure, no pair ensured for it, and the CLI exits 0. **The two closes.** On `main` both closes refused a container's write with `403 NOT_OVERRIDABLE`, naming a fix (a declared save into the pair) that the container schema refuses (measured, red leg below). Both closes are built on "close only where the fix can be taken" (#28576 ruling 2; the #28905 docblock "every write this closes has a door, the pair; every other one keeps the write it has on `main`"): - the package-less close classifies by the same `classifyPackagelessRow`, so a `cannot_declare` row's package-less save and DELETE stay open: they are the one door such a row keeps, and the way out ends with that DELETE; - `overlayPairFixAdmits`, the "fix can be taken" predicate both closes ask, now also asks the shape (for a real body; a bare identity has no shape), so the close of a write naming the read-only base leaves a container's write as it was before that close. The row such a write stores is listed `cannot_declare`, `boundToBase`, and a delete naming the base removes it. **CLI.** `reasonOf` prints the way out under "left as they are": save each of the container's views into the pair as a single view named `OBJECT.VIEW` (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). JSON rows carry `disposition: "cannot_declare"`, `outcome: "left"`. **Docs.** Two sentences this PR made false, amended: `content/docs/deployment/cli.mdx` (the `os migrate packageless-overlays` row: "move each") and `content/docs/concepts/metadata-lifecycle.mdx` (the D11 callout: every package-less write onto a ✅-type packaged item is refused). **Changeset:** `@objectstack/metadata-protocol` + `@objectstack/cli` patch, both declaration lines. ## Tests - `objectql` `protocol-packageless-overlay-migration.test.ts`, new top-level describe `[#30261]` (5 cases, real `ObjectQL` engine and registry): the census lists the active and draft container rows `cannot_declare` with base and pair, `--apply` returns no outcome and no base line, ensures no pair, and a re-run lists the same; a single view beside it still moves; the close leaves the container's package-less save, DELETE and first save open; the close of a write naming the base leaves it open and the stored row is listed `cannot_declare` `boundToBase`, removable by a delete naming the base; the way out holds end to end (the default view saved into the pair declaring the override, `showcase_task.mine` saved as the pair's own, the container deleted naming no package, then the census lists nothing and the read serves the pair's view). - `metadata-protocol` `protocol.override-declaration-shape.test.ts` (new; no harness for this predicate existed): the measurement above, plus a container with an unknown key of its own still `true`, a body claiming no view member `false`, a schema-less type `false`. - `cli` `packageless-overlays.integration.test.ts`, new describe over a real SQLite database: the preview lists the container `cannot_declare` / `left`; `--apply --yes` exits 0 with `failed: 0`, `moved: 0`, the rows unchanged and no pair stored. ## Acceptance notes - The order asked whether ADR-0146 D11/D2/D10 requires container rows to move through the ceremony. D11 reads "Every `sys_metadata` row with `package_id` NULL that overlays an item a code package ships becomes a declared override of that item"; D2 defines the declaration as a key "in the item's body" and never names a container. A container is not itself an item the read serves by name (it expands to `OBJECT.VIEW` items), and the way out moves each of those items as D11 describes. Read as not requiring the container row itself to move; reported here for the seat rather than as `needs_decision`. - `basedOn` is not judged and the probe's value is a fixed well-formed declaration: only the key's acceptance is read. ## Verification All readings on head `3413e7bb` (the fix's four commits plus a merge of `main` at `0837fdc0`, which carries #30322's change to the same `overlayPairFixAdmits`: the shape check runs first, then `overrideWriteVerdict(…, fix.pendingPairScope)`), except the red/green legs, taken before the merge on `efbca752` / `17adf261` with `main`'s bytes at `8b117c0e`. <details><summary>Red leg (main's bytes) and green leg</summary> ``` red: git restore --source=8b117c0e -- protocol-packaged-base-refusal.ts protocol-packageless-overlay-migration.ts pnpm --filter @objectstack/metadata-protocol build; ablation-dist-preflight 'cannot_declare' --absent -> marker absent from all 24 built files objectql protocol-packageless-overlay-migration.test.ts -t 30261 -> 4 failed (the first four cases): census: expected 'showcase_task@active cannot_declare', received 'showcase_task@active movable' (and @draft) close: package-less save of the container -> { code: 'NOT_OVERRIDABLE', status: 403 } way out: the package-less DELETE of the container -> { code: 'NOT_OVERRIDABLE' } second red leg (the named-base case, added after): -> 1 failed, { code: 'NOT_OVERRIDABLE' } restored with git checkout HEAD --; git hash-object equal to HEAD's blobs (4a2a3ba, b312e97); git status clean green: rebuild; preflight 'cannot_declare' present in 4 built files objectql protocol-packageless-overlay-migration.test.ts -> 33/33 (34/34 with the named-base case) metadata-protocol override-declaration-shape + named-base-overlay-write + view-expansion-reconcile-call-sites -> 61/61 ``` </details> <details><summary>Package suites and typechecks, on 3413e7b after the merge (under os-verify-lock.sh)</summary> ``` pnpm install --frozen-lockfile :: exit 0 pnpm exec turbo run build --concurrency=2 --filter=...@objectstack/cli --filter=...@objectstack/objectql :: exit 0 pnpm --filter @objectstack/metadata-protocol test :: exit 0 Test Files 313 passed | 4 skipped; Tests 22396 passed | 26 skipped pnpm --filter @objectstack/metadata-protocol typecheck :: exit 0 pnpm --filter @objectstack/objectql test :: exit 0 Test Files 550 passed; Tests 10956 passed pnpm --filter @objectstack/objectql typecheck :: exit 0 pnpm --filter @objectstack/cli exec vitest run --project unit :: exit 0 Test Files 384 passed; Tests 5427 passed pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/migrate/packageless-overlays.integration.test.ts :: exit 0 Tests 6 passed pnpm --filter @objectstack/cli typecheck :: exit 0 ``` </details> <details><summary>Derived gates (dispatch-gates --commands, 95 families) and lint, on 3413e7b</summary> ``` 94 of 95 :: exit 0 (each recorded before any pipe) node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 3 (shallow), exit 0 after git fetch origin 621a487 pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET (needs every package's dist) -> NOT MEASURED, CI Lint & Repo Gates runs it dispatch-gates --ran: 95 derived famil(ies) accounted for -- 94 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3) pnpm lint :: exit 0 ``` </details> Dogfood: not owed (no `!` in the title, no `(narrowing)` arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbC5BpV41NMLrJMt8Q)_ <!-- signed: domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 38717d3 commit 8fad7b1

9 files changed

Lines changed: 330 additions & 6 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
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
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
11+
- **`@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.
12+
- **A view container's write onto a packaged name is accepted again.** `PUT /api/v1/meta/view/<object>` 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=<base>`, 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.
13+
- **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.
14+
- **`@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 `<object>.<view>` (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.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ document **whole** for every read that serves the base's name
173173

174174
> **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.
175175

176-
> **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=<base>.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.
176+
> **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=<base>.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.
177177

178178
| Type | `allowEnvironmentOverlay` | Rationale |
179179
| :--- | :---: | :--- |

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1317,7 +1317,7 @@ where the data lives.
13171317
| `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 |
13181318
| `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 |
13191319
| `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 |
1320-
| `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 `<base>.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 |
1320+
| `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 `<base>.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 |
13211321
| `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 |
13221322
13231323
**The boot itself writes no row and no schema you did not ask for.** Each of these commands boots

‎packages/cli/src/commands/migrate/packageless-overlays.integration.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,27 @@ const BOUND_SEED: SeedRow[] = [
102102
{ type: 'view', name: ENV_VIEW, state: 'active', body: viewBody(ENV_VIEW, STORED_COLUMNS) },
103103
];
104104

105+
/**
106+
* [#30261] A database whose customization of the package's view container (a
107+
* list view the package does not ship added beside its default list) was
108+
* stored as a package-less CONTAINER row, beside the environment row (control).
109+
*/
110+
const CONTAINER = 'm28413_task';
111+
const CONTAINER_SEED: SeedRow[] = [
112+
{
113+
type: 'view', name: CONTAINER, state: 'active', body: {
114+
object: 'm28413_task',
115+
list: { label: 'All', type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: STORED_COLUMNS.map((field) => ({ field })) },
116+
listViews: { mine: { label: 'Mine', type: 'grid', data: { provider: 'object', object: 'm28413_task' }, columns: DRAFT_COLUMNS.map((field) => ({ field })) } },
117+
},
118+
},
119+
{ type: 'view', name: ENV_VIEW, state: 'active', body: viewBody(ENV_VIEW, STORED_COLUMNS) },
120+
];
121+
105122
let dir: string;
106123
let templateDb: string;
107124
let boundTemplateDb: string;
125+
let containerTemplateDb: string;
108126
const savedCwd = process.cwd();
109127
const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV',
110128
'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID'] as const;
@@ -250,8 +268,11 @@ beforeAll(async () => {
250268
await seed.shutdown();
251269
boundTemplateDb = join(dir, 'bound-template.db');
252270
copyFileSync(templateDb, boundTemplateDb);
271+
containerTemplateDb = join(dir, 'container-template.db');
272+
copyFileSync(templateDb, containerTemplateDb);
253273
await insertRows(templateDb, SEED);
254274
await insertRows(boundTemplateDb, BOUND_SEED);
275+
await insertRows(containerTemplateDb, CONTAINER_SEED);
255276
}, CASE_TIMEOUT_MS);
256277

257278
afterAll(() => {
@@ -384,3 +405,34 @@ describe('os migrate packageless-overlays: a row bound to the read-only package'
384405
expect(again.payload.rows).toEqual([]);
385406
}, CASE_TIMEOUT_MS * 3);
386407
});
408+
409+
/**
410+
* [#30261] A package-less view CONTAINER row of the package's view: its shape
411+
* refuses the `overrides` key, so the declared save `--apply` makes could
412+
* never land. The preview lists it `cannot_declare` (left), and `--apply`
413+
* attempts nothing: no failure, exit 0, the row and the database as they were.
414+
* Before, the preview listed it movable and every `--apply` failed it.
415+
*/
416+
describe('os migrate packageless-overlays: a package-less view container', () => {
417+
it('the preview lists it `cannot_declare`, left; --apply attempts nothing, fails nothing, and leaves the row', async () => {
418+
const db = caseDb(containerTemplateDb);
419+
const before = await storedRows(db);
420+
const preview = await runJson(['--database-url', `file:${db}`, '--json']);
421+
expect(preview.payload.error, JSON.stringify(preview.payload).slice(0, 600)).toBeUndefined();
422+
expect(preview.exitCode).toBe(0);
423+
expect(preview.payload.rows.map((r: any) => `${r.type}/${r.name}/${r.state} ${r.disposition} ${r.base} → ${r.pair} ${r.outcome}`)).toEqual([
424+
`view/${CONTAINER}/active cannot_declare ${PKG} → ${PAIR} left`,
425+
]);
426+
expect(preview.payload.movable).toBe(0);
427+
expect(preview.payload.left).toBe(1);
428+
429+
const { payload, exitCode } = await runJson(['--apply', '--yes', '--database-url', `file:${db}`, '--json']);
430+
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
431+
expect(exitCode).toBe(0);
432+
expect(payload.failed).toBe(0);
433+
expect(payload.moved).toBe(0);
434+
expect(payload.rows.map((r: any) => `${r.name} ${r.disposition} ${r.outcome}`)).toEqual([`${CONTAINER} cannot_declare left`]);
435+
expect(await storedRows(db)).toEqual(before);
436+
expect(await storedPackages(db)).not.toContain(PAIR);
437+
}, CASE_TIMEOUT_MS * 2);
438+
});

‎packages/cli/src/commands/migrate/packageless-overlays.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ function reasonOf(row: CensusRow): string {
6363
+ '(served then at that package\'s address, ?package=<base>).';
6464
case 'no_pair':
6565
return `'${row.base}' has no paired .custom package id, so there is nowhere to move it. Left as it is.`;
66+
case 'cannot_declare':
67+
// [#30261] Its shape refuses the `overrides` key, so the move's declared save can never land.
68+
return (row.type === 'view'
69+
? 'it is a view container (list/listViews/form/formViews), and only a single view can declare an override, '
70+
: 'its shape cannot carry an `overrides` declaration, ')
71+
+ 'so it cannot be moved as one. Left as it is: '
72+
+ (row.type === 'view'
73+
? `save each of its views into '${row.pair}' as a single view named <object>.<view> (?package=${row.pair}) — `
74+
+ `one '${row.base}' ships declaring overrides: { package: '${row.base}', basedOn }, a new one declaring nothing — `
75+
: `save its content into '${row.pair}' in a shape that declares overrides: { package: '${row.base}', basedOn } — `)
76+
+ `then delete this row with a delete naming ${row.boundToBase ? `'${row.base}'` : 'no package'}.`;
6677
case 'foreign_declaration':
6778
return `its body already carries an \`overrides\` key that does not name '${row.base}'. Left as it is: `
6879
+ (row.boundToBase

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
import { SysMetadataRepository } from './sys-metadata-repository.js';
4747
import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js';
4848
import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js';
49+
import { resolveOverlaySchema } from './protocol-meta-type-schema.js';
4950
import { resolvePackageClosure } from '@objectstack/lint/runtime';
5051
import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js';
5152
import {
@@ -462,10 +463,72 @@ export function overlayPairFixAdmits(
462463
...(body ?? { name, ...(disc && member !== null ? { [disc]: member } : {}) }),
463464
overrides: { package: fix.base, basedOn: fix.basedOn },
464465
};
466+
// [#30261] The save door's schema half: a body whose shape cannot carry
467+
// the declaration (a view container) is refused there, whatever the gate
468+
// answers. A bare identity has no shape to judge.
469+
if (body !== undefined && overrideDeclarationRefusedByShape(type, body)) return false;
465470
const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody }, fix.pendingPairScope);
466471
return verdict.verdict !== 'refuse';
467472
}
468473

474+
/**
475+
* [#30261, ADR-0146 D2, D11] Whether the schema the save door judges `body`
476+
* by (`resolveOverlaySchema`, the door's own resolver) refuses an `overrides`
477+
* key on it as unrecognized: the body's SHAPE cannot carry the override
478+
* declaration, so no save declaring it lands, whatever the override write gate
479+
* answers.
480+
*
481+
* Measured over the five overlay types the ceremony moves, one shape answers
482+
* `true`: the view CONTAINER (`ViewSchema`, the `container` member of
483+
* `ViewMetadataSchema`). A single view, a list or form overlay, a dashboard, a
484+
* report, a translation and an email template each spread
485+
* `OverrideDeclarationFields`. Read from the parse, not from a list of shapes,
486+
* so a member that gains or loses the key is answered by its own schema.
487+
*
488+
* Only the refusal of the KEY is read: an `unrecognized_keys` issue naming
489+
* `overrides` at the body's root. Inside a union it must be named by EVERY
490+
* branch still diagnosing the body — the view union keeps the branch the body
491+
* claims and mutes the others to a kind mismatch at the root
492+
* (`focusClaimedBranch`), which is dropped here as every consumer of a failed
493+
* union drops it — so a body that claims no branch, refused by the container
494+
* member and by a member that carries the key, is not answered `true`. Every
495+
* other finding on the body is the save door's to answer, as before, and
496+
* answers `false` here. The body's own `overrides` value, if any, is replaced
497+
* by a well-formed probe: the key alone decides.
498+
*/
499+
export function overrideDeclarationRefusedByShape(type: string, body: Record<string, unknown>): boolean {
500+
const schema = resolveOverlaySchema(type, body);
501+
if (!schema) return false;
502+
const parsed = schema.safeParse({ ...body, overrides: OVERRIDES_KEY_PROBE });
503+
return !parsed.success && parsed.error.issues.some(namesOverridesAtRoot);
504+
}
505+
506+
/** A well-formed declaration: the probe asks whether the KEY is carried, so its value raises nothing. */
507+
const OVERRIDES_KEY_PROBE = Object.freeze({ package: 'com.example.base', basedOn: '1.0.0' });
508+
509+
type ParseIssue = { code?: unknown; path?: unknown; keys?: unknown; errors?: unknown } | null;
510+
511+
/** An issue at the parsed value's root: a `path` of length 0. */
512+
const atRoot = (issue: ParseIssue): boolean => Array.isArray(issue?.path) && issue.path.length === 0;
513+
514+
/**
515+
* An `unrecognized_keys` issue naming `overrides` at the parsed body's root;
516+
* for a union, named by every branch whose issues are not a bare kind
517+
* mismatch at the root (a muted branch, or a member of another kind).
518+
*/
519+
function namesOverridesAtRoot(issue: unknown): boolean {
520+
const candidate = issue as ParseIssue;
521+
if (candidate?.code === 'unrecognized_keys') {
522+
return atRoot(candidate) && Array.isArray(candidate.keys) && candidate.keys.includes('overrides');
523+
}
524+
if (candidate?.code === 'invalid_union' && Array.isArray(candidate.errors)) {
525+
const diagnosing = candidate.errors.filter((branch): branch is unknown[] => Array.isArray(branch)
526+
&& !branch.every((i) => (i as ParseIssue)?.code === 'invalid_type' && atRoot(i as ParseIssue)));
527+
return diagnosing.length > 0 && diagnosing.every((branch) => branch.some(namesOverridesAtRoot));
528+
}
529+
return false;
530+
}
531+
469532
/** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */
470533
export interface NamedBaseCloseHost {
471534
readonly engine: ObjectStackProtocolImplementation['engine'];

0 commit comments

Comments
 (0)