diff --git a/.changeset/retirement-sentence-states-what-migrate-meta-does.md b/.changeset/retirement-sentence-states-what-migrate-meta-does.md
new file mode 100644
index 0000000000..04d354040e
--- /dev/null
+++ b/.changeset/retirement-sentence-states-what-migrate-meta-does.md
@@ -0,0 +1,49 @@
+---
+"@objectstack/spec": patch
+"@objectstack/lint": patch
+---
+
+fix(spec): the retirement prescriptions state what `os migrate meta` actually does (#9529)
+
+Every `retiredKey()` prescription whose surface an ADR-0087 conversion covers
+closed with a maintainer-ruled sentence (2026-08-09, #6856):
+
+> Run `os migrate meta --from N` to rewrite existing sources automatically.
+
+The command has never rewritten an authored source file. It replays the
+conversion chain over the loaded stack **in memory**, prints the attributed
+mechanical change list (`Applied N mechanical change(s)`, one line per site as
+`path: from → to (conversionId)`), and writes exactly one file — the `--out`
+JSON snapshot, when you ask for it. Every write site in
+`packages/cli/src/commands/migrate/meta.ts` is that snapshot; there is no
+`--write` / `--fix` / in-place flag. So an author who followed the prescription
+got the chain replayed, a printed diff and optionally a JSON document in a shape
+their per-artifact `.ts` modules are not written in — and then still edited every
+file by hand, with nothing in the message saying so.
+
+Under the maintainer's ruling of 2026-08-18 the sentence is withdrawn in favour
+of an honest one, class-wide:
+
+> Run `os migrate meta --from N` to list the mechanical edits for existing
+> sources; apply them by hand.
+
+The partial-value conversions keep their two-clause shape, reworded the same way
+(`… to list the mechanical edits for the \`1y\` case; the other durations are
+reported for you to re-state.`). Behaviour is unchanged in both packages — this
+is message text only, and no accept/reject verdict moves.
+
+The claim is withdrawn from every shipped site, not only the canonical sentence:
+the variant phrasings in tombstone and conversion-registry prose ("rewrites
+author sources", "rewrites it for you", "only `os migrate meta` rewrites
+sources") go with it, as do the upgrade-path statements in the hand-written docs
+(`upgrading.mdx` now carries the same "does not rewrite your source files" fact
+the `objectstack-upgrade` skill already told operators). The class-wide pin
+`packages/spec/src/shared/retired-key-migrate-sentence.test.ts` moves in
+lockstep and now holds **both** directions: the new sentence is required where a
+prescription names the command, and the withdrawn claim is a hard failure
+wherever it reappears — including in a prescription that spells the bare command
+without `--from N`, which the sentence-shape check alone would not have seen.
+
+The in-place AST codemod that would make the original claim true is commissioned
+separately for v18 (#9591); when it lands, the sentence may be restored by
+editing that one pin in the same PR.
diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx
index 62da81a546..09a71bc70e 100644
--- a/content/docs/automation/flows.mdx
+++ b/content/docs/automation/flows.mdx
@@ -1104,7 +1104,7 @@ on, and every removed `ETL*` name fails at `tsc`.
**17.0.0 breaking:** the base delay here was `retryDelayMs` and is now
`backoffMs` — same value, same meaning. `retryDelayMs` is rejected with the
- rename; `os migrate meta --from 16` rewrites it for you.
+ rename; `os migrate meta --from 16` lists the edit for you to apply.
| Property | Type | Description |
diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx
index 3889faa298..d14374b6b8 100644
--- a/content/docs/data-modeling/fields.mdx
+++ b/content/docs/data-modeling/fields.mdx
@@ -376,8 +376,8 @@ server enforces `requiredWhen` on submit and ignores writes to fields whose
`readonlyWhen` predicate is `TRUE`, so client-side affordances are not the
only guard. Use `requiredWhen`. The deprecated `conditionalRequired` alias was removed in
protocol 17 (#3855): authoring it is rejected with an error naming the
-replacement, not silently stripped, and `os migrate meta --from 16` rewrites
-existing sources automatically.
+replacement, not silently stripped, and `os migrate meta --from 16` lists the
+mechanical edits for existing sources.
#### Who the lock applies to — and the derived-field write path
diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx
index 4b0da79b8b..910fd9d67e 100644
--- a/content/docs/protocol/objectui/actions.mdx
+++ b/content/docs/protocol/objectui/actions.mdx
@@ -51,7 +51,7 @@ The `type` field selects how an action is dispatched. The complete enum is **`sc
| `api` | Call an API endpoint (`method` defaults to `POST`). | Yes |
| `form` | Open a FormView by name, routed to `/_console/forms/:name` **inside the console shell**; the submit lands on the created record. | Yes |
-`target` is the canonical binding for every non-`script` type, and since protocol 17 it is the **only** one. The deprecated `execute` alias was removed (#3855): authoring it is rejected with an error naming the replacement, not silently stripped. Run `os migrate meta --from 16` to rewrite existing sources automatically — the removal also ships in the machine-readable change manifest (`spec-changes.json`), which composes across however many majors you are jumping.
+`target` is the canonical binding for every non-`script` type, and since protocol 17 it is the **only** one. The deprecated `execute` alias was removed (#3855): authoring it is rejected with an error naming the replacement, not silently stripped. Run `os migrate meta --from 16` to list the mechanical edits for existing sources, then apply them by hand — the removal also ships in the machine-readable change manifest (`spec-changes.json`), which composes across however many majors you are jumping.
### Script Actions
diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx
index ad905160b6..2be4b08617 100644
--- a/content/docs/references/ai/agent.mdx
+++ b/content/docs/references/ai/agent.mdx
@@ -53,8 +53,8 @@ const result = AIModelConfigSchema.parse(data);
| **lifecycle** | `{ id: string; description?: string; contextSchema?: Record; initial: string; … }` | optional | [EXPERIMENTAL — not enforced] State machine defining the agent conversation flow and constraints. Parsed but no runtime consumer yet (liveness #1878/#1893). |
| **surface** | `Enum<'ask' \| 'build'>` | optional (default: `"ask"`) | Product surface this agent binds ('ask' \| 'build') — ADR-0063 §1 |
| **skills** | `string[]` | optional | Skill names to attach (Agent→Skill→Tool architecture) |
-| **tools** | `never` | optional | [REMOVED] `agent.tools` was removed in @objectstack/spec 17 (#3894) — use `skills`. An agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so move each reference into a skill: a platform tool by its registered name, or `action_` for one of your own AI-exposed Actions. This is NOT a rename — there is no key the value moves to: the migration DELETES the key and emits a notice naming each tool that was listed, and you re-declare each one in a skill by hand. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **knowledge** | `never` | optional | [REMOVED] `agent.knowledge` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — declaring knowledge sources/indexes on an agent never scoped retrieval: the `search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from the agent record. Delete the block. Restrict retrieval at the knowledge-service / source level (per-source permissions), and describe intended grounding in `instructions` so the model asks for the right sources. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **tools** | `never` | optional | [REMOVED] `agent.tools` was removed in @objectstack/spec 17 (#3894) — use `skills`. An agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so move each reference into a skill: a platform tool by its registered name, or `action_` for one of your own AI-exposed Actions. This is NOT a rename — there is no key the value moves to: the migration DELETES the key and emits a notice naming each tool that was listed, and you re-declare each one in a skill by hand. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **knowledge** | `never` | optional | [REMOVED] `agent.knowledge` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — declaring knowledge sources/indexes on an agent never scoped retrieval: the `search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from the agent record. Delete the block. Restrict retrieval at the knowledge-service / source level (per-source permissions), and describe intended grounding in `instructions` so the model asks for the right sources. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **active** | `boolean` | optional (default: `true`) | |
| **access** | `string[]` | optional | Who can chat with this agent |
| **permissions** | `string[]` | optional | Required permission-set capabilities |
diff --git a/content/docs/references/ai/skill.mdx b/content/docs/references/ai/skill.mdx
index 2896e7513c..016e457eb8 100644
--- a/content/docs/references/ai/skill.mdx
+++ b/content/docs/references/ai/skill.mdx
@@ -38,7 +38,7 @@ const result = SkillSchema.parse(data);
| **surface** | `Enum<'ask' \| 'build' \| 'both'>` | optional (default: `"ask"`) | Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3; read by the cloud agent runtime only |
| **instructions** | `string` | optional | LLM instructions when skill is active — also served as an MCP prompt (#3905) |
| **tools** | `string[]` | ✅ | Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only |
-| **triggerPhrases** | `never` | optional | [REMOVED] `skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — phrases were never matched against the user's message; skill activation is `triggerConditions` (AND of context field/operator/value) intersected with the agent's `skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in `triggerConditions`; describe intent in `description`/`instructions` for the LLM. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **triggerPhrases** | `never` | optional | [REMOVED] `skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — phrases were never matched against the user's message; skill activation is `triggerConditions` (AND of context field/operator/value) intersected with the agent's `skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in `triggerConditions`; describe intent in `description`/`instructions` for the LLM. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **triggerConditions** | `{ field: string; operator: Enum<'eq' \| 'neq' \| 'in' \| 'not_in' \| 'contains'>; value: string \| string[] }[]` | optional | Programmatic activation conditions — evaluated by the cloud agent runtime only |
| **active** | `boolean` | optional (default: `true`) | Whether the skill is enabled |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this skill. |
diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx
index d73a63befb..f222fc7a23 100644
--- a/content/docs/references/api/automation-api.mdx
+++ b/content/docs/references/api/automation-api.mdx
@@ -92,12 +92,12 @@ const result = AutomationApiErrorCode.parse(data);
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
-| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables |
| **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes |
| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections |
-| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. |
| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. |
diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx
index 1db3570e3f..3a863cd934 100644
--- a/content/docs/references/api/rest-server.mdx
+++ b/content/docs/references/api/rest-server.mdx
@@ -156,7 +156,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
| **enableOpenApi** | `boolean` | optional (default: `true`) | Enable OpenAPI 3.1 spec & docs viewer endpoints |
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
-| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config |
| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options |
diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx
index b7e184e03b..81fd7f8fff 100644
--- a/content/docs/references/automation/control-flow.mdx
+++ b/content/docs/references/automation/control-flow.mdx
@@ -149,7 +149,7 @@ const result = FlowRegionSchema.parse(data);
| **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat |
| **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) |
| **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries |
-| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx
index 6f6a21bf9c..e6e2b34aa1 100644
--- a/content/docs/references/automation/flow.mdx
+++ b/content/docs/references/automation/flow.mdx
@@ -46,12 +46,12 @@ const result = FlowSchema.parse(data);
| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. |
| **version** | `integer` | optional (default: `1`) | Version number |
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
-| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type |
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables |
| **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes |
| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections |
-| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. |
| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. |
@@ -97,7 +97,7 @@ const result = FlowSchema.parse(data);
| **position** | `{ x: number; y: number }` | optional | |
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node |
-| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx
index 4686d99fcb..2faf8a615e 100644
--- a/content/docs/references/automation/schemaless-node-config.mdx
+++ b/content/docs/references/automation/schemaless-node-config.mdx
@@ -150,11 +150,11 @@ const result = DecisionConditionSchema.parse(data);
| **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists |
| **inputs** | `Record` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
-| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to rewrite the shorthand case into `config.function` automatically; the stub and marker values are removed. |
-| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **variables** | `never` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **script** | `never` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; the stub and marker values are removed. |
+| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **variables** | `never` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **script** | `never` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx
index 439ac7036a..ad24ab5b4b 100644
--- a/content/docs/references/data/field.mdx
+++ b/content/docs/references/data/field.mdx
@@ -104,7 +104,7 @@ const result = CurrencyConfigSchema.parse(data);
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` |
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` |
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855). |
-| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". |
| **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI |
| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. |
diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx
index 7bce1d4b6a..148a6efa3c 100644
--- a/content/docs/references/data/object.mdx
+++ b/content/docs/references/data/object.mdx
@@ -68,8 +68,8 @@ const result = ApiMethod.parse(data);
| **name** | `string` | optional | Index name (auto-generated if not provided) |
| **fields** | `string[]` | ✅ | Fields included in the index |
| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' |
-| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx
index 29e095c1fc..19cb0d621d 100644
--- a/content/docs/references/integration/connector.mdx
+++ b/content/docs/references/integration/connector.mdx
@@ -56,8 +56,7 @@ surface that runs it:** the import mapping's own `mapping.fieldMapping[].transfo
(`data/mapping.zod.ts` — a string enum,
`none`/`constant`/`map`/`split`/`join`/`lookup`, with its settings in `params`),
applied row by row by the REST import path — or an ETL transformation step
-(L2 above). Already authored the retired key? `os migrate meta --from 16` rewrites
-existing sources automatically — the key itself is removed.
+(L2 above). Already authored the retired key? `os migrate meta --from 16` lists the mechanical edits for existing sources — the key itself is removed.
## Runtime contract — descriptor vs. registered connector (#2612)
@@ -175,7 +174,7 @@ Circuit breaker configuration
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| object; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules |
| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) |
-| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms |
| **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms |
@@ -263,7 +262,7 @@ Standard error category
| :--- | :--- | :--- | :--- |
| **source** | `string` | ✅ | Source field name |
| **target** | `string` | ✅ | Target field name |
-| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **defaultValue** | `any` | optional | Default if source is null/undefined |
| **dataType** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>` | optional | Target data type |
| **required** | `boolean` | optional (default: `false`) | Field is required |
@@ -497,7 +496,7 @@ Connector type
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| object; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules |
| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) |
-| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms |
| **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms |
diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx
index 86583fcd62..a4e43ba397 100644
--- a/content/docs/references/security/rls.mdx
+++ b/content/docs/references/security/rls.mdx
@@ -175,7 +175,7 @@ const result = RLSEvaluationResultSchema.parse(data);
| **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) |
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active |
-| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **tags** | `string[]` | optional | Policy categorization tags |
diff --git a/content/docs/references/shared/mapping.mdx b/content/docs/references/shared/mapping.mdx
index abb981e314..18424c837a 100644
--- a/content/docs/references/shared/mapping.mdx
+++ b/content/docs/references/shared/mapping.mdx
@@ -75,7 +75,7 @@ const result = FieldMappingSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **source** | `string` | ✅ | Source field name |
| **target** | `string` | ✅ | Target field name |
-| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **defaultValue** | `any` | optional | Default if source is null/undefined |
diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx
index f8f0bb8e1c..effc87452c 100644
--- a/content/docs/references/system/book.mdx
+++ b/content/docs/references/system/book.mdx
@@ -103,7 +103,7 @@ Type: `'public'`
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) |
| **label** | `string` | ✅ | Section title — first-class, i18n-homed |
-| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **order** | `number` | optional | Order of THIS group within the book |
| **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) |
| **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) |
diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx
index 98672d7ab1..c2be0d4d48 100644
--- a/content/docs/references/system/job.mdx
+++ b/content/docs/references/system/job.mdx
@@ -126,7 +126,7 @@ const result = CronScheduleSchema.parse(data);
| **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat |
| **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) |
| **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries |
-| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx
index 672b42b0e7..9f01bf364a 100644
--- a/content/docs/references/ui/action.mdx
+++ b/content/docs/references/ui/action.mdx
@@ -71,7 +71,7 @@ const result = ActionSchema.parse(data);
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). |
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
-| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
@@ -85,8 +85,8 @@ const result = ActionSchema.parse(data);
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
-| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. |
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |
| **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". |
diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx
index d149cfb47c..9af186aed9 100644
--- a/content/docs/references/ui/app.mdx
+++ b/content/docs/references/ui/app.mdx
@@ -66,7 +66,7 @@ const result = ActionNavItemSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | App unique machine name (lowercase snake_case) |
| **label** | `string \| Record` | ✅ | App display label |
-| **version** | `never` | optional | [REMOVED] `App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no consumer in framework or objectui). An app is versioned by its owning package: use `manifest.version`. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **version** | `never` | optional | [REMOVED] `App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no consumer in framework or objectui). An app is versioned by its owning package: use `manifest.version`. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **description** | `string \| Record` | optional | App description |
| **icon** | `string` | optional | App icon used in the App Launcher |
| **branding** | `{ primaryColor?: string; accentColor?: string; logo?: string; favicon?: string }` | optional | App-specific branding |
@@ -77,15 +77,15 @@ const result = ActionNavItemSchema.parse(data);
| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar |
| **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
| **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars |
-| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
-| **objects** | `never` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between #4936 and the executor landing it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE still belongs in a plugin manifest `contributes.routes` entry. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **sharing** | `never` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **objects** | `never` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between #4936 and the executor landing it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE still belongs in a plugin manifest `contributes.routes` entry. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **sharing** | `never` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 |
-| **aria** | `never` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the page component that renders the DOM node instead (`page.components[].aria`; `page.aria` and the list view `aria` are live too). Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **aria** | `never` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the page component that renders the DOM node instead (`page.components[].aria`; `page.aria` and the list view `aria` are live too). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx
index d48a00b762..82073ce8de 100644
--- a/content/docs/references/ui/component.mdx
+++ b/content/docs/references/ui/component.mdx
@@ -61,12 +61,12 @@ const result = AIChatWindowProps.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **object** | `never` | optional | [REMOVED] `element:filter` property `object` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
-| **fields** | `never` | optional | [REMOVED] `element:filter` property `fields` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
-| **targetVariable** | `never` | optional | [REMOVED] `element:filter` property `targetVariable` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
-| **layout** | `never` | optional | [REMOVED] `element:filter` property `layout` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
-| **showSearch** | `never` | optional | [REMOVED] `element:filter` property `showSearch` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
-| **aria** | `never` | optional | [REMOVED] `element:filter` property `aria` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
+| **object** | `never` | optional | [REMOVED] `element:filter` property `object` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **fields** | `never` | optional | [REMOVED] `element:filter` property `fields` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **targetVariable** | `never` | optional | [REMOVED] `element:filter` property `targetVariable` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **layout** | `never` | optional | [REMOVED] `element:filter` property `layout` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **showSearch** | `never` | optional | [REMOVED] `element:filter` property `showSearch` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **aria** | `never` | optional | [REMOVED] `element:filter` property `aria` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
---
@@ -149,12 +149,12 @@ const result = AIChatWindowProps.parse(data);
| **filter** | `any` | optional | Filter criteria for available records |
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set |
| **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) |
-| **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
+| **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **placeholder** | `string \| Record` | optional | Placeholder text |
| **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") |
-| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
@@ -173,7 +173,7 @@ const result = AIChatWindowProps.parse(data);
| **required** | `boolean` | optional (default: `false`) | Mark the field as required |
| **disabled** | `boolean` | optional (default: `false`) | Disable the input |
| **description** | `string \| Record` | optional | Helper text shown below the input |
-| **targetVariable** | `never` | optional | [REMOVED] `element:text_input` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the typed value, declare it on the variable — `variables: [{ name: '', type: 'string', source: '' }]`. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
+| **targetVariable** | `never` | optional | [REMOVED] `element:text_input` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the typed value, declare it on the variable — `variables: [{ name: '', type: 'string', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
@@ -405,9 +405,9 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **title** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **bordered** | `boolean` | optional (default: `true`) | |
-| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **children** | `any[]` | optional | Card content components, in order (the card body slot) |
-| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **footer** | `any[]` | optional | Card footer components (slot) |
| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
@@ -433,7 +433,7 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **title** | `string \| Record` | optional | Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from. |
| **subtitle** | `string \| Record` | optional | Page subtitle |
-| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **breadcrumb** | `boolean` | optional (default: `true`) | Show breadcrumb |
| **actions** | `string[]` | optional | Action IDs to show in header |
| **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. |
@@ -453,7 +453,7 @@ const result = AIChatWindowProps.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **tabStyle** | `Enum<'line' \| 'card' \| 'pill'>` | optional (default: `"line"`) | Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills |
-| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **position** | `Enum<'top' \| 'left'>` | optional (default: `"top"`) | |
| **alwaysShowStrip** | `boolean` | optional | Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden). |
| **items** | `{ label: string \| Record; icon?: string; visibleWhen?: string \| object; value?: string; … }[]` | ✅ | |
@@ -538,7 +538,7 @@ const result = AIChatWindowProps.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
-| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx
index d532777b17..d4dc5f3969 100644
--- a/content/docs/references/ui/dashboard.mdx
+++ b/content/docs/references/ui/dashboard.mdx
@@ -39,8 +39,8 @@ const result = DashboardSchema.parse(data);
| **refreshInterval** | `number` | optional | Auto-refresh interval in seconds |
| **dateRange** | `{ field?: string; defaultRange: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange: boolean }` | optional | Global dashboard date range filter configuration |
| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard |
-| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
@@ -98,9 +98,9 @@ Dashboard header action
| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming |
| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered |
| **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered |
-| **actionUrl** | `never` | optional | [REMOVED] `dashboard.widgets[].actionUrl` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **actionType** | `never` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **actionIcon** | `never` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **actionUrl** | `never` | optional | [REMOVED] `dashboard.widgets[].actionUrl` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **actionType** | `never` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **actionIcon** | `never` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) |
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
@@ -110,8 +110,8 @@ Dashboard header action
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration |
| **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
-| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. The shared `ResponsiveConfig` shape is NOT gone — it stays live on `page.components[].responsive`, which objectui `useResponsiveConfig` really does read; move the layout there if you need breakpoint behaviour today. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. The shared `ResponsiveConfig` shape is NOT gone — it stays live on `page.components[].responsive`, which objectui `useResponsiveConfig` really does read; move the layout there if you need breakpoint behaviour today. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
### Allowed Values: `DashboardWidget.type`
diff --git a/content/docs/references/ui/theme.mdx b/content/docs/references/ui/theme.mdx
index 8b9178adc9..9859229fff 100644
--- a/content/docs/references/ui/theme.mdx
+++ b/content/docs/references/ui/theme.mdx
@@ -101,8 +101,8 @@ const result = BorderRadiusSchema.parse(data);
| **typography** | `{ fontFamily?: object }` | optional | Typography settings |
| **borderRadius** | `{ none?: string; sm?: string; base?: string; md?: string; … }` | optional | Border radius scale |
| **shadows** | `{ none?: string; sm?: string; base?: string; md?: string; … }` | optional | Box shadow effects |
-| **animation** | `never` | optional | [REMOVED] `theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **zIndex** | `never` | optional | [REMOVED] `theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **animation** | `never` | optional | [REMOVED] `theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **zIndex** | `never` | optional | [REMOVED] `theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **customVars** | `Record` | optional | Custom CSS variables (key-value pairs) |
| **extends** | `string` | optional | Base theme to extend from |
@@ -127,10 +127,10 @@ const result = BorderRadiusSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **fontFamily** | `{ base?: string }` | optional | |
-| **fontSize** | `never` | optional | [REMOVED] `theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **fontWeight** | `never` | optional | [REMOVED] `theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **lineHeight** | `never` | optional | [REMOVED] `theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **letterSpacing** | `never` | optional | [REMOVED] `theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **fontSize** | `never` | optional | [REMOVED] `theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **fontWeight** | `never` | optional | [REMOVED] `theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **lineHeight** | `never` | optional | [REMOVED] `theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **letterSpacing** | `never` | optional | [REMOVED] `theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx
index 096a3f9f6f..c2ea7c8b31 100644
--- a/content/docs/references/ui/view.mdx
+++ b/content/docs/references/ui/view.mdx
@@ -281,12 +281,12 @@ Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on a
| **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | |
| **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. |
| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections |
-| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form |
| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). |
| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). |
| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). |
-| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
@@ -528,11 +528,11 @@ Map view configuration
| **allowPrinting** | `boolean` | optional | Allow users to print the view |
| **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found |
| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view |
-| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
---
@@ -617,11 +617,11 @@ Map view configuration
| **allowPrinting** | `boolean` | optional | Allow users to print the view |
| **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found |
| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view |
-| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
-| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
+| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
+| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | |
diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx
index c1be866f0c..681ad93cde 100644
--- a/content/docs/ui/apps.mdx
+++ b/content/docs/ui/apps.mdx
@@ -327,8 +327,8 @@ same holds for `requiresObject`.
`areas[].order`, `areas[].visible` and `areas[].requiredPermissions` were
removed in `@objectstack/spec` 17.0.0 (ADR-0049 enforce-or-remove) and are now
**refused at parse time** rather than ignored, so one leftover key fails the
- whole save. Run `os migrate meta --from 16` to rewrite existing sources
- automatically.
+ whole save. Run `os migrate meta --from 16` to list the mechanical edits for existing
+ sources; apply them by hand.
- `order` (#4667) — no renderer ever sorted areas, so declaration order
already *was* display order and an author who set `order` saw nothing move.
@@ -503,8 +503,8 @@ offers, widen `optionsSource.filter`.
`contextSelectors[].includeAll` and `contextSelectors[].placement` were removed
in `@objectstack/spec` 17.0.0 (#4509, ADR-0049 enforce-or-remove) and are now
**refused at parse time** rather than ignored, so one leftover key fails the
- whole save. Run `os migrate meta --from 16` to rewrite existing sources
- automatically.
+ whole save. Run `os migrate meta --from 16` to list the mechanical edits for existing
+ sources; apply them by hand.
- `includeAll` — not merely unread, deliberately **disobeyed**, for the reason
above: the renderer never offered an "All" row regardless of the flag, so
diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx
index 71b4db4c36..579b54fdd0 100644
--- a/content/docs/upgrading.mdx
+++ b/content/docs/upgrading.mdx
@@ -11,7 +11,7 @@ There are **two upgrades** on this platform, and they run on separate clocks:
| Versioned by | our release train (`17.0.0`) | your own catalog |
| Whose cadence | ours | yours |
| The upgrade action | move the image tag, restart | `os migrate meta --from 16`, then rebuild and ship |
-| Touches your metadata? | no | yes — it rewrites your source |
+| Touches your metadata? | no | yes — you edit your source, guided by the tool's change list |
Most upgrade questions are really the question *which of these am I doing*. You
can do either one alone. A platform move does not rewrite your app, and
@@ -88,7 +88,7 @@ as it reads them, emitting a deprecation notice per conversion. **That window is
exactly one major wide.** Metadata authored against the previous major loads and
runs; a shape retired one major further back is *rejected*, and the rejection
carries the fix — the old spelling, the new spelling, and the command that
-rewrites your sources.
+lists every site to edit.
The practical consequence:
@@ -131,25 +131,34 @@ 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` | Write the migrated stack instead of only reporting it |
+| `--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 |
| `--json` | Machine-readable output, for CI or an agent |
-It rewrites **your source** and reads no database. (Its sibling,
+It reads **your source** and no database. (Its sibling,
`os migrate meta --stored`, does the opposite — it rewrites one deployment's
stored metadata rows and reads no config. The two are mutually exclusive.)
### What it does not do — read the output
-`os migrate meta` applies the **mechanical** rewrites and never guesses at the
-rest. Changes that cannot be converted losslessly are reported as structured
-to-dos for you to resolve — they are not applied, and a clean-looking run can
-still carry dozens of them. The command's output is the work list, not a receipt.
+**`os migrate meta` does not rewrite your source files.** It replays the chain
+over the loaded stack *in memory* and reports the diff; the only file it writes
+is `--out`, a JSON snapshot. Porting the listed edits into your own `.ts`
+sources is your work — use `--out` as the oracle you diff against, never as the
+file you ship.
+
+
+
+It also applies the **mechanical** changes only, and never guesses at the rest.
+Changes that cannot be converted losslessly are reported as structured to-dos
+for you to resolve — and a clean-looking run can still carry dozens of them. The
+command's output is the work list, not a receipt.
That split is the reason the per-major checklists below still matter: renames
-and retired keys the tool handles; decisions it cannot make for you stay yours.
+and retired keys the tool enumerates for you; decisions it cannot make for you
+stay yours.
The run also ends by naming the per-deployment **data** migrations that remain
(`os migrate files-to-references`, `os migrate value-shapes`, and friends) —
scoped to the field classes your metadata actually declares. It reads no
@@ -159,8 +168,9 @@ has already done.
### The loop
```bash
-os migrate meta --from 15 # 1. rewrite sources; read the to-dos
- # 2. resolve them, and the checklist items below
+os migrate meta --from 15 # 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
os build # 4. compile to dist/objectstack.json
# 5. ship the artifact; restart
diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md
index 05599e1242..e27cddbac8 100644
--- a/docs/protocol-upgrade-guide.md
+++ b/docs/protocol-upgrade-guide.md
@@ -182,7 +182,7 @@ That convergence then had to be finished twice more, and WHY it was incomplete i
The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops `data.field.changed` (#4673). It had no producer anywhere — the engine emits `data.record.{created,updated,deleted}` and, since #4639, `data.records.{updated,deleted}` — so a subscriber switching on it held a branch that could never run, and the `switch` still compiled, which is why an empty member could sit in a public enum this long. It could not have been implemented against this contract as written: `DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, so the member advertised a granularity the payload has no room for. Nothing is lost — per-field detail already rides on `data.record.updated` as `changes` (with `before` / `after`), one event per write instead of N on a wide table. Like the driver contract above it is a runtime surface, never stored in stack metadata, so it is one semantic TODO for event consumers rather than a source rewrite, and it carries no tombstone: a removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` retirement noted. Should a real per-field stream ever be wanted, it earns its own contract on the #4639 precedent rather than reclaiming this slot.
-The object capability block closes out the same ADR-0049 pass: `enable.trash` and `enable.mru` left the schema in the 16.x line (#3207, the #2377 close-out — every delete has always been a hard delete and MRU tracking was never implemented, so both default-true flags gated nothing), and the `.strict()` capabilities block rejects them with the prescription. This step registers the migration surface that removal was missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, and `os migrate meta --from 16` rewrites authored sources. Soft delete stays parked at #3146; if built it returns as a live enforced flag rather than by reviving these keys.
+The object capability block closes out the same ADR-0049 pass: `enable.trash` and `enable.mru` left the schema in the 16.x line (#3207, the #2377 close-out — every delete has always been a hard delete and MRU tracking was never implemented, so both default-true flags gated nothing), and the `.strict()` capabilities block rejects them with the prescription. This step registers the migration surface that removal was missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, and `os migrate meta --from 16` lists the mechanical edits for authored sources. Soft delete stays parked at #3146; if built it returns as a live enforced flag rather than by reviving these keys.
The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block (#4579): `OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`) with `OpenApiWebhookEventSchema` and `CallbackSchema` under it. Declared-but-unenforced end to end: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, the served /openapi.json is the pre-generated contract enriched with the live server URL and registered objects, and `gen:openapi` never read a webhook or callback — so a definition authored under `openApi31.webhooks` never appeared in any served document, and zero import-level consumers existed across objectstack / cloud / objectui. `RestServerConfig` is plugin TS configuration (the REST plugin constructor / `plugin-hono-server` `restConfig`), never a stored metadata shape: the stack tree's own `api` block declares only its four scoping/auth knobs, so no `sys_metadata` row can carry `openApi31` and there is no source for the chain to rewrite — one semantic TODO for config authors rather than a stack conversion, the `validateOnly` shape. The key itself is tombstoned (the schema is not `.strict()`; a plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.
diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts
index 64b29f2468..12096e1f1b 100644
--- a/packages/lint/src/validate-expressions.test.ts
+++ b/packages/lint/src/validate-expressions.test.ts
@@ -151,14 +151,16 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
expect(issues[0].message).toMatch(/os migrate meta --from 16/);
// #7030 — house sentence (#6856 route D): names the TOOL's behaviour, never
// the retired key's fate. This branch can DELETE the key outright
- // (`template`/`recipients`/`variables`/`script`), so "rewrite it" reads two
- // ways ("it" = the key vs. "it" = your sources) while "rewrite existing
- // sources" has one antecedent. Pinned here AND class-wide in
+ // (`template`/`recipients`/`variables`/`script`), so "rewrite it" read two
+ // ways ("it" = the key vs. "it" = your sources); #9529 then withdrew the
+ // automatic-rewrite claim outright — the tool lists the edits and never
+ // writes a source file. Pinned here AND class-wide in
// `retired-key-migrate-sentence.test.ts` (widened to `packages/lint/src`).
expect(issues[0].message).toMatch(
- /Run `os migrate meta --from 16` to rewrite existing sources automatically\.$/,
+ /Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand\.$/,
);
expect(issues[0].message).not.toMatch(/rewrite it automatically/);
+ expect(issues[0].message).not.toMatch(/rewrite existing sources automatically/);
});
it('tells a shorthand actionType exactly where its name belongs', () => {
diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts
index 98bb6942d0..5762294db3 100644
--- a/packages/lint/src/validate-expressions.ts
+++ b/packages/lint/src/validate-expressions.ts
@@ -912,14 +912,16 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
? `\`actionType: '${action}'\` named a registered function — move it to \`function: '${action}'\`. `
: `Use a \`notify\` node for mail, a \`connector_action\` (Slack connector) or \`http\` node ` +
`for Slack, and a registered function for logic. `) +
- // #6856 route D (maintainer-ruled): the house sentence names the TOOL's
- // behaviour, never the retired key's fate — "rewrite it" reads two ways
- // over a branch that DELETES the key (template/recipients/variables/script),
- // "rewrite existing sources" only one. Plain-quoted (not a template literal)
+ // #6856 route D (maintainer-ruled), reworded under #9529: the house
+ // sentence names the TOOL's behaviour, never the retired key's fate —
+ // "rewrite it" read two ways over a branch that DELETES the key
+ // (template/recipients/variables/script), and the tool never rewrote a
+ // source file at all. Plain-quoted (not a template literal)
// so this site is a member of `retired-key-migrate-sentence.test.ts`'s
// widened scan (#7030) on the same textual shape as the spec corpus — no
// interpolation lives in this clause, so nothing is lost switching quote style.
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing '
+ + 'sources; apply them by hand.',
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
});
} else if (!fn) {
diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts
index d36fa7d54b..bc1093e695 100644
--- a/packages/spec/src/ai/agent.zod.ts
+++ b/packages/spec/src/ai/agent.zod.ts
@@ -236,7 +236,7 @@ export const AgentSchema = lazySchema(() => strictObject({
'This is NOT a rename — there is no key the value moves to: the migration ' +
'DELETES the key and emits a notice naming each tool that was listed, and ' +
'you re-declare each one in a skill by hand. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Knowledge */
@@ -252,7 +252,7 @@ export const AgentSchema = lazySchema(() => strictObject({
'the agent record. Delete the block. Restrict retrieval at the knowledge-service / ' +
'source level (per-source permissions), and describe intended grounding in ' +
'`instructions` so the model asks for the right sources. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Interface */
diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts
index c9f5c0988e..3b0e56b770 100644
--- a/packages/spec/src/ai/skill.zod.ts
+++ b/packages/spec/src/ai/skill.zod.ts
@@ -373,7 +373,7 @@ export const SkillSchema = lazySchema(() => strictObject({
"`triggerConditions` (AND of context field/operator/value) intersected with the agent's " +
'`skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in ' +
'`triggerConditions`; describe intent in `description`/`instructions` for the LLM. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts
index a99bb3f259..15a23ac2e1 100644
--- a/packages/spec/src/api/rest-server.zod.ts
+++ b/packages/spec/src/api/rest-server.zod.ts
@@ -128,7 +128,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({
+ 'To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a '
+ "share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of "
+ 'opening the whole data plane. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts
index 22d0f751f6..8c03292995 100644
--- a/packages/spec/src/automation/flow.zod.ts
+++ b/packages/spec/src/automation/flow.zod.ts
@@ -366,7 +366,7 @@ function flowNodeObject() { return strictObject(
'close-out) — it was never validated: the engine does not check node outputs against ' +
'it, so it documented a contract nothing enforced. Delete the key. Downstream nodes ' +
"read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. " +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
@@ -439,14 +439,16 @@ function flowNodeObject() { return strictObject(
+ "read as milliseconds, making `timeoutMs: 60000` and `timerDuration: '60000'` the same wait "
+ "(`timerDuration: 'PT1M'` is the ISO 8601 spelling of that same 60s). Stored flows are "
+ 'converted automatically — the conversion does the quoting for you. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing '
+ + 'sources; apply them by hand.',
),
onTimeout: retiredKey(
'`waitEventConfig.onTimeout` was removed in @objectstack/spec 17 (#4158). It had no readers at '
+ 'all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete '
+ 'the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when '
+ 'its timer elapses or its signal arrives. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing '
+ + 'sources; apply them by hand.',
),
}).optional().describe('Configuration for wait node event resumption'),
@@ -643,7 +645,7 @@ export const FlowSchema = lazySchema(() => strictObject(
'no designer or engine path ever read it, so flagging a flow as a template/subflow did ' +
'nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the ' +
'flow by name. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Trigger Type */
@@ -667,7 +669,7 @@ export const FlowSchema = lazySchema(() => strictObject(
'stop a flow (worse, the default read as disabled while the engine treated unset as ' +
"enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and " +
"disable a flow, `status: 'active'` to arm it. " +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
// ADR-0049 / #1888 — ENFORCED. The service-automation engine establishes the
// declared identity for the run's data operations and restores the caller's
@@ -802,7 +804,7 @@ export const FlowSchema = lazySchema(() => strictObject(
"edges (an edge with type: 'fault'), and never read this key: a fallback " +
'configured here silently did not exist. Delete the key and draw a fault edge from ' +
'the failing node to the handler node instead. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
}).superRefine((eh, ctx) => {
// `strategy: 'retry'` with 0 attempts is `strategy: 'fail'` wearing a
diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts
index d496a723c6..7334de5368 100644
--- a/packages/spec/src/automation/schemaless-node-config.zod.ts
+++ b/packages/spec/src/automation/schemaless-node-config.zod.ts
@@ -220,8 +220,9 @@ const DECISION_KEY_GUIDANCE: Readonly> = {
* its own.
*
* All five keys are tombstoned below; the ADR-0087 D2 conversion
- * `flow-node-script-branch-keys-removed` rewrites stored sources (moving a
- * shorthand `actionType` into `function`, where that is what it meant).
+ * `flow-node-script-branch-keys-removed` rewrites stored metadata rows (moving
+ * a shorthand `actionType` into `function`, where that is what it meant) and is
+ * what `os migrate meta` lists as a mechanical edit for authored sources.
*/
export const ScriptConfigSchema = lazySchema(() => strictObject({
surface: 'this script node config',
@@ -258,34 +259,37 @@ export const ScriptConfigSchema = lazySchema(() => strictObject({
+ 'through the messaging service — the in-app inbox by default, real email once '
+ '`@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with '
+ 'the Slack connector, or an `http` node posting to a webhook; for anything else, move the '
- + 'name into `config.function`. Run `os migrate meta --from 16` to rewrite the shorthand '
- + 'case into `config.function` automatically; the stub and marker values are removed.',
+ + 'name into `config.function`. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for the shorthand '
+ + 'case into `config.function`; the stub and marker values are removed.',
),
template: retiredKey(
'`script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the '
+ 'logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template '
+ 'id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and '
+ 'stored templates live in the messaging service (`sys_notification_template`), not on the '
- + 'node. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'node. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
recipients: retiredKey(
'`script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were '
+ 'logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a '
+ '`notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging '
- + 'service for real. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'service for real. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
variables: retiredKey(
'`script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values '
+ 'into a template no side effect ever rendered. Delete the key. A `notify` node carries '
+ 'structured data in `payload`; a registered function takes it in `config.inputs`. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
script: retiredKey(
'`script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has '
+ 'no server-side JS sandbox, so an inline body was recognized and never executed: the node '
+ 'warned and completed as a no-op. Move the logic into a registered function '
+ '(`defineStack({ functions })`) and name it in `config.function`. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
}));
diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts
index 1f38c2fe1b..b677ca4217 100644
--- a/packages/spec/src/conversions/registry.ts
+++ b/packages/spec/src/conversions/registry.ts
@@ -1814,7 +1814,7 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = {
*
* `retiredFromLoadPath`: the strict schema refuses the keys outright, so there
* is no alias window; the entry exists so `spec-changes.json` carries the
- * removal and `os migrate meta --from 16` can rewrite authored sources.
+ * removal and `os migrate meta --from 16` can list the mechanical edits for authored sources.
*/
const appAreaFailOpenGatesRemoved: MetadataConversion = {
id: 'app-area-fail-open-gates-removed',
@@ -2105,7 +2105,7 @@ const fieldRequiredNotNullExplicit: MetadataConversion = {
* pure lossless deletes — none of these keys ever had a runtime effect to
* lose — and all are retired from the load path: each schema tombstones its
* keys with the prescription (`retiredKey`), so the loader rejects loudly and
- * only `os migrate meta` rewrites sources.
+ * only `os migrate meta` lists the mechanical edits for existing sources.
*/
const stripKeys = (
item: Record,
@@ -2355,7 +2355,7 @@ const viewListPassthroughKeysRemoved: MetadataConversion = {
* `retiredFromLoadPath`: the enum rejects the value outright, so a live author
* is taught at parse rather than silently rewritten. The entry exists so
* stored 16.x/17-rc rows replay clean (`applyConversionsToStoredItem`) and so
- * `os migrate meta --from 16` rewrites author sources. As with `crypto.hash`,
+ * `os migrate meta --from 16` lists the mechanical edits for author sources. As with `crypto.hash`,
* the strip keeps the surrounding declaration — an emptied `formats` array
* stays (an export menu configured to offer nothing is a declaration, not an
* accident this conversion may invent an answer for).
@@ -3829,8 +3829,7 @@ const objectManagedBySystemToSystemData: MetadataConversion = {
* live author is taught at parse; this entry exists so stored 16.x rows
* replay clean (`applyConversionsToStoredItem` — without it a pre-removal row
* flags `metadata_spec_invalid` forever, mislabelling chain-owned history as
- * a current-contract violation) and so `os migrate meta --from 16` rewrites
- * sources.
+ * a current-contract violation) and so `os migrate meta --from 16` lists the mechanical edits for existing sources.
*/
const objectEnableTrashMruRemoved: MetadataConversion = {
id: 'object-enable-trash-mru-removed',
@@ -3912,7 +3911,7 @@ const objectEnableTrashMruRemoved: MetadataConversion = {
* author is taught by the parse error. This entry exists so stored ≤16 rows
* replay clean through `applyConversionsToStoredItem` (without it a
* pre-removal row would flag `metadata_spec_invalid` forever) and so
- * `os migrate meta --from 16` rewrites sources mechanically.
+ * `os migrate meta --from 16` lists the mechanical edits for existing sources.
*/
const objectIndexTypePartialRemoved: MetadataConversion = {
id: 'object-index-type-partial-removed',
@@ -4241,7 +4240,7 @@ const retryPolicyConverged: MetadataConversion = {
* 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a
* pre-removal row flags `metadata_spec_invalid` forever, mislabelling
* chain-owned history as a current-contract violation) and so
- * `os migrate meta --from 16` rewrites author sources. The
+ * `os migrate meta --from 16` lists the mechanical edits for author sources. The
* `object-enable-trash-mru-removed` precedent, one level deeper: the token is a
* VALUE inside `body.capabilities`, not a key, so `stripKeys` cannot reach it.
*
@@ -4417,7 +4416,7 @@ const hookBodyCryptoHashRemoved: MetadataConversion = {
* 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a
* pre-removal row flags `metadata_spec_invalid` forever, mislabelling
* chain-owned history as a current-contract violation) and so
- * `os migrate meta --from 16` rewrites author sources.
+ * `os migrate meta --from 16` lists the mechanical edits for author sources.
*/
const datasetMeasureAggRemoved: MetadataConversion = {
id: 'dataset-measure-array-string-agg-removed',
@@ -4541,8 +4540,7 @@ const datasetMeasureAggRemoved: MetadataConversion = {
* `flow-node-wait-timeout-keys-removed` distinction. Silently absorbing it at
* load would let the author keep believing they had configured a cap. The entry
* exists so stored 16.x/17-rc rows replay clean
- * (`applyConversionsToStoredItem`) and `os migrate meta --from 16` rewrites
- * author sources; live parses hit the `retiredKey()` tombstone instead.
+ * (`applyConversionsToStoredItem`) and `os migrate meta --from 16` lists the mechanical edits for author sources; live parses hit the `retiredKey()` tombstone instead.
*/
const connectorRateLimitConfigRemoved: MetadataConversion = {
id: 'connector-rate-limit-config-removed',
@@ -4658,8 +4656,7 @@ const connectorRateLimitConfigRemoved: MetadataConversion = {
* `connector-rate-limit-config-removed` shape exactly. Absorbing it silently at
* load would let an author keep believing their values were being cast, mapped
* or looked up. The entry exists so stored rows replay clean
- * (`applyConversionsToStoredItem`) and `os migrate meta --from 16` rewrites
- * author sources; live parses hit the tombstone.
+ * (`applyConversionsToStoredItem`) and `os migrate meta --from 16` lists the mechanical edits for author sources; live parses hit the tombstone.
*/
const fieldMappingTransformRemoved: MetadataConversion = {
id: 'field-mapping-transform-removed',
@@ -4771,8 +4768,7 @@ const fieldMappingTransformRemoved: MetadataConversion = {
* authored config a rewrite would have saved.
*
* `retiredFromLoadPath`: the schema tombstones each key with its prescription,
- * so a live parse rejects loudly and only `os migrate meta --from 16` rewrites
- * sources. Absorbing these at load would let an author keep believing they had
+ * so a live parse rejects loudly and only `os migrate meta --from 16` lists the mechanical edits for existing sources. Absorbing these at load would let an author keep believing they had
* configured a type scale.
*/
const themeInertTokenScalesRemoved: MetadataConversion = {
@@ -5130,12 +5126,12 @@ const pageHeaderSubtitleAlias: MetadataConversion = {
* panel is rewritten where it sits. The tombstones still carry the refusal at
* parse time for anything a conversion declines to touch (a disagreeing pair
* under {@link renameKey}'s house rule, or a source no migration ran over);
- * what changed is that "run `os migrate meta`" is now a promise the rewrite can
- * keep at a nested site, not only at region level.
+ * what changed is that "run `os migrate meta`" is now a prescription the
+ * conversion can honour at a nested site, not only at region level.
*
* All three are **retired from the load path**: each key is tombstoned in
* `ui/component.zod.ts`, so the loader rejects it loudly with the prescription
- * and only `os migrate meta` rewrites sources.
+ * and only `os migrate meta` lists the mechanical edits for existing sources.
*/
const RECORD_PICKER_COMPONENT_TYPE = 'element:record_picker';
@@ -5827,7 +5823,7 @@ const inlineActionApiParamsToBodyExtra: MetadataConversion = {
*
* `retiredFromLoadPath: true`: no alias window, deliberately. The tombstone owns
* the refusal; this entry exists so `spec-changes.json`, the upgrade guide and
- * `os migrate meta` still carry the rewrite.
+ * `os migrate meta` still carry the conversion.
*/
const pageTabsTypeToTabStyle: MetadataConversion = {
id: 'page-tabs-type-to-tab-style',
@@ -6432,7 +6428,7 @@ const appHiddenToUnpublished: MetadataConversion = {
* 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a
* pre-removal row flags `metadata_spec_invalid` forever, mislabelling
* chain-owned history as a current-contract violation) and so
- * `os migrate meta --from 16` rewrites author sources.
+ * `os migrate meta --from 16` lists the mechanical edits for author sources.
*/
const actionGlobalNavLocationRemoved: MetadataConversion = {
id: 'action-global-nav-location-removed',
@@ -6624,8 +6620,7 @@ const fieldMalformedScalePrecisionRemoved: MetadataConversion = {
* with a per-value prescription (`CHATTER_POSITION_RETIRED`,
* ui/component.zod.ts), so a live author is taught at parse rather than
* silently rewritten. The entry exists so stored rows replay clean
- * (`applyConversionsToStoredItem`) and so `os migrate meta` rewrites author
- * sources mechanically.
+ * (`applyConversionsToStoredItem`) and so `os migrate meta` lists the mechanical edits for author sources.
*
* The same #8762 ruling dropped all three schema defaults (`position`,
* `collapsible`, `defaultCollapsed`) per the `maxVisible` principle; that
diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts
index fb8cdc33bc..30849e3fe3 100644
--- a/packages/spec/src/data/datasource.zod.ts
+++ b/packages/spec/src/data/datasource.zod.ts
@@ -58,7 +58,8 @@ const RETIRED_CAPABILITIES: Record = {
+ 'Pushdown is decided by the runtime driver\'s own `supports.*` object, not by datasource '
+ 'metadata, so declaring a capability here never changed which engine path ran. Delete the '
+ 'block. If you wrote `readOnly: true`, read its note below — it did NOT make anything '
- + 'read-only. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'read-only. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
readOnly:
CAPABILITIES_REMOVED_PREFIX
+ '`readOnly` in particular NEVER made a datasource read-only: no write path consulted it, '
@@ -90,7 +91,8 @@ const RETIRED_DATASOURCE_BLOCKS: Record = {
+ 'CAREFUL — do NOT "fix" this by renaming keys: `hook.retryPolicy` and `job.retryPolicy` ARE '
+ 'enforced, but they are a DIFFERENT key on a different type and spell the delay `backoffMs`, '
+ 'not `baseDelayMs`. Moving these values onto a hook or a job only makes sense if you '
- + 'actually want that hook or job retried. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'actually want that hook or job retried. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
healthCheck:
'`datasource.healthCheck` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — no '
+ 'health-check loop ever read it, so `enabled: true` scheduled nothing and the two timeouts '
@@ -98,18 +100,20 @@ const RETIRED_DATASOURCE_BLOCKS: Record = {
+ '(`ping()` / `checkHealth()`), which the datasource admin service calls for "Test '
+ 'connection". The only recurring datasource timer is `external.validation.checkIntervalMs`, '
+ 'which checks SCHEMA DRIFT — a different concern, not a liveness probe. Delete the block. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
externalLabel:
'`external.label` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — nothing read '
+ "the federation block's own label. Use the datasource's TOP-LEVEL `label`, which is what "
- + 'Setup → Datasources actually renders. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Setup → Datasources actually renders. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
externalRequirePermission:
'`external.requirePermission` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — no '
+ 'authorization check ever consulted it, so a permission named here gated nothing. Access to '
+ "a federated datasource's data is governed by the ordinary object permission sets and RLS, "
+ 'exactly as for a managed datasource. Naming a permission that is never required is the '
+ 'false-compliance shape ADR-0049 exists to remove — grant or withhold the object '
- + 'permissions instead. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'permissions instead. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
};
/**
@@ -167,7 +171,7 @@ const RETIRED_READ_REPLICAS =
+ 'Delete the key. There is no read-replica routing to migrate to — if your database fronts '
+ 'its replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint), point '
+ '`config` at that endpoint, which is the only read-scaling path that works today. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
export const DriverType = z.string().describe('Underlying driver identifier');
export type DriverType = z.input;
diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts
index bb77efa5a6..c6e4ecbb90 100644
--- a/packages/spec/src/data/field.zod.ts
+++ b/packages/spec/src/data/field.zod.ts
@@ -1213,7 +1213,7 @@ export const FieldSchema = lazySchema(() => strictObject({
conditionalRequired: retiredKey(
'`conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. ' +
'Rename the key; the value (a CEL predicate) is unchanged. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts
index c6eca26cbc..3d08695286 100644
--- a/packages/spec/src/data/hook-body.zod.ts
+++ b/packages/spec/src/data/hook-body.zod.ts
@@ -18,7 +18,7 @@ const CRYPTO_HASH_RETIRED =
+ 'in the host (a Connector recipe, or an engine-side hook) instead. If you need hashing in '
+ 'a body, reopen it through the capability admission process — implementation first, the '
+ 'declaration lands with the implementation. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Capability tokens a script body may request.
diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts
index 117942d617..adea9ffa8d 100644
--- a/packages/spec/src/data/mapping.zod.ts
+++ b/packages/spec/src/data/mapping.zod.ts
@@ -43,15 +43,15 @@ const RETIRED_EXTRACT_QUERY =
+ 'exporter ever read a mapping artifact, so "Query to run for export only" promised an '
+ 'export path that does not exist. Delete the key. Exports run through the ordinary '
+ 'query API (`POST /api/v1/data/:object/query`); if a mapping-driven export is ever '
- + 'designed, this is where it plugs back in. Run `os migrate meta --from 16` to rewrite '
- + 'existing sources automatically.';
+ + 'designed, this is where it plugs back in. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
const RETIRED_ERROR_POLICY =
'`mapping.errorPolicy` was removed in @objectstack/spec 17.0.0 (#4509, ADR-0049) — no '
+ 'import code ever read it, so `skip` / `abort` / `retry` selected between three '
+ 'behaviours that were all the same behaviour. Delete the key. Error handling on the '
+ 'import path belongs to the import REQUEST\'s own options, not to the stored mapping. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
const RETIRED_BATCH_SIZE =
'`mapping.batchSize` was removed in @objectstack/spec 17.0.0 (#4509, ADR-0049) — no '
@@ -60,8 +60,8 @@ const RETIRED_BATCH_SIZE =
+ '`bulkActionDef.batchSize`, `connector.batchSize`, `sync.batchSize`, `offline.batchSize`, '
+ 'the seed loader\'s and the NoSQL driver cursor\'s are all LIVE and enforced — but each is '
+ 'a DIFFERENT key on a different type sizing its own path, and none of them sizes a '
- + 'mapping import. Run `os migrate meta --from 16` to rewrite existing sources '
- + 'automatically.';
+ + 'mapping import. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
const MAPPING_RETIRED_KEY_GUIDANCE: Readonly> = {
extractQuery: RETIRED_EXTRACT_QUERY,
diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts
index 108ff25cf7..512b065c2e 100644
--- a/packages/spec/src/data/object.zod.ts
+++ b/packages/spec/src/data/object.zod.ts
@@ -141,13 +141,13 @@ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = {
'soft-delete that never ran). Delete the key. For recoverability use per-field ' +
'`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked at ' +
'#3146 and, if built, returns as a live enforced flag (ADR-0049 prune-or-build). ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
mru:
'`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' +
'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' +
'existed, so the flag changed nothing. Delete the key. If MRU tracking is ' +
'built it returns as a live enforced flag (ADR-0049 prune-or-build). ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
};
/**
@@ -439,7 +439,7 @@ export const IndexSchema = lazySchema(() => strictObject({
'output. Delete the key. The index method is the driver/dialect\'s decision (Postgres ' +
'defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a ' +
'database-layer migration when a workload actually needs one). ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
partial: retiredKey(
'`indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no ' +
@@ -449,7 +449,7 @@ export const IndexSchema = lazySchema(() => strictObject({
'WHERE ` from a runtime migration (this is what `metadata-protocol`\'s ' +
'`ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it ' +
'reads partiality back from the database\'s own DDL, never from this key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
}));
@@ -1244,8 +1244,8 @@ const MANAGED_BY_SYSTEM_RETIRED =
+ '`userActions` block that existed only to re-open create/edit/delete is now redundant '
+ 'and can be deleted; keep it only to NARROW. CSV `import` is deliberately NOT in that '
+ 'default (#4671): it stays opt-in per object via `userActions: { import: true }`, which '
- + 'is what a v16 `system` object already resolved to. Run `os migrate meta --from 16` to '
- + 'rewrite existing sources automatically.';
+ + 'is what a v16 `system` object already resolved to. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Known-confusable schema keys → precise authoring guidance.
diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts
index 77fc40f4cb..386a0eeb0b 100644
--- a/packages/spec/src/data/query.zod.ts
+++ b/packages/spec/src/data/query.zod.ts
@@ -81,7 +81,7 @@ const AGG_RETIRED_TAIL =
'There is no replacement in the query vocabulary: read the rows with an ordinary `fields` '
+ 'query and shape them in the caller, or model the roll-up as a stored field. It returns '
+ 'only WITH a portable lowering — ADR-0049\'s enforce leg, implementation first. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
const ARRAY_AGG_RETIRED =
'`array_agg`' + AGG_RETIRED_MIDDLE
diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts
index e11744a862..2ab4536817 100644
--- a/packages/spec/src/integration/connector.zod.ts
+++ b/packages/spec/src/integration/connector.zod.ts
@@ -60,8 +60,7 @@ import { retiredKey } from '../shared/retired-key';
* (`data/mapping.zod.ts` — a string enum,
* `none`/`constant`/`map`/`split`/`join`/`lookup`, with its settings in `params`),
* applied row by row by the REST import path — or an ETL transformation step
- * (L2 above). Already authored the retired key? `os migrate meta --from 16` rewrites
- * existing sources automatically — the key itself is removed.
+ * (L2 above). Already authored the retired key? `os migrate meta --from 16` lists the mechanical edits for existing sources — the key itself is removed.
*
* ## Runtime contract — descriptor vs. registered connector (#2612)
*
@@ -808,7 +807,7 @@ export const ConnectorSchema = lazySchema(() => z.object({
'here was inert while reading like a configured cap. Delete the key. Do NOT substitute ' +
'`shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; ' +
'until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts
index c7a157620a..73d00c4fcb 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -388,8 +388,8 @@ const step16: MigrationStep = {
* The three conversions are `retiredFromLoadPath` from the day they land: 17
* gives the aliases no acceptance window at all, and each schema tombstones its
* key with a fix-it error instead. This step is what makes that affordable —
- * `os migrate meta --from ` rewrites the consumer's source rather than
- * leaving them to hand-edit against a changelog.
+ * `os migrate meta --from ` lists the consumer's edits site by site rather
+ * than leaving them to hand-edit against a changelog.
*/
const step17: MigrationStep = {
toMajor: 17,
@@ -825,7 +825,7 @@ const step17: MigrationStep = {
+ 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them '
+ 'with the prescription. This step registers the migration surface that removal was '
+ 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, '
- + 'and `os migrate meta --from 16` rewrites authored sources. Soft delete stays parked at '
+ + 'and `os migrate meta --from 16` lists the mechanical edits for authored sources. Soft delete stays parked at '
+ '#3146; if built it returns as a live enforced flag rather than by reviving these keys.\n\n'
+ 'The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block (#4579): '
+ '`OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / '
diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts
index 1d9b54c0e4..7914484740 100644
--- a/packages/spec/src/security/rls.zod.ts
+++ b/packages/spec/src/security/rls.zod.ts
@@ -463,7 +463,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject(
'`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). ' +
'It never had an effect and could not: applicable policies OR-combine (most permissive wins), ' +
'so there is no conflict to order. Delete the key — policy outcomes are unchanged. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/shared/mapping.test.ts b/packages/spec/src/shared/mapping.test.ts
index 69d2cb1d22..ae164e998d 100644
--- a/packages/spec/src/shared/mapping.test.ts
+++ b/packages/spec/src/shared/mapping.test.ts
@@ -90,7 +90,7 @@ describe('[#5552] FieldMapping.transform is retired, and says so', () => {
// It must name the live mechanism, not merely refuse: an author who wrote a
// transform wants to know where transforms actually run.
expect(result.error!.issues[0]!.message).toMatch(/mapping\.fieldMapping\[\]\.transform/s);
- // And the migration command, since the conversion rewrites sources.
+ // And the migration command, since an ADR-0087 conversion covers this key.
expect(result.error!.issues[0]!.message).toMatch(/os migrate meta --from 16/s);
});
diff --git a/packages/spec/src/shared/mapping.zod.ts b/packages/spec/src/shared/mapping.zod.ts
index adb6df247f..dd880155ea 100644
--- a/packages/spec/src/shared/mapping.zod.ts
+++ b/packages/spec/src/shared/mapping.zod.ts
@@ -98,7 +98,8 @@ export const FieldMappingSchema = lazySchema(() => z.object({
+ "import mapping's: `mapping.fieldMapping[].transform` (a string enum — "
+ '`none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), '
+ 'applied by the REST import path, which rejects `javascript` with a 400 rather than '
- + 'pretending to run it. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'pretending to run it. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts
index 98007fd46e..f946acadd3 100644
--- a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts
+++ b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts
@@ -1,25 +1,37 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
- * [#6856] Class pin: every `os migrate meta --from ` prescription sentence
- * in `packages/spec/src` is the house sentence, whether the backing ADR-0087
- * conversion STRIPS the key or REWRITES the value (maintainer-ruled route D,
- * 2026-08-09):
+ * [#6856, reworded #9529] Class pin: every `os migrate meta --from `
+ * prescription sentence in `packages/spec/src` is the house sentence, whether
+ * the backing ADR-0087 conversion STRIPS the key or REWRITES the value
+ * (maintainer-ruled route D, 2026-08-09; reworded by maintainer ruling
+ * 2026-08-18):
*
- * Run `os migrate meta --from ` to rewrite existing sources automatically.
+ * Run `os migrate meta --from ` to list the mechanical edits for
+ * existing sources; apply them by hand.
*
- * The sentence states a property of the TOOL (it rewrites your source files),
- * never the fate of the key — the retired "rewrite it" spelling was misread
- * over strip conversions because "it" has two antecedents (the key vs your
- * sources), and the key's fate is the body prose's job ("Delete the key…",
- * "Rename the key to…"). Full rule: `shared/retired-key.ts` module docblock.
+ * The sentence states a property of the TOOL, never the fate of the key — the
+ * retired "rewrite it" spelling was misread over strip conversions because
+ * "it" has two antecedents (the key vs your sources), and the key's fate is
+ * the body prose's job ("Delete the key…", "Rename the key to…").
+ *
+ * [#9529] It must also be TRUE of the tool, which is why the wording moved off
+ * "rewrite existing sources automatically": `os migrate meta` replays the chain
+ * in memory and prints the attributed mechanical change list; the only file it
+ * writes is the `--out` JSON snapshot. It has never written an authored source
+ * file, so 90-odd shipped prescriptions promised an affordance that did not
+ * exist. This pin therefore holds BOTH directions — the new sentence is
+ * required where a prescription names the command, and the withdrawn claim is
+ * a hard RED wherever it reappears. Full rule: `shared/retired-key.ts` module
+ * docblock. (When #9591's in-place codemod lands, the claim may be restored —
+ * by editing this pin in the same PR, never by exempting a site.)
*
* ONE allowed variant, by SHAPE and never by site (#6935's no-allowlist
- * discipline): a conversion that rewrites only PART of the value keeps the
+ * discipline): a conversion that covers only PART of the value keeps the
* two-clause form naming which part —
- * "… to rewrite the case … automatically; ." (`ui/dashboard.zod.ts` `compareTo.offset` is the model; the script
- * node's `config.actionType` is the other member.)
+ * "… to list the mechanical edits for the case; ." (`ui/dashboard.zod.ts` `compareTo.offset` is the model; the
+ * script node's `config.actionType` is the other member.)
*
* [#7030] Widened, not duplicated: `packages/lint/src/validate-expressions.ts`
* carries one live occurrence of the identical sentence (the lint diagnostic
@@ -90,14 +102,24 @@ const MARKER = /(?:Run )?`os migrate meta --from \d+`/g;
* quote), so a prescription cannot bury the command mid-prose either.
*/
const HOUSE_AT_MARKER =
- /^Run `os migrate meta --from \d+` to rewrite existing sources automatically\.['"]/;
+ /^Run `os migrate meta --from \d+` to list the mechanical edits for existing sources; apply them by hand\.['"]/;
/**
- * MIXED two-clause shape: clause one names the part of the value the tool
- * rewrites, clause two says what it does with the rest. Shape, not sites.
+ * MIXED two-clause shape: clause one names the part of the value the chain
+ * covers mechanically, clause two says what it does with the rest. Shape, not
+ * sites.
*/
const MIXED_AT_MARKER =
- /^Run `os migrate meta --from \d+` to rewrite the [^;'"]+ case[^;'"]* automatically; [^;'"]+\.['"]/;
+ /^Run `os migrate meta --from \d+` to list the mechanical edits for the [^;'"]+ case[^;'"]*; [^;'"]+\.['"]/;
+
+/**
+ * [#9529] The withdrawn claim, in every spelling the sweep found. Judged over
+ * the SAME reconstructed text as the house form, but as a hard RED wherever it
+ * appears — a site reverting to it fails even if it never names `--from `
+ * (three enum-value prescriptions spell the bare command).
+ */
+const WITHDRAWN_CLAIM =
+ /to rewrite (?:existing sources|it) automatically|rewrites (?:it for you|existing sources|author(?:ed)? sources|your sources?|your source files?)/g;
interface JudgedSite {
/** Corpus-prefixed path, e.g. `spec:data/object.zod.ts` or `lint:validate-expressions.ts`. */
@@ -113,9 +135,9 @@ interface JudgedSite {
* Reconstruct judgeable text from one source file: drop comment lines, then
* merge string-concatenation seams (`'…' + '…'`, same line or across lines,
* single or double quotes) so a sentence split across literals is judged
- * whole. Returns every marker occurrence with its verdict.
+ * whole. `segments` maps an offset in the merged text back to a source line.
*/
-export function judgeMigrateSentences(raw: string, file = ''): JudgedSite[] {
+function reconstruct(raw: string): { merged: string; segments: Array<{ start: number; line: number }> } {
const kept: Array<{ text: string; line: number }> = [];
raw.split('\n').forEach((text, i) => {
const t = text.trim();
@@ -137,22 +159,51 @@ export function judgeMigrateSentences(raw: string, file = ''): JudgedSit
segments.push({ start: merged.length, line: k.line });
merged += `${lineText}\n`;
}
+ return { merged, segments };
+}
+
+const lineAt = (segments: Array<{ start: number; line: number }>, at: number): number => {
+ let line = 0;
+ for (const seg of segments) {
+ if (seg.start > at) break;
+ line = seg.line;
+ }
+ return line;
+};
+/** Every `os migrate meta --from ` prescription in one file, with its verdict. */
+export function judgeMigrateSentences(raw: string, file = ''): JudgedSite[] {
+ const { merged, segments } = reconstruct(raw);
const judged: JudgedSite[] = [];
for (const m of merged.matchAll(MARKER)) {
const at = m.index ?? 0;
const rest = merged.slice(at);
const ok = HOUSE_AT_MARKER.test(rest) || MIXED_AT_MARKER.test(rest);
- let line = 0;
- for (const seg of segments) {
- if (seg.start > at) break;
- line = seg.line;
- }
- judged.push({ file, line, excerpt: rest.slice(0, 120).replace(/\n/g, ' '), ok });
+ judged.push({
+ file, line: lineAt(segments, at), excerpt: rest.slice(0, 120).replace(/\n/g, ' '), ok,
+ });
}
return judged;
}
+/**
+ * [#9529] Every occurrence of the WITHDRAWN automatic-rewrite claim in one
+ * file's prescription text. Separate from `judgeMigrateSentences` on purpose:
+ * the claim is red wherever it appears, not only where it closes a sentence
+ * that names `--from `.
+ */
+export function findWithdrawnClaims(raw: string, file = ''): Array<{ file: string; line: number; excerpt: string }> {
+ const { merged, segments } = reconstruct(raw);
+ return [...merged.matchAll(WITHDRAWN_CLAIM)].map((m) => {
+ const at = m.index ?? 0;
+ return {
+ file,
+ line: lineAt(segments, at),
+ excerpt: merged.slice(Math.max(0, at - 60), at + 60).replace(/\n/g, ' '),
+ };
+ });
+}
+
function* walk(dir: string): Generator {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
@@ -173,6 +224,18 @@ function judgeTree(): JudgedSite[] {
return all;
}
+function claimTree(): Array<{ file: string; line: number; excerpt: string }> {
+ const all: Array<{ file: string; line: number; excerpt: string }> = [];
+ for (const corpus of CORPORA) {
+ for (const file of walk(corpus.root)) {
+ const rel = path.relative(corpus.root, file);
+ if (corpus.outOfScope.has(rel)) continue;
+ all.push(...findWithdrawnClaims(fs.readFileSync(file, 'utf8'), `${corpus.name}:${rel}`));
+ }
+ }
+ return all;
+}
+
describe('`os migrate meta` sentences are the house sentence, across corpora (#6856 route D, widened #7030)', () => {
it('every prescription sentence in packages/spec/src and packages/lint/src is house-form or MIXED two-clause', () => {
const judged = judgeTree();
@@ -209,6 +272,39 @@ describe('`os migrate meta` sentences are the house sentence, across corpora (#6
expect(lintSites.every((j) => j.ok)).toBe(true);
});
+ it('[#9529] the withdrawn automatic-rewrite claim is absent from every prescription', () => {
+ // The other direction of the same ruling: requiring the new sentence where
+ // `--from ` appears would still let the claim survive in a prescription
+ // that spells the bare command (`CHATTER_POSITION_RETIRED` does) or names
+ // the tool mid-prose. `os migrate meta` writes no authored source file.
+ const claims = claimTree();
+ expect(
+ claims,
+ claims.map((c) => `${c.file}:${c.line} — "${c.excerpt}"`).join('\n'),
+ ).toEqual([]);
+ });
+
+ it('[#9529] the withdrawn-claim scan is not vacuous — every retired spelling trips it', () => {
+ const withdrawn = [
+ "const a = 'Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically.';",
+ "const b = 'Delete the key. Run `os migrate meta --from 16` to rewrite it automatically.';",
+ "const c = 'Rename the key to `b`. `os migrate meta --from 16` rewrites it for you.';",
+ "const d = 'The chain covers this: `os migrate meta --from 16` rewrites author sources.';",
+ "const e = 'The chain covers this: `os migrate meta --from 16` rewrites authored sources.';",
+ "const f = 'Run `os migrate meta`. It rewrites your source files.';",
+ // Split across a concatenation seam — the reconstruction must still see it.
+ "const g = 'Run `os migrate meta --from 16` to rewrite existing sources '\n + 'automatically.';",
+ ];
+ for (const src of withdrawn) {
+ expect(findWithdrawnClaims(src, 'withdrawn.zod.ts'), src).not.toEqual([]);
+ }
+ // And the house sentence itself must NOT trip it.
+ expect(findWithdrawnClaims(
+ "const h = 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';",
+ 'house.zod.ts',
+ )).toEqual([]);
+ });
+
it('goes RED on the retired "rewrite it" spelling, naming the site', () => {
const planted = [
"const X = retiredKey(",
@@ -225,14 +321,18 @@ describe('`os migrate meta` sentences are the house sentence, across corpora (#6
it('goes RED on every other retired spelling and on an emptied sentence', () => {
const bad = [
- // The three other spellings the sweep retired.
+ // The three other spellings the #6856 sweep retired.
"'Delete the key. Run `os migrate meta --from 16` to remove it.'",
"'Delete the key. Run `os migrate meta --from 16` to remove it automatically.'",
"'Rename the key to `b`; the value is unchanged. `os migrate meta --from 16` rewrites it for you.'",
+ // #9529: the sentence #6856 itself ruled, withdrawn as untrue of the tool.
+ "'Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically.'",
+ // …and the MIXED shape's withdrawn spelling.
+ "'Run `os migrate meta --from 16` to rewrite the `1y` case automatically; the rest are reported.'",
// Emptied sentence: the command with no object at all.
"'Delete the key. Run `os migrate meta --from 16`.'",
// Sentence not final in its literal: prose buries the command.
- "'Run `os migrate meta --from 16` to rewrite existing sources automatically. Also do X.'",
+ "'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. Also do X.'",
];
for (const literal of bad) {
const judged = judgeMigrateSentences(`const s = ${literal};`, 'bad.zod.ts');
@@ -244,13 +344,13 @@ describe('`os migrate meta` sentences are the house sentence, across corpora (#6
it('accepts the two legal shapes, including across concatenation seams', () => {
const good = [
// House, single literal.
- "const a = '`k` was removed (#1). Delete the key. Run `os migrate meta --from 16` to rewrite existing sources automatically.';",
+ "const a = '`k` was removed (#1). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';",
// House, sentence split across a cross-line concatenation seam.
- "const b = '`k` was removed (#1). Run `os migrate meta --from 16` to rewrite existing sources '\n + 'automatically.';",
+ "const b = '`k` was removed (#1). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; '\n + 'apply them by hand.';",
// MIXED two-clause (the dashboard model).
- "const c = 'Run `os migrate meta --from 16` to rewrite the `1y` case automatically; the other durations are reported for you to re-state.';",
+ "const c = 'Run `os migrate meta --from 16` to list the mechanical edits for the `1y` case; the other durations are reported for you to re-state.';",
// MIXED two-clause (the script `actionType` member).
- "const d = 'Run `os migrate meta --from 16` to rewrite the shorthand case into `config.function` automatically; the stub and marker values are removed.';",
+ "const d = 'Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; the stub and marker values are removed.';",
];
for (const src of good) {
const judged = judgeMigrateSentences(src, 'good.zod.ts');
diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts
index 7cdf14a6c5..d57b862acd 100644
--- a/packages/spec/src/shared/retired-key.ts
+++ b/packages/spec/src/shared/retired-key.ts
@@ -34,20 +34,34 @@
* **The `os migrate meta` sentence is standardized — do not choose a verb.**
* A prescription whose surface an ADR-0087 conversion covers closes with
* exactly this sentence, whether the conversion STRIPS the key or REWRITES
- * its value (#6856, maintainer-ruled 2026-08-09):
+ * its value (#6856, maintainer-ruled 2026-08-09; reworded #9529,
+ * maintainer-ruled 2026-08-18):
*
- * Run `os migrate meta --from ` to rewrite existing sources automatically.
+ * Run `os migrate meta --from ` to list the mechanical edits for
+ * existing sources; apply them by hand.
*
- * The sentence states a property of the TOOL — it rewrites your source
- * files — never the fate of the key. The retired "rewrite it" spelling was
- * misread over strip conversions because "it" has two antecedents (the key
- * vs your sources) and the wrong reading promises a value conversion that
- * never happens; "existing sources" has one antecedent. The KEY's fate
- * belongs in the body prose ("Delete the key…", "Rename the key to…"),
- * which every prescription already carries — the sentence never restates
- * it. ONE exception: a conversion that rewrites only PART of the value
- * keeps the two-clause form naming which part — "… to rewrite the case
- * … automatically; ." (model:
+ * The sentence states a property of the TOOL — what running it gets you —
+ * never the fate of the key. Two rulings shaped it, and both still bind:
+ *
+ * - **It must be TRUE of the tool.** The sentence used to promise
+ * "rewrite existing sources automatically", and `os migrate meta` has
+ * never written an authored source file: it replays the conversion
+ * chain over the loaded stack in memory, prints the attributed
+ * mechanical change list (`Applied N mechanical change(s)`, one line per
+ * site), and writes exactly one file — the `--out` JSON snapshot, when
+ * you ask for it. Porting the listed edits into the project's own `.ts`
+ * sources is the author's work, which is why the sentence says so
+ * (#9529; the in-place AST codemod is commissioned separately as #9591,
+ * and the automatic-rewrite claim may return with it).
+ * - **One antecedent.** The retired "rewrite it" spelling was misread over
+ * strip conversions because "it" names either the key or your sources;
+ * "existing sources" names one thing. The KEY's fate belongs in the body
+ * prose ("Delete the key…", "Rename the key to…"), which every
+ * prescription already carries — the sentence never restates it.
+ *
+ * ONE exception: a conversion that covers only PART of the value keeps the
+ * two-clause form naming which part — "… to list the mechanical edits for
+ * the case; ." (model:
* `ui/dashboard.zod.ts` `compareTo.offset`). Both shapes are pinned
* class-wide by `retired-key-migrate-sentence.test.ts`; a new spelling
* fails the pin, not code review.
@@ -77,7 +91,7 @@ import { z } from 'zod';
* conditionalRequired: retiredKey(
* '`conditionalRequired` was removed in @objectstack/spec 17.0.0 (#3855). ' +
* 'Rename the key to `requiredWhen` — the value (a CEL predicate) is unchanged. ' +
- * 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ * 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
* ),
* ```
*/
diff --git a/packages/spec/src/shared/retry-policy.zod.ts b/packages/spec/src/shared/retry-policy.zod.ts
index cbedb72cc3..87336952d4 100644
--- a/packages/spec/src/shared/retry-policy.zod.ts
+++ b/packages/spec/src/shared/retry-policy.zod.ts
@@ -124,7 +124,8 @@ export function retryPolicyShape() {
'has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, ' +
"a `try_catch` node's `retry` and `flow.errorHandling`. " +
'Rename the key to `backoffMs`; the value (milliseconds before the first retry) ' +
- 'is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'is unchanged. ' +
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
};
}
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index af2f19c9b2..7ee420c19f 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -433,7 +433,8 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({
+ 'is now always denied. Delete the key; publish public surfaces by declaration instead — a public '
+ "form view, a share link, or `book.audience: 'public'`. A stack that mounts no auth at all now "
+ 'fails at boot rather than silently serving object data to anonymous callers. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing '
+ + 'sources; apply them by hand.',
),
/** Enable environment-scoped routing for data/meta/AI APIs. */
enableProjectScoping: z.boolean().optional(),
diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts
index b385b8f418..869869eadd 100644
--- a/packages/spec/src/system/book.zod.ts
+++ b/packages/spec/src/system/book.zod.ts
@@ -75,7 +75,8 @@ const BOOK_TRANSLATIONS_RETIRED =
+ 'book shipped its authoring-locale strings to every reader. Delete the key. NOTE the '
+ 'near neighbour that DOES work: `doc.translations` is live and read on every doc render '
+ 'path — localize the docs themselves, and the portal picks the reader\'s locale up from '
- + 'there. Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'there. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
export const BookGroupSchema = lazySchema(() =>
z.object({
diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts
index 74490fdaf1..72642bfe79 100644
--- a/packages/spec/src/system/job.zod.ts
+++ b/packages/spec/src/system/job.zod.ts
@@ -122,8 +122,8 @@ const JOB_ID_RETIRED =
+ 'override that did not exist. `name` IS the job\'s identity everywhere: the scheduling '
+ 'key, the `sys_job` row key, and the `JobExecution.jobId` stamp. Two jobs differing only '
+ 'in `id` were the same job. Delete the key; rename the job via `name` if you need a '
- + 'different identity. Run `os migrate meta --from 16` to rewrite existing sources '
- + 'automatically.';
+ + 'different identity. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
export const JobSchema = lazySchema(() => strictObject({
surface: 'this job',
diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts
index 86dbf31a33..84e1c9e61f 100644
--- a/packages/spec/src/system/translation.zod.ts
+++ b/packages/spec/src/system/translation.zod.ts
@@ -342,7 +342,7 @@ const TRANSLATION_KEY_GUIDANCE: Record'",
app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.'",
nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps..navigation..label'",
diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts
index 37ac5435b8..413e86f8c7 100644
--- a/packages/spec/src/ui/action.zod.ts
+++ b/packages/spec/src/ui/action.zod.ts
@@ -502,7 +502,7 @@ const TARGET_REQUIRED_TYPES: ReadonlySet = new Set(
* The `execute` alias was **removed in protocol 17** (#3855). `target` is the
* only handler slot, so no consumer has a second slot to disagree about. An
* authored `execute` is rejected with the rename prescription rather than
- * silently stripped; `os migrate meta --from 16` rewrites it for you.
+ * silently stripped; `os migrate meta --from 16` lists the edit for you to apply.
*
* @example Good action names
* - 'on_close_deal'
@@ -536,7 +536,7 @@ const GLOBAL_NAV_RETIRED =
+ '`record_related`, `record_section`), or — for an action that deliberately has no UI home, '
+ 'such as an object-less one invoked over REST/MCP/AI — declare it headless with '
+ '`locations: []`, which keeps its capability gate, param contract and audit trail. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Action Location — where an action is allowed to surface in the UI.
@@ -980,7 +980,7 @@ const actionObject = () => strictObject({
execute: retiredKey(
'`execute` was removed in @objectstack/spec 17 (#3855) — use `target`. ' +
'Rename the key; the value (a handler / flow / URL ref) is unchanged. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
@@ -1200,14 +1200,14 @@ const actionObject = () => strictObject({
"objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults " +
'action metadata. Delete the key. For a real shortcut, register the key in the Console ' +
'keyboard stack and have its handler invoke the action by name. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
bulkEnabled: retiredKey(
'`action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' +
'the multi-select toolbar is driven by the LIST VIEW\'s `bulkActions` / `bulkActionDefs`, ' +
'never by this flag, so setting it changed nothing. Delete the key and declare the action ' +
"in the view's `bulkActions` instead. " +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts
index 9a47d120a9..022c6fa007 100644
--- a/packages/spec/src/ui/app.zod.ts
+++ b/packages/spec/src/ui/app.zod.ts
@@ -809,7 +809,7 @@ const AREA_ORDER_RETIRED =
+ 'authored, so declaration order already IS display order. Delete the key and reorder the '
+ '`areas` array itself. NOTE the neighbour that behaves differently: a navigation ITEM\'s '
+ '`order` is genuinely sorted — this removal does not touch it. Run '
- + '`os migrate meta --from 16` to rewrite existing sources automatically.';
+ + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* `app.areas[].visible` and `app.areas[].requiredPermissions`, retired in
@@ -860,7 +860,7 @@ const AREA_VISIBLE_RETIRED =
+ 'server-side since #4722. The distinction survives at every level: `visible` is CEL '
+ 'evaluated in the browser, so it hides an entry that has already been sent, while '
+ '`requiredPermissions` stops that entry from being served at all. Run '
- + '`os migrate meta --from 16` to rewrite existing sources automatically.';
+ + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
const AREA_REQUIRED_PERMISSIONS_RETIRED =
'`areas[].requiredPermissions` was removed in @objectstack/spec 17.0.0 (#4651, ADR-0049) — '
@@ -874,8 +874,8 @@ const AREA_REQUIRED_PERMISSIONS_RETIRED =
+ 'area never reaches the browser either. That enforces the items INSIDE an area; the '
+ 'area-level key is not revived. Still evaluated client-side ONLY, at every level: '
+ '`visible` (CEL) and `requiresObject` — so anything that must never reach the browser '
- + 'goes in `requiredPermissions`, never in `visible`. Run `os migrate meta --from 16` to '
- + 'rewrite existing sources automatically.';
+ + 'goes in `requiredPermissions`, never in `visible`. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Navigation Area Schema
@@ -1014,8 +1014,8 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> =
+ 'kernel metadata to a developer who scoped to their own package. The renderer never '
+ 'offered an All row regardless of this flag, so `includeAll: false` hardened nothing '
+ 'and `includeAll: true` unlocked nothing. Delete the key. To widen what a selector '
- + 'offers, widen `optionsSource.filter` instead. Run `os migrate meta --from 16` to '
- + 'rewrite existing sources automatically.',
+ + 'offers, widen `optionsSource.filter` instead. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
showall:
'`contextSelectors[].includeAll` (which `showall` aliased) was removed in '
+ '@objectstack/spec 17.0.0 (#4509) — selectors are mandatory-scope and never render an '
@@ -1024,7 +1024,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> =
'`contextSelectors[].placement` was removed in @objectstack/spec 17.0.0 (#4509, '
+ 'ADR-0049) — no renderer ever read it. Selectors always render in the sidebar header '
+ "block, and `'topbar'` placed nothing in the topbar. Delete the key. Run "
- + '`os migrate meta --from 16` to rewrite existing sources automatically.',
+ + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
location:
'`contextSelectors[].placement` (which `location` aliased) was removed in '
+ '@objectstack/spec 17.0.0 (#4509) — selectors always render in the sidebar header. '
@@ -1206,8 +1206,8 @@ const HOME_PAGE_ID_RETIRED =
+ 'app\'s landing page IS its first navigation item (by `order`), and the root landing '
+ 'follows `isDefault` routing. Delete the key; to change where an app opens, '
+ 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that '
- + 'should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources '
- + 'automatically.';
+ + 'should own the root landing. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* The prescription for every author-shaped spelling of the ADR-0045 publish
@@ -1299,7 +1299,7 @@ export const AppSchema = lazySchema(() => strictObject(
'`App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' +
'no consumer in framework or objectui). An app is versioned by its owning package: ' +
'use `manifest.version`. Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Description */
@@ -1441,7 +1441,7 @@ export const AppSchema = lazySchema(() => strictObject(
'never read; the spec itself labelled it "config file convenience"). Objects belong ' +
'to the stack (`defineStack({ objects })`); an app reaches them through its ' +
'navigation items. Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
apis: retiredKey(
'`App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' +
@@ -1460,7 +1460,7 @@ export const AppSchema = lazySchema(() => strictObject(
'`declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a ' +
'security review, not a rename. A route that genuinely needs handler CODE still ' +
'belongs in a plugin manifest `contributes.routes` entry. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
@@ -1475,13 +1475,13 @@ export const AppSchema = lazySchema(() => strictObject(
'ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared ' +
'sharing that did not exist. Public access is granted per FORM VIEW ' +
'(`FormView.sharing`, the public-data-collection surface). Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
embed: retiredKey(
'`App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ' +
'ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface ' +
'(`FormView.sharing`), not an app-level switch. Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
@@ -1494,7 +1494,7 @@ export const AppSchema = lazySchema(() => strictObject(
'`App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness ' +
'audit — fully unimplemented; no renderer, including packages/mobile, ever read ' +
'it). Delete the key; the block returns if/when a real mobile navigation ships. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
@@ -1534,7 +1534,7 @@ export const AppSchema = lazySchema(() => strictObject(
'renderer read app-level ARIA attributes). Declare `aria` on the page component ' +
'that renders the DOM node instead (`page.components[].aria`; `page.aria` and the ' +
'list view `aria` are live too). Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
diff --git a/packages/spec/src/ui/aria-carrier-tombstones.test.ts b/packages/spec/src/ui/aria-carrier-tombstones.test.ts
index c619dad536..84e3327a7e 100644
--- a/packages/spec/src/ui/aria-carrier-tombstones.test.ts
+++ b/packages/spec/src/ui/aria-carrier-tombstones.test.ts
@@ -104,7 +104,7 @@ describe('the `aria` tombstones name only live `AriaProps` carriers (#6756)', ()
// site that already flipped once.
expect(message).toContain('Delete the key.');
expect(message).toContain('os migrate meta --from 16');
- expect(message).toMatch(/to rewrite existing sources automatically\.$/);
+ expect(message).toMatch(/to list the mechanical edits for existing sources; apply them by hand\.$/);
});
it('the App.aria tombstone points at a page component, not at the retired widget surface', () => {
diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts
index 0759a89d0c..64313c1af0 100644
--- a/packages/spec/src/ui/component.test.ts
+++ b/packages/spec/src/ui/component.test.ts
@@ -915,7 +915,7 @@ describe('Interactive Elements — element:filter (retired, #9220)', () => {
// sentence (the D2 conversion `element-filter-removed` strips it).
it('rejects the retired `targetVariable` with its prescription', () => {
expect(() => ElementFilterPropsSchema.parse({ targetVariable: 'active_filter' }))
- .toThrow(/`element:filter` property `targetVariable`.*removed.*Run `os migrate meta --from 17` to rewrite existing sources automatically/s);
+ .toThrow(/`element:filter` property `targetVariable`.*removed.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand/s);
});
// The migrated shape — `element-filter-removed` strips all six keys and
diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts
index 49a1881842..42570af696 100644
--- a/packages/spec/src/ui/component.zod.ts
+++ b/packages/spec/src/ui/component.zod.ts
@@ -455,7 +455,8 @@ export const PageHeaderProps = strictObject({
+ 'never off the header\'s own props bag, and the component registry never published it as an '
+ 'input, so an authored value was accepted and dropped. Delete the key. The header\'s own '
+ 'identity is drawn by the record chrome (`recordChrome`, on by default) and each action '
- + 'carries its own `icon`. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'carries its own `icon`. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
breadcrumb: z.boolean().default(true).describe('Show breadcrumb'),
actions: z.array(z.string()).optional().describe('Action IDs to show in header'),
@@ -565,7 +566,7 @@ export const PageTabsProps = strictObject({
+ 'a props key named `type` collides with the page component\'s own dispatch key, so it is '
+ 'unauthorable in the flat and JSX carriers and was never validated in them. Rename the key '
+ 'to `tabStyle`; the value (`line` | `card` | `pill`) is unchanged. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
position: z.enum(['top', 'left']).default('top'),
/**
@@ -694,7 +695,7 @@ export const PageCardProps = strictObject({
+ 'never published it as an input, so an authored value was accepted and dropped. Delete the '
+ 'key and author the buttons as components in the card\'s `children` or `footer` '
+ '(`element:button`, `record:quick_actions`), which is what actually renders. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* Card content, in order — the canonical composition slot, matching every
@@ -717,7 +718,8 @@ export const PageCardProps = strictObject({
'`page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — '
+ 'it was a second spelling of the composition slot every other container calls `children`, '
+ 'and the renderer reads both. Rename the key to `children`; the value (an array of child '
- + 'components) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'components) is unchanged. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Slot for footer content */
footer: z.array(z.unknown()).optional().describe('Card footer components (slot)'),
@@ -764,7 +766,7 @@ export const RecordDetailsProps = strictObject({
+ 'values took the same branch and the key selected nothing. Delete the key — the body is '
+ 'already chosen by what you author: `sections` renders the explicit groups (the old '
+ '`custom`), and omitting it falls back to the object\'s `highlightFields` (the old `auto`). '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* Field groups rendered as the detail body, IN ORDER.
@@ -1037,17 +1039,18 @@ const CHATTER_POSITION_RETIRED: ReadonlyMap = new Map([
+ 'no renderer branch ever compared the old vocabulary: `RecordChatterPanel` docks on '
+ "'right'/'left' and renders in flow on 'bottom', so a spec-valid 'sidebar' silently fell "
+ "through to the in-flow render. Write 'right' — the docked side panel 'sidebar' meant. "
- + 'Run `os migrate meta` to rewrite existing sources automatically (registered under '
- + 'protocol major 18).'],
+ + 'Run `os migrate meta` to list the mechanical edits for existing sources '
+ + '(registered under protocol major 18); apply them by hand.'],
['inline', "'inline' was removed from `record:chatter` / `record:discussion` `position` (#8762) — "
+ 'no renderer branch ever compared the old vocabulary. Write \'bottom\' — the renderer\'s '
+ "in-flow branch, which is where 'inline' already rendered. Run `os migrate meta` to "
- + 'rewrite existing sources automatically (registered under protocol major 18).'],
+ + 'list the mechanical edits for existing sources (registered under protocol major 18); '
+ + 'apply them by hand.'],
['drawer', "'drawer' was removed from `record:chatter` / `record:discussion` `position` (#8762) "
+ 'with no successor: no renderer branch ever implemented an overlay drawer — the value fell '
+ "through to the in-flow render. Write 'right' — the docked side panel is the nearest "
- + 'surviving shape of a side drawer. Run `os migrate meta` to rewrite existing sources '
- + 'automatically (registered under protocol major 18).'],
+ + 'surviving shape of a side drawer. Run `os migrate meta` to list the mechanical edits '
+ + 'for existing sources (registered under protocol major 18); apply them by hand.'],
]);
/**
@@ -1637,7 +1640,7 @@ const elementFilterRetired = (key: string): string =>
+ 'no-renderer exclusion), so every key on this element was a capability claim nothing '
+ 'kept. Delete the `element:filter` component; list surfaces own their filtering — use a '
+ "view's `userFilters` quick-filter bar or the list toolbar's filter builder. "
- + 'Run `os migrate meta --from 17` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.';
/**
* RETIRED at element grain (#9220, ADR-0049 enforce-or-remove). `element:filter`
@@ -1785,7 +1788,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({
+ "component's `id`, so authoring only `targetVariable` bound nothing while reporting "
+ 'success. Delete the key; to bind the picked record id, declare it on the variable — '
+ "`variables: [{ name: '', type: 'record_id', source: '' }]`. "
- + 'Run `os migrate meta --from 17` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
),
placeholder: I18nLabelSchema.optional().describe('Placeholder text'),
/** Shown in place of the row list when the query returns nothing. */
@@ -1799,7 +1802,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({
+ '(#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the '
+ 'renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key '
+ 'to `labelField`; the value (a field name) is unchanged. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* REMOVED (#5775). ADR-0049 enforce-or-remove: the control has no search
@@ -1810,7 +1813,8 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({
+ '(#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no '
+ 'renderer ever read it and it narrowed nothing. Delete the key. To restrict which records '
+ 'the picker offers, use `filter` (or the component-level `dataSource.filter`), which the '
- + 'query path does apply. Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'query path does apply. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* REMOVED (#5775). ADR-0049 enforce-or-remove: the control is a single-select
@@ -1821,7 +1825,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({
+ '(#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable '
+ 'holds one record id, so `multiple: true` selected nothing extra and reported success. '
+ 'Delete the key; multi-record selection is not implemented on this element. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
@@ -1868,7 +1872,7 @@ export const ElementTextInputPropsSchema = lazySchema(() => strictObject({
+ "component's `id`, so authoring only `targetVariable` bound nothing while reporting "
+ 'success. Delete the key; to bind the typed value, declare it on the variable — '
+ "`variables: [{ name: '', type: 'string', source: '' }]`. "
- + 'Run `os migrate meta --from 17` to rewrite existing sources automatically.',
+ + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts
index a032617a88..61200e21b9 100644
--- a/packages/spec/src/ui/dashboard.zod.ts
+++ b/packages/spec/src/ui/dashboard.zod.ts
@@ -278,7 +278,7 @@ const COMPARE_TO_OFFSET_RETIRED =
+ "(`'7d'`, `'1M'`, …) there is no faithful one-key rewrite: state the window you want on the "
+ "widget's own `filter` and compare it with `previousPeriod`, which shifts by whatever length "
+ 'that window resolves to. '
- + 'Run `os migrate meta --from 16` to rewrite the `1y` case automatically; the other durations '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for the `1y` case; the other durations '
+ 'are reported for you to re-state.';
// The two string arms. They parsed, and on a dataset widget they then did
@@ -292,7 +292,8 @@ const COMPARE_TO_STRING_RETIRED = (kind: 'previousPeriod' | 'previousYear') =>
+ `Write \`compareTo: { kind: '${kind}' }\` instead — same comparison, spelled the way the `
+ 'analytics executor actually reads it (`DatasetSelection.compareTo`). Add `dimension` only '
+ 'when the selection has more than one dated time dimension; with one, the executor resolves '
- + 'it. Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'it. '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
// ── Per-widget action button prescriptions (#5010) ───────────────────────────
//
@@ -315,7 +316,7 @@ const WIDGET_ACTION_RETIRED = (key: 'actionUrl' | 'actionType' | 'actionIcon') =
+ '(`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of '
+ '`actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` '
+ 'bound to a dataset: its rows are clickable and drill through the semantic layer. '
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Dashboard Widget Schema
@@ -599,7 +600,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
'The shared `ResponsiveConfig` shape is NOT gone — it stays live on `page.components[].responsive`, ' +
'which objectui `useResponsiveConfig` really does read; move the layout there if you need ' +
'breakpoint behaviour today. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
// `aria` REMOVED (#5010, ADR-0049 D2): the same "false compliance" the
@@ -627,7 +628,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
'`description`) on the widget instead — those ARE what the renderer labels the card with. ' +
'The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, ' +
'`page.components[].aria` and the list view `aria`. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
// ADR-0021 single-form: every widget binds a `dataset` and selects `values`
// (both required above) — there is no inline-query shape to disambiguate.
@@ -917,13 +918,13 @@ export const DashboardSchema = lazySchema(() => strictObject({
'`dashboard.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no ' +
'dashboard renderer ever applied it, so declared ARIA attributes silently did not reach ' +
'the DOM. Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
performance: retiredKey(
'`dashboard.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit ' +
'close-out) — no renderer or runtime read it; dashboard performance tuning was never ' +
'implemented. Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* ADR-0010 §3.7 — Package-level protection envelope. Package
diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts
index 250c7ab73d..8b9ea5d46d 100644
--- a/packages/spec/src/ui/theme.zod.ts
+++ b/packages/spec/src/ui/theme.zod.ts
@@ -221,25 +221,31 @@ export const TypographySchema = lazySchema(() => strictObject(
base: z.string().optional().describe('Base font family (default: system fonts)'),
heading: retiredKey(
- '`theme.typography.fontFamily.heading` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-heading`, which no objectui component or stylesheet reads, so headings always rendered in the base font stack. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-heading": "Georgia, serif" }` emits exactly the same `--font-heading`). Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ '`theme.typography.fontFamily.heading` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-heading`, which no objectui component or stylesheet reads, so headings always rendered in the base font stack. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-heading": "Georgia, serif" }` emits exactly the same `--font-heading`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
mono: retiredKey(
- '`theme.typography.fontFamily.mono` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-mono`, which no objectui component or stylesheet reads, so code always rendered in the browser default monospace. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-mono": "ui-monospace, monospace" }` emits exactly the same `--font-mono`). Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ '`theme.typography.fontFamily.mono` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-mono`, which no objectui component or stylesheet reads, so code always rendered in the browser default monospace. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-mono": "ui-monospace, monospace" }` emits exactly the same `--font-mono`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
},
).optional(),
fontSize: retiredKey(
- '`theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ '`theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
fontWeight: retiredKey(
- '`theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ '`theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
lineHeight: retiredKey(
- '`theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ '`theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
letterSpacing: retiredKey(
- '`theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ '`theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
},
));
@@ -352,10 +358,12 @@ const THEME_RETIRED_KEY_GUIDANCE: Readonly> = {
* does not make the tombstone redundant.
*/
const THEME_ANIMATION_RETIRED =
- '`theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ '`theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
const THEME_ZINDEX_RETIRED =
- '`theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ '`theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). '
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Theme Configuration Schema
diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts
index ebe46e2536..7bbd25be34 100644
--- a/packages/spec/src/ui/view.test.ts
+++ b/packages/spec/src/ui/view.test.ts
@@ -2969,7 +2969,7 @@ describe('ListViewSchema — retired striped/bordered/virtualScroll (#7176 pass-
ListViewSchema.parse({ type: 'grid', columns: ['name'], [key]: true });
} catch (e) { message = String((e as Error).message); }
expect(message).toMatch(/#7176/);
- expect(message).toMatch(/Run `os migrate meta --from 16` to rewrite existing sources automatically\./);
+ expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand\./);
}
});
it('accepts the live grid siblings byte-identically (rowHeight/selection/pagination/resizable)', () => {
@@ -3038,7 +3038,7 @@ describe('ListViewSchema.exportOptions — object form + array lift + pdf retire
expect(message).toMatch(/'pdf' was removed from `view\.exportOptions` formats/);
expect(message).toMatch(/#1301/);
expect(message).toMatch(/'csv', 'xlsx' and 'json'/);
- expect(message).toMatch(/Run `os migrate meta --from 16` to rewrite existing sources automatically\./);
+ expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand\./);
});
it("REJECTS 'pdf' in the object form's `formats` with the same prescription", () => {
diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts
index 03276d04dd..a37628441d 100644
--- a/packages/spec/src/ui/view.zod.ts
+++ b/packages/spec/src/ui/view.zod.ts
@@ -1309,7 +1309,7 @@ const LIST_VIEW_EXPORT_PDF_RETIRED =
+ 'export: ObjectGrid dropped the declared format from the export menu with only a runtime '
+ "console.warn, so authoring it was a parse-clean no-op. Delete the value; the surviving "
+ "formats are 'csv', 'xlsx' and 'json'. "
- + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.';
+ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.';
/**
* Export formats the platform actually delivers (#8010): `csv`/`json` on both
@@ -1631,13 +1631,13 @@ export const ListViewSchema = lazySchema(() => strictObject({
responsive: retiredKey(
'`view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' +
'no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
performance: retiredKey(
'`view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' +
'no renderer or runtime read it; list-view performance tuning was never implemented. ' +
'Delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
// `striped` / `bordered` / `virtualScroll` REMOVED (#7176, ADR-0049
@@ -1651,19 +1651,19 @@ export const ListViewSchema = lazySchema(() => strictObject({
'`view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — ' +
'every measured reader only copied it forward and no renderer ever applied it, so authoring ' +
'it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
bordered: retiredKey(
'`view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — ' +
'every measured reader only copied it forward and no renderer ever applied it (the grid frame ' +
"is the renderer's own constant, not authorable). Delete the key. " +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
virtualScroll: retiredKey(
'`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — ' +
'every measured reader only copied it forward and no grid ever virtualized off it; authoring ' +
'it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
}));
@@ -2481,7 +2481,7 @@ export const FormViewSchema = lazySchema(() => strictObject({
'`form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' +
'nothing read it: a related list inside a form sorts by its own list view\'s `sort`. ' +
'Delete the key and set the sort on the related list view instead. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Public form sharing configuration */
@@ -2615,7 +2615,7 @@ export const FormViewSchema = lazySchema(() => strictObject({
'renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. ' +
'Delete the key. The form renderer emits its own semantic markup; report gaps as ' +
'renderer issues rather than per-view attribute overrides. ' +
- 'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
}).superRefine((view, ctx) => {
// `section.pane` is split-only vocabulary. On any other form type it would