diff --git a/.changeset/19056-migration-support-floor-16.md b/.changeset/19056-migration-support-floor-16.md new file mode 100644 index 00000000000..b9e0ad7e13f --- /dev/null +++ b/.changeset/19056-migration-support-floor-16.md @@ -0,0 +1,95 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": patch +--- + +chore(spec)!: the metadata migration chain is supported from protocol 16 — `MIGRATION_SUPPORT_FLOOR` 10 → 16, and `step11`–`step16` retire with it (#19056) + +Clause-②: yes (narrowing) + + + +**BREAKING** for a consumer still authored against protocol **10, 11, 12, 13, 14 +or 15**. Landing in the launch window as `minor` under the lockstep convention. + +Maintainer ruling, 2026-09-18, verbatim and untranslated: + +> 升级只需要支持从 16.0版本开始。 + +「16.0」reads as protocol major 16 — the same unit as the constant +(`PROTOCOL_VERSION` is `17.0.0`, so the package version `17.x` and the protocol +major are not the same number). That reading was put back to the maintainer and +was not contradicted. + +## What changes for you + +`MIGRATION_SUPPORT_FLOOR` — a published export of `@objectstack/spec` — moves +from `10` to `16`. Two consequences, both at the boundary: + +| you call | before | after | +| --- | --- | --- | +| `applyMetaMigrations(stack, N)` for N ∈ 10..15 | replays the chain from N | throws `MigrationFloorError` | +| `os migrate meta --from N` for N ∈ 10..15 | migrates | refuses, naming the floor | +| `applyMetaMigrations(stack, N)` for N ≥ 16 | unchanged | unchanged | +| `MIGRATION_SUPPORT_FLOOR` as a TS literal type | `10` | `16` | + +The fix, and the only one there is: **reach protocol 16 by another path first, +then re-run.** The refusal says so itself — `Cannot migrate from protocol N: the +chain's support floor is 16 (ADR-0087 D3). Upgrade to protocol 16 by another +path first, then re-run.` A stack already at 16 or above is unaffected, and the +16 → 17 and 17 → 18 hops are untouched. + +If you pin `MIGRATION_SUPPORT_FLOOR`'s literal type (`const f: 10 = …`), that +annotation stops compiling. The value was always a release-policy knob, so read +the constant rather than restating it. + +## What this is NOT + +It is **not** a slimming change, and the measurement is the reason to say so. +Counted on `src/migrations/registry.ts` at `e6a03e649` (17,718 lines): + +| block | lines | share | +| --- | ---: | ---: | +| `step11`–`step16` — what leaves | 328 | 1.9% | +| `step17` | 4,699 | 26.5% | +| `step18` | 7,565 | 42.7% | +| the registration map + the two retirement tables | 5,077 | 28.7% | +| file header | 49 | 0.3% | + +Everything but the first row stays. What the raise buys is a **narrower support +promise**: six permanently-replayable chains no longer have to be maintained, +and the CI replay shrinks to the range the project actually promises — 10 of the +98 conversion fixtures leave the chain-replay gate, because the chain no longer +reaches the major that graduated them. + +## What was deliberately NOT removed + +`RETIRED_KEYS_BY_MAJOR` and `RETIRED_DEFS_BY_MAJOR` live in the same file and +are keyed by protocol major, which makes them look like chain state. They are +not, and both are kept whole: + +- the chain never reads either table (`chain.ts` imports the steps and the floor + and nothing else); +- their one non-test reader, `packages/spec/scripts/build-schemas.ts` + (`check:authorable-surface`), folds every major into one set and never + mentions `MIGRATION_SUPPORT_FLOOR`. + +So a row below the floor is still the live proof that its retirement was +declared. Measured by ablation: a row planted under major **11** — a major whose +step this change deletes — was still read and judged, reported as *"(registered +at major 11)"*. Both facts are pinned in +`src/migrations/retired-tables-not-floor-scoped.test.ts` so the next floor move +reads them first. Dropping such a row errors nowhere at the moment it is +dropped; the declared retirement simply stops being declared. + +The D2 conversion registry is untouched for the same reason: every rehydration +seam replays the **full** conversion chain over stored `sys_metadata` rows, +retired entries included, so the protocol-11/13/14/15 conversions keep +converting rows at rest long after the source-side chain stops reaching them. + +## `@objectstack/cli` + +`os migrate meta --help` advertised `--from 10`, `--from 10 --step`, +`--from 11 --to 12` and `--from 10 --out …`. Every one of those refuses after +this change. The examples are now derived from `MIGRATION_SUPPORT_FLOOR`, so the +next floor move cannot leave them advertising commands that throw. diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 59f4220ba07..21c86a4e479 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -182,7 +182,7 @@ followed by your normal build. ### One command covers every major you skipped ```bash -os migrate meta --from 15 # the major your metadata was authored against +os migrate meta --from 16 # the major your metadata was authored against ``` `--from` is the major **you** wrote against, not the one you are going to. The @@ -193,22 +193,29 @@ them by hand: | You ran | Steps it replays | | :--- | :--- | -| `os migrate meta --from 16` | 17 | -| `os migrate meta --from 15` | 16, 17 | -| `os migrate meta --from 14` | 15, 16, 17 | -| `os migrate meta --from 12` | 13, 14, 15, 16, 17 | +| `os migrate meta --from 16` | 17, then every later major this build carries a step for | -The chain reaches back to **protocol 10** — that floor is a release-policy +The chain reaches back to **protocol 16** — that floor is a release-policy decision, not an accident of what still exists. Below it the command refuses with a message naming the floor rather than half-migrating you. + + **The floor was raised from 10 to 16.** Earlier releases replayed the chain + from protocol 10, so `--from 10` through `--from 15` used to work and now + answer `Cannot migrate from protocol N: the chain's support floor is 16`. + Nothing half-migrates: the command refuses before it rewrites anything. If + your metadata is still authored against protocol 10–15, reach protocol 16 by + another path first — an older `@objectstack/cli` still carries those steps — + and then run the command above. + + Useful flags: | Flag | What it does | | :--- | :--- | | `--step` | Report each major's hop separately, so a failure bisects to the exact major | | `--out migrated.stack.json` | Also write the migrated stack as a JSON snapshot — the only file the command writes | -| `--to 16` | Stop at an intermediate major instead of this runtime's | +| `--to 17` | Stop at an intermediate major instead of this runtime's | | `--json` | Machine-readable output, for CI or an agent | It reads **your source** and no database. (Its sibling, @@ -265,7 +272,7 @@ has already done. ### The loop ```bash -os migrate meta --from 15 # 1. read the mechanical change list and the to-dos +os migrate meta --from 16 # 1. read the mechanical change list and the to-dos # 2. apply the edits by hand; resolve the to-dos # and the checklist items below os validate # 3. the gate — schema, CEL predicates, widget bindings diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 972a010e803..8fc7260a589 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -3,116 +3,19 @@ # Metadata protocol upgrade guide -Current protocol: **17.0.0** · chain support floor: **protocol 10** · generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`). +Current protocol: **17.0.0** · chain support floor: **protocol 16** · generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`). -## How to upgrade — from any past major +## How to upgrade — from protocol 16 onward ```bash objectstack migrate meta --from # replays every step below, in order -objectstack migrate meta --from 10 --step # checkpoint after each major (bisect a failure) +objectstack migrate meta --from 16 --step # checkpoint after each major (bisect a failure) objectstack validate && tsc --noEmit && # your own verify loop is the acceptance test ``` -Mechanical rewrites are applied for you and reported as a diff; **semantic TODOs** are printed with acceptance criteria and are yours to resolve — the chain never auto-applies a change that requires judgment. Arriving several majors late is the designed-for case: timeliness is never load-bearing (ADR-0087). +Mechanical rewrites are applied for you and reported as a diff; **semantic TODOs** are printed with acceptance criteria and are yours to resolve — the chain never auto-applies a change that requires judgment. -## Protocol 10 → 11 - -Protocol 11 unified the divergent HTTP callout node types to `http`, made `html` the canonical page kind (deprecating the `jsx` alias), canonicalized the CRUD flow-node filter key, and renamed object `compactLayout` to `highlightFields` (ADR-0085). These are mechanical and replay losslessly. Two related deprecations are semantic and cannot be auto-applied: a composite `titleFormat` render template has no single canonical `nameField`, and SQL-ish RLS predicates must be rewritten to canonical CEL — both are delegated to the consumer with explicit acceptance criteria. - -### Mechanical (applied for you) - -| Conversion | Surface | Change | Load window | -|---|---|---|---| -| `flow-node-http-callout-rename` | `flow.node.type` | flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http' | live — protocol 11 loader accepts the old shape | -| `page-kind-jsx-to-html` | `page.kind` | page kind 'jsx' → 'html' (ADR-0080 canonical spelling) | live — protocol 11 loader accepts the old shape | -| `flow-node-crud-filter-alias` | `flow.node.config.filter` | CRUD flow-node config key 'filters' → 'filter' | live — protocol 11 loader accepts the old shape | -| `object-compactLayout-to-highlightFields` | `object.compactLayout` | object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles) | retired — `migrate meta` only | - -### Semantic (delegated to you, with acceptance criteria) - -- **`object-titleFormat-to-nameField`** — `object.titleFormat` → object.nameField - - Why not automatic: A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make. - - Done when: Each object with a `titleFormat` declares a `nameField`; a composite title is backed by a formula field. `objectstack validate` passes and record display names render identically to before. -- **`rls-sql-predicate-to-cel`** — `security.rls.predicate` → CEL predicate - - Why not automatic: SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain. - - Done when: Every RLS predicate parses as CEL and `objectstack validate` reports no expression errors; row visibility is unchanged for a representative fixture set. - -## Protocol 11 → 12 - -Protocol 12 flipped the REST data-API default to authenticated (`api.requireAuth: true`, ADR-0056 D2). No metadata shape changed, so there is nothing to rewrite mechanically; a deployment that intentionally serves data anonymously must now declare that posture explicitly. - -### Semantic (delegated to you, with acceptance criteria) - -- **`rest-requireauth-default-flip`** — `api.requireAuth` → explicit `api: { requireAuth: false }` (intentionally-public deployments only) - - Why not automatic: The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make. - - Done when: A deployment that relies on anonymous data access declares `api: { requireAuth: false }` on the stack config (and accepts the boot warning); every other consumer verifies its clients authenticate. `objectstack validate` and the consumer test suite pass. - -## Protocol 12 → 13 - -Protocol 13 (ADR-0090 P1) converged the permission model: Role became Position (flat; hierarchy lives on the business-unit tree), the Profile concept was removed, the OWD enum shrank to its canonical four values, and a custom object with an owner field and no `sharingModel` now defaults to `private` instead of public. Key renames replay mechanically; everything that changes *meaning* (profile → position/permission-set design, hierarchy re-homing, CEL identifier rewrites, sharing postures) is delegated with acceptance criteria. - -### Mechanical (applied for you) - -| Conversion | Surface | Change | Load window | -|---|---|---|---| -| `stack-roles-to-positions` | `stack.roles` | stack collection key 'roles' → 'positions' (ADR-0090 D3) | retired — `migrate meta` only | -| `owd-legacy-read-aliases` | `object.sharingModel` | object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4) | retired — `migrate meta` only | -| `sharing-recipient-role-to-position` | `sharingRule.sharedWith.type` | sharing-rule recipient type 'role' → 'position' (ADR-0090 D3) | retired — `migrate meta` only | - -### Semantic (delegated to you, with acceptance criteria) - -- **`cel-current-user-roles-to-positions`** — `CEL/formula: current_user.roles` → current_user.positions - - Why not automatic: The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author. - - Done when: No expression references `current_user.roles`; formula validation and `objectstack validate` report no unknown-identifier errors; predicate behavior is unchanged for representative users. -- **`owd-full-alias-removed`** — `object.sharingModel: 'full'` → 'public_read_write' or explicit sharing rules - - Why not automatic: The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision. - - Done when: No object declares sharingModel 'full'; the chosen replacement posture is verified against the intended access (who can read/write/delete) for a representative fixture set. -- **`permission-set-profile-removed`** — `permissionSet.kind / permissionSet.isProfile` → position-based assignment + permission-set grants (ADR-0090 D2) - - Why not automatic: The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename. - - Done when: No permission set declares `isProfile` or kind `profile`; the intended assignees hold equivalent grants via positions/permission sets. The access matrix (`os compile` access-matrix gate, where enabled) is reviewed and `objectstack validate` passes. -- **`position-hierarchy-flattened`** — `position.parent / sharingRule recipient role_and_subordinates` → business-unit tree + `unit_and_subordinates` (ADR-0090 D3) - - Why not automatic: Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call. - - Done when: No position declares `parent`; former `role_and_subordinates` rules are re-expressed with `unit_and_subordinates` over an equivalent business-unit tree. Row visibility is unchanged for a representative fixture set. -- **`sharing-model-secure-default`** — `object.sharingModel (absent, custom object with owner field)` → an explicit `sharingModel` declaration - - Why not automatic: ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data. - - Done when: Every custom object that relied on the implicit public posture declares an explicit `sharingModel`; row visibility is verified for a representative fixture set (owners, non-owners, admins). - -## Protocol 13 → 14 - -Protocol 14 renamed the book audience gated arm from `{ profile }` to `{ permissionSet }` (packages own permission sets, never positions — ADR-0090 D9). A pure key rename, preserved as a retired conversion; there is no semantic residue. - -### Mechanical (applied for you) - -| Conversion | Surface | Change | Load window | -|---|---|---|---| -| `book-audience-profile-to-permission-set` | `book.audience` | book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9) | retired — `migrate meta` only | - -## Protocol 14 → 15 - -Protocol 15 unified the conditional-visibility predicate under `visibleWhen` (ADR-0089): view-form `visibleOn` and page-component `visibility` are deprecated aliases, accepted and converted at load for this major. It also flipped `FormFieldSchema`, `FormSectionSchema`, and `PageComponentSchema` to `.strict()` — a key those schemas do not declare is now a loud parse error instead of a silent strip (ADR-0049/0078). - -### Mechanical (applied for you) - -| Conversion | Surface | Change | Load window | -|---|---|---|---| -| `view-visibleOn-to-visibleWhen` | `view.form.visibleOn` | view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089) | live — protocol 15 loader accepts the old shape | -| `page-component-visibility-to-visibleWhen` | `page.component.visibility` | page component key 'visibility' → 'visibleWhen' (ADR-0089) | live — protocol 15 loader accepts the old shape | - -### Semantic (delegated to you, with acceptance criteria) - -- **`ui-schemas-strict-unknown-keys`** — `view form fields/sections · page components (undeclared keys)` → declared keys only (`visibleWhen` for visibility predicates) - - Why not automatic: The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata. - - Done when: `objectstack validate` passes with no unknown-key parse errors on form fields, form sections, or page components. - -## Protocol 15 → 16 - -Protocol 16 flipped `DashboardWidgetSchema` to `.strict()` (framework#3251, ADR-0021 endpoint): an undeclared top-level widget key is now a loud parse error instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). The inline analytics shape it most often catches (`object`+`categoryField`+`valueField`+`aggregate`, pivot `rowField`/`columnField`) was already removed at protocol 9, so no mechanical rewrite applies; the residue is the strictness itself, delegated to the author because an arbitrary unknown key has no lossless canonical target. - -### Semantic (delegated to you, with acceptance criteria) - -- **`dashboard-widget-strict-unknown-keys`** — `dashboard widgets (undeclared top-level keys — legacy inline analytics, objectui-internal `component`/`data`, or typos)` → declared keys only (`dataset` + `dimensions` + `values` for analytics; `options` for renderer-specific extras) - - Why not automatic: The `.strict()` flip turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: bind a `dataset` and select `dimensions`/`values`, move a renderer setting under `options`, or delete the dead key. - - Done when: `objectstack validate` passes with no unknown-key parse errors on dashboard widgets. +The chain's support floor is protocol **16** — 1 major behind the current protocol **17**, and no earlier. A consumer further behind must reach protocol 16 by another path first (an older `@objectstack/cli` still carries the retired steps) before this command will run. From protocol 16 forward, replaying every remaining hop in one command **is** the designed-for case — that part of timeliness is never load-bearing (ADR-0087); arriving from *before* the floor is not supported at all. ## Protocol 16 → 17 diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 7af7441d121..e80aee74e3e 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -12,6 +12,7 @@ import { normalizeStackInput, MigrationFloorError, MIGRATION_MAJORS, + MIGRATION_SUPPORT_FLOOR, } from '@objectstack/spec'; import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; import { FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, STRUCTURED_JSON_TYPES } from '@objectstack/spec/data'; @@ -274,11 +275,16 @@ export default class MigrateMeta extends Command { 'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3). ' + 'With --stored, replay it over this deployment\'s sys_metadata rows instead of an authored config.'; + // Derived from the chain's own floor, never typed: every `--from N` below is + // a command a reader copies, and `applyMetaMigrations` throws + // `MigrationFloorError` for any N under the floor. Written as literals these + // went stale the moment the floor moved 10 -> 16 (#19056), advertising four + // commands that all refuse. static override examples = [ - '$ os migrate meta --from 10', - '$ os migrate meta --from 10 --step', - '$ os migrate meta --from 11 --to 12 --json', - '$ os migrate meta --from 10 --out migrated.stack.json', + `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR}`, + `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --step`, + `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --to ${MIGRATION_SUPPORT_FLOOR + 1} --json`, + `$ os migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --out migrated.stack.json`, '$ os migrate meta --stored', '$ os migrate meta --stored --apply', '$ os migrate meta --stored --apply --yes --json', diff --git a/packages/cli/test/migrate-meta-default-range.test.ts b/packages/cli/test/migrate-meta-default-range.test.ts index e168127752b..81926d6d477 100644 --- a/packages/cli/test/migrate-meta-default-range.test.ts +++ b/packages/cli/test/migrate-meta-default-range.test.ts @@ -51,7 +51,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MIGRATION_MAJORS } from '@objectstack/spec'; +import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from '@objectstack/spec'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { childEnv } from './helpers/serve-process.js'; @@ -215,15 +215,91 @@ describe('os migrate meta — an empty range answers as an empty range (#17134)' expect(stdout).toContain('replayed no conversion'); }, 120_000); - it('still says `Nothing to migrate` for a range that HAS steps and rewrote nothing', async () => { - // ⛔ The success sentence is not collateral damage: a range holding real - // steps that matched nothing is a finding about the metadata, and it keeps - // the answer published acceptance checks grep for. `13 → 14` is chosen - // because step 14 carries no semantic entries, so a canonical stack comes - // back with both lists empty for a NON-empty chain. - const { stdout, code } = await runMeta(['--from', '13', '--to', '14'], canonicalDir); + /** + * ⚠️ #19056 did not change this behaviour — it removed every INPUT that can + * reach it, and the honest pin is that reason rather than a re-pointed number. + * + * The success sentence needs `applied` AND `todos` empty over a NON-empty + * chain. `applyMetaMigrations` surfaces a step's whole `semantic` list as + * todos on every hop it replays, whatever the stack holds, so an empty + * `todos` needs a hop whose own `semantic` list is empty. `13 → 14` was that + * hop: step 14 carried none. Raising `MIGRATION_SUPPORT_FLOOR` to 16 retired + * steps 11–16, and both hops the chain still reaches carry semantic entries — + * so on this build no `--from` / `--to` reaches the sentence at all. Measured: + * `--from 16 --to 17` on the canonical stack prints 77 manual changes and + * `Migrated stack is schema-valid` at exit 0. + * + * ⛔ Re-pointing `13` to `16` would NOT have pinned this behaviour — it would + * have pinned the OTHER branch of the same `if` under this branch's name, + * which is the failure this whole describe block exists to stop. So the + * spawned case is selected by the registry instead: it revives by itself the + * day a reachable hop ships with no semantic residue, and the case below it + * pins what IS reachable today. + * + * WHAT WAS LOST, named: while no reachable hop is semantic-free, nothing + * spawns the CLI over the success sentence. The branch is still covered + * in-process (`printSuccess` is chosen by `applied.length === 0 && + * todos.length === 0 && hops.length > 0`), but not end to end from a real + * terminal. + */ + const SEMANTIC_FREE_HOP = MIGRATION_MAJORS.find( + (m) => m - 1 >= MIGRATION_SUPPORT_FLOOR && MIGRATIONS_BY_MAJOR[m]!.semantic.length === 0, + ); + + it('states which world we are in — whether any reachable hop is semantic-free', () => { + // Anti-vacuity for the two cases below: both are selected by + // SEMANTIC_FREE_HOP, so a registry that could not answer the question would + // silently turn the spawned case off and leave nothing in its place. + expect(MIGRATION_MAJORS.length).toBeGreaterThan(0); + const reachable = MIGRATION_MAJORS.filter((m) => m - 1 >= MIGRATION_SUPPORT_FLOOR); + expect(reachable.length).toBeGreaterThan(0); + for (const m of reachable) expect(MIGRATIONS_BY_MAJOR[m]).toBeDefined(); + + if (SEMANTIC_FREE_HOP === undefined) { + // The #19056 world: every hop the floor still reaches carries semantic + // entries, so the success sentence has no input. This is the assertion + // that makes the skip below a measurement instead of a hole. + expect(reachable.map((m) => MIGRATIONS_BY_MAJOR[m]!.semantic.length)).not.toContain(0); + } else { + expect(MIGRATIONS_BY_MAJOR[SEMANTIC_FREE_HOP]!.semantic).toHaveLength(0); + expect(SEMANTIC_FREE_HOP - 1).toBeGreaterThanOrEqual(MIGRATION_SUPPORT_FLOOR); + } + }); + + it.skipIf(SEMANTIC_FREE_HOP === undefined)( + 'still says `Nothing to migrate` for a range that HAS steps and rewrote nothing', + async () => { + // ⛔ The success sentence is not collateral damage: a range holding real + // steps that matched nothing is a finding about the metadata, and it keeps + // the answer published acceptance checks grep for. + const hop = SEMANTIC_FREE_HOP!; + const { stdout, code } = await runMeta(['--from', String(hop - 1), '--to', String(hop)], canonicalDir); + expect(code).toBe(0); + expect(stdout).toContain('Nothing to migrate'); + expect(stdout).toContain('Migrated stack is schema-valid'); + }, + 120_000, + ); + + it('a canonical stack over the oldest SUPPORTED range is not answered as an empty range', async () => { + // What is reachable now, and the half #17134 is actually about: the oldest + // range the floor still admits HAS a step, so the empty-range warning must + // not appear, the run must still reach its schema verdict, and it must not + // claim canonicality it did not check. Derived from the registry, so the + // next floor move re-points this instead of inviting another delete. + const from = String(MIGRATION_SUPPORT_FLOOR); + const to = String(MIGRATION_MAJORS.find((m) => m > MIGRATION_SUPPORT_FLOOR)!); + const { stdout, code } = await runMeta(['--from', from, '--to', to], canonicalDir); + expect(code).toBe(0); - expect(stdout).toContain('Nothing to migrate'); + // The chain really ran — without this the three negatives below all hold + // on a run that did nothing at all. + expect(stdout).toContain('manual change(s) require your judgment'); expect(stdout).toContain('Migrated stack is schema-valid'); + expect(stdout).not.toContain(`No migration step exists for protocol ${from} → ${to}`); + // ⛔ The whole sentence, never the phrase — step-18 semantic entries open a + // `replacement` with "Nothing to migrate to, because …" (see the sibling + // case above), so the bare phrase collides with prose this command prints. + expect(stdout).not.toContain('Nothing to migrate — the metadata is already canonical for this range'); }, 120_000); }); diff --git a/packages/spec/scripts/build-upgrade-guide.ts b/packages/spec/scripts/build-upgrade-guide.ts index d7703e63016..9080d808a45 100644 --- a/packages/spec/scripts/build-upgrade-guide.ts +++ b/packages/spec/scripts/build-upgrade-guide.ts @@ -41,19 +41,37 @@ function build(): string { 'generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`).', ); out(); - out('## How to upgrade — from any past major'); + out(`## How to upgrade — from protocol ${MIGRATION_SUPPORT_FLOOR} onward`); out(); out('```bash'); out(`objectstack migrate meta --from # replays every step below, in order`); - out('objectstack migrate meta --from 10 --step # checkpoint after each major (bisect a failure)'); + out( + `objectstack migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --step ` + + '# checkpoint after each major (bisect a failure)', + ); out('objectstack validate && tsc --noEmit && # your own verify loop is the acceptance test'); out('```'); out(); out( 'Mechanical rewrites are applied for you and reported as a diff; **semantic TODOs** are printed ' + 'with acceptance criteria and are yours to resolve — the chain never auto-applies a change that ' + - 'requires judgment. Arriving several majors late is the designed-for case: timeliness is never ' + - 'load-bearing (ADR-0087).', + 'requires judgment.', + ); + out(); + const supportedLateness = PROTOCOL_MAJOR - MIGRATION_SUPPORT_FLOOR; + out( + supportedLateness > 0 + ? `The chain's support floor is protocol **${MIGRATION_SUPPORT_FLOOR}** — ` + + `${supportedLateness} major${supportedLateness === 1 ? '' : 's'} behind the current protocol ` + + `**${PROTOCOL_MAJOR}**, and no earlier. A consumer further behind must reach protocol ` + + `${MIGRATION_SUPPORT_FLOOR} by another path first (an older \`@objectstack/cli\` still carries ` + + `the retired steps) before this command will run. From protocol ${MIGRATION_SUPPORT_FLOOR} ` + + 'forward, replaying every remaining hop in one command **is** the designed-for case — that part ' + + 'of timeliness is never load-bearing (ADR-0087); arriving from *before* the floor is not supported ' + + 'at all.' + : `The chain's support floor is protocol **${MIGRATION_SUPPORT_FLOOR}**, which is also the current ` + + 'protocol — there is no supported lateness window right now; the next major to ship widens it ' + + 'again.', ); out(); diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index fb929df3b5f..d8ee038e8a6 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -1,73 +1,13 @@ { "$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data. A record's `added`/`removed` are NOT at its `from` → `to` MAJOR resolution: they come from an api-surface diff against the previously PUBLISHED artifact, so they span ONE RELEASE. When they are non-empty the record carries `surfaceScope: { fromVersion, toVersion }` naming exactly that pair, and a release whose arrays disagree with the two tarballs — or carry no `surfaceScope` — does not publish. Absent `surfaceScope` means the record carries no export diff at all (`added`/`removed` empty), never \"nothing was added between from and to\". When a `release` section is present, its four ADR-0087 D4 arrays report what that release ADDED: `added`/`removed` are the export-surface diff of the two published tarballs, and `converted`/`migrated` are the D2/D3 ids FIRST REGISTERED in it. An id that LEFT the published chain between the two releases is reported in none of them — `converted: []` means \"this release registered none\", never \"none was withdrawn\"; a withdrawal is visible only by comparing two published manifests.", "protocolVersion": "17.0.0", - "supportFloor": 10, - "migrateCommand": "objectstack migrate meta --from (N >= 10)", + "supportFloor": 16, + "migrateCommand": "objectstack migrate meta --from (N >= 16)", "aggregate": { - "from": 10, + "from": 16, "to": 17, "added": [], "converted": [ - { - "surface": "flow.node.type", - "to": "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", - "conversionId": "flow-node-http-callout-rename", - "toMajor": 11 - }, - { - "surface": "page.kind", - "to": "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)", - "conversionId": "page-kind-jsx-to-html", - "toMajor": 11 - }, - { - "surface": "flow.node.config.filter", - "to": "CRUD flow-node config key 'filters' → 'filter'", - "conversionId": "flow-node-crud-filter-alias", - "toMajor": 11 - }, - { - "surface": "object.compactLayout", - "to": "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", - "conversionId": "object-compactLayout-to-highlightFields", - "toMajor": 11 - }, - { - "surface": "stack.roles", - "to": "stack collection key 'roles' → 'positions' (ADR-0090 D3)", - "conversionId": "stack-roles-to-positions", - "toMajor": 13 - }, - { - "surface": "object.sharingModel", - "to": "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", - "conversionId": "owd-legacy-read-aliases", - "toMajor": 13 - }, - { - "surface": "sharingRule.sharedWith.type", - "to": "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", - "conversionId": "sharing-recipient-role-to-position", - "toMajor": 13 - }, - { - "surface": "book.audience", - "to": "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", - "conversionId": "book-audience-profile-to-permission-set", - "toMajor": 14 - }, - { - "surface": "view.form.visibleOn", - "to": "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)", - "conversionId": "view-visibleOn-to-visibleWhen", - "toMajor": 15 - }, - { - "surface": "page.component.visibility", - "to": "page component key 'visibility' → 'visibleWhen' (ADR-0089)", - "conversionId": "page-component-visibility-to-visibleWhen", - "toMajor": 15 - }, { "surface": "action.execute", "to": "action key 'execute' → 'target' (the deprecated handler alias, #3713)", @@ -412,76 +352,6 @@ } ], "migrated": [ - { - "surface": "object.titleFormat", - "replacement": "object.nameField", - "migrationId": "object-titleFormat-to-nameField", - "toMajor": 11, - "rationale": "A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make." - }, - { - "surface": "security.rls.predicate", - "replacement": "CEL predicate", - "migrationId": "rls-sql-predicate-to-cel", - "toMajor": 11, - "rationale": "SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain." - }, - { - "surface": "api.requireAuth", - "replacement": "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", - "migrationId": "rest-requireauth-default-flip", - "toMajor": 12, - "rationale": "The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make." - }, - { - "surface": "CEL/formula: current_user.roles", - "replacement": "current_user.positions", - "migrationId": "cel-current-user-roles-to-positions", - "toMajor": 13, - "rationale": "The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author." - }, - { - "surface": "object.sharingModel: 'full'", - "replacement": "'public_read_write' or explicit sharing rules", - "migrationId": "owd-full-alias-removed", - "toMajor": 13, - "rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision." - }, - { - "surface": "permissionSet.kind / permissionSet.isProfile", - "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)", - "migrationId": "permission-set-profile-removed", - "toMajor": 13, - "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename." - }, - { - "surface": "position.parent / sharingRule recipient role_and_subordinates", - "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)", - "migrationId": "position-hierarchy-flattened", - "toMajor": 13, - "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call." - }, - { - "surface": "object.sharingModel (absent, custom object with owner field)", - "replacement": "an explicit `sharingModel` declaration", - "migrationId": "sharing-model-secure-default", - "toMajor": 13, - "rationale": "ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data." - }, - { - "surface": "view form fields/sections · page components (undeclared keys)", - "replacement": "declared keys only (`visibleWhen` for visibility predicates)", - "migrationId": "ui-schemas-strict-unknown-keys", - "toMajor": 15, - "rationale": "The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata." - }, - { - "surface": "dashboard widgets (undeclared top-level keys — legacy inline analytics, objectui-internal `component`/`data`, or typos)", - "replacement": "declared keys only (`dataset` + `dimensions` + `values` for analytics; `options` for renderer-specific extras)", - "migrationId": "dashboard-widget-strict-unknown-keys", - "toMajor": 16, - "rationale": "The `.strict()` flip turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: bind a `dataset` and select `dimensions`/`values`, move a renderer setting under `options`, or delete the dead key." - }, { "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)", "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", @@ -1025,193 +895,6 @@ "removed": [] }, "perMajor": [ - { - "from": 10, - "to": 11, - "added": [], - "converted": [ - { - "surface": "flow.node.type", - "to": "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", - "conversionId": "flow-node-http-callout-rename", - "toMajor": 11 - }, - { - "surface": "page.kind", - "to": "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)", - "conversionId": "page-kind-jsx-to-html", - "toMajor": 11 - }, - { - "surface": "flow.node.config.filter", - "to": "CRUD flow-node config key 'filters' → 'filter'", - "conversionId": "flow-node-crud-filter-alias", - "toMajor": 11 - }, - { - "surface": "object.compactLayout", - "to": "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", - "conversionId": "object-compactLayout-to-highlightFields", - "toMajor": 11 - } - ], - "migrated": [ - { - "surface": "object.titleFormat", - "replacement": "object.nameField", - "migrationId": "object-titleFormat-to-nameField", - "toMajor": 11, - "rationale": "A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make." - }, - { - "surface": "security.rls.predicate", - "replacement": "CEL predicate", - "migrationId": "rls-sql-predicate-to-cel", - "toMajor": 11, - "rationale": "SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain." - } - ], - "removed": [] - }, - { - "from": 11, - "to": 12, - "added": [], - "converted": [], - "migrated": [ - { - "surface": "api.requireAuth", - "replacement": "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", - "migrationId": "rest-requireauth-default-flip", - "toMajor": 12, - "rationale": "The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make." - } - ], - "removed": [] - }, - { - "from": 12, - "to": 13, - "added": [], - "converted": [ - { - "surface": "stack.roles", - "to": "stack collection key 'roles' → 'positions' (ADR-0090 D3)", - "conversionId": "stack-roles-to-positions", - "toMajor": 13 - }, - { - "surface": "object.sharingModel", - "to": "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", - "conversionId": "owd-legacy-read-aliases", - "toMajor": 13 - }, - { - "surface": "sharingRule.sharedWith.type", - "to": "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", - "conversionId": "sharing-recipient-role-to-position", - "toMajor": 13 - } - ], - "migrated": [ - { - "surface": "CEL/formula: current_user.roles", - "replacement": "current_user.positions", - "migrationId": "cel-current-user-roles-to-positions", - "toMajor": 13, - "rationale": "The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author." - }, - { - "surface": "object.sharingModel: 'full'", - "replacement": "'public_read_write' or explicit sharing rules", - "migrationId": "owd-full-alias-removed", - "toMajor": 13, - "rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision." - }, - { - "surface": "permissionSet.kind / permissionSet.isProfile", - "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)", - "migrationId": "permission-set-profile-removed", - "toMajor": 13, - "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename." - }, - { - "surface": "position.parent / sharingRule recipient role_and_subordinates", - "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)", - "migrationId": "position-hierarchy-flattened", - "toMajor": 13, - "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call." - }, - { - "surface": "object.sharingModel (absent, custom object with owner field)", - "replacement": "an explicit `sharingModel` declaration", - "migrationId": "sharing-model-secure-default", - "toMajor": 13, - "rationale": "ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data." - } - ], - "removed": [] - }, - { - "from": 13, - "to": 14, - "added": [], - "converted": [ - { - "surface": "book.audience", - "to": "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", - "conversionId": "book-audience-profile-to-permission-set", - "toMajor": 14 - } - ], - "migrated": [], - "removed": [] - }, - { - "from": 14, - "to": 15, - "added": [], - "converted": [ - { - "surface": "view.form.visibleOn", - "to": "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)", - "conversionId": "view-visibleOn-to-visibleWhen", - "toMajor": 15 - }, - { - "surface": "page.component.visibility", - "to": "page component key 'visibility' → 'visibleWhen' (ADR-0089)", - "conversionId": "page-component-visibility-to-visibleWhen", - "toMajor": 15 - } - ], - "migrated": [ - { - "surface": "view form fields/sections · page components (undeclared keys)", - "replacement": "declared keys only (`visibleWhen` for visibility predicates)", - "migrationId": "ui-schemas-strict-unknown-keys", - "toMajor": 15, - "rationale": "The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata." - } - ], - "removed": [] - }, - { - "from": 15, - "to": 16, - "added": [], - "converted": [], - "migrated": [ - { - "surface": "dashboard widgets (undeclared top-level keys — legacy inline analytics, objectui-internal `component`/`data`, or typos)", - "replacement": "declared keys only (`dataset` + `dimensions` + `values` for analytics; `options` for renderer-specific extras)", - "migrationId": "dashboard-widget-strict-unknown-keys", - "toMajor": 16, - "rationale": "The `.strict()` flip turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: bind a `dataset` and select `dimensions`/`values`, move a renderer setting under `options`, or delete the dead key." - } - ], - "removed": [] - }, { "from": 16, "to": 17, diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index e52675a0d29..2371b6f4407 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -197,9 +197,13 @@ const flowNodeFilterAlias: MetadataConversion = { * * A pure key rename — the value (ordered field-name list) is unchanged. * **Retired from the load path**: the schema tombstones `compactLayout` with a - * fix-it error, so the loader must NOT quietly accept it; the entry exists so - * `migrate meta --from 10|11` rewrites old *sources* (backfilled per the - * ADR-0087 true-up — the rename shipped before the conversion layer existed). + * fix-it error, so the loader must NOT quietly accept it. Protocol 11 is below + * `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this conversion any + * more and `migrate meta --from 10|11` refuses before it would ever reach it + * (backfilled per the ADR-0087 true-up — the rename shipped before the + * conversion layer existed). Its one remaining reader is the stored-row replay + * (`applyConversionsToStoredItem`, `conversions/stored.ts`), which is not + * floor-scoped and still walks it for rows at rest. */ const objectCompactLayoutRename: MetadataConversion = { id: 'object-compactLayout-to-highlightFields', @@ -232,9 +236,12 @@ const objectCompactLayoutRename: MetadataConversion = { * The distribution concept was renamed Role → Position across the platform; * the stack-definition collection key renamed with it. A pure key move — the * item shapes migrate separately (`position.parent` removal is semantic, see - * the step-13 TODOs). **Retired from the load path**: ADR-0090 shipped this as - * a pre-launch one-step rename with no alias window; the entry preserves it as - * replayable chain history. + * the step-13 TODOs, historically). **Retired from the load path**: ADR-0090 + * shipped this as a pre-launch one-step rename with no alias window. Protocol + * 13 is below `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this + * conversion any more; the entry now survives only as the stored-row replay + * (`applyConversionsToStoredItem`, `conversions/stored.ts`), which is not + * floor-scoped. */ const stackRolesToPositions: MetadataConversion = { id: 'stack-roles-to-positions', @@ -381,8 +388,11 @@ const sharingRecipientRoleToPosition: MetadataConversion = { * * Packages own permission sets but never positions (ADR-0090 D9), so the * gate is a capability reference. Value carried over 1:1. **Retired from the - * load path** — the zod union rejects `{ profile }` at parse; this entry is - * the replayable chain history the one-step ship skipped. + * load path** — the zod union rejects `{ profile }` at parse. Protocol 14 is + * below `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this + * conversion any more; this entry now survives only as the stored-row replay + * (`applyConversionsToStoredItem`, `conversions/stored.ts`) the one-step ship + * skipped, which is not floor-scoped. */ const bookAudienceProfileToPermissionSet: MetadataConversion = { id: 'book-audience-profile-to-permission-set', diff --git a/packages/spec/src/migrations/entries/semantic/11.object-titleFormat-to-nameField.ts b/packages/spec/src/migrations/entries/semantic/11.object-titleFormat-to-nameField.ts deleted file mode 100644 index 168a559a5c5..00000000000 --- a/packages/spec/src/migrations/entries/semantic/11.object-titleFormat-to-nameField.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'object-titleFormat-to-nameField', - surface: 'object.titleFormat', - replacement: 'object.nameField', - reason: - 'A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template ' + - '(e.g. `{firstName} {lastName}`) has no lossless single-field target — it must ' + - 'become a formula field designated as `nameField`. The choice of formula is a ' + - 'judgment the transform cannot make.', - acceptanceCriteria: - 'Each object with a `titleFormat` declares a `nameField`; a composite title is ' + - 'backed by a formula field. `objectstack validate` passes and record display ' + - 'names render identically to before.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/11.rls-sql-predicate-to-cel.ts b/packages/spec/src/migrations/entries/semantic/11.rls-sql-predicate-to-cel.ts deleted file mode 100644 index ef3a37f2adc..00000000000 --- a/packages/spec/src/migrations/entries/semantic/11.rls-sql-predicate-to-cel.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'rls-sql-predicate-to-cel', - surface: 'security.rls.predicate', - replacement: 'CEL predicate', - reason: - 'SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation ' + - 'is not a pure token rename — operators, functions, and null semantics differ — ' + - 'so it cannot be applied losslessly by the chain.', - acceptanceCriteria: - 'Every RLS predicate parses as CEL and `objectstack validate` reports no ' + - 'expression errors; row visibility is unchanged for a representative fixture set.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/12.rest-requireauth-default-flip.ts b/packages/spec/src/migrations/entries/semantic/12.rest-requireauth-default-flip.ts deleted file mode 100644 index f7a580af812..00000000000 --- a/packages/spec/src/migrations/entries/semantic/12.rest-requireauth-default-flip.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'rest-requireauth-default-flip', - surface: 'api.requireAuth', - replacement: "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", - reason: - 'The global default flipped from `false` to `true` in protocol 12: anonymous ' + - 'requests to the `/data/*` CRUD and batch endpoints are rejected with 401 ' + - 'unless the stack opts out. Whether anonymous access was intentional (demo / ' + - 'kiosk) or an accident is a security judgment no transform can make.', - acceptanceCriteria: - 'A deployment that relies on anonymous data access declares ' + - '`api: { requireAuth: false }` on the stack config (and accepts the boot ' + - 'warning); every other consumer verifies its clients authenticate. ' + - '`objectstack validate` and the consumer test suite pass.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/13.cel-current-user-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/13.cel-current-user-roles-to-positions.ts deleted file mode 100644 index b4c107150de..00000000000 --- a/packages/spec/src/migrations/entries/semantic/13.cel-current-user-roles-to-positions.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'cel-current-user-roles-to-positions', - surface: 'CEL/formula: current_user.roles', - replacement: 'current_user.positions', - reason: - 'The EvalUser/CEL contract renamed `current_user.roles` to ' + - '`current_user.positions`. The token lives inside free-form expression ' + - 'strings, where a blind textual substitution could corrupt string literals ' + - 'or comments — so the rewrite is delegated to the author.', - acceptanceCriteria: - 'No expression references `current_user.roles`; formula validation and ' + - '`objectstack validate` report no unknown-identifier errors; predicate ' + - 'behavior is unchanged for representative users.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/13.owd-full-alias-removed.ts b/packages/spec/src/migrations/entries/semantic/13.owd-full-alias-removed.ts deleted file mode 100644 index adaf9c1588d..00000000000 --- a/packages/spec/src/migrations/entries/semantic/13.owd-full-alias-removed.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'owd-full-alias-removed', - surface: "object.sharingModel: 'full'", - replacement: "'public_read_write' or explicit sharing rules", - reason: - "The legacy `'full'` OWD alias implied full access (including transfer/ " + - 'delete) — wider than any canonical OWD value, so it has no lossless ' + - "target ('read'/'read_write' converted mechanically; this one did not). " + - 'Choosing between `public_read_write` and explicit sharing rules is a ' + - 'security-posture decision.', - acceptanceCriteria: - "No object declares sharingModel 'full'; the chosen replacement posture is " + - 'verified against the intended access (who can read/write/delete) for a ' + - 'representative fixture set.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/13.permission-set-profile-removed.ts b/packages/spec/src/migrations/entries/semantic/13.permission-set-profile-removed.ts deleted file mode 100644 index 58154a6d263..00000000000 --- a/packages/spec/src/migrations/entries/semantic/13.permission-set-profile-removed.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'permission-set-profile-removed', - surface: 'permissionSet.kind / permissionSet.isProfile', - replacement: 'position-based assignment + permission-set grants (ADR-0090 D2)', - reason: - 'The Profile concept was removed: `isProfile` is gone from ' + - '`PermissionSetSchema` and the `profile` metadata kind folded into ' + - '`position`. Mapping a profile onto positions and permission-set grants is ' + - 'an authorization-design decision, not a rename.', - acceptanceCriteria: - 'No permission set declares `isProfile` or kind `profile`; the intended ' + - 'assignees hold equivalent grants via positions/permission sets. The access ' + - 'matrix (`os compile` access-matrix gate, where enabled) is reviewed and ' + - '`objectstack validate` passes.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/13.position-hierarchy-flattened.ts b/packages/spec/src/migrations/entries/semantic/13.position-hierarchy-flattened.ts deleted file mode 100644 index 9f723b1dcc4..00000000000 --- a/packages/spec/src/migrations/entries/semantic/13.position-hierarchy-flattened.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'position-hierarchy-flattened', - surface: 'position.parent / sharingRule recipient role_and_subordinates', - replacement: 'business-unit tree + `unit_and_subordinates` (ADR-0090 D3)', - reason: - 'Positions are flat in v2 — `parent` was removed and the ' + - '`role_and_subordinates` recipient with it; hierarchy lives on the ' + - 'business-unit tree, which expands a DIFFERENT structure than the retired ' + - 'role tree. Re-homing an org hierarchy is a judgment call.', - acceptanceCriteria: - 'No position declares `parent`; former `role_and_subordinates` rules are ' + - 're-expressed with `unit_and_subordinates` over an equivalent business-unit ' + - 'tree. Row visibility is unchanged for a representative fixture set.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/13.sharing-model-secure-default.ts b/packages/spec/src/migrations/entries/semantic/13.sharing-model-secure-default.ts deleted file mode 100644 index a9b566ecb01..00000000000 --- a/packages/spec/src/migrations/entries/semantic/13.sharing-model-secure-default.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'sharing-model-secure-default', - surface: 'object.sharingModel (absent, custom object with owner field)', - replacement: 'an explicit `sharingModel` declaration', - reason: - 'ADR-0090 D1 secure default: a custom object with an owner field and NO ' + - '`sharingModel` now resolves `private` (it used to fall through to fully ' + - 'public). Restoring the old exposure must be a deliberate, visible ' + - 'declaration — the chain must not silently re-open data.', - acceptanceCriteria: - 'Every custom object that relied on the implicit public posture declares ' + - 'an explicit `sharingModel`; row visibility is verified for a ' + - 'representative fixture set (owners, non-owners, admins).', -}; diff --git a/packages/spec/src/migrations/entries/semantic/15.ui-schemas-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/15.ui-schemas-strict-unknown-keys.ts deleted file mode 100644 index 9f0ee543580..00000000000 --- a/packages/spec/src/migrations/entries/semantic/15.ui-schemas-strict-unknown-keys.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'ui-schemas-strict-unknown-keys', - surface: 'view form fields/sections · page components (undeclared keys)', - replacement: 'declared keys only (`visibleWhen` for visibility predicates)', - reason: - 'The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped ' + - 'unknown key into a parse error. There is no mapping target for an ' + - 'arbitrary unknown key — auto-deleting it would be exactly the silent data ' + - 'loss ADR-0078 bans — so each occurrence needs the author to decide: fix ' + - 'the typo, move it to the right layer, or delete dead metadata.', - acceptanceCriteria: - '`objectstack validate` passes with no unknown-key parse errors on form ' + - 'fields, form sections, or page components.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/16.dashboard-widget-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/16.dashboard-widget-strict-unknown-keys.ts deleted file mode 100644 index 3b45c0b8145..00000000000 --- a/packages/spec/src/migrations/entries/semantic/16.dashboard-widget-strict-unknown-keys.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'dashboard-widget-strict-unknown-keys', - surface: 'dashboard widgets (undeclared top-level keys — legacy inline ' + - 'analytics, objectui-internal `component`/`data`, or typos)', - replacement: 'declared keys only (`dataset` + `dimensions` + `values` for ' + - 'analytics; `options` for renderer-specific extras)', - reason: - 'The `.strict()` flip turns a previously silently-stripped unknown key into a ' + - 'parse error. There is no mapping target for an arbitrary unknown key — ' + - 'auto-deleting it would be exactly the silent data loss ADR-0078 bans — so ' + - 'each occurrence needs the author to decide: bind a `dataset` and select ' + - '`dimensions`/`values`, move a renderer setting under `options`, or delete ' + - 'the dead key.', - acceptanceCriteria: - '`objectstack validate` passes with no unknown-key parse errors on dashboard ' + - 'widgets.', -}; diff --git a/packages/spec/src/migrations/index.ts b/packages/spec/src/migrations/index.ts index 7349c02fd58..e93036be7c6 100644 --- a/packages/spec/src/migrations/index.ts +++ b/packages/spec/src/migrations/index.ts @@ -3,10 +3,11 @@ /** * Metadata migration chain + change manifest (ADR-0087 D3/D4) — public surface. * - * The permanent, replayable chain that carries any past major's metadata to - * current in one command (`objectstack migrate meta --from N`), and the - * machine-readable `spec-changes.json` manifest every other release artifact is - * a projection of. See {@link ./types} and {@link ./spec-changes} for rationale. + * The permanent, replayable chain that carries metadata from the support floor + * (`MIGRATION_SUPPORT_FLOOR`, below which `--from` refuses) to current in one + * command (`objectstack migrate meta --from N`), and the machine-readable + * `spec-changes.json` manifest every other release artifact is a projection + * of. See {@link ./types} and {@link ./spec-changes} for rationale. */ export type { diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 68f64fd42f4..9e40c941102 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -55,6 +55,24 @@ describe('migration chain (ADR-0087 D3)', () => { it('the support floor is at or below the earliest step', () => { expect(MIGRATION_SUPPORT_FLOOR).toBeLessThanOrEqual(MIGRATION_MAJORS[0]!); }); + + // #19056 raised the floor 10 → 16 and retired `step11`–`step16` with it. + // The assertion above is satisfied by a chain with NO step at all, so it + // cannot see either half of what a floor move has to leave behind. + it('no step survives at or below the floor — a step the chain cannot reach is dead code', () => { + // `composeMigrationChain(from, to)` keeps `m > from`, so a step at or + // below the floor is replayed by no supported `--from`, and CI stops + // proving it still works while it keeps reading as a promise. + expect(MIGRATION_MAJORS.filter((m) => m <= MIGRATION_SUPPORT_FLOOR)).toEqual([]); + }); + + it('`--from ` is a usable command — the floor+1 hop exists', () => { + // The other half: raising the floor to a major with no step above it + // would leave `migrate meta --from ` a no-op that reports success. + const chain = composeMigrationChain(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR); + expect(chain.length).toBeGreaterThan(0); + expect(chain[0]!.toMajor).toBe(MIGRATION_SUPPORT_FLOOR + 1); + }); }); // The rationale is not decoration: `docs/protocol-upgrade-guide.md` is a pure @@ -375,8 +393,8 @@ describe('migration chain (ADR-0087 D3)', () => { describe('composition (cross-major is the designed-for case)', () => { it('composes only the steps in (from, to]', () => { - const chain = composeMigrationChain(10, 11); - expect(chain.map((s) => s.toMajor)).toEqual([11]); + const chain = composeMigrationChain(MIGRATION_SUPPORT_FLOOR, MIGRATION_SUPPORT_FLOOR + 1); + expect(chain.map((s) => s.toMajor)).toEqual([MIGRATION_SUPPORT_FLOOR + 1]); }); it('a consumer already at current gets an empty chain', () => { @@ -386,51 +404,82 @@ describe('migration chain (ADR-0087 D3)', () => { it('refuses a from-major below the support floor', () => { expect(() => applyMetaMigrations({}, MIGRATION_SUPPORT_FLOOR - 1)).toThrow(MigrationFloorError); }); + + // The cost #19056 bought, pinned where it is paid: a consumer stopped at + // any major the floor move dropped gets a refusal, not a silent no-op + // chain. The refusal names the floor and the other path, which is the + // whole prescription those consumers have. + it('every major the #19056 floor move dropped is refused, by name', () => { + for (const from of [10, 11, 12, 13, 14, 15]) { + let thrown: unknown; + try { + applyMetaMigrations({}, from); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(MigrationFloorError); + const err = thrown as MigrationFloorError; + expect(err.fromMajor).toBe(from); + expect(err.floor).toBe(MIGRATION_SUPPORT_FLOOR); + expect(err.message).toContain(`support floor is ${MIGRATION_SUPPORT_FLOOR}`); + } + }); }); describe('replay — the chain applies the graduated mechanical transforms', () => { - it('migrates a 10.x stack with all three protocol-11 shapes to canonical', () => { - const stack = { - flows: [ - { - name: 'f', - nodes: [ - { id: 'a', type: 'http_request', config: { url: 'x' } }, - { id: 'b', type: 'delete_record', config: { objectName: 'lead', filters: { s: 1 } } }, - ], - }, - ], - pages: [{ name: 'p', kind: 'jsx', source: '
' }], - }; - const result = applyMetaMigrations(stack, 10, 11); - - const flow = (result.stack.flows as any[])[0]; - expect(flow.nodes[0].type).toBe('http'); - expect(flow.nodes[1].config).toEqual({ objectName: 'lead', filter: { s: 1 } }); - expect((result.stack.pages as any[])[0].kind).toBe('html'); - - // Three mechanical rewrites, no semantic TODOs triggered by these shapes - // (semantic TODOs are advisory per-major, always surfaced for the hop). - expect(result.applied).toHaveLength(3); - expect(result.todos.map((t) => t.id).sort()).toEqual([ - 'object-titleFormat-to-nameField', - 'rls-sql-predicate-to-cel', + // These used to replay a 10.x stack through `step11`. #19056 raised the + // floor to 16 and retired `step11`–`step16` with it, so the oldest hop the + // chain still guarantees is FLOOR → FLOOR + 1 and the shapes are that + // hop's. Written against the constant rather than the literal 16: the next + // floor move should re-point this replay, not delete it. + const OLDEST_HOP = MIGRATION_SUPPORT_FLOOR + 1; + const oldShape = () => ({ + actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], + objects: [ + { + name: 'crm_task', + label: 'Task', + fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + }, + ], + }); + + it('migrates the oldest supported major\'s shapes to canonical', () => { + const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); + + const action = (result.stack.actions as any[])[0]; + expect(action).not.toHaveProperty('execute'); + expect(action.target).toBe('convertHandler'); + const field = (result.stack.objects as any[])[0].fields.due_date; + expect(field).not.toHaveProperty('conditionalRequired'); + expect(field.requiredWhen).toBe('record.stage == "closed"'); + + // Two mechanical rewrites, named — a count alone would survive one of + // them being replaced by an unrelated conversion firing on this shape. + expect([...new Set(result.applied.map((a) => a.conversionId))].sort()).toEqual([ + 'action-execute-to-target', + 'field-conditionalRequired-to-requiredWhen', ]); + // Semantic TODOs are advisory per-major and always surfaced for the hop, + // whatever the stack contains. + expect(result.todos.map((t) => t.id).sort()).toEqual( + MIGRATIONS_BY_MAJOR[OLDEST_HOP]!.semantic.map((s) => s.id).sort(), + ); + expect(result.todos.length).toBeGreaterThan(0); }); it('is immutable — the input stack is not mutated', () => { - const stack = { pages: [{ name: 'p', kind: 'jsx', source: '
' }] }; + const stack = oldShape(); const snapshot = structuredClone(stack); - applyMetaMigrations(stack, 10, 11); + applyMetaMigrations(stack, MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); expect(stack).toEqual(snapshot); }); it('checkpoints each hop for per-hop verify / bisection', () => { - const stack = { pages: [{ name: 'p', kind: 'jsx', source: '
' }] }; - const result = applyMetaMigrations(stack, 10, 11); + const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); expect(result.hops).toHaveLength(1); - expect(result.hops[0]!.toMajor).toBe(11); - expect((result.hops[0]!.stack.pages as any[])[0].kind).toBe('html'); + expect(result.hops[0]!.toMajor).toBe(OLDEST_HOP); + expect((result.hops[0]!.stack.actions as any[])[0].target).toBe('convertHandler'); }); }); @@ -438,7 +487,34 @@ describe('migration chain (ADR-0087 D3)', () => { // Each graduated conversion's old-shape fixture must reach canonical when // replayed through the full chain from the support floor — a composability // break is a release blocker (ADR-0087 D3), caught here, not by a consumer. - for (const conversion of ALL_CONVERSIONS) { + // + // Scoped to the conversions the chain can still REACH. `composeMigrationChain` + // keeps `m > fromMajor`, so a conversion graduated at or below the floor has + // no hop to replay through and its fixture would arrive unconverted — a red + // that says nothing about composability. #19056 moved the floor 10 → 16 and + // this is where that lands. + const replayable = ALL_CONVERSIONS.filter((c) => c.toMajor > MIGRATION_SUPPORT_FLOOR); + const belowFloor = ALL_CONVERSIONS.filter((c) => c.toMajor <= MIGRATION_SUPPORT_FLOOR); + + it('the gate has cases — anti-vacuity, since every case below reads through this filter', () => { + expect(replayable.length).toBeGreaterThan(0); + expect(replayable.length + belowFloor.length).toBe(ALL_CONVERSIONS.length); + }); + + it('a below-floor conversion is excluded for having no hop — it is NOT deregistered', () => { + // D2 conversions are deliberately not floor-scoped: every rehydration + // seam replays the FULL conversion chain over stored `sys_metadata` + // rows, retired entries included (ADR-0087 addendum), so these keep + // converting rows at rest long after the source-side chain stops + // reaching them. What the floor removed is the D3 step that carried + // them, which is exactly why they leave this gate and nothing else. + for (const c of belowFloor) { + expect(ALL_CONVERSIONS).toContain(c); + expect(composeMigrationChain(MIGRATION_SUPPORT_FLOOR, c.toMajor)).toEqual([]); + } + }); + + for (const conversion of replayable) { it(`${conversion.id}: fixture.before → fixture.after via the chain`, () => { const result = applyMetaMigrations( structuredClone(conversion.fixture.before), @@ -452,30 +528,39 @@ describe('migration chain (ADR-0087 D3)', () => { }); describe('spec-changes.json manifest (ADR-0087 D4)', () => { + // The range is the SUPPORTED one. `composeSpecChanges` is a pure projection + // with no floor check of its own, so a range below the floor still composes — + // it just projects steps that no longer exist and quietly reports nothing. + const HOP_FROM = MIGRATION_SUPPORT_FLOOR; + const HOP_TO = MIGRATION_SUPPORT_FLOOR + 1; + it('composes conversions + semantic migrations across the range', () => { - const changes = composeSpecChanges(10, 11); - expect(changes.from).toBe(10); - expect(changes.to).toBe(11); + const changes = composeSpecChanges(HOP_FROM, HOP_TO); + expect(changes.from).toBe(HOP_FROM); + expect(changes.to).toBe(HOP_TO); expect(changes.converted.map((c) => c.conversionId).sort()).toEqual( - (CONVERSIONS_BY_MAJOR[11] ?? []).map((c) => c.id).sort(), + (CONVERSIONS_BY_MAJOR[HOP_TO] ?? []).map((c) => c.id).sort(), + ); + expect(changes.migrated.map((m) => m.migrationId).sort()).toEqual( + MIGRATIONS_BY_MAJOR[HOP_TO]!.semantic.map((s) => s.id).sort(), ); - expect(changes.migrated.map((m) => m.migrationId).sort()).toEqual([ - 'object-titleFormat-to-nameField', - 'rls-sql-predicate-to-cel', - ]); + // Anti-vacuity: both sides are read off the registry, so empty on both + // sides would pass while projecting nothing at all. + expect(changes.converted.length).toBeGreaterThan(0); + expect(changes.migrated.length).toBeGreaterThan(0); }); it('validates against its own schema', () => { - const changes = composeSpecChanges(10, 11, { - added: [{ surface: 'applyConversions (function)', since: 11 }], - removed: [{ surface: 'httpRequestNode (const)', removedIn: 11, replacement: 'http node' }], + const changes = composeSpecChanges(HOP_FROM, HOP_TO, { + added: [{ surface: 'applyConversions (function)', since: HOP_TO }], + removed: [{ surface: 'actionExecute (const)', removedIn: HOP_TO, replacement: 'action.target' }], }); expect(SpecChangesSchema.safeParse(changes).success).toBe(true); }); it('per-major manifests compose into one aggregate view', () => { - // Folding 10→11 (the only major with a step today) must match a direct 10→11. - const direct = composeSpecChanges(10, PROTOCOL_MAJOR); + // The fold over every supported major must match one direct aggregate. + const direct = composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR); const convertedIds = direct.converted.map((c) => c.conversionId); // Every conversion in range appears exactly once (no duplication across the fold). expect(new Set(convertedIds).size).toBe(convertedIds.length); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c336cc1e393..847f0075215 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -44,336 +44,25 @@ import type { MigrationStep } from './types.js'; * The oldest protocol major the chain guarantees a replayable path from. * `objectstack migrate meta --from N` supports any `N >= MIGRATION_SUPPORT_FLOOR`. * A release-policy decision (ADR-0087 D3), not an accident of what still exists. - */ -export const MIGRATION_SUPPORT_FLOOR = 10; - -/** - * Protocol 11 step. * - * Mechanical: the four protocol-11 conversions graduated from the D2 load path - * (`flow-node-http-callout-rename`, `page-kind-jsx-to-html`, - * `flow-node-crud-filter-alias`, plus the backfilled - * `object-compactLayout-to-highlightFields` rename that shipped in 11.7.0 before - * the conversion layer existed). Semantic: the two non-lossless live windows the - * conversion layer deliberately excludes — a composite `titleFormat` template and - * SQL-ish RLS predicates — each surfaced as a structured TODO rather than a silent - * or lossy auto-rewrite. - */ -const step11: MigrationStep = { - toMajor: 11, - rationale: - 'Protocol 11 unified the divergent HTTP callout node types to `http`, made ' + - "`html` the canonical page kind (deprecating the `jsx` alias), canonicalized " + - 'the CRUD flow-node filter key, and renamed object `compactLayout` to ' + - '`highlightFields` (ADR-0085). These are mechanical and replay losslessly. Two ' + - 'related deprecations are semantic and cannot be auto-applied: a composite ' + - '`titleFormat` render template has no single canonical `nameField`, and SQL-ish ' + - 'RLS predicates must be rewritten to canonical CEL — both are delegated to the ' + - 'consumer with explicit acceptance criteria.', - conversionIds: [ - 'flow-node-http-callout-rename', - 'page-kind-jsx-to-html', - 'flow-node-crud-filter-alias', - 'object-compactLayout-to-highlightFields', - ], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - { - id: 'object-titleFormat-to-nameField', - surface: 'object.titleFormat', - replacement: 'object.nameField', - reason: - 'A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template ' + - '(e.g. `{firstName} {lastName}`) has no lossless single-field target — it must ' + - 'become a formula field designated as `nameField`. The choice of formula is a ' + - 'judgment the transform cannot make.', - acceptanceCriteria: - 'Each object with a `titleFormat` declares a `nameField`; a composite title is ' + - 'backed by a formula field. `objectstack validate` passes and record display ' + - 'names render identically to before.', - }, - { - id: 'rls-sql-predicate-to-cel', - surface: 'security.rls.predicate', - replacement: 'CEL predicate', - reason: - 'SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation ' + - 'is not a pure token rename — operators, functions, and null semantics differ — ' + - 'so it cannot be applied losslessly by the chain.', - acceptanceCriteria: - 'Every RLS predicate parses as CEL and `objectstack validate` reports no ' + - 'expression errors; row visibility is unchanged for a representative fixture set.', - }, - // - ], -}; - -/** - * Protocol 12 step. - * - * The one metadata-facing break was a **secure-default flip**, not a shape - * change: `api.requireAuth` went from `false` to `true` (ADR-0056 D2), so - * anonymous `/data/*` access is denied unless explicitly opted out. Whether a - * deployment *intends* public data access is a judgment the chain cannot make - * — surfaced as a structured TODO. - */ -const step12: MigrationStep = { - toMajor: 12, - rationale: - 'Protocol 12 flipped the REST data-API default to authenticated ' + - '(`api.requireAuth: true`, ADR-0056 D2). No metadata shape changed, so there ' + - 'is nothing to rewrite mechanically; a deployment that intentionally serves ' + - 'data anonymously must now declare that posture explicitly.', - conversionIds: [], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - { - id: 'rest-requireauth-default-flip', - surface: 'api.requireAuth', - replacement: "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", - reason: - 'The global default flipped from `false` to `true` in protocol 12: anonymous ' + - 'requests to the `/data/*` CRUD and batch endpoints are rejected with 401 ' + - 'unless the stack opts out. Whether anonymous access was intentional (demo / ' + - 'kiosk) or an accident is a security judgment no transform can make.', - acceptanceCriteria: - 'A deployment that relies on anonymous data access declares ' + - '`api: { requireAuth: false }` on the stack config (and accepts the boot ' + - 'warning); every other consumer verifies its clients authenticate. ' + - '`objectstack validate` and the consumer test suite pass.', - }, - // - ], -}; - -/** - * Protocol 13 step — the ADR-0090 permission-model-v2 breaking wave. - * - * ADR-0090 shipped these as **pre-launch one-step renames with no alias - * window** (its D3/D4 explicitly supersede the alias discipline). The lossless - * subset is preserved here as retired conversions so the chain replays it; the - * judgment-laden remainder (profiles, hierarchy re-homing, CEL rewrites, - * postures) is delegated as structured TODOs. - */ -const step13: MigrationStep = { - toMajor: 13, - rationale: - 'Protocol 13 (ADR-0090 P1) converged the permission model: Role became ' + - 'Position (flat; hierarchy lives on the business-unit tree), the Profile ' + - 'concept was removed, the OWD enum shrank to its canonical four values, and ' + - 'a custom object with an owner field and no `sharingModel` now defaults to ' + - '`private` instead of public. Key renames replay mechanically; everything ' + - 'that changes *meaning* (profile → position/permission-set design, hierarchy ' + - 're-homing, CEL identifier rewrites, sharing postures) is delegated with ' + - 'acceptance criteria.', - conversionIds: [ - 'stack-roles-to-positions', - 'owd-legacy-read-aliases', - 'sharing-recipient-role-to-position', - ], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - { - id: 'cel-current-user-roles-to-positions', - surface: 'CEL/formula: current_user.roles', - replacement: 'current_user.positions', - reason: - 'The EvalUser/CEL contract renamed `current_user.roles` to ' + - '`current_user.positions`. The token lives inside free-form expression ' + - 'strings, where a blind textual substitution could corrupt string literals ' + - 'or comments — so the rewrite is delegated to the author.', - acceptanceCriteria: - 'No expression references `current_user.roles`; formula validation and ' + - '`objectstack validate` report no unknown-identifier errors; predicate ' + - 'behavior is unchanged for representative users.', - }, - { - id: 'owd-full-alias-removed', - surface: "object.sharingModel: 'full'", - replacement: "'public_read_write' or explicit sharing rules", - reason: - "The legacy `'full'` OWD alias implied full access (including transfer/ " + - 'delete) — wider than any canonical OWD value, so it has no lossless ' + - "target ('read'/'read_write' converted mechanically; this one did not). " + - 'Choosing between `public_read_write` and explicit sharing rules is a ' + - 'security-posture decision.', - acceptanceCriteria: - "No object declares sharingModel 'full'; the chosen replacement posture is " + - 'verified against the intended access (who can read/write/delete) for a ' + - 'representative fixture set.', - }, - { - id: 'permission-set-profile-removed', - surface: 'permissionSet.kind / permissionSet.isProfile', - replacement: 'position-based assignment + permission-set grants (ADR-0090 D2)', - reason: - 'The Profile concept was removed: `isProfile` is gone from ' + - '`PermissionSetSchema` and the `profile` metadata kind folded into ' + - '`position`. Mapping a profile onto positions and permission-set grants is ' + - 'an authorization-design decision, not a rename.', - acceptanceCriteria: - 'No permission set declares `isProfile` or kind `profile`; the intended ' + - 'assignees hold equivalent grants via positions/permission sets. The access ' + - 'matrix (`os compile` access-matrix gate, where enabled) is reviewed and ' + - '`objectstack validate` passes.', - }, - { - id: 'position-hierarchy-flattened', - surface: 'position.parent / sharingRule recipient role_and_subordinates', - replacement: 'business-unit tree + `unit_and_subordinates` (ADR-0090 D3)', - reason: - 'Positions are flat in v2 — `parent` was removed and the ' + - '`role_and_subordinates` recipient with it; hierarchy lives on the ' + - 'business-unit tree, which expands a DIFFERENT structure than the retired ' + - 'role tree. Re-homing an org hierarchy is a judgment call.', - acceptanceCriteria: - 'No position declares `parent`; former `role_and_subordinates` rules are ' + - 're-expressed with `unit_and_subordinates` over an equivalent business-unit ' + - 'tree. Row visibility is unchanged for a representative fixture set.', - }, - { - id: 'sharing-model-secure-default', - surface: 'object.sharingModel (absent, custom object with owner field)', - replacement: 'an explicit `sharingModel` declaration', - reason: - 'ADR-0090 D1 secure default: a custom object with an owner field and NO ' + - '`sharingModel` now resolves `private` (it used to fall through to fully ' + - 'public). Restoring the old exposure must be a deliberate, visible ' + - 'declaration — the chain must not silently re-open data.', - acceptanceCriteria: - 'Every custom object that relied on the implicit public posture declares ' + - 'an explicit `sharingModel`; row visibility is verified for a ' + - 'representative fixture set (owners, non-owners, admins).', - }, - // - ], -}; - -/** - * Protocol 14 step. + * Raised 10 → 16 by maintainer ruling, 2026-09-18 (#19056), verbatim and + * untranslated: * - * One metadata-facing break: the book audience gated arm renamed `{ profile }` - * → `{ permissionSet }` (ADR-0090 D2 fallout, shipped one-step pre-launch). - * Fully lossless → one retired conversion, no semantic residue. - */ -const step14: MigrationStep = { - toMajor: 14, - rationale: - 'Protocol 14 renamed the book audience gated arm from `{ profile }` to ' + - '`{ permissionSet }` (packages own permission sets, never positions — ' + - 'ADR-0090 D9). A pure key rename, preserved as a retired conversion; there ' + - 'is no semantic residue.', - conversionIds: ['book-audience-profile-to-permission-set'], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // - ], -}; - -/** - * Protocol 15 step. + * > 升级只需要支持从 16.0版本开始。 * - * Mechanical: the ADR-0089 conditional-visibility unification — `visibleOn` - * (view forms) and `visibility` (page components) → canonical `visibleWhen`. - * These are LIVE D2 windows (the 15 loader still accepts the old keys); the - * chain replays the same transforms against source. Semantic: the `.strict()` - * flip on the three UI schemas — an unknown key is now a parse error, and only - * the author can say whether it was a typo, a wrong layer, or dead metadata. - */ -const step15: MigrationStep = { - toMajor: 15, - rationale: - 'Protocol 15 unified the conditional-visibility predicate under ' + - '`visibleWhen` (ADR-0089): view-form `visibleOn` and page-component ' + - '`visibility` are deprecated aliases, accepted and converted at load for ' + - 'this major. It also flipped `FormFieldSchema`, `FormSectionSchema`, and ' + - '`PageComponentSchema` to `.strict()` — a key those schemas do not declare ' + - 'is now a loud parse error instead of a silent strip (ADR-0049/0078).', - conversionIds: ['view-visibleOn-to-visibleWhen', 'page-component-visibility-to-visibleWhen'], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - { - id: 'ui-schemas-strict-unknown-keys', - surface: 'view form fields/sections · page components (undeclared keys)', - replacement: 'declared keys only (`visibleWhen` for visibility predicates)', - reason: - 'The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped ' + - 'unknown key into a parse error. There is no mapping target for an ' + - 'arbitrary unknown key — auto-deleting it would be exactly the silent data ' + - 'loss ADR-0078 bans — so each occurrence needs the author to decide: fix ' + - 'the typo, move it to the right layer, or delete dead metadata.', - acceptanceCriteria: - '`objectstack validate` passes with no unknown-key parse errors on form ' + - 'fields, form sections, or page components.', - }, - // - ], -}; - -/** - * Protocol 16 step. + * Raising it is what RETIRES the steps below it: `step11`–`step16` went with + * this move, because a step the floor no longer reaches is replayed by nothing + * and CI stops proving it still works — dead code that reads as a promise. + * `applyMetaMigrations(doc, N)` now throws `MigrationFloorError` for + * N ∈ 10..15; those consumers reach 16 by another path first. * - * Mechanical: none — the pre-ADR-0021 inline analytics shape - * (`object`+`categoryField`+`valueField`+`aggregate`, pivot - * `rowField`/`columnField`) was already removed at protocol 9 (the single-form - * cutover), below the chain floor, so there is no key to rewrite. Semantic: the - * `.strict()` flip on `DashboardWidgetSchema` (framework#3251) turns a - * previously silently-stripped undeclared widget key into a parse error — a - * class of error that must move from fallible human review to deterministic CI, - * with no lossless auto-target for an arbitrary unknown key. + * ⚠️ The floor scopes the CHAIN and nothing else. {@link RETIRED_KEYS_BY_MAJOR} + * and {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are NOT floor-scoped: + * their only consumer (`scripts/build-schemas.ts`) folds every major into one + * set, so a row below the floor is still the live proof that its retirement was + * declared. ⛔ Never drop rows from either table when this constant moves. */ -const step16: MigrationStep = { - toMajor: 16, - rationale: - 'Protocol 16 flipped `DashboardWidgetSchema` to `.strict()` (framework#3251, ' + - 'ADR-0021 endpoint): an undeclared top-level widget key is now a loud parse ' + - 'error instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078 ' + - 'no-silently-inert). The inline analytics shape it most often catches ' + - '(`object`+`categoryField`+`valueField`+`aggregate`, pivot ' + - '`rowField`/`columnField`) was already removed at protocol 9, so no mechanical ' + - 'rewrite applies; the residue is the strictness itself, delegated to the author ' + - 'because an arbitrary unknown key has no lossless canonical target.', - conversionIds: [], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - { - id: 'dashboard-widget-strict-unknown-keys', - surface: 'dashboard widgets (undeclared top-level keys — legacy inline ' + - 'analytics, objectui-internal `component`/`data`, or typos)', - replacement: 'declared keys only (`dataset` + `dimensions` + `values` for ' + - 'analytics; `options` for renderer-specific extras)', - reason: - 'The `.strict()` flip turns a previously silently-stripped unknown key into a ' + - 'parse error. There is no mapping target for an arbitrary unknown key — ' + - 'auto-deleting it would be exactly the silent data loss ADR-0078 bans — so ' + - 'each occurrence needs the author to decide: bind a `dataset` and select ' + - '`dimensions`/`values`, move a renderer setting under `options`, or delete ' + - 'the dead key.', - acceptanceCriteria: - '`objectstack validate` passes with no unknown-key parse errors on dashboard ' + - 'widgets.', - }, - // - ], -}; +export const MIGRATION_SUPPORT_FLOOR = 16; /** * Protocol 17 step. @@ -12801,12 +12490,6 @@ const step18: MigrationStep = { /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { - 11: step11, - 12: step12, - 13: step13, - 14: step14, - 15: step15, - 16: step16, 17: step17, 18: step18, }; diff --git a/packages/spec/src/migrations/retired-tables-not-floor-scoped.test.ts b/packages/spec/src/migrations/retired-tables-not-floor-scoped.test.ts new file mode 100644 index 00000000000..9f6e31d2c15 --- /dev/null +++ b/packages/spec/src/migrations/retired-tables-not-floor-scoped.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` are keyed by protocol major + * and live in the migration registry, which makes them look like chain state. + * They are not: the chain never reads them, and their one reader folds every + * major into one set with no idea a support floor exists. So raising + * {@link MIGRATION_SUPPORT_FLOOR} can never make a row dead, and dropping rows + * "below the floor" would delete live proof that those retirements were + * declared. + * + * This matters because the loss is SILENT. Both tables are consumed as sets: a + * row that goes missing produces no error at the moment it goes missing — the + * tombstone the build gate was waiting for simply never arrives, and the + * declared retirement stops being declared (#6957, 613 hand-resolved lines of + * conflict markers over four days). The two assertions below are the structural + * facts that make the floor and the tables independent, pinned where the next + * floor move will read them. + * + * Measured on #19056 by ablation, for the record: a row planted under major 11 + * — a major whose migration step that PR deleted — was still read and judged by + * `check:authorable-surface`, which reported it as "(registered at major 11)". + * The reading survives the step's removal because it never depended on it. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from './registry.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** The chain — the only thing `MIGRATION_SUPPORT_FLOOR` scopes. */ +const CHAIN_SOURCE = readFileSync(resolve(HERE, 'chain.ts'), 'utf8'); + +/** The tables' one non-test reader (`check:authorable-surface`). */ +const BUILD_SCHEMAS_SOURCE = readFileSync(resolve(HERE, '../../scripts/build-schemas.ts'), 'utf8'); + +describe('the retirement tables are NOT scoped by the migration support floor', () => { + it('both tables carry rows — anti-vacuity for every assertion below', () => { + expect(Object.values(RETIRED_KEYS_BY_MAJOR).flat().length).toBeGreaterThan(0); + expect(Object.values(RETIRED_DEFS_BY_MAJOR).flat().length).toBeGreaterThan(0); + }); + + it('the chain never reads either table, so a floor move cannot orphan a row', () => { + // `chain.ts` owns `MIGRATION_SUPPORT_FLOOR`'s only enforcement + // (`MigrationFloorError`). It imports the steps and the floor, and nothing + // from the retirement tables — which is why raising the floor retires + // STEPS and nothing else. + expect(CHAIN_SOURCE).toContain('MIGRATION_SUPPORT_FLOOR'); + expect(CHAIN_SOURCE).not.toContain('RETIRED_KEYS_BY_MAJOR'); + expect(CHAIN_SOURCE).not.toContain('RETIRED_DEFS_BY_MAJOR'); + }); + + it('their reader does not know the floor exists, so it reads every major alike', () => { + // `build-schemas.ts` is the only non-test importer of either table. It + // folds `Object.entries(...)` across ALL majors into one set; the major is + // kept to date a tombstone's aging clock, never to decide whether to read + // the row. If this goes red, someone taught the gate about the floor — + // which would make rows below it stop counting as declarations. Read + // `RETIRED_KEYS_BY_MAJOR`'s "Lifecycle" docblock before changing it. + expect(BUILD_SCHEMAS_SOURCE).toContain('RETIRED_KEYS_BY_MAJOR'); + expect(BUILD_SCHEMAS_SOURCE).toContain('RETIRED_DEFS_BY_MAJOR'); + expect(BUILD_SCHEMAS_SOURCE).not.toContain('MIGRATION_SUPPORT_FLOOR'); + }); +}); diff --git a/packages/spec/src/migrations/types.ts b/packages/spec/src/migrations/types.ts index 92bd55e8910..03614ace576 100644 --- a/packages/spec/src/migrations/types.ts +++ b/packages/spec/src/migrations/types.ts @@ -7,11 +7,16 @@ * at load for one major — the migration chain is the L2 rung: *break * executably*. For the breaks D2 cannot hide (semantic changes with no lossless * mapping) and for the graduated conversions retired from the load path, the - * spec ships a **permanent, ordered chain of per-major steps**. A consumer that - * slept through four majors runs `objectstack migrate meta --from N` and replays - * every step in one command — it never needed to be present, warned, or reading - * anything while those majors shipped. This is the database-migration model - * applied to metadata source files; **timeliness is never load-bearing**. + * spec ships a **permanent, ordered chain of per-major steps**, back to the + * chain's support floor (`MIGRATION_SUPPORT_FLOOR`, `registry.ts`). A consumer + * at or above the floor runs `objectstack migrate meta --from N` and replays + * every remaining step in one command, however many majors that spans — it + * never needed to be present, warned, or reading anything while those majors + * shipped; from the floor forward this is the database-migration model applied + * to metadata source files, and **timeliness is never load-bearing**. Below + * the floor the command refuses (`MigrationFloorError`) rather than + * half-migrating — reaching the floor is the one prerequisite this model does + * not remove. * * Two feeders compose each major's step (ADR-0087 D3): * - **graduated conversions** — the D2 entries with `toMajor === N`, retired