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