diff --git a/.changeset/25767-retire-16x-retirement-kits.md b/.changeset/25767-retire-16x-retirement-kits.md new file mode 100644 index 00000000000..ba12561c794 --- /dev/null +++ b/.changeset/25767-retire-16x-retirement-kits.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +'@objectstack/metadata-core': patch +--- + +feat(spec)!: retirement kits whose conversion retired at or below protocol 16.x leave the tree; the strict unknown-key refusal names the protocol upgrade guide + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + +**BREAKING** for a source or a stored `sys_metadata` row still carrying a key whose ≤16.x kit aged out. + +- **The rule (ADR-0087, amended 2026-10-26, #25767 ruling D2 = A as scoped by ruling 6434038284).** A retirement kit — the `retiredKey()` tombstone and its D2 conversion with fixtures — lives through the protocol major after the one it retired in and is removed when the next major is cut. At 18 that removes the kits whose conversion retired at or below 16.x; 19 repeats the move one major on. The kit ages out; the migration chain's step stays. +- **Before.** Such a key met a `retiredKey()` tombstone: the parse refused it with the key's own prescription in the error, `os migrate meta --from 16` listed and applied the mechanical edit, and a stored row in that shape was rewritten at read by the ≤16.x conversions. +- **Now.** Where the tombstone sat in a strict container, the key is gone from the shape, so a `strictObject` refuses it as an unknown key. The refusal carries one more sentence, after its fix channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, exported with `PROTOCOL_UPGRADE_GUIDE_URL` from `@objectstack/spec`): it says that keys retired before the previous major are no longer prescribed in the error and names the protocol upgrade guide, `https://objectstack.ai/docs/protocol-upgrade`, where the prescription lives; the CHANGELOG of the release that retired the key carries it too. A tombstone that stays (its container strips unknown keys) ends its error with a sentence that says the edit left `os migrate meta` with the 16.x conversions, to be applied by hand or on a release that still carries the conversion, and links the guide. +- **The chain stays.** `MIGRATION_SUPPORT_FLOOR` stays 16; step 17 and its entry files stay, and `os migrate meta --from 16` still replays 16 → 18, listing every surviving conversion and semantic to-do. Step 17's graduated list drops the ids of the 45 conversions that aged out, so their edits are no longer applied for you. +- **Stored rows.** A `sys_metadata` row still carrying such a spelling is no longer rewritten at read; canonicalize it once with `os migrate meta --stored --apply` on 18.2 or earlier, before moving to 18.3. `app-hidden-to-unpublished` is among the removed conversions, so a pre-split `hidden: true` app row is no longer rewritten to `_unpublished` by any seam, and the artifact door's refusal of it by id is removed as moot (`@objectstack/metadata-core`). +- **What left.** 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0) and their fixtures; 55 tombstones already `[RETIRED]` at `@objectstack/spec@17.0.0` whose container is strict; 61 `[RETIRED]` lines of the authorable surface and their liveness-ledger rows. 58 same-age tombstones whose container strips unknown keys stay, because deleting one would turn a refusal into a silent strip; the four inline-credential refusals are not retirements and are untouched. +- **The deletion gate.** `check:authorable-surface` proof 1 ages a tombstone one major (was two). A deleted `[RETIRED]` line is dated by an exact `RETIRED_KEYS_BY_MAJOR` row or, new, by the previous major's release tag: a line already `[RETIRED]` on the surface at `@objectstack/spec@.0.0` was retired before that cut. The tag is immutable, so neither a row a PR writes nor one it deletes moves the clock; where the tag cannot be read the gate dates by the registry alone and says so. No gate was added. +- **Who moves.** No `examples/**`, `ui/` or `hotcrm` source in this repository writes a removed key (the `ui/` hits for the removed names are the console's own same-named props and type mirrors, not readers of the retired spec keys, per the liveness ledger's measurements). A consumer source still carrying one meets the strict refusal and the guide link at `os validate`. + + diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index c3a51b3745b..5b366ebf16d 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -191,11 +191,11 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 注册中了 `ui/FormView:aria`,protocol 11 的 `flow.node.type` 注册中了任意 `.type`(#4658)。对每个常见叶名,保证早已失效。 *条目同时启动老化时钟(#5898):* 门 (c) 读同一张表来决定这个墓碑的 - `authorable-surface/` 行何时可删(~两个 major),所以你把它写在哪个 major 下,时钟就从那个 - release 数起。比这张表更老的退役是**未申报的、因此不可删的** —— - 没有东西能诚实地给它们定日期(叶匹配曾把 `data/Index:type` 的日期从一个不相干 - 的 `flow.node.type` 定到 major 11,而 baseline 文件自己的 git 历史始于 - 17.0.0-rc.0)。要删那类行,先考证它真实的 major、补条目、在 PR 里写明。 + `authorable-surface/` 行何时可删(一个 major —— ADR-0087 2026-10-26 修订,#25767; + 此前 ~两个),时钟从你写它的那个 major 的 release 数起。更老的退役由第二个来源 + 定日期:上一个 major 的 tag 上已带 `[RETIRED]` 的行可删;tag 不可读时只按表判。 + 两个来源都不认的墓碑**不可删**(叶匹配曾把 `data/Index:type` 定到 major 11); + 要删那类行,先考证真实 major、补条目、在 PR 里写明。 ⚠ 永不加一行你定不了日期的:这里的估计会被之后每个门当成事实读。 - [ ] **`surface` 保持散文 —— 它不再被匹配。** 按作者写元数据的方式写 (`flow.nodes[].outputSchema`),那也是 upgrade guide 打印的。多键 conversion @@ -208,8 +208,8 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 条目,它**一处也拦不住**:`applyConversionsToStoredItem`(钉死)、automation engine 的 flow rehydration、`applyArtifactForwardConversions`。对*改名*它意味着 「没有 alias 窗口,故意的」;对**默认值翻转**,只有确知输入早于翻转的 seam 才可重 - 放,其余按 id 退订 `excludeConversionIds` —— `app-hidden-to-unpublished` 在 artifact - 门即如此。 + 放,其余按 id 退订 `excludeConversionIds` —— `flow-decision-mode-inclusive-explicit` 在 + artifact 门即如此(`app-hidden-to-unpublished` 曾是另一例,随 ≤16.x 套件在 #25767 退役)。 - [ ] **一步 D3 链**,在 `packages/spec/src/migrations/registry.ts.template`(`registry.ts` 由 `gen:migration-registry` 从它与 `entries/` 整文件生成并被 git 忽略,在 `registry.ts` 里手改不会 进提交);`conversion.toMajor` **必须等于**该步的 major。**18 步**:conversion 只进 diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index bea57ece8b3..7ad8a8a1fde 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -52,8 +52,6 @@ const result = AIModelConfigSchema.parse(data); | **lifecycle** | `never` | optional | [REMOVED] `agent.lifecycle` was removed in @objectstack/spec 17.7.0 (ADR-0049 enforce-or-remove) — no runtime ever read it: no agent moved through a declared state and no transition was ever refused. Delete the key. A phase of a conversation is a skill with its own `instructions` and `tools`, selected by its `triggerConditions` (ADR-0064); multi-step process orchestration is a Flow (ADR-0019); a record's status transitions are a `state_machine` validation rule on the object (ADR-0020). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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 — 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. ADR-0064 itself still reads `Proposed` and is cloud-owned — that scopes its RUNTIME half (tool resolution, which lives in cloud `service-ai`), not this rejection: the authoring invariant binds you here, and ADR-0109 (Accepted — implemented) is the in-repo record that carries it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **knowledge** | `never` | optional | [REMOVED] `agent.knowledge` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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 978815485c1..4da4f3b7afe 100644 --- a/content/docs/references/ai/skill.mdx +++ b/content/docs/references/ai/skill.mdx @@ -39,7 +39,6 @@ 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 | | **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 (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; `--write` applies the ones it can prove, and you apply the rest 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/analytics.mdx b/content/docs/references/api/analytics.mdx index 950d20d4103..8a2643a9943 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -100,8 +100,6 @@ const result = AnalyticsEndpoint.parse(data); | **limit** | `integer` | optional | Maximum number of rows to return, applied after `order` — a non-negative integer (`0` returns no rows) | | **offset** | `integer` | optional | Number of rows to skip before the first row returned, applied after `order` — a non-negative integer; an `offset` with no `limit` returns every row after it | | **timezone** | `string` | optional | | -| **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. | -| **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. | ### Nested Shape: `AnalyticsQueryRequest.timeDimensions[number]` diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index a7981fc7296..ff2b97e60fd 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -114,12 +114,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -154,7 +152,6 @@ const result = AutomationApiErrorCode.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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -180,8 +177,6 @@ const result = AutomationApiErrorCode.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 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `CreateFlowRequest.protection` @@ -239,12 +234,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -772,12 +765,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -1083,12 +1074,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -1149,12 +1138,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 9168815b6aa..354a24fbbc6 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -85,7 +85,6 @@ const result = AppDefinitionResponseSchema.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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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 | @@ -96,15 +95,8 @@ const result = AppDefinitionResponseSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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. Before the executor landed 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. An endpoint whose work needs CODE still lands here: compute in a flow and register the code as a stack function (`defineStack({ functions })`) or a connector — an app does not mount routes on `http.server`, and the manifest `contributes.routes` key was removed in @objectstack/spec 17 (nothing ever read it; authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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; `--write` applies the ones it can prove, and you apply the rest 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; `--write` applies the ones it can prove, and you apply the rest 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; `--write` applies the ones it can prove, and you apply the rest 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 2327d521811..68a4515e5eb 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -103,7 +103,6 @@ Installed package row whose manifest is the assembled package body | **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -285,7 +284,6 @@ This schema accepts one of the following structures: | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -349,7 +347,6 @@ This schema accepts one of the following structures: | **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index a935b3487b8..a16418000aa 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -159,7 +159,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -206,7 +205,6 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -315,7 +313,6 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -433,7 +430,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -567,7 +563,6 @@ Upgrade package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -662,7 +657,6 @@ Resolve dependencies request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index db943b5a160..89fff10f3fd 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1872,11 +1872,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `GetUiViewResponse.form` @@ -1902,12 +1897,10 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `GetUiViewResponse.listViews[string]` @@ -1958,11 +1951,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `GetUiViewResponse.formViews[string]` @@ -1988,12 +1976,10 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `GetUiViewResponse.protection` @@ -2105,7 +2091,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 1fd84cef370..4a946f656a2 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -249,7 +249,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) | | **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. 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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. 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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version | | **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. | @@ -297,7 +297,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) | | **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. 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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. 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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version | | **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. | diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index 77a6f5bae10..27f65897c0e 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -119,7 +119,6 @@ const result = FlowRegionSchema.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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -182,7 +181,6 @@ const result = FlowRegionSchema.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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -231,7 +229,6 @@ 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 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -270,7 +267,6 @@ 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 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 13ddfba3c41..208077b5660 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -35,12 +35,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -75,7 +73,6 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -101,8 +98,6 @@ const result = FlowSchema.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 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Flow.protection` @@ -146,7 +141,6 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -173,8 +167,6 @@ const result = FlowSchema.parse(data); | **eventType** | `Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>` | ✅ | What kind of event resumes the execution | | **timerDuration** | `string` | optional | ISO 8601 duration (e.g., "PT1H") or wait time for timer events | | **signalName** | `string` | optional | Named signal or webhook event to wait for | -| **timeoutMs** | `never` | optional | [REMOVED] `waitEventConfig.timeoutMs` was removed in @objectstack/spec 17. It documented a timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its only reader treated it as the timer DURATION when `timerDuration` was absent, so use `timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **onTimeout** | `never` | optional | [REMOVED] `waitEventConfig.onTimeout` was removed in @objectstack/spec 17. 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `FlowNode.boundaryConfig` @@ -257,12 +249,10 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index b45f61f391d..65dd8a5fba2 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -167,11 +167,6 @@ 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: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated against the live flow variables, or a literal written as it is — a `{…}` template token is refused | | **outputVariable** | `string` | optional | Flow variable the function's return value is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | -| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 — 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`; `--write` applies the ones it can prove, and the stub and marker values are removed. | -| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 — 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. For templated content use a `notify` node and name an email template (`email_template`) in its own `template` key, with `templateData` filling its placeholders: the email and in-app inbox channels render that template in each recipient's locale. Otherwise the node carries inline `title`/`message`. The messaging service's `sys_notification_template` rows are read only by the `email` and `sms` channels, `email` only when the node names no template. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **variables** | `never` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **script** | `never` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 0597c0bee21..2f1bffa18dd 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -108,7 +108,6 @@ 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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 9a5f7f9556d..5694cab1198 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -67,8 +67,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| '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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -276,7 +274,6 @@ const result = ApiMethod.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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -308,8 +305,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| '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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Object.fieldGroups[number]` @@ -416,11 +411,6 @@ const result = ApiMethod.parse(data); | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `Object.enable` @@ -495,7 +485,6 @@ const result = ApiMethod.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest 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. | @@ -511,8 +500,6 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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". | @@ -648,7 +635,6 @@ const result = ApiMethod.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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -680,8 +666,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| '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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 8566a20c5e7..29ada7d1831 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -194,7 +194,7 @@ const result = ConnectorSchema.parse(data); | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | @@ -491,7 +491,7 @@ Connector type | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 04180596719..2c6f02925b9 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -59,7 +59,6 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index eeb89d6a322..9179ae0ded7 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -328,7 +328,6 @@ const result = MetadataBulkResultSchema.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest 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. | @@ -344,8 +343,6 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index da0ea3229a6..f25f1f83e3e 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -214,7 +214,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -313,7 +312,6 @@ Installed package with runtime lifecycle state | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index bc879c94058..ccf7054e6c4 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -174,7 +174,6 @@ const result = AdminScopeSchema.parse(data); | **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. | | **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. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `PermissionSet.adminScope` diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index cbd396b2c8c..99ab6568045 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -254,7 +254,6 @@ The membership key name: policies read the set as `current_user.KEY` | **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. | | **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. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/shared/mapping.mdx b/content/docs/references/shared/mapping.mdx index ca355d829f9..d109579ad50 100644 --- a/content/docs/references/shared/mapping.mdx +++ b/content/docs/references/shared/mapping.mdx @@ -76,7 +76,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 (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. 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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (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. 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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **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 fd613241dd3..48e785108dc 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -68,7 +68,7 @@ const result = BookSchema.parse(data); | :--- | :--- | :--- | :--- | | **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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **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) | @@ -116,7 +116,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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (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. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **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 884b58f83d9..0368ea63d91 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -120,7 +120,6 @@ 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 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -177,7 +176,6 @@ 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 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 110c79bf40e..413597d0e3c 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -109,7 +109,6 @@ Add a new field to an existing object | **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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -540,7 +539,6 @@ Add a new field to an existing object | **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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 28c713f6126..faf96b23b22 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -42,7 +42,6 @@ const result = ActionSchema.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest 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. | @@ -58,8 +57,6 @@ const result = ActionSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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 506590c5e57..0c6db4aceb2 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -68,7 +68,6 @@ 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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 | @@ -79,15 +78,8 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 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 (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; `--write` applies the ones it can prove, and you apply the rest 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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. Before the executor landed 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. An endpoint whose work needs CODE still lands here: compute in a flow and register the code as a stack function (`defineStack({ functions })`) or a connector — an app does not mount routes on `http.server`, and the manifest `contributes.routes` key was removed in @objectstack/spec 17 (nothing ever read it; authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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; `--write` applies the ones it can prove, and you apply the rest 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; `--write` applies the ones it can prove, and you apply the rest 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; `--write` applies the ones it can prove, and you apply the rest 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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 68d7edcdbea..96b2898cf2d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -391,9 +391,6 @@ const result = ActionButtonPropsSchema.parse(data); | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (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 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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `ElementRecordPickerProps.aria` @@ -1467,9 +1464,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **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 (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; `--write` applies the ones it can prove, and you apply the rest 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **footer** | `any[]` | optional | Card footer components (slot) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -1503,7 +1498,6 @@ View filter rule | :--- | :--- | :--- | :--- | | **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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **breadcrumb** | `never` | optional | [REMOVED] `page:header` property `breadcrumb` was removed in @objectstack/spec 17 (ADR-0087 D2) — no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the navigation trail is drawn once, by the app shell's header. Delete the key, whether it was `true` or `false`; the shell's trail is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **actions** | `string[]` | optional | Action IDs the header draws, resolved against the actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` (or `record_more`) in its `locations` but is not named here is not drawn, and omitting the key draws none of the object's actions. A named action that declares neither location renders nowhere. The host's own Edit / Share / Delete chrome is separate from this list. | | **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | @@ -1531,7 +1525,6 @@ View filter rule | 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 (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; `--write` applies the ones it can prove, and you apply the rest 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; … }[]` | ✅ | | @@ -1676,7 +1669,6 @@ View filter rule | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sections** | `{ name?: string; label?: string \| Record; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor?, count? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). `count: { field }` shows how many values a multi-value field holds in the heading, through its `{{ count }}` hole. | | **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 12ac9c76bca..cccd1cf17d4 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -40,8 +40,6 @@ const result = DashboardSchema.parse(data); | **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 | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | -| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | @@ -71,9 +69,6 @@ const result = DashboardSchema.parse(data); | **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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionType** | `never` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionIcon** | `never` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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) | @@ -83,8 +78,6 @@ const result = DashboardSchema.parse(data); | **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 (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. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (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. 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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Dashboard.dateRange` @@ -176,9 +169,6 @@ 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionType** | `never` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionIcon** | `never` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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) | @@ -188,8 +178,6 @@ 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 (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. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (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. 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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Allowed Values: `DashboardWidget.type` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 3f28ff734f5..ac3fe141787 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -406,12 +406,10 @@ Form-view select option — the object-field option shape minus the per-option ` | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `FormView.data[provider='object']` @@ -813,11 +811,6 @@ Map view configuration | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ListView.data[provider='object']` @@ -1199,11 +1192,6 @@ View filter rule | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `ObjectListView.data[provider='object']` @@ -1776,11 +1764,6 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `View.form` @@ -1806,12 +1789,10 @@ Tab configuration for multi-tab view interface | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `View.listViews[string]` @@ -1862,11 +1843,6 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `View.formViews[string]` @@ -1892,12 +1868,10 @@ Tab configuration for multi-tab view interface | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `View.protection` @@ -2072,11 +2046,6 @@ This schema accepts one of the following structures: | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItem[viewKind='list'].protection` @@ -2136,12 +2105,10 @@ This schema accepts one of the following structures: | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItem[viewKind='form'].protection` @@ -2251,11 +2218,6 @@ This schema accepts one of the following structures: | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | 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 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItemWire[viewKind='list'].protection` @@ -2327,12 +2289,10 @@ This schema accepts one of the following structures: | **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?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItemWire[viewKind='form'].protection` diff --git a/docs/adr/0072-reference-scope-and-resolvability.md b/docs/adr/0072-reference-scope-and-resolvability.md index db1654455f5..601adf8670e 100644 --- a/docs/adr/0072-reference-scope-and-resolvability.md +++ b/docs/adr/0072-reference-scope-and-resolvability.md @@ -65,7 +65,7 @@ Two consequences for the picker model: - **Token shape is surface-specific** (flow = bare-or-dotted; everything else = dotted). The picker must know the surface. - There is **one canonical root allowlist** the validator should reuse, instead of a hand-rolled one. -**`outputSchema` today** (`packages/spec/src/automation/flow.zod.ts#outputSchema`): `Record`, **optional**, carries **no `objectName`**, is **not consumed by the engine**, and is read by the designer only for connector/Tool display. So it cannot, today, tell the picker *which object* an `object`-typed output holds. The reliable signal is the **producing node's `config.objectName`** (required on CRUD nodes) found by walking the graph back — exactly the walk the objectui picker already does (`@object-ui/app-shell` · `inspectors/flow-scope.ts` `flowAncestors`). +**`outputSchema` today** (`packages/spec/src/automation/flow.zod.ts`; removed at 17.0.0 and, since #25767, refused there as an unknown key): `Record`, **optional**, carries **no `objectName`**, is **not consumed by the engine**, and is read by the designer only for connector/Tool display. So it cannot, today, tell the picker *which object* an `object`-typed output holds. The reliable signal is the **producing node's `config.objectName`** (required on CRUD nodes) found by walking the graph back — exactly the walk the objectui picker already does (`@object-ui/app-shell` · `inspectors/flow-scope.ts` `flowAncestors`). ### What already shipped (Slice 0/1, objectui #1934) diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index cb57d5641be..4b77bc91afb 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -1,6 +1,6 @@ # ADR-0087: Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-verifiable upgrades -**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-27, #26220, the maintainer's decision A: D8's predicate 4 gains an index-signature limb, and D9's predicate 1 maps a `src/index.tsx` source entry; see the 2026-10-27 amendment notes under D8's predicate 4 and under D9's unmappable-entry paragraph) +**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-27, #26220, the maintainer's decision A: D8's predicate 4 gains an index-signature limb, and D9's predicate 1 maps a `src/index.tsx` source entry; see the 2026-10-27 amendment notes under D8's predicate 4 and under D9's unmappable-entry paragraph) · **Amended** (2026-10-26, #25767 ruling D2 = A, scoped by ruling 6434038284 of 2026-10-27 — a retirement kit (the `retiredKey()` tombstone and its D2 conversion with fixtures) lives one protocol major after the one it retired in and is then removed; a removed key is refused as a strict unknown key whose refusal names the protocol upgrade guide; the kit ages out, the chain's step stays, and the support floor stays 16; see the 2026-10-26 addendum, which also amends the "never deleted" wording in D2 and the conversion "forever" in the 2026-07-31 and 2026-09-13 addenda) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0059](./0059-third-party-backward-compatibility-gates.md) (layered backward-compat gates — this ADR is its consumer-facing sequel), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-unenforced metadata — the un-checked `engines.protocol` is exactly this class), [ADR-0025](./0025-plugin-package-distribution.md) (§3.2 `engines.protocol` / `engines.platform` compatibility ranges, §3.10 #3 protocol-first check order), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the authoring population this ADR designs for), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs), AGENTS.md Prime Directive #12 (contract-first, no consumer-side dialect fallbacks — §"Why the conversion layer does not violate PD #12" draws the line) **Consumers**: `@objectstack/spec` (protocol version constant, conversion layer, deprecation/change registries), `@objectstack/cli` (`validate`, `doctor`, `migrate meta`), the runtime metadata loader (handshake + conversion), `@objectstack/mcp` (the AI-native change/migration surface), `@objectstack/create-objectstack`, the Release workflow, and every third-party consumer — whose maintainer is assumed to be an **AI agent** @@ -144,7 +144,16 @@ new shape, and the *fleet* upgrades incrementally. accepts N−1 shapes at load), then retired from the load path in N+1 — but it is **never deleted**: a retired conversion graduates into the D3 migration chain as that major's mechanical step, so the transform history is permanent even though the - runtime only ever carries one major of it. The window is deliberately **not** a + runtime only ever carries one major of it. — **amended 2026-10-26 (#25767, D2 = A; + scope per ruling 6434038284):** "never deleted" is withdrawn for the CONVERSION, not + for the step. A retired conversion and its tombstone (the kit) are carried for **one + further major** (the kit retired in N is in the tree through N+1 and leaves when + N+2 is cut); after that the key is refused as a strict unknown key, the refusal + names the protocol upgrade guide, and the edit's prescription lives in the guide, + the CHANGELOG and git. The chain's step for that major — its semantic entries and + retired-key / retired-def rows — stays replayable down to the support floor (D3), + so `--from N` keeps working; only the aged-out edit is no longer applied for you + — see the 2026-10-26 addendum. The window is deliberately **not** a notification device — a warning window is worthless to a consumer that arrives three majors later. It is a **fleet-compatibility window**: the period both shapes are loadable, which is what lets a multi-app fleet upgrade incrementally and roll @@ -550,7 +559,13 @@ diverged (#3903). This addendum extends the contract to data at rest: no author to teach, and refusing its historical shape would only break data that once worked. D3 keeps every conversion forever precisely so any past major replays forward — a stored row is the perpetual "consumer arriving - late", and the read path is its chain. + late", and the read path is its chain. (**Amended 2026-10-26**, #25767: the + CONVERSION's "forever" is now one major past its retirement. A stored row + carrying a spelling whose conversion aged out is no longer rewritten at read; it + is canonicalized once, with `os migrate meta --stored --apply` on a release that + still carries the conversion, before the deployment moves past it — the + 2026-08-01 finish line is the mechanism, and for those rows it is the required + one, not an optional one.) - **Flows canonicalize at their own seam.** `AutomationEngine.registerFlow` (the rehydration seam PD #12 names) now also replays retired entries; the generic metadata seams deliberately skip `type: 'flow'` because flow-node @@ -1210,7 +1225,9 @@ unconditional strip would start deleting legal metadata the day the keys return. manifest declares (`engines.protocol`, ADR-0025) and the `@objectstack/spec` version the process actually runs. `floor < runtime` replays the FULL chain, retired entries included, before the strict parse — the artifact is the - "consumer arriving late" D3 keeps every conversion forever for. ~~`floor >= + "consumer arriving late" D3 keeps every conversion forever for (**amended + 2026-10-26**, #25767: for one major, after which the artifact's retired keys meet + the strict refusal and the guide link, exactly as an authored source does). ~~`floor >= runtime` replays nothing: the artifact claims the current or a newer surface, and the strict parse, tombstones included, stays the authority.~~ — **amended 2026-09-30** — `floor >= runtime` replays only the retired entries whose @@ -1325,3 +1342,76 @@ within-line retirements (17.1 → 17.2), which are the case that created it. > still answers first: the protocol constant moves to M when M's prerelease > opens, so a `^M` floor passes it while the package label still names the > last release of M-1, and only then does the window decide. + +## Addendum (2026-10-26) — retirement kits age out after one major (#25767, ruling D2 = A) + +**The ruling, as scoped.** A *retirement kit* — the `retiredKey()` tombstone and its D2 +conversion with fixtures — lives through the protocol major **after** the one it +retired in, and is removed when the next major is cut. At major 18 that removes the +kits whose conversion retired at or below 16.x; 19 repeats the move one major on. The +maintainer's ruling of 2026-10-27 (#25767 comment 6434038284, 「同意」) kept that part +and **withdrew** the original ruling's reach over the migration chain: the kit ages +out, the step stays. The "never deleted" wording in D2 and the conversion "forever" +in the 2026-07-31 and 2026-09-13 addenda are amended in place above; D3's "permanent, +ordered chain", the "forever artifact" consequence and the 2026-08-01 addendum stand +as written. No gate was added: the existing deletion gate's clock was shortened and +given a second, registry-independent source. + +**What a removed key meets.** The key leaves the shape entirely, so a `strictObject` +refuses it as an unknown key. The refusal carries one more sentence, after its fix +channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, +`packages/spec/src/shared/protocol-upgrade-guide.ts`, keeping the #5955 order): it +says that keys retired before the previous major are no longer prescribed in the +error and names the protocol upgrade guide +(`https://objectstack.ai/docs/protocol-upgrade`); the guide and the CHANGELOG carry +the prescription from then on. A tombstone that stays (below) but whose conversion +left says so in its own sentence: the edit left `os migrate meta` with the 16.x +conversions, so it is applied by hand or by a release that still carries it. + +**The chain is not shortened.** `MIGRATION_SUPPORT_FLOOR` stays **16** (#19056): step +17 and its entry files stay, and `os migrate meta --from 16` keeps replaying 16 → 18. +Each step still lists every surviving conversion it graduated — the 12 conversions +with `toMajor: 17` and `retiredAfter: 17.7.0` stay listed under step 17 — and its +semantic to-dos; only the edits of the conversions that aged out are no longer +applied by the replay. Moving the floor is its own release-policy decision (D3, "revisitable +per major"), not a by-product of kit aging. + +**What was removed at 18 (this amendment's own diff).** + +- 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0), + with their fixture pairs and the comments that named them. No semantic entry + linked one of them, but step 17's graduated list named the 45 at 16.1.0; those + ids left the list with their conversions (the step and its entries stay). +- 55 tombstones whose key was already `[RETIRED]` at `@objectstack/spec@17.0.0` and + whose container is `strictObject` — the ones whose deletion yields a refusal. 58 + tombstones in the same age class sit in containers that **strip** unknown keys + (`z.object` / `.strip()`): deleting one of those would downgrade a refusal to a + silent strip, the very defect PD #12 names, so they stay until their container is + made strict. The four inline-credential refusals (`refusedInlineCredentialKey`) + are security refusals, not retirements, and are untouched. +- 61 `[RETIRED]` lines of the authorable surface. + +**The deletion gate (`check:authorable-surface`, proof 1).** `TOMBSTONE_AGE_MAJORS` is +1. A deleted `[RETIRED]` line is dated either by an exact `RETIRED_KEYS_BY_MAJOR` row +(registered at a major ≥ 1 behind the current one) or — new — by the previous major's +release tag: a line already `[RETIRED]` on the authorable surface at +`@objectstack/spec@.0.0` was retired before that cut, so its kit +has lived its one major. The tag is immutable and lives on origin, so neither a row +a PR writes nor one it deletes moves the clock. The rows alone do not suffice: 46 of +the 61 deleted lines predate the table and no row names them. Where the tag cannot +be read (offline), the gate falls back to the registry alone and says so — strictly +more refusals, never fewer. + +**Stored rows.** A `sys_metadata` row still carrying a spelling whose conversion aged +out is no longer rewritten at read. Canonicalize it once, `os migrate meta --stored +--apply` on 18.2 or earlier, before moving the deployment to 18.3; the ruling +puts that one-time notice in 18.2's release notes. One consequence is specific: `app-hidden-to-unpublished` +(ADR-0045 amendment 2026-08-09) is among the removed conversions, so a pre-split +`hidden: true` app row is no longer rewritten to `_unpublished` by any seam — the +artifact door's refusal of it by id is moot and was removed. + +**Residuals.** The clock reads the release tag, so a repository whose checkout cannot +fetch `refs/tags/@objectstack/spec@.0.0` dates by the registry only; CI's shallow +jobs fetch the one tag, `--depth=1`, exactly as they fetch `origin/main`. The 58 kept +strip-container tombstones are a list in the #25767 report, to be retired container +by container as each is made strict. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 9473b0f21ff..31543c1049f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -8,7 +8,7 @@ This file is a pointer. The guide is generated from the ADR-0087 registries and - Protocol 16 → 17: https://objectstack.ai/docs/protocol-upgrade/17 - Protocol 17 → 18: https://objectstack.ai/docs/protocol-upgrade/18 -Protocol N's page is `https://objectstack.ai/docs/protocol-upgrade/N`, so a link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. A hop leaves the site only when the migration chain's support floor rises past it (ADR-0087 D3). +Protocol N's page is `https://objectstack.ai/docs/protocol-upgrade/N`, so a link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. A hop leaves the site only when the migration chain's support floor rises past it (ADR-0087 D3). A key whose retirement kit has aged out (ADR-0087's 2026-10-26 amendment, #25767) is refused by the schema's strict parse with a link here; its prescription lives in the guide and the CHANGELOG of the release that retired it. **The copy a release shipped:** every `@objectstack/spec` release after 17.7.0 carries `protocol-upgrade-guide.md` in the package (`node_modules/@objectstack/spec/protocol-upgrade-guide.md`) and attached to its `@objectstack/spec@VERSION` GitHub Release. diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index 2b9622ad166..39ede44529e 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -9,7 +9,7 @@ "title": "agent / tool / skill metadata kinds author, persist and list over the meta surface — retired keys reject with prescriptions, the closed agent kind has no runtime write door", "since": "v15", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -90,9 +90,9 @@ ], "traps": ["stale-dist"], "source": [ - "packages/spec/src/ai/agent.zod.ts#tenantId (requireds, aliases, retiredKey tools/knowledge, visibility/tenantId guidance)", + "packages/spec/src/ai/agent.zod.ts#tenantId (requireds, aliases, retiredKey lifecycle, visibility/tenantId guidance; `tools`/`knowledge` aged out on #25767 — strict unknown keys naming the upgrade guide)", "packages/spec/src/ai/tool.zod.ts#TOOL_RETIRED_KEY_GUIDANCE (.strict() + TOOL_RETIRED_KEY_GUIDANCE; READ-ONLY PROJECTION note)", - "packages/spec/src/ai/skill.zod.ts#triggerPhrases (requireds, retiredKey triggerPhrases, permissions/trigger guidance #5013)", + "packages/spec/src/ai/skill.zod.ts#SkillSchema (requireds, permissions/trigger guidance #5013; `triggerPhrases` aged out on #25767 — a strict unknown key naming the upgrade guide)", "packages/spec/src/kernel/metadata-type-schemas.ts#registered (agent/tool/skill registered with schemas)", "packages/spec/src/kernel/metadata-plugin.zod.ts#allowRuntimeCreate (registry rows: agent allowRuntimeCreate:false ADR-0063 §2; tool/skill true; file patterns)", "packages/runtime/src/route-ledger.ts (GET /meta, GET /meta/types, GET/PUT /meta/:type/:name)", @@ -119,6 +119,12 @@ "date": "2026-10-19", "change": "steps[1] and acceptance[3].verify corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.agent-tool-skill-metadata-roundtrip; wave anchor #24579 §3). Compiling an app-package agent now warns agent-authoring-withdrawn (packages/lint/src/validate-ai-agent-authoring.ts, warning tier) while the meta door still serves it, and the package-shipped agent PUT answers NOT_OVERRIDABLE where revision 5's history cited only the runtime-name NOT_CREATABLE. Re-derived at base 9a7838edf5. No clause changed", "ref": "#24649" + }, + { + "revision": 7, + "date": "2026-10-26", + "change": "source anchors re-pointed after the ≤16.x retirement kits aged out (#25767): `skill.triggerPhrases` and `agent.tools`/`agent.knowledge` are no longer declared — their probes are strict unknown keys whose refusal names the protocol upgrade guide, not tombstones; the skill anchor now cites SkillSchema and the agent line names the surviving `lifecycle` tombstone. The negative clause holds as written: a probe accepted with a clean parse is still a FAIL.", + "ref": "#25767" } ] }, diff --git a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts index 801592cecb3..c6f44db17f4 100644 --- a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts +++ b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts @@ -288,7 +288,7 @@ beforeAll(async () => { // engine instead of failing. unreachableDir = mkdtempSync(join(tmpdir(), 'os-24899-unreachable-')); writeArtifact(unreachableDir, [ - { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', user: 'os24899' }, autoConnect: true }, + { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', username: 'os24899' }, autoConnect: true }, ]); unreachableRun = await runCommand(unreachableDir, join(unreachableDir, 'main.db'), ['--apply', '--yes', '--json']); }, HOOK_TIMEOUT_MS); diff --git a/packages/cli/src/commands/migrate/meta.report-order.test.ts b/packages/cli/src/commands/migrate/meta.report-order.test.ts index 407fda198dc..6738b335429 100644 --- a/packages/cli/src/commands/migrate/meta.report-order.test.ts +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -515,7 +515,9 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m // No authored link crosses a hop yet, though `SemanticMigration.conversionIds` // allows one, so this pin adds it: a protocol-18 entry made to judge a // protocol-17 conversion. Everything else is the chain's own result. - const earlier = 'action-execute-to-target'; + // (`action-execute-to-target`, the conversion this replayed until #25767, + // aged out with its 16.x kit; this one is a step-17 member that stays.) + const earlier = 'sharing-rule-access-level-full-to-edit'; const conversion = ALL_CONVERSIONS.find((c) => c.id === earlier)!; const judgeId = DECISION_JUDGE; const { result, lines } = run( diff --git a/packages/cli/test/migrate-meta-protocol-range.test.ts b/packages/cli/test/migrate-meta-protocol-range.test.ts index 8635680412a..80023a8812c 100644 --- a/packages/cli/test/migrate-meta-protocol-range.test.ts +++ b/packages/cli/test/migrate-meta-protocol-range.test.ts @@ -95,10 +95,12 @@ const RUN_TIMEOUT = 120_000; const SHAPES = [ { from: 16, - member: "views: [{ object: 'rg_item', list: { type: 'grid', columns: ['title'], striped: true } }]", - converted: ['view-list-passthrough-keys-removed'], - before: ', striped: true', - after: '', + // A step-17 conversion that survives the 16.x kits' age-out (#25767): the + // row used to ride `view-list-passthrough-keys-removed`, which left with them. + member: "sharingRules: [{ name: 'rg_share', type: 'criteria', object: 'rg_item', accessLevel: 'full', condition: 'record.title != null', sharedWith: { type: 'business_unit', value: 'bu_sales' } }]", + converted: ['sharing-rule-access-level-full-to-edit'], + before: "accessLevel: 'full'", + after: "accessLevel: 'edit'", }, { from: 17, member: "dashboards: [{ name: 'ops', label: 'Ops', widgets: [], refreshInterval: 300 }]", converted: ['dashboard-refresh-interval-to-refresh-interval-seconds'], before: 'refreshInterval: 300', after: 'refreshIntervalSeconds: 300' }, ]; diff --git a/packages/cli/test/migrate-meta-write.test.ts b/packages/cli/test/migrate-meta-write.test.ts index 14bbf710e9f..e23638aa19d 100644 --- a/packages/cli/test/migrate-meta-write.test.ts +++ b/packages/cli/test/migrate-meta-write.test.ts @@ -168,7 +168,7 @@ beforeAll(() => { writeFileSync(join(root, 'node_modules', 'fake-kit', 'package.json'), JSON.stringify({ name: 'fake-kit', main: 'index.js' })); writeFileSync( join(root, 'node_modules', 'fake-kit', 'index.js'), - "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], striped: true } };\n", + "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], filterableFields: ['title'] } };\n", ); }); @@ -192,8 +192,6 @@ import { allFlows } from './src/flows/index.js'; export default defineStack({ manifest: { id: 'com.example.write', name: 'Write fixture', version: '1.0.0', type: 'app' }, objects: [TicketObject], - // The document store, spelled the old way. - datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: {} }], dashboards: Object.values(dashboards), views: Object.values(views), agents: [SupportAgent], @@ -212,6 +210,11 @@ export const OpsDashboard = Dashboard.create({ refreshInterval: 300, // seconds }); `, + // [#25767] The three `compareTo` spellings below were rewritten by + // `dashboard-widget-compareto-converged`, a 16.x conversion that aged out with + // its kit: `--write` leaves them as written now, and the schema still refuses + // them — the control that an aged-out spelling is not silently rewritten by + // a conversion that no longer exists, though step 17 itself still replays. 'src/dashboards/kpi.dashboard.ts': `import { Dashboard } from '@objectstack/spec/ui'; export const KpiDashboard = Dashboard.create({ @@ -233,9 +236,8 @@ export const TicketView = defineView({ list: { type: 'grid', columns: ['title'], - // Zebra rows. - striped: true, - bordered: false, // no borders + // Filter-panel whitelist. + filterableFields: ['title'], }, }); `, @@ -246,9 +248,10 @@ export const SupportAgent = defineAgent({ label: 'Support', role: 'Support assistant', instructions: 'Help customers.', - knowledge: { - sources: ['faq'], - indexes: ['docs'], + lifecycle: { + id: 'support', + initial: 'open', + states: { open: {} }, }, }); `, @@ -262,7 +265,6 @@ export const EscalateFlow = defineFlow({ name: 'escalate', label: 'Escalate', type: 'autolaunched', - active: false, nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'done', type: 'end', label: 'Done' }, @@ -276,7 +278,7 @@ export const TicketObject = ObjectSchema.create({ name: 'wr_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } }, - tenancy: { enabled: true, organizationField: 'organization_id' }, + externalSharingModel: 'private', }); `, }; @@ -286,42 +288,22 @@ function expectedAfterWrite(): Record { const p = PROJECT; return { ...p, - 'objectstack.config.ts': edit(p['objectstack.config.ts']!, "driver: 'mongo'", "driver: 'mongodb'"), 'src/dashboards/ops.dashboard.ts': edit(p['src/dashboards/ops.dashboard.ts']!, 'refreshInterval: 300,', 'refreshIntervalSeconds: 300,'), - 'src/dashboards/kpi.dashboard.ts': edit( - edit(p['src/dashboards/kpi.dashboard.ts']!, "compareTo: 'previousPeriod'", "compareTo: { kind: 'previousPeriod' }"), - "compareTo: { offset: '1y' }", - "compareTo: { kind: 'previousYear' }", - ), - 'src/views/ticket.view.ts': edit( - edit(p['src/views/ticket.view.ts']!, ' striped: true,\n', ''), - ' bordered: false, // no borders\n', - '', - ), + 'src/views/ticket.view.ts': edit(p['src/views/ticket.view.ts']!, " filterableFields: ['title'],\n", ''), 'src/agents/support.agent.ts': edit( p['src/agents/support.agent.ts']!, - " knowledge: {\n sources: ['faq'],\n indexes: ['docs'],\n },\n", + " lifecycle: {\n id: 'support',\n initial: 'open',\n states: { open: {} },\n },\n", '', ), - 'src/flows/escalate.flow.ts': edit(p['src/flows/escalate.flow.ts']!, ' active: false,\n', ''), - 'src/objects/ticket.object.ts': edit( - p['src/objects/ticket.object.ts']!, - "tenancy: { enabled: true, organizationField: 'organization_id' }", - 'tenancy: { enabled: true }', - ), + 'src/objects/ticket.object.ts': edit(p['src/objects/ticket.object.ts']!, " externalSharingModel: 'private',\n", ''), }; } const EXPECTED_SITES = [ - 'agents[0].knowledge (agent-knowledge-removed)', - 'dashboards[0].widgets[0].compareTo (dashboard-widget-compareto-converged)', - 'dashboards[0].widgets[1].compareTo (dashboard-widget-compareto-converged)', + 'agents[0].lifecycle (agent-lifecycle-removed)', 'dashboards[1].refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds)', - 'datasources[0].driver (datasource-driver-mongo-to-mongodb)', - 'flows[0].active (flow-inert-keys-removed)', - 'objects[0].tenancy.organizationField (object-tenancy-organization-field-removed)', - 'views[0].list.bordered (view-list-passthrough-keys-removed)', - 'views[0].list.striped (view-list-passthrough-keys-removed)', + 'objects[0].externalSharingModel (object-external-sharing-model-removed)', + 'views[0].list.filterableFields (view-list-filterable-fields-removed)', ]; describe('os migrate meta --write over per-artifact modules', () => { @@ -352,11 +334,8 @@ describe('os migrate meta --write over per-artifact modules', () => { // Byte-for-byte, every file: the barrels and `index.ts` files included. expect(snapshot(dir)).toEqual(expectedAfterWrite()); expect(firstJson.write.files.map((f: any) => f.file).sort()).toEqual([ - 'objectstack.config.ts', 'src/agents/support.agent.ts', - 'src/dashboards/kpi.dashboard.ts', 'src/dashboards/ops.dashboard.ts', - 'src/flows/escalate.flow.ts', 'src/objects/ticket.object.ts', 'src/views/ticket.view.ts', ]); @@ -364,10 +343,10 @@ describe('os migrate meta --write over per-artifact modules', () => { it('reports each written site at its file and line as read', () => { const at = Object.fromEntries(firstJson.write.written.map((w: any) => [w.path, `${w.file}:${w.line}`])); - expect(at['datasources[0].driver']).toBe('objectstack.config.ts:12'); + expect(at['agents[0].lifecycle']).toBe('src/agents/support.agent.ts:8'); expect(at['dashboards[1].refreshIntervalSeconds']).toBe('src/dashboards/ops.dashboard.ts:7'); - expect(at['views[0].list.striped']).toBe('src/views/ticket.view.ts:9'); - expect(at['views[0].list.bordered']).toBe('src/views/ticket.view.ts:10'); + expect(at['objects[0].externalSharingModel']).toBe('src/objects/ticket.object.ts:7'); + expect(at['views[0].list.filterableFields']).toBe('src/views/ticket.view.ts:9'); }); it('writes the applied set and nothing else: every site it reports is an applied entry', () => { @@ -380,9 +359,17 @@ describe('os migrate meta --write over per-artifact modules', () => { // Relative to the dry run of the same build, not a pinned listing: which // notices the default list carries is the chain's business, not --write's. expect(firstJson.todos.map((t: any) => t.id)).toEqual(dry.todos.map((t: any) => t.id)); - // The `compareTo` arm the conversion declines has no mechanical change, so - // its bytes stay — and the schema still refuses it, as before the write. - expect(readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8')).toContain("compareTo: { offset: '7d' }"); + }); + + it('leaves an aged-out spelling as written (#25767): no step rewrites it, and the schema still refuses it', () => { + // The `compareTo` spellings rode a 16.x conversion that aged out with its + // kit: nothing is attributed to them, their bytes stay, and the + // stack is still schema-invalid after the write — the refusal, not a + // silent rewrite, is the upgrade channel for a kit one major past its own. + expect(firstJson.applied.map((a: any) => a.conversionId)).not.toContain('dashboard-widget-compareto-converged'); + const kpi = readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8'); + expect(kpi).toContain("compareTo: 'previousPeriod'"); + expect(kpi).toContain("compareTo: { offset: '7d' }"); expect(firstJson.schemaValid).toBe(false); }); @@ -405,7 +392,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--json', '--out', out]); expect(run.exitCode, run.stderr).toBeUndefined(); expect(snapshot(dir)).toEqual(before); - expect(JSON.parse(readFileSync(out, 'utf8')).datasources[0].driver).toBe('mongodb'); + expect(JSON.parse(readFileSync(out, 'utf8')).dashboards[1].refreshIntervalSeconds).toBe(300); }, RUN_TIMEOUT); it('prints the human report with --write, naming no capability it lacks', async () => { @@ -413,7 +400,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--write']); expect(run.exitCode, run.stderr).toBeUndefined(); const group = run.stdout.slice(run.stdout.indexOf('Wrote ')); - expect(group).toMatch(/^Wrote 9 of 9 mechanical change\(s\) into 7 file\(s\):/); + expect(group).toMatch(/^Wrote 4 of 4 mechanical change\(s\) into 4 file\(s\):/); expect(group).toContain('Re-ran the chain over the written sources: no mechanical change remains.'); // The group's own words; the spec's notices above it are the spec's. expect(group).not.toMatch(/automatic/i); @@ -536,17 +523,20 @@ const REFUSALS: Record = { 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec'; import { KitView } from 'fake-kit'; -const MONGO = 'mongo'; -const sharedList = { type: 'grid', columns: ['title'], striped: true }; -const listBase = { bordered: true }; +const START = '10:00Z'; +const sharedList = { type: 'grid', columns: ['title'], filterableFields: ['title'] }; +const listBase = { filterableFields: ['title'] }; function makeAgent(name: string) { - return { name, label: name, role: 'r', instructions: 'i', knowledge: { sources: ['faq'] } }; + return { name, label: name, role: 'r', instructions: 'i', lifecycle: 'draft' }; } export default defineStack({ manifest: { id: 'com.example.refusals', name: 'Refusals', version: '1.0.0', type: 'app' }, - objects: [{ name: 'rf_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], - datasources: [{ name: 'docs', label: 'Docs', driver: MONGO, config: {} }], + objects: [{ + name: 'rf_ticket', label: 'Ticket', + externalSharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' }, starts_at: { type: 'time', label: 'Starts', defaultValue: START } }, + }], views: [ { object: 'rf_ticket', list: sharedList }, { object: 'rf_ticket', list: sharedList }, @@ -554,14 +544,13 @@ export default defineStack({ { object: 'rf_ticket', list: { - type: 'grid', virtualScroll: true, + type: 'grid', filterableFields: ['title'], columns: ['title'], }, }, KitView, ], agents: [makeAgent('helper_agent')], - flows: [{ name: 'plain', label: 'Plain', type: 'autolaunched', active: true, nodes: [], edges: [] }], }); `, }; @@ -581,21 +570,21 @@ describe('os migrate meta --write refuses what it cannot prove, and says why', ( payload.write.manual.find((m: any) => m.path === path)?.kind; it('names the refusal kind of every site it leaves', () => { - expect(kindOf('datasources[0].driver')).toBe('computed'); - expect(kindOf('views[0].list.striped')).toBe('shared'); - expect(kindOf('views[1].list.striped')).toBe('shared'); - expect(kindOf('views[2].list.bordered')).toBe('spread'); - expect(kindOf('views[3].list.virtualScroll')).toBe('layout'); - expect(kindOf('views[4].list.striped')).toBe('outside-project'); - expect(kindOf('agents[0].knowledge')).toBe('helper'); + expect(kindOf('objects[0].fields.starts_at.defaultValue')).toBe('computed'); + expect(kindOf('views[0].list.filterableFields')).toBe('shared'); + expect(kindOf('views[1].list.filterableFields')).toBe('shared'); + expect(kindOf('views[2].list.filterableFields')).toBe('spread'); + expect(kindOf('views[3].list.filterableFields')).toBe('layout'); + expect(kindOf('views[4].list.filterableFields')).toBe('outside-project'); + expect(kindOf('agents[0].lifecycle')).toBe('helper'); expect(payload.write.manual).toHaveLength(7); for (const m of payload.write.manual) expect(m.reason.length).toBeGreaterThan(0); }); it('still writes the site it can prove, beside them — and nothing else', () => { - expect(sites(payload.write.written)).toEqual(['flows[0].active (flow-inert-keys-removed)']); + expect(sites(payload.write.written)).toEqual(['objects[0].externalSharingModel (object-external-sharing-model-removed)']); expect(snapshot(dir)).toEqual({ - 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, ' active: true,', ''), + 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, " externalSharingModel: 'private',\n", ''), }); expect(payload.write.verification.ok).toBe(true); }); diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index 3aee45540cb..ca3aa5a1aa3 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -119,7 +119,10 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = visibleWhen: 'presentation', readonlyWhen: 'presentation', requiredWhen: 'presentation', - conditionalRequired: 'presentation', + // `conditionalRequired` (the pre-17 spelling of `requiredWhen`) was a row + // here while the spec carried its tombstone; the tombstone aged out of + // `FieldSchema` at spec 18.2 (objectstack#25767, ADR-0087 one major on), and + // the test beside this map refuses a classification the spec does not declare. dueLike: 'presentation', // declares a date a deadline (#22227) — read by the renderer's date cells, never by `createColumn`: the column is the date the type already sizes settledWhen: 'presentation', // the deadline's settle predicate (#22227), display only like `visibleWhen` — no DDL reads it conditionalFormatting: 'presentation', // a field's cell formatting rules (#22228), display only — no DDL reads it diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index 6820bda5c15..bbc3c25738b 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -472,12 +472,13 @@ describe('security-owd-alias reaches the rule only through the unparsed doors (# expect(aliasFindings(result.errors)).toEqual(['objects.tier_owd.sharingModel']); }); - it('CONTROL — a pre-D4 stored sibling does not surface: read/read_write fold on rehydration, and the gate diff cancels the rest', () => { - // The stored-row chain replays retired conversions, so `read` / `read_write` - // come back canonical… - expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('public_read'); - expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('public_read_write'); - // …`full` / `public` have no conversion and come back as authored… + it('CONTROL — a pre-D4 stored sibling does not surface: no alias folds on rehydration any more, and the gate diff cancels the rest', () => { + // [#25767] `owd-legacy-read-aliases` was a ≤16.x retirement kit and left + // the registry, so the stored-row chain no longer folds `read` / + // `read_write`: a pre-D4 row comes back as authored, like `full`… + expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('read'); + expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('read_write'); + // …as `full` / `public` always did, having no conversion… const storedFull = applyConversionsToStoredItem('object', owdObject('full')) as AnyRec; expect(storedFull.sharingModel).toBe('full'); // …and even so, a sibling in the gate's universe produces the finding in diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index c90051c1c63..9d4e5d3ae02 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -684,8 +684,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // harness. // // [#22032, pass 2] The field-rule-slot pass joins the object door: every - // field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` / - // `visibleWhen`, with the `parent` gate, the `requiredWhen` null guard and + // field's `requiredWhen` / `readonlyWhen` / `visibleWhen` (and, until its + // tombstone aged out at #25767, `conditionalRequired`), with the `parent` + // gate, the `requiredWhen` null guard and // the reference-traversal refusal — the same sentence of `formulas.mdx`, // and the gap the card measured (a bare `requiredWhen: 'amount > 1'` saved // with a 200). The per-option `visibleWhen` stays fenced. No entry-level diff --git a/packages/lint/src/component-field-specs-liveness.test.ts b/packages/lint/src/component-field-specs-liveness.test.ts index 9051ab60ef7..cb0f0ce2d92 100644 --- a/packages/lint/src/component-field-specs-liveness.test.ts +++ b/packages/lint/src/component-field-specs-liveness.test.ts @@ -88,10 +88,14 @@ describe('COMPONENT_FIELD_SPECS liveness against ComponentPropsMap (#6629)', () // ── 0. the detector, before anything is asserted THROUGH it ───────────── describe('the tombstone detector actually detects (anti-vacuity)', () => { // `element:record_picker` is the incident's own schema, and it carries both - // halves of the discrimination at once: `labelField` live, `displayField` - // and `searchFields` tombstoned by #5775. If any of these four assertions - // goes red the reconciliation below is reporting nothing, whatever colour - // it shows. + // halves of the discrimination at once: `labelField` live, `object` and + // `filter` tombstoned (the flat-binding retirement, 17.x). The #5775 pair + // this block was born on — `displayField` / `searchFields` — was a 16.x + // retirement whose tombstones aged out on #25767 (ADR-0087 D2 = A): both + // keys are now ABSENT from the shape, refused as strict unknown keys, which + // is the other verdict the reconciliation keeps separate. If any of these + // assertions goes red the reconciliation below is reporting nothing, + // whatever colour it shows. const shape = shapeOf(map['element:record_picker']); it('reaches a real object shape through the lazySchema proxy', () => { @@ -99,10 +103,13 @@ describe('COMPONENT_FIELD_SPECS liveness against ComponentPropsMap (#6629)', () // Both spellings are PRESENT in the shape — that is the tombstone route // (declared-but-unwritable), and it is why "absent from the shape" and // "retired" have to be two separate verdicts below. - expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['labelField', 'displayField', 'searchFields'])); + expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['labelField', 'object', 'filter'])); + // The aged-out pair is gone from the shape outright (#25767). + expect(Object.keys(shape!)).not.toEqual(expect.arrayContaining(['displayField'])); + expect(Object.keys(shape!)).not.toEqual(expect.arrayContaining(['searchFields'])); }); - it.each(['displayField', 'searchFields'])('recognises the #5775 `%s` tombstone', (key) => { + it.each(['object', 'filter'])('recognises the flat-binding `%s` tombstone', (key) => { expect(isRetiredTombstone(shape![key])).toBe(true); }); diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 3eac4ef5cd7..9ab5746c3fc 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -136,16 +136,26 @@ describe('lintLivenessProperties', () => { // UNPARSED stack (`os lint` does not Zod-parse), grades them dead. Until // #16094 this pin asserted silence; the silence was the opt-in's, not the // tombstone's. - it('retired flow keys are graded dead when the rule is handed an unparsed stack', () => { + // + // #25767 (ADR-0087 D2 = A): these two were ≤16.x retirements, so their + // tombstones aged out and left the shape, and their `dead` rows left the + // ledger with them (a strict-removed key has no row — check:liveness calls a + // left-behind row an ORPHAN). The rule therefore has nothing to grade them + // by; the strict parse refuses them as unknown keys, with the protocol + // upgrade guide link in the refusal. Pinned the other way round now, with a + // still-`dead` row in the SAME call so a silent walk cannot pass it. + it('flow keys retired at or below 16.x are no longer graded — their rows left with their tombstones (#25767)', () => { const findings = lintLivenessProperties({ + objects: [{ name: 'widget', externalSharingModel: 'read' }], flows: [{ name: 'f1', errorHandling: { fallbackNodeId: 'n2' }, nodes: [{ id: 'n2', outputSchema: { type: 'object' } }], }], }); - expect(ruleOf(findings, 'errorHandling.fallbackNodeId')).toBe(LIVENESS_DEAD_PROPERTY); - expect(ruleOf(findings, 'nodes.outputSchema')).toBe(LIVENESS_DEAD_PROPERTY); + expect(findings.some((f) => f.message.includes('externalSharingModel'))).toBe(true); + expect(ruleOf(findings, 'errorHandling.fallbackNodeId')).toBeUndefined(); + expect(ruleOf(findings, 'nodes.outputSchema')).toBeUndefined(); }); it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { @@ -206,11 +216,17 @@ describe('lintLivenessProperties', () => { // above). form.data is the sweep's one CORRECTION: the removal attempt broke // the build — defineForm writes data.provider='schema' on every metadata // form — so its ledger entry flipped to live and it must stay silent. - it('retired view keys are graded dead; the corrected live key stays silent', () => { + // + // #25767: `list.responsive` / `form.defaultSort` were ≤16.x retirements, so + // the tombstones and their rows are gone and the rule no longer grades them + // (the strict parse refuses them as unknown keys). The 17.x list tombstones + // (`filterableFields`, `pageName`, `tabs`) still carry `dead` rows, so one of + // them is the positive this case keeps. + it('retired view keys are graded dead while their tombstone stands; the corrected live key stays silent', () => { const findings = lintLivenessProperties({ views: [{ object: 'task', - list: { type: 'grid', responsive: { breakpoint: 'md' } }, + list: { type: 'grid', responsive: { breakpoint: 'md' }, filterableFields: ['title'] }, form: { type: 'wizard', sections: [{ fields: ['title'] }], @@ -219,8 +235,9 @@ describe('lintLivenessProperties', () => { }, }], }); - expect(ruleOf(findings, 'list.responsive')).toBe(LIVENESS_DEAD_PROPERTY); - expect(ruleOf(findings, 'form.defaultSort')).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, 'list.filterableFields')).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, 'list.responsive')).toBeUndefined(); + expect(ruleOf(findings, 'form.defaultSort')).toBeUndefined(); expect(paths(findings).some((m) => m.includes('form.data'))).toBe(false); }); @@ -230,15 +247,21 @@ describe('lintLivenessProperties', () => { // this rule silent until #16094 made the `dead` verdict warn on its own. At a // parsing door the tombstone's rejection still comes first; handed an // unparsed stack, the rule now grades them dead. - it('the #7176 pass-through-only list keys are graded dead when the rule is handed an unparsed stack', () => { + // + // #25767: the #7176 keys were 16.x retirements, so at 18 their tombstones + // aged out and their rows left the ledger. Handed an unparsed stack the rule + // now has no row for them; a parsing door refuses them as unknown keys. The + // 17.x `list.pageName` tombstone in the same call is the anti-vacuity. + it('the #7176 pass-through-only list keys are no longer graded — their rows left with their tombstones (#25767)', () => { const findings = lintLivenessProperties({ views: [{ object: 'task', - list: { type: 'grid', striped: true, bordered: true, virtualScroll: true }, + list: { type: 'grid', striped: true, bordered: true, virtualScroll: true, pageName: 'tasks' }, }], }); + expect(ruleOf(findings, 'list.pageName')).toBe(LIVENESS_DEAD_PROPERTY); for (const key of ['list.striped', 'list.bordered', 'list.virtualScroll']) { - expect(ruleOf(findings, key), key).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, key), key).toBeUndefined(); } }); @@ -609,12 +632,27 @@ describe('lintLivenessProperties', () => { // so the hint is the `dead` default, never the row's note (the #16094 // author-facing block at the bottom of this file). Pinned per key, so a row // that leaves the ledger or changes verdict shows up by name. + // + // #25767: the four #5010 keys were ≤16.x retirements, so their tombstones + // aged out and their rows left the ledger — the rule grades nothing for + // them now, and the strict widget parse refuses them as unknown keys. No + // widget-level `dead` row remains one `children` level down (the depth the + // ledger read walks; the `chartConfig.*` rows sit two down and were never + // graded), so the per-key pin moves to the dashboard's own `refreshInterval` + // row — a 17.x tombstone still `dead` in the ledger; the four are pinned + // ABSENT below. + it('grades the retired dashboard `refreshInterval` dead when handed an unparsed stack (#16094)', () => { + const [dashboard] = dash({}).dashboards; + const findings = lintLivenessProperties({ dashboards: [{ ...dashboard, refreshInterval: 60 }] }); + expect(ruleOf(findings, 'refreshInterval')).toBe(LIVENESS_DEAD_PROPERTY); + }); + it.each(['actionUrl', 'actionType', 'actionIcon', 'aria'])( - 'grades the retired `%s` dead when handed an unparsed stack (#5010, #16094)', + 'no longer grades `%s` — a ≤16.x retirement whose row left with its tombstone (#25767)', (key) => { const value = key === 'aria' ? { ariaLabel: 'Total pipeline' } : 'x'; const findings = lintLivenessProperties(dash({ [key]: value })); - expect(ruleOf(findings, `widgets.${key}`)).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, `widgets.${key}`)).toBeUndefined(); }, ); @@ -631,23 +669,20 @@ describe('lintLivenessProperties', () => { // warning and not six. // Since #16094 the retired keys warn, so this one call also proves the // dashboard walk is registered and running: the `colorVariant` silence sits - // beside four findings from the same walk, same widget, same ledger load. + // beside the `refreshInterval` finding from the same walk, same dashboard, + // same ledger load (#25767: the widget-level retired keys aged out). it('the dashboard silence is a real verdict, not a lint that stopped loading ledgers', () => { + const [dashboard] = dash({ + chartConfig: { type: 'bar', xAxis: 'region', yAxis: 'total', series: ['total'] }, + colorVariant: 'teal', + }).dashboards; const findings = lintLivenessProperties({ objects: [{ name: 'widget', externalSharingModel: 'read' }], - ...dash({ - actionUrl: '/apps/sales/orders', - actionType: 'url', - actionIcon: 'plus', - aria: { ariaLabel: 'Total pipeline' }, - colorVariant: 'teal', - }), + dashboards: [{ ...dashboard, refreshInterval: 60 }], }); const messages = findings.map((f) => f.message); expect(messages.some((m) => m.includes('externalSharingModel'))).toBe(true); - for (const retired of ['actionUrl', 'actionType', 'actionIcon', 'aria']) { - expect(ruleOf(findings, `widgets.${retired}`), retired).toBe(LIVENESS_DEAD_PROPERTY); - } + expect(ruleOf(findings, 'refreshInterval')).toBe(LIVENESS_DEAD_PROPERTY); expect(messages.some((m) => m.includes('widgets.colorVariant'))).toBe(false); }); @@ -1666,16 +1701,17 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)', // either way, which is the point. it('the SHIPPED field ledger warns on no stable subject — which is why the walk needs a subject of its own', () => { // `picklist` was the last opted-in row (`planned` + `authorWarn`) and left - // when the server began resolving picklist references (`live`). What is - // left is `conditionalRequired`, a `retiredKey` tombstone whose `dead` row - // warns on its own since #16094 — but a parse refuses it first, so it is no - // subject for #11385 either. `mailAssociation` (#22870) opted in as + // when the server began resolving picklist references (`live`). The last + // `dead` row, `conditionalRequired`, warned on its own since #16094 — a + // parse refused it first, so it was no subject for #11385 either — and left + // on #25767 when its ≤16.x tombstone aged out of the shape (a strict-removed + // key keeps no row). `mailAssociation` (#22870) opted in as // `planned` + `authorWarn` — the inbound-mail correspondent flag on an email // field, declared ahead of the mailbox ingest that reads it — and left this // set when that reader landed and the row flipped `live` (#22871): a // verdict that moved, so it was never a stable subject either. The // synthetic slot below is warned by nothing shipped. - expect([...authorWarnedProperties('field')]).toEqual(['conditionalRequired']); + expect([...authorWarnedProperties('field')]).toEqual([]); expect( lintLivenessProperties({ objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], @@ -1877,12 +1913,15 @@ describe('the dead and live-elsewhere verdicts warn on their own (#16094)', () = // adds for such a key is the rule's verdict when it IS handed an unparsed // stack (the `#7176` pin above), not a second report at a parsing door. it('CONTROL: a tombstoned dead key is refused at parse by the shipped schema', () => { + // `filterableFields` (17.x, #20925) is the control since #25767: `striped` + // was the 16.x one and its tombstone has aged out — it is still refused, + // but as a strict unknown key on `list`, not at its own path. const parsed = ViewSchema.safeParse({ object: 'fx_account', - list: { type: 'grid', striped: true, data: { provider: 'object', object: 'fx_account' }, columns: [{ field: 'name' }] }, + list: { type: 'grid', filterableFields: ['name'], data: { provider: 'object', object: 'fx_account' }, columns: [{ field: 'name' }] }, }); expect(parsed.success).toBe(false); - expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('list.striped'); + expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('list.filterableFields'); }); // `manifest.runtime` is the one `live-elsewhere` row. The verdict now admits diff --git a/packages/lint/src/page-walk-conversion-parity.test.ts b/packages/lint/src/page-walk-conversion-parity.test.ts index 09fc4c32129..dad2ca43408 100644 --- a/packages/lint/src/page-walk-conversion-parity.test.ts +++ b/packages/lint/src/page-walk-conversion-parity.test.ts @@ -156,16 +156,19 @@ describe('#6775 — walkPageComponents and the conversion walk reach the same co ]); }); - it('a conversion rewrites the probe at every one of them, spelling the same paths — and at the retired `body` besides', () => { + it('a conversion rewrites the probe at every one of them, spelling the same paths — and nowhere else', () => { // Order-insensitive: the walks are free to visit in different orders, but - // the conversion walker may not miss a component lint judges. What it - // reaches BEYOND lint is exactly the retired spelling's probe. + // the conversion walker may not miss a component lint judges. Until #25767 + // it reached ONE position beyond lint — the retired `body` spelling of + // `page:card` — because the slot list carried that entry as retired for the + // stored-row conversion that moved it. That kit was a ≤16.x retirement and + // left the list with its conversion, so today the two walks agree exactly; + // the fixture keeps the `body` probe so the next retired slot entry shows + // up here by path rather than silently. const walked = new Set(walkedProbePaths()); const converted = new Set(convertedProbePaths()); expect([...walked].filter((p) => !converted.has(p))).toEqual([]); - expect([...converted].filter((p) => !walked.has(p))).toEqual([ - 'pages[0].regions[0].components[3].properties.body[0]', - ]); + expect([...converted].filter((p) => !walked.has(p))).toEqual([]); }); it('source-authored pages are the one deliberate difference', () => { @@ -188,7 +191,7 @@ describe('#6775 — walkPageComponents and the conversion walk reach the same co }); describe('#20940 — the three page walks read one slot list and reach the same positions', () => { - it('lint, the exported walk and the conversion walker reach the same probes on one page — the retired spelling aside', () => { + it('lint, the exported walk and the conversion walker reach the same probes on one page', () => { const { lint, exportedWalk, conversion } = reachedBy(); // Guards the fixture: a walk reaching nothing would agree with another // reaching nothing. @@ -198,15 +201,17 @@ describe('#20940 — the three page walks read one slot list and reach the same // The two authoring walks: identical, `card footer` and `two deep` // (under the footer) included — the pair #20940 found apart. expect([...exportedWalk].sort()).toEqual([...lint].sort()); - // The conversion walker: the same, plus exactly the probe under a - // position the list marks retired. - expect([...conversion].filter((title) => !lint.has(title))).toEqual(['card body']); + // The conversion walker: the same. (Until #25767 it also reached `card + // body`, the one position the list marked retired; that entry left with + // its ≤16.x conversion, so the three sets are now equal.) + expect([...conversion].filter((title) => !lint.has(title))).toEqual([]); expect([...lint].filter((title) => !conversion.has(title))).toEqual([]); }); it('the difference IS the list\'s retired entries — no walk keeps a position of its own', () => { const positions = pageComponentSlotPositions(); - expect(positions.filter((p) => p.retired).map((p) => p.key)).toEqual(['body']); + // No position is marked retired today: `body` was, and left on #25767. + expect(positions.filter((p) => p.retired).map((p) => p.key)).toEqual([]); // Every authorable position has a probe on the fixture page, so the // equality above covers the whole list, not a sample of it. const authorable = positions.filter((p) => !p.retired) diff --git a/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts index e64575aec8c..8061696fc95 100644 --- a/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts @@ -7,8 +7,10 @@ * ## The state this closes * * `validateStackExpressions` is the build's expression rule. Its field walk - * judges every field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` - * / `visibleWhen` as a `record`-scoped predicate, with the root verdict, the + * judges every field's `requiredWhen` / `readonlyWhen` / `visibleWhen` as a + * `record`-scoped predicate (`conditionalRequired` left the walk at #25767: + * its tombstone aged out of `FieldSchema`, so the key is a strict unknown key + * and no longer a slot the pass judges), with the root verdict, the * `parent` gate (a slot reading `parent` on an object that does not declare * exactly one `master_detail`), the #4811 null-guard gate over `requiredWhen`, * and the #20078 refusal of a `requiredWhen` / `readonlyWhen` read through a @@ -67,7 +69,6 @@ const REFUSED = [ { slot: 'requiredWhen', slots: { requiredWhen: 'amount > 1' }, subject: 'bare reference `amount`' }, { slot: 'readonlyWhen', slots: { readonlyWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, { slot: 'visibleWhen', slots: { visibleWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, - { slot: 'conditionalRequired', slots: { conditionalRequired: 'amount > 1' }, subject: 'bare reference `amount`' }, // The root verdict: a root a field-level rule never binds. { slot: 'requiredWhen', slots: { requiredWhen: 'current_user.id != null' }, subject: 'reads `current_user`' }, // The `parent` gate: `fx_field` declares no `master_detail`. diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 84513445170..73605adbd9f 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -572,7 +572,10 @@ describe('validateComponentProps — value verdicts', () => { * the `safeParse` half of this rule (a `never` type is a value verdict, not * an unknown key), carrying the rename verbatim. */ - it('surfaces the retired `displayField` prescription (#5775)', () => { + it('reports the aged-out `displayField` as an unknown key (#5775, #25767)', () => { + // [#25767] The #5775 tombstone was a ≤16.x retirement kit and left the + // picker's props, so the key is an unknown key now: the lint reports it + // under its unknown-key rule, and the rename lives in the upgrade guide. const findings = validateComponentProps( stackWith([ { @@ -582,10 +585,10 @@ describe('validateComponentProps — value verdicts', () => { }, ]), ); - const retired = invalid(findings).filter((f) => f.path.endsWith('.displayField')); - expect(retired).toHaveLength(1); - expect(retired[0].severity).toBe('error'); - expect(retired[0].message).toMatch(/removed in @objectstack\/spec 17\.0\.0[\s\S]*`labelField`/); + expect(invalid(findings).filter((f) => f.path.endsWith('.displayField'))).toEqual([]); + const unknown = unknownKeys(findings).filter((f) => f.path.endsWith('.displayField')); + expect(unknown).toHaveLength(1); + expect(unknown[0].severity).toBe('error'); }); /** @@ -706,24 +709,20 @@ describe('validateComponentProps — value verdicts', () => { }); /** - * The other direction of the same card. `page:tabs.type` is now a tombstone, - * and a tombstone is REFUSED rather than reported as unknown — so the finding - * arrives as `component-props-invalid` carrying the rename prescription, not - * as `component-props-unknown-key` carrying nothing. The conversion that - * rewrites it is `retiredFromLoadPath`, so it deliberately does NOT run on - * this normalized input: an already-stored page keeps the old key and the - * author is told, which is the whole point of the tombstone. + * The other direction of the same card. `page:tabs.type` was a tombstone + * until #25767, when the ≤16.x kits aged out: the schema no longer declares + * it, so the finding arrives as `component-props-unknown-key`, not as + * `component-props-invalid` carrying the rename — that prescription now + * lives in the upgrade guide the strict parse names. */ - it('refuses the retired `page:tabs.type` by name, with the prescription (#6776)', () => { + it('reports the aged-out `page:tabs.type` as an unknown key (#6776, #25767)', () => { const findings = validateComponentProps( stackWith([{ type: 'page:tabs', properties: { type: 'card', items: [] } }]), ); - expect(invalid(findings).map((f) => f.path)).toEqual([ + expect(invalid(findings)).toEqual([]); + expect(unknownKeys(findings).map((f) => f.path)).toEqual([ 'pages[0].regions[0].components[0].properties.type', ]); - expect(invalid(findings)[0].message).toContain('tabStyle'); - // Not an unknown key — the schema still declares it, as a tombstone. - expect(unknownKeys(findings)).toEqual([]); }); /** diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index f799e332758..2a852ba58dd 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -151,6 +151,17 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { // #4343 — the retired dispatch keys. Naming them beats the generic "no // callable": they are what the author actually wrote, and each branch has a // different replacement. + // + // [#25767] Their tombstones were ≤16.x retirement kits and aged out of the + // script config contract, so the contract now refuses each key as unknown — + // one issue per key, naming the protocol upgrade guide — and the dispatch + // prescription below rides beside those refusals, not instead of them. + const REFUSED_RE = /Unrecognized key\(s\) on this script node config/; + const split = (issues: ReturnType) => ({ + refused: issues.filter((i) => REFUSED_RE.test(i.message)), + rest: issues.filter((i) => !REFUSED_RE.test(i.message)), + }); + it('flags a retired dispatch key and prescribes the replacement mechanism', () => { const issues = validateStackExpressions({ flows: [{ @@ -162,12 +173,15 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { edges: [], }], }); - expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/made `script` a call to a registered function and nothing else/); - expect(issues[0].message).toMatch(/config\.actionType/); - expect(issues[0].message).toMatch(/config\.template/); - expect(issues[0].message).toMatch(/`notify` node/); - expect(issues[0].message).toMatch(/os migrate meta --from 16/); + const { refused, rest } = split(issues); + expect(refused).toHaveLength(3); + for (const r of refused) expect(r.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(rest).toHaveLength(1); + expect(rest[0].message).toMatch(/made `script` a call to a registered function and nothing else/); + expect(rest[0].message).toMatch(/config\.actionType/); + expect(rest[0].message).toMatch(/config\.template/); + expect(rest[0].message).toMatch(/`notify` node/); + expect(rest[0].message).not.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" read two @@ -175,11 +189,11 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { // automatic-rewrite claim outright, and #9591 names `--write`, which writes // only the edits it can prove — never the unqualified claim. 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\.$/, + expect(rest[0].message).toMatch( + /This edit left `os migrate meta` with the protocol-16\.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.$/, ); - expect(issues[0].message).not.toMatch(/rewrite it automatically/); - expect(issues[0].message).not.toMatch(/rewrite existing sources automatically/); + expect(rest[0].message).not.toMatch(/rewrite it automatically/); + expect(rest[0].message).not.toMatch(/rewrite existing sources automatically/); }); it('tells a shorthand actionType exactly where its name belongs', () => { @@ -193,8 +207,10 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { edges: [], }], }); - expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/function: 'helpdesk\.aiTriageStub'/); + const { refused, rest } = split(issues); + expect(refused).toHaveLength(1); + expect(rest).toHaveLength(1); + expect(rest[0].message).toMatch(/function: 'helpdesk\.aiTriageStub'/); }); it('flags an inline script body — the runtime never executed it', () => { @@ -208,8 +224,10 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { edges: [], }], }); - expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/config\.script/); + const { refused, rest } = split(issues); + expect(refused).toHaveLength(1); + expect(rest).toHaveLength(1); + expect(rest[0].message).toMatch(/config\.script/); }); // #1928 — bare field references are silently null in `record`-scoped sites @@ -3476,7 +3494,9 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t const fieldKeys = /for \(const key of \[([^\]]*)\] as const\)/.exec(RULE_CODE); expect(fieldKeys, 'the field-predicate loop moved — update this guard').not.toBeNull(); const slots = [...fieldKeys![1].matchAll(/'([^']+)'/g)].map((m) => m[1]); - expect(slots).toEqual(['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen', 'settledWhen']); + // [#25767] `conditionalRequired` left the list with its tombstone: the + // schema no longer declares it, so the walk no longer reads it. + expect(slots).toEqual(['requiredWhen', 'readonlyWhen', 'visibleWhen', 'settledWhen']); expect(slots.filter((k) => !Object.keys(FieldSchema.shape).includes(k))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 990b134bfa6..6f0685ad4c8 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -805,10 +805,10 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] * VISIBLE" was not merely imprecise here, it named the wrong property of * the field. * - * `conditionalRequired` also reaches this helper (the field walk still passes - * it). It is a `retiredKey` in `FieldSchema` — the strict schema rejects it by - * name — so the branch is inert on the parsed compile path, and it gets a - * slot-agnostic clause rather than a fabricated fourth measurement. + * `conditionalRequired` reached this helper until #25767: its `FieldSchema` + * tombstone was a ≤16.x retirement kit and aged out, so the schema no longer + * declares the key and the field walk no longer passes it (the strict parse + * refuses it as an unknown key, naming the upgrade guide). * * ### Axis 2 — the prescription, by ROOT (#6713) * @@ -973,12 +973,6 @@ const FIELD_RULE_SLOT_CONSEQUENCE: Record = { 'whose requirement it cannot evaluate (ADR-0137 D2: the refusal names the field and the ' + 'rule) — while the form does not mark the field required, so nothing on screen says why the ' + 'save failed', - // Listed rather than left to the `??` below, so the map covers every slot - // the field walk passes and the default stays unreachable. `FieldSchema` - // declares this key only as a `retiredKey`, which rejects it by name, so - // there is no fourth runtime to measure — the honest clause is the generic - // one, not a fabricated fourth cell (#6716). - conditionalRequired: FIELD_RULE_SLOT_CONSEQUENCE_GENERIC, // [#22227] The deadline's settle predicate (objectui#11815 ruling D) joins // the family on the `visibleWhen` side: display only, nothing on the write // path reads it. Its one consumer — objectui's date cells, the host @@ -1965,8 +1959,8 @@ export interface StackExpressionOptions { * `condition` called `sqrt` or read a bare `amount` saved with a 200 and * then faulted on every write the rule judged; * - [#22032, pass 2] the field-rule-slot pass over `fields[]` — each of - * `requiredWhen` / `readonlyWhen` / `conditionalRequired` / `visibleWhen` - * (and, since #22227, a deadline's `settledWhen`) + * `requiredWhen` / `readonlyWhen` / `visibleWhen` (and, since #22227, a + * deadline's `settledWhen`; `conditionalRequired` left with its tombstone, #25767) * as a `record`-scoped predicate with its root verdict, plus the `parent` * gate (a `readonlyWhen` / `requiredWhen` reading `parent` on an object * without exactly one `master_detail`), the #4811 null-guard gate over @@ -2852,8 +2846,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // 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 list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), }); } else if (!fn) { @@ -3133,7 +3126,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // root verdict over FIELD_RULE_BOUND_ROOTS. Like `visibleWhen` it is // display only, so the server-side gates further down (the `parent` // gate, the null guard, the traversal refusal) do not apply to it. - for (const key of ['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen', 'settledWhen'] as const) { + for (const key of ['requiredWhen', 'readonlyWhen', 'visibleWhen', 'settledWhen'] as const) { const where = `object '${objectName}' · field '${fname}' ${key}`; const raw = (f as AnyRec)[key]; // [#13935] Verdict FIRST, emitted second. `check` needs to know which @@ -3258,9 +3251,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // unusable, and this gate is where the author meets it instead of the // first write in production. // - // `conditionalRequired` (retired alias) and `visibleWhen` (no - // server-enforced `parent` binding of its own) keep their verdicts - // untouched. + // `visibleWhen` (no server-enforced `parent` binding of its own) keeps + // its verdict untouched. // Destructured in the loop head on purpose: both predicate slots stay // LITERAL member reads (`f.readonlyWhen` / `f.requiredWhen`) rather than a // computed `f[key]`, so the #5017 meta-test's source scan still sees every @@ -3304,8 +3296,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // (both server-side seams first — the flow trigger-record half is // outstanding). When it is wired it needs `'fail-closed'`, like // `requiredWhen` above: a faulting `readonlyWhen` refuses the write since - // ADR-0137 D2 too. `conditionalRequired` / `visibleWhen` have no record-scoped - // total binding of their own. See the surface ledger in + // ADR-0137 D2 too. `visibleWhen` has no record-scoped total binding of + // its own. See the surface ledger in // `validate-null-guards.ts`. checkNullGuards( `object '${objectName}' · field '${fname}' requiredWhen`, diff --git a/packages/lint/src/validate-object-field-refs.ts b/packages/lint/src/validate-object-field-refs.ts index ed751582636..58986956469 100644 --- a/packages/lint/src/validate-object-field-refs.ts +++ b/packages/lint/src/validate-object-field-refs.ts @@ -186,9 +186,11 @@ * ## The retired `compactLayout` alias * * `compactLayout` was renamed to `highlightFields` in `@objectstack/spec` - * 11.7.0 (ADR-0085) and the `object-compactLayout-to-highlightFields` - * conversion normalizes it before a parsed stack reaches any rule, so on the - * parsed tier this alias cannot appear. It is read here anyway, at the same + * 11.7.0 (ADR-0085); the `object-compactLayout-to-highlightFields` conversion + * normalized it before a parsed stack reached any rule until #25767 retired it + * (a ≤16.x kit), and since then the strict parse refuses the alias with the + * rename prescription — so on the parsed tier this alias cannot appear either + * way. It is read here anyway, at the same * position, for one reason and not as a tolerance: the clause this rule takes * over from `validateSemanticRoles` read it, and the `lint` path carries raw * config that has not been through the conversion. Dropping the read would be diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 6f7ce759dac..d552898edb6 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -101,7 +101,7 @@ * | a pre-D4 stored `sys_metadata` sibling in the gate's universe | no — cancels in the diff| * | **`os lint` on a RAW object-literal config** (never parses; | **yes — fires** | * | `loadConfig` returns the default export as authored, and | | - * | `owd-legacy-read-aliases` is `retiredFromLoadPath`, so | | + * | `owd-legacy-read-aliases` is retired (#25767), so | | * | `normalizeStackInput` leaves the alias intact) | | * | **`defineStack(x, { strict: false })`** | **yes — fires** | * | **`check:doc-security-posture`** (docs gate: statically | **yes — fires**; its | @@ -167,7 +167,8 @@ const CANONICAL_OWD = ['private', 'public_read', 'public_read_write', 'controlle * THREE — "The legacy aliases `read`, `read_write`, `full` are **removed from * the zod enum**" — and only these three have a retirement behind them: the * `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` - * spellings, and the `13.owd-full-alias-removed` semantic entry for `full`. + * spellings (a ≤16.x kit, retired on #25767), and the `13.owd-full-alias-removed` + * semantic entry for `full`. */ const OWD_RETIRED_ALIAS_FIX: Record = { read: 'public_read', diff --git a/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts b/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts index 9279e113112..6cdcab89323 100644 --- a/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts @@ -34,8 +34,9 @@ import { applyArtifactForwardConversions } from './artifact-forward-conversion.j const SUBJECT = 'dashboard-refresh-interval-to-refresh-interval-seconds'; -/** The door's DEFAULT-FLIP refusals (`DEFAULT_FLIPS_NOT_REPLAYED_HERE`, module-local by design). */ -const DOOR_DEFAULT_FLIPS: readonly string[] = ['app-hidden-to-unpublished', 'flow-decision-mode-inclusive-explicit']; +/** The door's DEFAULT-FLIP refusals (`DEFAULT_FLIPS_NOT_REPLAYED_HERE`, module-local by design). + * `app-hidden-to-unpublished` left the list when #25767 retired it with the ≤16.x kits. */ +const DOOR_DEFAULT_FLIPS: readonly string[] = ['flow-decision-mode-inclusive-explicit']; const versionTriple = (v: string): [number, number, number] => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]; diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 52f3f6cc21a..cf8d24d3a0b 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -50,7 +50,7 @@ void _mirrorPin; // named, as the blocks below already name them. /** The door's DEFAULT-FLIP refusals, named in their own blocks below (#17885, #15429). */ -const DOOR_DEFAULT_FLIPS: readonly string[] = ['app-hidden-to-unpublished', 'flow-decision-mode-inclusive-explicit']; +const DOOR_DEFAULT_FLIPS: readonly string[] = ['flow-decision-mode-inclusive-explicit']; const versionTriple = (v: string): [number, number, number] => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]; @@ -379,8 +379,13 @@ describe('the artifact door never stamps a column constraint (ADR-0113, #16693)' /** * #17885 — the artifact door must not replay the DEFAULT-FLIP class. * - * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09) rewrites - * `app.hidden: true` into `app._unpublished: true`. Both keys are live and + * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09) rewrote + * `app.hidden: true` into `app._unpublished: true`. #25767 retired the entry + * with the ≤16.x kits, so the door no longer has it to refuse — the pins below + * stay because the outcome they guard (an authored `hidden: true` reaches + * registration untouched) is the outcome, whatever the registry carries; the + * step-18 reading further down is where the door's refusal-by-id is still + * exercised on a live entry. Both keys are live and * they mean opposite kinds of thing: `hidden` is navigation presentation and * *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the * machine-managed publish gate `filterAppForUser` drops the app on for every @@ -552,18 +557,22 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub }); /** - * ⭐ SEAM SCOPE — the entry is refused by THIS DOOR, not removed from the - * chain. The stored-row seam, where a `hidden: true` row can only have come - * from the pre-split materialization path, still carries the population - * across. A fix that had neutered the entry would go green on every leg - * above and silently strand those rows. + * SEAM SCOPE, after #25767. Until then this case pinned the OTHER half of the + * #17885 ruling: the entry was refused by this door and still fired at the + * stored-row seam, where a `hidden: true` row could only have come from the + * pre-split materialization path. #25767 retired the entry itself (a ≤16.x + * kit, ADR-0087 D2 = A), so no seam rewrites that row any more: a pre-split + * row is canonicalized once, `os migrate meta --stored --apply` on a release + * that still carries the step, and from here the stored-row seam reads + * `hidden: true` as what it says. Pinned so a re-registration of the entry + * is a deliberate act, not a merge accident. */ - it('leaves the entry firing at the stored-row seam it is sound at', () => { + it('the stored-row seam no longer rewrites `hidden: true` either — the entry is retired, not merely refused here', () => { const row = applyConversionsToStoredItem('app', { name: 'production_management', label: 'Production', hidden: true, navigation: [], }) as Record; - expect(row._unpublished, 'the stored-row seam still converts').toBe(true); - expect(row.hidden).toBeUndefined(); + expect(row._unpublished, 'no seam invents the publish gate now').toBeUndefined(); + expect(row.hidden).toBe(true); }); }); diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index de347ae550f..3ad48eeb465 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -415,8 +415,9 @@ export function resolveInstalledSpecVersion(): string | null { * is the door's own refusal, not a fact about the registry, and exporting it * would invite a second caller to inherit a judgement it has not made. * - * - `app-hidden-to-unpublished` (#17885, #4829, ADR-0045 amended 2026-08-09): - * rewrites `app.hidden: true` into `app._unpublished: true`. `hidden` is a + * - `app-hidden-to-unpublished` (#17885, #4829, ADR-0045 amended 2026-08-09; + * retired with the ≤16.x kits on #25767, kept here as the worked example): + * rewrote `app.hidden: true` into `app._unpublished: true`. `hidden` is a * LIVE authorable key — navigation presentation, *"never an access gate"* * (`ui/app.zod.ts`) — while `_unpublished` is the machine-managed publish * gate that `filterAppForUser` (`packages/rest/src/rest-server.ts`) drops @@ -450,7 +451,10 @@ export function resolveInstalledSpecVersion(): string | null { * `packages/services/service-automation/src/engine.ts`); this door does too. */ const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [ - 'app-hidden-to-unpublished', + // `app-hidden-to-unpublished` stood here until #25767 retired it with the + // ≤16.x kits (ADR-0087 D2 = A): the registry no longer carries it, so there is + // nothing for this door to refuse. The reasoning in the docblock above is kept + // because it is the reasoning the NEXT default flip is judged by. 'flow-decision-mode-inclusive-explicit', ]; diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index fbb83f3e1ff..daa5d62ac05 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -605,7 +605,6 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { group: keep, widget: keep, hidden: keep, internal: keep, readonly: keep, requiredPermissions: keep, maskingRule: keep, ackPlaintextMasking: keep, system: keep, sortable: keep, inlineHelpText: keep, placeholder: keep, externalId: keep, - conditionalRequired: keep, // A boolean facet of this date field: it declares the field a deadline // (#22227) and names no field. dueLike: keep, diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index bc317b9eb43..a20b0d59916 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -248,7 +248,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // is named in the sample below so the served schema is held to // carrying it, not merely to having one more key than before. // [#21795] 49 → 50: `requiresMembershipReach` joined the accepted set. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(50); + // [#25767] 50 → 47: the three keys that admitted no instance were ≤16.x + // retirements, and a retirement kit lives one protocol major past the + // major it retired in — at 18 they left the declared set with their + // tombstones. The declared total shrinks by exactly those three; the + // live sample below still holds every live key. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(47); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) { @@ -328,9 +333,17 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * when it gained `requiredPermissions` (who may start the flow, checked at * every start door). */ + // [#25767] The ≤16.x tombstones aged out of the declared sets at 18 (a kit + // lives one major past the major it retired in), so each count below moved + // DOWN by exactly the top-level tombstones that left its shape: `agent` + // 26 → 24 (`tools`, `knowledge`), `app` 30 → 22 (`version`, `homePageId`, + // `objects`, `apis`, `sharing`, `embed`, `mobileNavigation`, `aria`), + // `dashboard` 22 → 20 (`aria`, `performance`), `field` 80 → 79 + // (`conditionalRequired`), `flow` 24 → 22 (`template`, `active`), `skill` + // 17 → 16 (`triggerPhrases`). The live sets are unchanged. const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 24, - hook: 22, object: 48, page: 26, position: 13, report: 21, skill: 17, tool: 14, + agent: 24, app: 22, dashboard: 20, dataset: 16, field: 79, flow: 22, + hook: 22, object: 48, page: 26, position: 13, report: 21, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts b/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts index 5946110fe78..d009f1bb390 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts @@ -17,9 +17,16 @@ * `saveMetaItem` then refuses. * * Measured on `origin/main` at 74eaab8614 over the whole served registry: - * **80 tombstone nodes across 16 types**. Five of them are `dashboard.widgets[]`'s + * **80 tombstone nodes across 16 types**. Five of them were `dashboard.widgets[]`'s * `actionUrl`, `actionType`, `actionIcon`, `responsive` and `aria` — the row - * this file pins, and the carrier the card was filed on. ⚠️ They are not the + * this file pinned, and the carrier the card was filed on. [#25767] Those five + * were ≤16.x retirements, and a retirement kit lives one protocol major past + * the major it retired in: at 18 they left the shape with their tombstones, so + * the widget row carries nothing to strip any more. The row pin moved to + * `page.regions[].components[]`, whose `visibility` (→ `visibleWhen`, 17.7.0) + * and `responsive` (removed, 17.2.0) tombstones are 17.x kits still on a + * repeater row; `dashboard` keeps the depth pin through `refreshInterval` and + * the five tombstones inside the widget's `chartConfig`. ⚠️ They are not the * whole reachable set: a repeater row in another type reaches an author the * same way, as does the flat schema-driven fallback for a layout-less type and * an inspector that grafts server-only properties into a "More fields" section. @@ -212,10 +219,33 @@ function widgetRow(served: Record | undefined): Record; } -/** The five columns the parse door refuses (`ui/dashboard.zod.ts` tombstones). */ -const RETIRED_WIDGET_COLUMNS = ['actionUrl', 'actionType', 'actionIcon', 'responsive', 'aria'] as const; +/** `page.regions[].components[]`'s row shape, off the served document. */ +function componentRow(served: Record | undefined): Record { + const resolve = (node: any): any => (typeof node?.$ref === 'string' + ? (served as any)?.$defs?.[String(node.$ref).replace('#/$defs/', '')] + : node); + const regions = resolve((served?.properties as any)?.regions); + const region = resolve(regions?.items); + const components = resolve(region?.properties?.components); + const component = resolve(components?.items); + return (component?.properties ?? {}) as Record; +} + +/** + * The two columns the parse door refuses on the pinned repeater row + * (`ui/page.zod.ts` tombstones: `visibility` → `visibleWhen` at 17.7.0, + * `responsive` removed at 17.2.0). [#25767] The five `dashboard.widgets[]` + * columns this file pinned before aged out of the shape with the ≤16.x kits. + */ +const RETIRED_COMPONENT_COLUMNS = ['visibility', 'responsive'] as const; -/** The live columns that must survive beside them — the lit control. */ +/** The live component columns that must survive beside them — the lit control. */ +const LIVE_COMPONENT_COLUMNS = [ + 'aria', 'className', 'dataSource', 'events', 'id', 'label', 'properties', + 'responsiveStyles', 'style', 'type', 'visibleWhen', +] as const; + +/** The widget row's live columns — still the lit control there, with nothing left to strip beside them. */ const LIVE_WIDGET_COLUMNS = [ 'chartConfig', 'colorVariant', 'compareTo', 'dataset', 'description', 'dimensions', 'filter', 'filterBindings', 'id', 'layout', 'options', 'requiresObject', @@ -223,16 +253,29 @@ const LIVE_WIDGET_COLUMNS = [ ] as const; describe('#17502 — the served repeater row offers no column the parse door refuses', () => { - it('control: the pre-strip derivation really did carry the five tombstone columns', () => { + it('control: the pre-strip derivation really did carry the two tombstone columns', () => { // Without this, both halves below could pass over a payload that never // had the nodes — and the pin would be green on `origin/main` too. - const row = widgetRow(preStripDerivation('dashboard')); - for (const key of RETIRED_WIDGET_COLUMNS) { - expect(Object.keys(row), `pre-strip dashboard.widgets row declares ${key}`).toContain(key); + const row = componentRow(preStripDerivation('page')); + for (const key of RETIRED_COMPONENT_COLUMNS) { + expect(Object.keys(row), `pre-strip page.regions[].components row declares ${key}`).toContain(key); expect(acceptsNothing((row as any)[key]), `${key} is a node that admits nothing`).toBe(true); expect(String((row as any)[key].description)).toMatch(/^\[REMOVED\] /); } - expect(Object.keys(row).length).toBe(22); + expect(Object.keys(row).length).toBe(13); + }); + + it('[#25767] control: the former carrier, the pre-strip `dashboard.widgets` row, carries no tombstone any more', () => { + // The five ≤16.x tombstones left the shape with their kits, so the + // derivation and the served payload agree on this row without a strip. + const row = widgetRow(preStripDerivation('dashboard')); + expect(Object.keys(row).sort()).toEqual([...LIVE_WIDGET_COLUMNS].sort()); + expect(Object.entries(row).filter(([, node]) => acceptsNothing(node)).map(([k]) => k)).toEqual([]); + }); + + it('lit: the served `page.regions[].components` row still carries every live column', async () => { + const row = componentRow((await servedSchemas()).get('page')); + expect(Object.keys(row).sort()).toEqual([...LIVE_COMPONENT_COLUMNS].sort()); }); it('lit: the served `dashboard.widgets` row still carries every live column', async () => { @@ -240,10 +283,10 @@ describe('#17502 — the served repeater row offers no column the parse door ref expect(Object.keys(row).sort()).toEqual([...LIVE_WIDGET_COLUMNS].sort()); }); - it('dark: the five retired columns are gone from the served row', async () => { - const row = widgetRow((await servedSchemas()).get('dashboard')); - for (const key of RETIRED_WIDGET_COLUMNS) { - expect(Object.keys(row), `dashboard.widgets must not offer ${key}`).not.toContain(key); + it('dark: the two retired columns are gone from the served component row', async () => { + const row = componentRow((await servedSchemas()).get('page')); + for (const key of RETIRED_COMPONENT_COLUMNS) { + expect(Object.keys(row), `page.regions[].components must not offer ${key}`).not.toContain(key); } }); @@ -304,15 +347,14 @@ describe('#17502 — the served repeater row offers no column the parse door ref // Non-vacuity: without this the two assertions above pass over a ledger // that read nothing at all. `dashboard` is the type that exercises every - // depth — the five repeater-row columns pinned above, three top-level - // tombstones, and FIVE inside the widget's own `chartConfig` — so the - // ledger is proven to reach a row shape and not only the surface. + // depth — one top-level tombstone and FIVE inside the widget's own + // `chartConfig` — so the ledger is proven to reach a row shape and not + // only the surface. [#25767] The five widget-row columns and the + // top-level `aria` / `performance` left this list with the ≤16.x kits; + // the repeater-row depth is pinned on `page` right below instead. // Sorted, so key ORDER is not what is pinned. expect([...(removedByType.get('dashboard') ?? [])].sort()).toEqual([ - ...RETIRED_WIDGET_COLUMNS.map((k) => `dashboard.properties.widgets.items.properties.${k}`), 'dashboard.properties.refreshInterval', - 'dashboard.properties.aria', - 'dashboard.properties.performance', // [#17751] `ChartConfigSchema.aria` retired one level deeper than the // widget row, inside `chartConfig`. 'dashboard.properties.widgets.items.properties.chartConfig.properties.aria', @@ -327,6 +369,11 @@ describe('#17502 — the served repeater row offers no column the parse door ref (k) => `dashboard.properties.widgets.items.properties.chartConfig.properties.${k}`, ), ].sort()); + // [#25767] The repeater-row depth: the two component-row columns pinned + // above are among what the strip took away from `page`. + expect(removedByType.get('page') ?? []).toEqual(expect.arrayContaining( + RETIRED_COMPONENT_COLUMNS.map((k) => `page.properties.regions.items.properties.components.items.properties.${k}`), + )); }); // ⚠️ This control derives with zod's DEFAULT (output) arm only, which is @@ -352,28 +399,43 @@ describe('#17502 — the served repeater row offers no column the parse door ref describe('#17502 — the removal is payload-only: every prescription channel survives', () => { it('the parse still refuses the key with the tombstone prescription, byte for byte', async () => { - const dashboard = getMetadataTypeSchema('dashboard') as z.ZodTypeAny; - const result = dashboard.safeParse({ - name: 'ops', label: 'Ops', - widgets: [{ id: 'w1', type: 'metric', actionUrl: '/x' }], + const page = getMetadataTypeSchema('page') as z.ZodTypeAny; + const result = page.safeParse({ + name: 'ops_page', label: 'Ops', type: 'app', + regions: [{ name: 'main', components: [{ id: 'c1', type: 'object-grid', responsive: { xsmall: { columns: 1 } } }] }], } as never); - expect(result.success, 'a retired widget column is still refused at publish').toBe(false); - const issue = result.error!.issues.find((i) => i.path[i.path.length - 1] === 'actionUrl'); + expect(result.success, 'a retired component column is still refused at publish').toBe(false); + const issue = result.error!.issues.find((i) => i.path[i.path.length - 1] === 'responsive'); expect(issue, 'the refusal names the retired key').toBeDefined(); expect((issue as { expected?: string }).expected).toBe('never'); // The prescription — the FROM -> TO mapping this retirement exists to // deliver — is carried by the refusal, which the strip never touches. - expect(issue!.message).toContain('was removed in @objectstack/spec 17.0.0'); - expect(issue!.message).toContain('header: { actions:'); - expect(issue!.message).toContain('os migrate meta --from 16'); + expect(issue!.message).toContain('was removed in @objectstack/spec 17'); + expect(issue!.message).toContain('responsiveStyles'); + expect(issue!.message).toContain('os migrate meta --from 17'); + }); + + it('[#25767] an aged-out column is refused as a strict unknown key naming the upgrade guide — no prescription, no silent strip', () => { + // The former carrier's `actionUrl` has no tombstone now; the strict + // widget shape refuses it, and the sentence points at the guide that + // carries the prescription the kit used to. + const dashboard = getMetadataTypeSchema('dashboard') as z.ZodTypeAny; + const result = dashboard.safeParse({ + name: 'ops', label: 'Ops', + widgets: [{ id: 'w1', type: 'metric', actionUrl: '/x' }], + } as never); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => /Unrecognized key\(s\) on this dashboard widget: `actionUrl`/.test(i.message)); + expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('the Zod shape still declares the tombstone — nothing is un-retired upstream', () => { // `packages/spec`'s `authorable-surface/` ratchet and the generated // reference pages read this shape, not the served payload, so both keep // publishing the retirement. The strip is a property of ONE emitter. - const row = widgetRow(preStripDerivation('dashboard')); - for (const key of RETIRED_WIDGET_COLUMNS) { + const row = componentRow(preStripDerivation('page')); + for (const key of RETIRED_COMPONENT_COLUMNS) { expect(Object.keys(row)).toContain(key); } }); diff --git a/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts b/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts index 811116eb436..2e9180a5e92 100644 --- a/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts +++ b/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts @@ -192,7 +192,8 @@ const legacyObjectsPluralRow: SeedRow = { label: 'Invoice', fields: { status: { type: 'select', label: 'Status' }, - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }; @@ -299,8 +300,8 @@ describe('migrateStoredMetadata — a non-canonical stored `type` is reported, n expect(report.pending).toBe(1); const row = report.rows[0]!; expect(row).toMatchObject({ type: 'object', name: 'crm_invoice', outcome: 'pending' }); - expect(row.notices[0]!.from).toBe('conditionalRequired'); - expect(row.notices[0]!.to).toBe('requiredWhen'); + expect(row.notices[0]!.from).toBe('reference_to'); + expect(row.notices[0]!.to).toBe('reference'); }); it('⛔ an apply run writes NOTHING for it — the identity move stays unruled', async () => { diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 7f16b9d1821..3f57dcd8fcc 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -450,19 +450,38 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' }); }); - it('an older-retired flow shape (`flow.active`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => { - // `flow-inert-keys-removed` (retired from the load path after 16.1.0) - // tombstones `active`. On a host with no automation service the - // fallback used to judge it in the authoring posture and refuse it - // 422; the service-backed path replays the entry and heals it. Since - // #23487 the fallback replays the same chain, so both hosts agree. + it('an older-retired flow shape (`errorHandling.retryDelayMs`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => { + // `retry-policy-converged` (retired from the load path after 17.7.0) + // renames `errorHandling.retryDelayMs` → `backoffMs`, and the strict + // block refuses the old spelling. On a host with no automation + // service the fallback used to judge it in the authoring posture and + // refuse it 422; the service-backed path replays the entry and heals + // it. Since #23487 the fallback replays the same chain, so both + // hosts agree. ([#25767] This pin rode `flow.active` and + // `flow-inert-keys-removed`, a ≤16.x kit, until the one-major rule + // retired that kit; the aged-out spelling is pinned right below.) const { protocol, rows, engine } = makeProtocol(new Map()); - const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }; + const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), errorHandling: { retryDelayMs: 1000 } }; await seedStoredFlow(engine, body); const result = await save(protocol, body); expect(result.success).toBe(true); + expect(storedFlow(rows)!.body.errorHandling).toEqual({ retryDelayMs: 1000 }); + }); + + it('[#25767] a spelling whose kit aged out (`flow.active`, retired at or below 16.x) is refused on a stored row too — no step of the chain replays it', async () => { + // The kit left the registry one major past its retirement, so the + // replay heals nothing here: the strict flow shape refuses the key + // as unknown, naming the protocol upgrade guide, on either host. + const { protocol, rows, engine } = makeProtocol(new Map()); + const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }; + await seedStoredFlow(engine, body); + + await expect(save(protocol, body)).rejects.toThrow( + /Unrecognized key\(s\) on this flow: `active`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/, + ); + // The refused re-save wrote nothing: the row is the one seeded. expect(storedFlow(rows)!.body.active).toBe(false); }); diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index bba8a3b8dd5..60b999ff699 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -95,9 +95,11 @@ function makeStubEngine( return { engine, rows, registered }; } -// A protocol-≤16 object row: the `conditionalRequired` alias was removed from -// the spec in 17 (#3855) and its conversion is `retiredFromLoadPath` — the -// authored load seam refuses it, but the stored seam must keep lowering it. +// A protocol-17 object row: the `reference_to` alias was renamed in 17 and its +// conversion (`field-reference-to-alias`, retired from the load path after +// 17.2.0) is `retiredFromLoadPath` — the authored load seam refuses it, but +// the stored seam must keep lowering it. (Until #25767 this row rode the +// ≤16.x `conditionalRequired` kit, aged out of the registry since.) const legacyObjectRow = { type: 'object', name: 'crm_invoice', @@ -106,12 +108,18 @@ const legacyObjectRow = { label: 'Invoice', fields: { status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }; -// A pre-17 standalone action row still carrying the removed `execute` alias. +// The retired `aria` block the legacy action row still carries +// (`action-aria-removed`, 17.4.0; the ≤16.x `execute` alias it rode until +// #25767 aged out of the registry). +const RETIRED_ACTION_ARIA = { label: 'Convert the invoice' }; + +// A protocol-17 standalone action row still carrying the removed `aria` block. // NOTE: this once wrote `object: 'crm_invoice'`. `ActionSchema` has never // declared `object` — the key is `objectName` — and `.strip` ate it, so the // fixture read as a claim about real legacy data while being a typo. Closing @@ -120,26 +128,29 @@ const legacyObjectRow = { const legacyActionRow = { type: 'action', name: 'convert', - metadata: { name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', execute: 'convertHandler' }, + metadata: { + name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', target: 'convertHandler', + aria: RETIRED_ACTION_ARIA, + }, }; describe('getMetaItems — stored rows are served canonical (#3903)', () => { - it('lowers the retired conditionalRequired alias on a stored object row', async () => { + it('lowers the retired reference_to alias on a stored object row', async () => { const { engine } = makeStubEngine([legacyObjectRow]); const protocol = new ObjectStackProtocolImplementation(engine); const res = await protocol.getMetaItems({ type: 'object' }); const obj = (res.items as any[]).find((i) => i.name === 'crm_invoice'); - expect(obj.fields.amount.requiredWhen).toBe("record.status == 'sent'"); - expect('conditionalRequired' in obj.fields.amount).toBe(false); + expect(obj.fields.account_id.reference).toBe('crm_account'); + expect('reference_to' in obj.fields.account_id).toBe(false); }); - it('lowers the retired execute alias on a stored action row', async () => { + it('strips the retired aria block on a stored action row', async () => { const { engine } = makeStubEngine([legacyActionRow]); const protocol = new ObjectStackProtocolImplementation(engine); const res = await protocol.getMetaItems({ type: 'action' }); const action = (res.items as any[]).find((i) => i.name === 'convert'); expect(action.target).toBe('convertHandler'); - expect('execute' in action).toBe(false); + expect('aria' in action).toBe(false); }); it('deliberately does NOT convert flow rows — that seam is registerFlow (the engine canonicalizes flows)', async () => { @@ -221,7 +232,7 @@ describe('getMetaItem — single stored read is canonical (#3903)', () => { const { engine } = makeStubEngine([legacyObjectRow]); const protocol = new ObjectStackProtocolImplementation(engine); const res: any = await protocol.getMetaItem({ type: 'object', name: 'crm_invoice' }); - expect(res.item.fields.amount.requiredWhen).toBe("record.status == 'sent'"); + expect(res.item.fields.account_id.reference).toBe('crm_account'); // Pre-#3903 this row validated RAW: the tombstoned alias made every // legacy row read as invalid metadata. Converted first, it is valid. expect(res.item._diagnostics?.valid).toBe(true); @@ -239,8 +250,8 @@ describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3 expect(res).toEqual({ loaded: 2, errors: 0, invalid: 0, storeUnavailable: false }); const obj = registered.find((r) => r.kind === 'object')!; - expect(obj.body.fields.amount.requiredWhen).toBe("record.status == 'sent'"); - expect('conditionalRequired' in obj.body.fields.amount).toBe(false); + expect(obj.body.fields.account_id.reference).toBe('crm_account'); + expect('reference_to' in obj.body.fields.account_id).toBe(false); const action = registered.find((r) => r.kind === 'item' && r.type === 'action')!; expect(action.body.target).toBe('convertHandler'); diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 8e70ceb3f5f..a86e6e095b3 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -127,10 +127,14 @@ const historyRows = (tables: Map[]>) => tables.get('sys_metadata_history') ?? []; /** - * A protocol-≤16 object row: `conditionalRequired` was removed from the spec in - * 17 (#3855) and its conversion is `retiredFromLoadPath` — the authored load - * seam refuses it, the stored seam keeps lowering it, and this pass persists - * the lowering. + * A protocol-17 object row: the field key `reference_to` was renamed to + * `reference` in 17 and its conversion (`field-reference-to-alias`, retired + * from the load path after 17.2.0) is `retiredFromLoadPath` — the authored + * load seam refuses it, the stored seam keeps lowering it, and this pass + * persists the lowering. (Until #25767 this row rode the ≤16.x + * `conditionalRequired` → `requiredWhen` kit; that kit aged out of the + * registry with the one-major rule, so the row rides a 17.x kit now — the + * seam under test is the same.) */ const legacyObjectRow = { type: 'object', @@ -140,7 +144,8 @@ const legacyObjectRow = { label: 'Invoice', fields: { status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }; @@ -159,11 +164,17 @@ const canonicalObjectRow = { }, }; -/** A pre-17 standalone action row still carrying the removed `execute` alias. */ +/** The retired `aria` block the legacy action row still carries (`action-aria-removed`, 17.4.0). */ +const RETIRED_ACTION_ARIA = { label: 'Convert the invoice' }; + +/** A protocol-17 standalone action row still carrying the removed `aria` block. */ const legacyActionRow = { type: 'action', name: 'convert', - metadata: { name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', execute: 'convertHandler' }, + metadata: { + name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', target: 'convertHandler', + aria: RETIRED_ACTION_ARIA, + }, }; describe('migrateStoredMetadata — preview (#4327)', () => { @@ -195,8 +206,8 @@ describe('migrateStoredMetadata — preview (#4327)', () => { const row = report.rows[0]!; expect(row).toMatchObject({ type: 'object', name: 'crm_invoice', outcome: 'pending', state: 'active' }); expect(row.notices.length).toBeGreaterThan(0); - expect(row.notices[0]!.from).toBe('conditionalRequired'); - expect(row.notices[0]!.to).toBe('requiredWhen'); + expect(row.notices[0]!.from).toBe('reference_to'); + expect(row.notices[0]!.to).toBe('reference'); // An already-canonical row is counted, never itemised — otherwise a // healthy deployment's report is a wall of rows that need nothing. expect(report.rows.every((r) => r.outcome !== 'canonical')).toBe(true); @@ -221,8 +232,8 @@ describe('migrateStoredMetadata — apply (#4327)', () => { expect(storedMigrationClean(report)).toBe(true); const stored = JSON.parse(metaRows(tables)[0]!.metadata); - expect(stored.fields.amount.requiredWhen).toBe("record.status == 'sent'"); - expect('conditionalRequired' in stored.fields.amount).toBe(false); + expect(stored.fields.account_id.reference).toBe('crm_account'); + expect('reference_to' in stored.fields.account_id).toBe(false); }); it('goes through the normal write path — history row, fresh checksum, migrate-stored source', async () => { @@ -316,7 +327,7 @@ describe('migrateStoredMetadata — apply (#4327)', () => { // Non-destructive: the stored bytes are exactly as they were. const stored = metaRows(tables).find((r) => r.id === actionRow.id)!; - expect(JSON.parse(stored.metadata).execute).toBe('convertHandler'); + expect(JSON.parse(stored.metadata).aria).toEqual(RETIRED_ACTION_ARIA); }); it('a driver seam that cannot answer refuses the pass before any row is written', async () => { @@ -336,7 +347,7 @@ describe('migrateStoredMetadata — apply (#4327)', () => { const report = await protocol.migrateStoredMetadata({ apply: true }); expect(report.scanned).toBe(0); - expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBeDefined(); + expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.account_id.reference_to).toBeDefined(); }); it('restricts to the requested types', async () => { @@ -580,7 +591,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', metadata: { name: 'corrupt_thing', label: 'Corrupt', - fields: { amount: { type: 'currency', conditionalRequired: 'x' } }, + fields: { account_id: { type: 'lookup', reference_to: 'x' } }, // an off-contract key the schema rejects and no conversion lowers listViews: 42, }, @@ -594,7 +605,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', expect(report.rewritten).toBe(0); expect(report.rows[0]!.reason).toMatch(/failed spec validation/); expect(historyRows(tables)).toHaveLength(0); - expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBe('x'); + expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.account_id.reference_to).toBe('x'); }); it('marks a legacy row whose select has no option source `failed` and leaves its bytes alone', async () => { @@ -620,7 +631,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', expect(historyRows(tables)).toHaveLength(0); const stored = JSON.parse(metaRows(tables)[0]!.metadata); expect(stored.fields.status).toEqual({ type: 'select', label: 'Status' }); - expect(stored.fields.amount.conditionalRequired).toBe("record.status == 'sent'"); + expect(stored.fields.account_id.reference_to).toBe('crm_account'); }); it('does not clobber a row a concurrent writer moved — the optimistic lock is real', async () => { @@ -673,7 +684,7 @@ describe('formatStoredMigrationReport (#4327)', () => { const protocol = new ObjectStackProtocolImplementation(engine); const text = formatStoredMigrationReport(await protocol.migrateStoredMetadata()).join('\n'); expect(text).toMatch(/object\/crm_invoice \[env-wide\]/); - expect(text).toMatch(/conditionalRequired → requiredWhen/); + expect(text).toMatch(/reference_to → reference/); }); }); diff --git a/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts b/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts index 17638f54457..4a081af23fe 100644 --- a/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts @@ -185,7 +185,9 @@ describe('#8421 — `migrate meta --stored` and a residue row', () => { name: 'crm_invoice', label: 'Invoice', fields: { - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + // `field-reference-to-alias` (17.2.0): the stored seam still lowers it (#25767 retired the ≤16.x kits). + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }]); @@ -195,7 +197,7 @@ describe('#8421 — `migrate meta --stored` and a residue row', () => { expect(report).toMatchObject({ scanned: 2, rewritten: 1, failed: 0 }); const invoice = metaRows(tables).find((r) => r.name === 'crm_invoice')!; - expect(JSON.parse(invoice.metadata).fields.amount.requiredWhen).toBe("record.status == 'sent'"); + expect(JSON.parse(invoice.metadata).fields.account_id.reference).toBe('crm_account'); }); }); diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index e538fb7d652..85f72e8b407 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -71,20 +71,36 @@ describe('field requiredWhen enforcement (B2)', () => { expect(() => evaluateValidationRules(invoiceFields, { amount: 25 }, 'update', { previous: { status: 'sent' } })).not.toThrow(); }); // #3903 — the `conditionalRequired` alias read is RETIRED. Stored pre-17 - // rows are lowered to `requiredWhen` by the ADR-0087 chain at rehydration - // (`applyConversionsToStoredItem`), so this validator only ever sees the + // rows were lowered to `requiredWhen` by the ADR-0087 chain at rehydration + // (`applyConversionsToStoredItem`), so this validator only ever saw the // canonical key; raw legacy input no longer gets a consumer-side fallback. + // [#25767] That kit was a ≤16.x retirement, and a retirement kit lives one + // protocol major past the major it retired in: at 18 the chain lowers + // nothing here any more. A row still carrying the alias is refused at the + // strict parse door (naming the protocol upgrade guide) and is for + // `os migrate meta --stored` on a release that still carried the step; this + // validator keeps ignoring the alias either way. it('does NOT read the retired conditionalRequired alias (PD #12 — no dialect fallback)', () => { const f = { fields: { amount: { type: 'currency', conditionalRequired: "record.status == 'sent'" } } } as any; expect(() => evaluateValidationRules(f, { status: 'sent' }, 'insert')).not.toThrow(); }); - it('enforces the rule once the stored-conversion chain has lowered the alias', async () => { + it('[#25767] the stored-conversion chain no longer lowers the aged-out alias — the rule is enforced on the canonical key only', async () => { const { applyConversionsToStoredItem } = await import('@objectstack/spec'); - const storedRow = { + const legacyRow = { name: 'invoice', fields: { amount: { type: 'currency', conditionalRequired: "record.status == 'sent'" } }, }; - const converted = applyConversionsToStoredItem('object', storedRow) as any; + const untouched = applyConversionsToStoredItem('object', legacyRow) as any; + // No step of the chain replays the ≤16.x kit: the alias is neither lowered + // nor read, so the rule it once spelled is not enforced from this row. + expect(untouched.fields.amount).toEqual({ type: 'currency', conditionalRequired: "record.status == 'sent'" }); + expect(() => evaluateValidationRules(untouched, { status: 'sent' }, 'insert')).not.toThrow(); + // The canonical spelling is what the chain hands this validator, and it is enforced. + const canonicalRow = { + name: 'invoice', + fields: { amount: { type: 'currency', requiredWhen: "record.status == 'sent'" } }, + }; + const converted = applyConversionsToStoredItem('object', canonicalRow) as any; expect(converted.fields.amount.requiredWhen).toBe("record.status == 'sent'"); expect(() => evaluateValidationRules(converted, { status: 'sent' }, 'insert')).toThrow(ValidationError); }); diff --git a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts index 55682824d97..724cc284cfc 100644 --- a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts +++ b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts @@ -67,10 +67,19 @@ import { AppPlugin } from './app-plugin.js'; /** * One artifact carrying a legacy/retired shape in every security collection - * the ADR-0087 registry can reach. `engines.protocol: '^17.1.0'` is the real - * incident's declared floor — below the installed `@objectstack/spec`, so the - * door's versioned window opens (the same evidence the 17.1-built hotcrm + * the ADR-0087 registry can STILL reach. `engines.protocol: '^17.1.0'` is the + * real incident's declared floor — below the installed `@objectstack/spec`, so + * the door's versioned window opens (the same evidence the 17.1-built hotcrm * artifact carries). + * + * [#25767] The kits retired at or below protocol 16.x aged out of the registry + * one major on (the `roles` → `positions` collection rename, the RLS + * `priority` tombstone, the `sharedWith.type: 'role'` recipient reconcile), so + * no conversion governs those keys any more: the door refuses each as a strict + * unknown key / invalid option naming the upgrade guide (pinned below), and + * this fixture writes them canonically. The 17.x kits — the `allowRestore` / + * `allowPurge` grant bits (17.2.0) and `accessLevel: 'full'` (17.7.0) — are + * what the conversion axis still measures. */ const ARTIFACT = { manifest: { @@ -80,9 +89,10 @@ const ARTIFACT = { version: '1.0.0', engines: { protocol: '^17.1.0' }, }, - // `roles` → `positions` is a COLLECTION-KEY rename (`stack-roles-to-positions`, - // ADR-0090 D3). The raw reader looks for `positions` and finds nothing. - roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + // Canonical since #25767: the `roles` → `positions` collection-key rename + // (`stack-roles-to-positions`, ADR-0090 D3) aged out, and `roles` is a + // strict unknown key on the stack definition now. + positions: [{ name: 'sales_rep', label: 'Sales Rep' }], permissions: [ { name: 'support_agent', @@ -109,8 +119,9 @@ const ARTIFACT = { allowPurge: false, }, }, - // `priority` is a `retiredKey()` tombstone on the RLS policy - // (`permission-rls-priority-removed`). + // No `priority`: its tombstone (`permission-rls-priority-removed`) + // aged out at #25767, so the key is a strict unknown key on the + // RLS policy now, not a conversion-governed one. rowLevelSecurity: [ { name: 'own_tasks', @@ -118,7 +129,6 @@ const ARTIFACT = { operation: 'select', using: 'assignee == current_user.email', enabled: true, - priority: 10, }, ], }, @@ -129,13 +139,15 @@ const ARTIFACT = { name: 'share_open_deals', type: 'criteria', object: 'crm_deal', - // Both legacy spellings are REJECTED by the current schema: - // `accessLevel: 'full'` (→ 'edit') and the recipient type - // `'role'` (→ 'position'). A raw copy of this item is not merely - // stale — it is unparseable at the next re-validating seam. + // The legacy level is REJECTED by the current schema: + // `accessLevel: 'full'` (→ 'edit', the 17.7.0 conversion). A raw + // copy of this item is not merely stale — it is unparseable at the + // next re-validating seam. The recipient is written canonically: + // the `'role'` → `'position'` reconcile aged out at #25767, and + // `'role'` is an invalid option at the door now. accessLevel: 'full', condition: 'record.status == "open"', - sharedWith: { type: 'role', value: 'sales_mgr' }, + sharedWith: { type: 'position', value: 'sales_mgr' }, }, ], }; @@ -233,19 +245,20 @@ function diffPaths(a: any, b: any, at = ''): string[] { /** * The keys the ADR-0087 conversion layer governs on these collections — the * axis #12844 was about, enumerated from the registry - * (`packages/spec/src/conversions/registry.ts`): the two `permissions` - * entries, the two `sharingRules` entries, and the `roles` -> `positions` - * collection rename. Nothing else in that registry reaches the four security - * collections. + * (`packages/spec/src/conversions/registry.ts`): the `permissions` grant-bit + * entry (`permission-allow-restore-purge-removed`, 17.2.0) and the + * `sharingRules` level entry (`sharing-rule-access-level-full-to-edit`, + * 17.7.0). Nothing else in that registry reaches the four security + * collections: the RLS `priority`, the `'role'` recipient and the `roles` -> + * `positions` rename left it at #25767 (a kit retired at or below 16.x ages + * out one major on) and are strict refusals now, not conversions. */ const CONVERSION_GOVERNED_PATHS = [ 'objects.crm_ticket.allowRestore', 'objects.crm_ticket.allowPurge', 'objects.crm_lead.allowRestore', 'objects.crm_lead.allowPurge', - 'rowLevelSecurity.0.priority', 'accessLevel', - 'sharedWith.type', ]; /** The ADR-0010 envelope `applyProtection` stamps from the manifest. */ @@ -301,16 +314,46 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security // seam (Studio re-save through `saveMetaItem`) would apply. const raw = bytes(); expect(raw.permissions[0].objects.crm_ticket.allowRestore).toBe(true); - expect(raw.permissions[0].rowLevelSecurity[0].priority).toBe(10); expect(raw.sharingRules[0].accessLevel).toBe('full'); - expect(raw.sharingRules[0].sharedWith.type).toBe('role'); - expect(raw.positions).toBeUndefined(); - expect(raw.roles).toHaveLength(1); + // Written canonically since #25767: no conversion reaches these now. + expect(raw.permissions[0].rowLevelSecurity[0]).not.toHaveProperty('priority'); + expect(raw.sharingRules[0].sharedWith.type).toBe('position'); + expect(raw.positions).toHaveLength(1); + expect(raw.roles).toBeUndefined(); // And the raw bytes are genuinely unparseable as authored. expect(ObjectStackDefinitionSchema.safeParse(raw).success).toBe(false); }); + it('[#25767] the aged-out ≤16.x spellings are refused at the door outright — strict unknown keys naming the upgrade guide, an invalid recipient option — with no conversion to reach them', () => { + // Each kit retired at or below protocol 16.x left the registry one + // major on: `stack-roles-to-positions`, `permission-rls-priority-removed` + // and the `'role'` recipient reconcile. The strict parse the door + // applies says so by name; the ADR-0057 reader has nothing to convert. + const messages = (r: ReturnType) => + r.success ? '' : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'); + + const withRoles = { ...bytes(), roles: [{ name: 'legacy_rep', label: 'Legacy Rep' }] }; + const roles = ObjectStackDefinitionSchema.safeParse(withRoles); + expect(roles.success).toBe(false); + expect(messages(roles)).toMatch(/Unrecognized key\(s\) on this stack definition: `roles`/); + expect(messages(roles)).toContain('https://objectstack.ai/docs/protocol-upgrade'); + + const withPriority = bytes(); + withPriority.permissions[0].rowLevelSecurity[0].priority = 10; + const priority = ObjectStackDefinitionSchema.safeParse(withPriority); + expect(priority.success).toBe(false); + expect(messages(priority)).toMatch(/Unrecognized key\(s\) on this RLS policy: `priority`/); + expect(messages(priority)).toContain('https://objectstack.ai/docs/protocol-upgrade'); + + const withRole = bytes(); + withRole.sharingRules[0].sharedWith = { type: 'role', value: 'sales_mgr' }; + const role = ObjectStackDefinitionSchema.safeParse(withRole); + expect(role.success).toBe(false); + expect(messages(role)).toMatch(/sharingRules\.0\.sharedWith\.type: Invalid option/); + expect(messages(role)).toContain('"position"'); + }); + // ── ARTIFACT boot: one registrar ────────────────────────────────────── it('artifact boot: AppPlugin registers NOTHING under the four security kinds — and the door reaches every one of them', async () => { @@ -391,7 +434,6 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security expect(perm.objects.crm_ticket).not.toHaveProperty('allowPurge'); expect(perm.objects.crm_lead).not.toHaveProperty('allowRestore'); expect(perm.objects.crm_lead).not.toHaveProperty('allowPurge'); - expect(perm.rowLevelSecurity[0]).not.toHaveProperty('priority'); expect(perm.objects.crm_ticket).toMatchObject({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, }); @@ -408,10 +450,13 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security expect(rule.sharedWith.type).toBe('position'); }); - it('non-artifact boot: positions — the collection-key rename reaches AppPlugin', async () => { + it('non-artifact boot: positions — the canonical collection key reaches AppPlugin', async () => { const bundle = collapse(await readerBundle()); // Before #12844 this reader looked for `positions` on bytes that - // spelled the collection `roles`, and registered NOTHING. + // spelled the collection `roles`, and registered NOTHING; since #25767 + // `roles` has no conversion at all (a strict unknown key at the door), + // so the fixture spells the collection `positions` and this reader + // registers it as authored. expect(bundle.get('position:sales_rep')).toMatchObject({ name: 'sales_rep', label: 'Sales Rep', @@ -449,7 +494,6 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security expect(perm.objects.crm_ticket).not.toHaveProperty('allowPurge'); expect(perm.objects.crm_lead).not.toHaveProperty('allowRestore'); expect(perm.objects.crm_lead).not.toHaveProperty('allowPurge'); - expect(perm.rowLevelSecurity[0]).not.toHaveProperty('priority'); const rule = copy.get('sharing_rule:share_open_deals'); expect(rule.accessLevel).toBe('edit'); expect(rule.sharedWith.type).toBe('position'); diff --git a/packages/runtime/src/dispatcher-validation-error.real.test.ts b/packages/runtime/src/dispatcher-validation-error.real.test.ts index a85b4367463..d1361a9262a 100644 --- a/packages/runtime/src/dispatcher-validation-error.real.test.ts +++ b/packages/runtime/src/dispatcher-validation-error.real.test.ts @@ -193,12 +193,17 @@ async function postAnalyticsBody(body: unknown) { } describe('#3878 — entry validation reaches the wire as a 400, service untouched', () => { - it('the retired envelope answers 400 with the tombstone prescription', async () => { + // [#25767] The `{ cube, query: {…} }` envelope's tombstone aged out with the + // ≤16.x kits: the entry refuses `query` as a strict unknown key naming the + // upgrade guide, not with the per-key prescription it used to carry. + it('the retired envelope answers 400 as a strict unknown key naming the upgrade guide', async () => { const { res, query } = await postAnalyticsBody({ cube: 'x', query: { measures: ['count'] } }); expect(res.statusCode).toBe(400); expect(res.body.error.code).toBe('VALIDATION_FAILED'); - expect(res.body.error.message).toContain('top level'); + expect(res.body.error.message).toMatch(/Unrecognized key\(s\) on this analytics query: `query`/); + expect(res.body.error.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(res.body.error.message).not.toContain('top level'); expect(query).not.toHaveBeenCalled(); // A caller mistake, not a server fault: the reporter side-channel stays clear. expect(res.__obsRecordedError).toBeUndefined(); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0b92e0cfc69..6fedd310dae 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1023,14 +1023,17 @@ describe('HttpDispatcher', () => { return mockAnalytics; }; - it('rejects the retired {cube, query:{...}} envelope with the tombstone prescription', async () => { + // [#25767] The envelope's tombstone aged out with the ≤16.x + // kits: `query` is a strict unknown key naming the upgrade + // guide now, with no per-key prescription. + it('rejects the retired {cube, query:{...}} envelope as a strict unknown key naming the upgrade guide', async () => { const mockAnalytics = service(); await expect(dispatcher.dispatch( 'POST', '/analytics/query', { cube: 'x', query: { measures: ['count'] } }, {}, { request: {} }, )).rejects.toMatchObject({ name: 'ValidationError', code: 'VALIDATION_FAILED', - message: expect.stringContaining('top level'), + message: expect.stringMatching(/Unrecognized key\(s\) on this analytics query: `query`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/), }); expect(mockAnalytics.query).not.toHaveBeenCalled(); }); diff --git a/packages/runtime/src/standalone-stack-security-registrar.test.ts b/packages/runtime/src/standalone-stack-security-registrar.test.ts index 6226a2dd4e2..48856921e64 100644 --- a/packages/runtime/src/standalone-stack-security-registrar.test.ts +++ b/packages/runtime/src/standalone-stack-security-registrar.test.ts @@ -36,11 +36,16 @@ import '@objectstack/service-datasource'; /** * Same probe artifact as the two-reader harness: a legacy shape in every - * security collection, an `engines.protocol` floor that opens the door's - * conversion window. The values asserted below are the door's measured output - * for these bytes (DOOR_COPY in the harness). The range is `>=17.1.0`, not - * `^17.1.0`: this boot runs the ADR-0087 D1 handshake, which refuses a caret on - * 17 from protocol 18, and only the floor opens the window. + * security collection a 17.x conversion still reaches (the `allowRestore` / + * `allowPurge` grant bits, `accessLevel: 'full'`), an `engines.protocol` floor + * that opens the door's conversion window. The values asserted below are the + * door's measured output for these bytes (DOOR_COPY in the harness). The range + * is `>=17.1.0`, not `^17.1.0`: this boot runs the ADR-0087 D1 handshake, + * which refuses a caret on 17 from protocol 18, and only the floor opens the + * window. [#25767] The ≤16.x kits aged out, so `positions` and the `position` + * recipient are written canonically and the RLS carries no `priority`: the + * door refuses each legacy spelling outright now (pinned in the harness), and + * a boot over them would not start. */ const ARTIFACT = { manifest: { @@ -50,7 +55,7 @@ const ARTIFACT = { version: '2.0.0', engines: { protocol: '>=17.1.0' }, }, - roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + positions: [{ name: 'sales_rep', label: 'Sales Rep' }], permissions: [ { name: 'support_agent', @@ -59,7 +64,7 @@ const ARTIFACT = { crm_ticket: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowRestore: true, allowPurge: false }, }, rowLevelSecurity: [ - { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true, priority: 10 }, + { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, ], }, ], @@ -71,7 +76,7 @@ const ARTIFACT = { object: 'crm_deal', accessLevel: 'full', condition: 'record.status == "open"', - sharedWith: { type: 'role', value: 'sales_mgr' }, + sharedWith: { type: 'position', value: 'sales_mgr' }, }, ], }; @@ -154,7 +159,6 @@ describe('createStandaloneStack — the artifact boot serves ONE copy of each se expect(got.permission[0]).toMatchObject({ name: 'support_agent', isDefault: false, _packageVersion: '2.0.0' }); expect(got.permission[0].objects.crm_ticket).not.toHaveProperty('allowRestore'); expect(got.permission[0].objects.crm_ticket).toMatchObject({ allowTransfer: false, viewAllRecords: false, modifyAllRecords: false }); - expect(got.permission[0].rowLevelSecurity[0]).not.toHaveProperty('priority'); }, BOOT_TIMEOUT); it('control: the pre-step-2 composition (door + a default AppPlugin) reproduces the raw copy winning', async () => { diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index b1251cdfd47..9f6c55e92e6 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -39,8 +39,9 @@ // The two copies used to differ on two independent axes: // // CONVERSION the ADR-0087 forward conversion: the retired `allowRestore` / -// `allowPurge` grant bits, the RLS `priority`, the legacy -// `sharedWith.type: 'role'` and `accessLevel: 'full'`. +// `allowPurge` grant bits and the legacy `accessLevel: 'full'` +// (the RLS `priority` and `sharedWith.type: 'role'` rode it too, +// until #25767 aged their ≤16.x kits out; see the fixture). // PARSE the door's strict parse: schema defaults the registry copy has // never carried (`allowTransfer` / `viewAllRecords` / // `modifyAllRecords`, `isDefault`, a sharing rule's `active`, a @@ -121,16 +122,19 @@ import '@objectstack/service-datasource'; * Each declaration isolates one axis of triage's question: * * - `permissions[0]` — `allowRestore` / `allowPurge` are keys the current - * `PermissionSet` schema does not carry, and `rowLevelSecurity[].priority` is - * another; the forward conversion strips all three (on both copies since - * #22994), and the door's parse defaults three more grant bits in (on its - * copy only). So the first three read the CONVERSION axis and the defaults - * read the PARSE axis. + * `PermissionSet` schema does not carry; the forward conversion strips both + * (on both copies since #22994), and the door's parse defaults three more + * grant bits in (on its copy only). So the two bits read the CONVERSION axis + * and the defaults read the PARSE axis. (`rowLevelSecurity[].priority` read + * the conversion axis too until #25767: its ≤16.x kit aged out, the key is a + * strict unknown key at the door now, and the fixture no longer carries it.) * - `capabilities[0]` — no `scope`, so the door's schema default is observable * (triage question 3). - * - `sharingRules[0]` `share_legacy_deals` — BOTH legacy spellings at once - * (`sharedWith.type: 'role'`, `accessLevel: 'full'`), the commit 317132495 probe - * bytes. + * - `sharingRules[0]` `share_legacy_deals` — the commit 317132495 probe bytes + * less the `sharedWith.type: 'role'` spelling: that recipient reconcile aged + * out at #25767 (`'role'` is an invalid option at the door now), so the rule + * carries the legacy `accessLevel: 'full'` over a `position` recipient and + * is still the rule the seeder once dropped as unmappable. * - `sharingRules[1]` `share_legacy_level` — the legacy `accessLevel` ALONE, * over a recipient type the seeder accepts, so the `accessLevel` axis is not * masked by the recipient axis. @@ -161,7 +165,7 @@ const ARTIFACT = { rowLevelSecurity: [ { name: 'own_tasks', object: 'crm_task', operation: 'select', - using: 'assignee == current_user.email', enabled: true, priority: 10, + using: 'assignee == current_user.email', enabled: true, }, ], }, @@ -177,7 +181,7 @@ const ARTIFACT = { object: 'crm_deal', accessLevel: 'full', condition: 'record.status == "open"', - sharedWith: { type: 'role', value: 'sales_mgr' }, + sharedWith: { type: 'position', value: 'sales_mgr' }, }, { name: 'share_legacy_level', @@ -398,11 +402,11 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins const door = byName(doorCopy.permission, 'probe_agent'); // [#22994] CONVERGED on the conversion axis. The forward conversion strips - // the retired grant bits and the RLS `priority` from BOTH copies now, so the - // registry's grant map is the authored map less those two keys, and its RLS - // entry is the door's, whole. (Measured raw before #22994: - // `allowRestore: true, allowPurge: false` and `priority: 10` on the registry - // copy only.) + // the retired grant bits from BOTH copies now, so the registry's grant map + // is the authored map less those two keys, and its RLS entry is the door's, + // whole. (Measured raw before #22994: `allowRestore: true, allowPurge: + // false` — and `priority: 10`, whose kit aged out at #25767 — on the + // registry copy only.) expect(reg.objects.crm_ticket).toEqual({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, }); @@ -414,8 +418,6 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, ]); expect(reg.rowLevelSecurity).toEqual(door.rowLevelSecurity); - expect(reg.rowLevelSecurity[0]).not.toHaveProperty('priority'); - expect(door.rowLevelSecurity[0]).not.toHaveProperty('priority'); // Keys the door DEFAULTS IN and the registry copy has never had (the parse // axis, #14491's, unchanged). @@ -433,12 +435,13 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(rows.sys_permission_set.filter((r: any) => r.name === 'probe_agent')).toEqual([]); }); - it('`sharing_rule`: the registry copy carries the forward-converted `sharedWith.type` and `accessLevel` the door carries', () => { + it('`sharing_rule`: the registry copy carries the forward-converted `accessLevel` the door carries, and the authored recipient', () => { const regLegacy = byName(registryCopy.sharing_rule, 'share_legacy_deals'); const doorLegacy = byName(doorCopy.sharing_rule, 'share_legacy_deals'); // [#22994] CONVERGED: both copies carry the conversion of the legacy - // `role` / `full` spellings. (Measured raw before #22994: the registry copy - // kept `{ type: 'role' }` and `'full'`.) + // `full` spelling. (Measured raw before #22994: the registry copy kept + // `'full'` — and `{ type: 'role' }`, whose conversion aged out at #25767; + // the recipient is authored `position` now and reaches both copies as is.) expect(regLegacy.sharedWith).toEqual({ type: 'position', value: 'sales_mgr' }); expect(doorLegacy.sharedWith).toEqual({ type: 'position', value: 'sales_mgr' }); expect(regLegacy.sharedWith).toEqual(doorLegacy.sharedWith); @@ -470,13 +473,16 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins }); }); - it('the rule whose recipient the door converts is SERVED, as a position share, exactly once — and no row is written', () => { + it('the rule the seeder once dropped as unmappable is SERVED, as a position share, exactly once — and no row is written', () => { // AN AUTHORIZATION OUTCOME. `mapRecipientType` still refuses `'role'`, and // used to: before #22994 the registry copy kept the legacy recipient, the // seeder took its `skipped (unmappable recipient)` branch, and this rule had - // NO row (three declared, two seeded). [#22994] The registry copy now - // carries the forward conversion, so the seeder is handed the recipient the - // door's copy says, `position`, which `mapRecipientType` accepts. + // NO row (three declared, two seeded). [#22994] The registry copy carried + // the forward conversion, so the seeder was handed `position`. [#25767] That + // conversion aged out with the ≤16.x kits: a `'role'` recipient is refused + // at the door before any seeder runs (the two-reader harness pins the + // refusal), so the rule authors `position`, and what is measured here is + // that the authored recipient reaches the seeder unchanged on both copies. expect(byName(doorCopy.sharing_rule, 'share_legacy_deals').sharedWith.type).toBe('position'); expect(byName(registryCopy.sharing_rule, 'share_legacy_deals').sharedWith.type).toBe('position'); expect(byName(served, 'share_legacy_deals')).toMatchObject({ diff --git a/packages/services/service-automation/src/builtin/config-parse.test.ts b/packages/services/service-automation/src/builtin/config-parse.test.ts index 232802ad7d0..c4e4f109efc 100644 --- a/packages/services/service-automation/src/builtin/config-parse.test.ts +++ b/packages/services/service-automation/src/builtin/config-parse.test.ts @@ -296,29 +296,38 @@ describe('execute-time config parse (#4277)', () => { }); it('script refuses a retired email stub — it used to log a line and report success', async () => { - const engine = engineWith(); - // `registerFlow` strips the retired keys on rehydration (#3903), so what - // reaches the parse is a node with nothing to run. Before #4343 this was a - // green step that delivered no mail. Since #20316 the flow parse behind - // that conversion refuses the stripped node itself — it names no - // `function` — so the flow no longer registers; the run below meets the - // same stripped shape past the doors. - expect(doorRefusal('script', { + // `registerFlow` used to strip the retired keys on rehydration (#3903), so + // what reached the parse was a node with nothing to run. Before #4343 this + // was a green step that delivered no mail; since #20316 the flow parse + // behind that conversion refused the stripped node for naming no + // `function`. [#25767] The `actionType` stubs were ≤16.x retirements, and a + // retirement kit lives one protocol major past the major it retired in: + // their conversion left the registry at 18, so nothing strips the keys any + // more — the strict script shape refuses them outright, naming each key + // and the protocol upgrade guide, and the flow no longer registers. Same + // verdict (no mail is ever sent silently), one door earlier again. + const refusal = doorRefusal('script', { actionType: 'email', template: 'task_done', recipients: ['{record.owner}'], - })).toContain("config leaves out `function`"); - const result = await runStripped(engine, 'script', { + }); + expect(refusal).toMatch(/Unrecognized key\(s\) on this script node config: `actionType`, `template`, `recipients`/); + expect(refusal).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Naming a callable beside the stub does not rescue it: the retired keys + // are refused, not stripped around `function`. + expect(doorRefusal('script', { function: 'send_mail', actionType: 'email', template: 'task_done', recipients: ['{record.owner}'], - }, ['function']); - expect(result.success).toBe(false); - expect(result.error).toContain('does not satisfy the script contract'); + })).toMatch(/Unrecognized key\(s\) on this script node config: `actionType`, `template`, `recipients`/); }); it('a script parse refusal is a guard — a fault edge does NOT route it', async () => { const engine = engineWith(); + // [#25767] The retired `actionType` / `template` keys no longer ride along + // (they are refused at registration, above), so the guard is exercised on + // the bare contract: the registered node loses its `function` past the + // doors and the execute-time parse refuses it. const result = await runStripped( engine, 'script', - { function: 'post_to_slack', actionType: 'slack', template: 't' }, + { function: 'post_to_slack' }, ['function'], { nodes: [{ id: 'recover', type: 'assignment', label: 'R', config: { recovered: true } }], diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index d9905bd6cb7..90c2f8e4fbd 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -144,6 +144,15 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, # * script node that leaves it out — so the stored flow no longer REGISTERS (at * boot: skipped with a `failed to register flow` warn naming it), rather than * registering and refusing at run time. Same verdict, named the same way. + * + * [#25767] The `actionType` branches were ≤16.x retirements, and a retirement + * kit lives one protocol major past the major it retired in: at 18 their + * tombstones and their conversion left the registry together. A stored flow + * still carrying one meets neither channel now — the strict script shape + * refuses the key as unknown, naming the protocol upgrade guide (which carries + * the prescription the kit used to), and the row is for `os migrate meta` on a + * release that still carried the step. The `function`-less refusal below is + * unchanged; the shorthand-to-`function` move is gone with the kit. */ describe('script retired branches, as a stored flow meets them (#4343)', () => { let engine: AutomationEngine; @@ -165,13 +174,15 @@ describe('script retired branches, as a stored flow meets them (#4343)', () => { expect(await engine.getFlow('script_flow')).toBeNull(); }); - it('converts a shorthand `actionType` into the function it always named, and runs it', async () => { + it('[#25767] a stored flow carrying the aged-out `actionType` shorthand no longer registers — refused naming the key and the upgrade guide', async () => { // The one retired value carrying real intent: `actionType` that matched - // no built-in was a function name (#1870), so the conversion moves it - // rather than dropping it — and the node keeps working. - engine.registerFlow('script_flow', scriptFlow({ actionType: 'score_lead' })); - const result = await engine.execute('script_flow', {} as any); - expect(result.success).toBe(true); + // no built-in was a function name (#1870), and the ≤16.x conversion + // moved it to `function`. That kit aged out of the registry, so no step + // of the chain replays it: the key is refused outright, and the flow is + // never registered. + expect(() => engine.registerFlow('script_flow', scriptFlow({ actionType: 'score_lead' }))) + .toThrow(/Unrecognized key\(s\) on this script node config: `actionType`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); + expect(await engine.getFlow('script_flow')).toBeNull(); }); it('accepts the converged shape', async () => { @@ -180,13 +191,16 @@ describe('script retired branches, as a stored flow meets them (#4343)', () => { expect(result.success).toBe(true); }); - it('the tombstones still refuse an AUTHORED key outright, prescription and all', () => { + it('the strict shape still refuses an AUTHORED retired key outright — as an unknown key naming the upgrade guide', () => { // The other channel: no conversion runs in front of a direct parse, so // this is what `tsc` and `os validate` put in front of an author. + // [#25767] The tombstone's own prescription aged out with the kit; the + // refusal now names the key and the guide that carries the prescription. const result = ScriptConfigSchema.safeParse({ function: 'score_lead', actionType: 'email' }); expect(result.success).toBe(false); - expect(result.error!.issues[0]!.message).toMatch(/`script\.config\.actionType` was removed/); - expect(result.error!.issues[0]!.message, 'the prescription teaches the replacement, never a tracker id') + expect(result.error!.issues[0]!.message).toMatch(/Unrecognized key\(s\) on this script node config: `actionType`/); + expect(result.error!.issues[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(result.error!.issues[0]!.message, 'the refusal teaches where the prescription lives, never a tracker id') .not.toMatch(/#\d{3,5}/); expect(ScriptConfigSchema.parse({ function: 'score_lead', diff --git a/packages/services/service-automation/src/builtin/wait-node.test.ts b/packages/services/service-automation/src/builtin/wait-node.test.ts index ac4c65eb0b8..7b7f8a5dc8c 100644 --- a/packages/services/service-automation/src/builtin/wait-node.test.ts +++ b/packages/services/service-automation/src/builtin/wait-node.test.ts @@ -110,6 +110,13 @@ describe('parseIsoDuration', () => { * is EXTRACTED from the live message, never compared to a copy: a hard-coded * `'60000'` would go green the moment someone reworded the prose, which is * exactly when this needs to be checked. + * + * [#25767] The `timeoutMs` tombstone was a ≤16.x retirement, and a retirement + * kit lives one protocol major past the major it retired in: at 18 it left the + * shape, so `timeoutMs` is a strict unknown key whose prescription lives in the + * protocol upgrade guide, which this package does not read. The `timeout` + * misspelling's `guidance` entry is the channel still printing a + * `timerDuration` value here, so it is the one held to the round trip. */ describe("the spec's `timeoutMs` → `timerDuration` prescription round-trips (#6758)", () => { const waitNode = (waitEventConfig: Record) => ({ @@ -122,13 +129,13 @@ describe("the spec's `timeoutMs` → `timerDuration` prescription round-trips (# }; it('every `timerDuration` it prints parses AND yields the wait it promises', () => { - // Channel 1 — the tombstone itself. Channel 2 — the `timeout` misspelling's - // `guidance` entry, which repeats the same advice to an author who has had - // no first failure to learn from. - const tombstone = issueMessage({ eventType: 'timer', timeoutMs: 60_000 }, 'invalid_type'); + // The `timeout` misspelling's `guidance` entry repeats the advice to an + // author who has had no first failure to learn from. ([#25767] The + // `timeoutMs` tombstone, the first channel until the ≤16.x kits aged out, + // is pinned below as the strict refusal it is now.) const guidance = issueMessage({ eventType: 'timer', timeout: 60_000 }, 'unrecognized_keys'); - for (const [channel, message] of Object.entries({ tombstone, guidance })) { + for (const [channel, message] of Object.entries({ guidance })) { // Anti-vacuity: gut either channel and this fails, rather than the loop // below passing because it found nothing to check. expect(message, `${channel}: raised no message at all`).toBeDefined(); @@ -156,20 +163,15 @@ describe("the spec's `timeoutMs` → `timerDuration` prescription round-trips (# } } - // The tombstone does not merely prescribe a value, it claims an EQUALITY: - // "`timeoutMs: N` and `timerDuration: X` the same wait". Read both sides out - // of the message and hold it to that claim. - const claimedMs = Number(/`timeoutMs:\s*(\d+)`/.exec(tombstone!)?.[1]); - expect(claimedMs, 'the tombstone must still name the `timeoutMs` wait it is equating') - .toBeGreaterThan(0); - for (const literal of [...tombstone!.matchAll(/`timerDuration:\s*([^`]+)`/g)].map((m) => m[1])) { - const authored = JSON.parse(literal.replace(/^'(.*)'$/, '"$1"')) as string; - expect( - parseIsoDuration(authored), - `the tombstone equates \`timeoutMs: ${claimedMs}\` with \`timerDuration: ${literal}\`, ` - + 'but they are not the same wait', - ).toBe(claimedMs); - } + }); + + it('[#25767] the aged-out `timeoutMs` is refused as a strict unknown key naming the upgrade guide', () => { + // Its kit (tombstone, entry, conversion) left at 18 under the one-major + // rule, so the prescription that used to equate `timeoutMs: N` with a + // `timerDuration` lives in the guide now, and the parse says where. + const message = issueMessage({ eventType: 'timer', timeoutMs: 60_000 }, 'unrecognized_keys'); + expect(message).toMatch(/`timeoutMs`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts index 8b5e421d532..21c9175b112 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts @@ -414,11 +414,18 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => { // #4456 — this restore path is a stored-row rehydration seam (ADR-0087 D2 // addendum, #3903): it reads sys_metadata directly, so it must replay the - // conversion chain itself. A row persisted before the #4410 config gate may - // carry the legacy spellings the factory's deleted `??` fallbacks used to - // tolerate; without the replay, a sqlite `file:` row would silently fall - // back to `:memory:` — the data-loss shape the conversion exists to prevent. - it('restores a pre-#4410 row with legacy config keys CANONICAL (conversion chain replayed)', async () => { + // conversion chain itself. [#25767] The chain it replays is the REGISTRY, + // and a kit retired at or below protocol 16.x leaves the registry one major + // on: the pre-#4410 config-key aliases (`file` → `filename`, `connectionString` + // / `user` → `url` / `username`, the mongo `uri` → `url`) are no longer + // conversions, so a row still spelled that way is served AS STORED — and the + // factory's deleted `??` fallbacks mean a sqlite `file:` row would open + // `:memory:`, the shape the retired conversion used to prevent. Per the + // ruling, such a row is canonicalized once with `os migrate meta --stored + // --apply` on 18.1 or earlier; nothing at 18.2 rewrites it. What the seam + // still replays is every 17.x row — the driver alias `mongo` → `mongodb` + // (`datasource-driver-mongo-to-mongodb`, 17.7.0) below. + it('restores a stored row through the registry: a 17.x kit still replays (the mongo driver alias); the aged-out ≤16.x config aliases are served as stored', async () => { const data = fakeSysMetadataEngine(); const now = new Date().toISOString(); for (const [name, driver, config] of [ @@ -444,14 +451,16 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => { // The list DTO is a summary; `getDatasource` (concrete service) is the // config-bearing read the admin routes serve. const svc = b.service as unknown as DatasourceAdminService; - expect((await svc.getDatasource('legacy_sqlite'))?.config).toEqual({ filename: '/tmp/legacy.db' }); + // Served as stored: no conversion reaches these spellings any more. + expect((await svc.getDatasource('legacy_sqlite'))?.config).toEqual({ file: '/tmp/legacy.db' }); expect((await svc.getDatasource('legacy_pg'))?.config).toEqual({ - url: 'postgresql://db.internal/analytics', - username: 'analyst', - }); - expect((await svc.getDatasource('legacy_mongo'))?.config).toEqual({ - url: 'mongodb://mongo.internal:27017/events', + connectionString: 'postgresql://db.internal/analytics', + user: 'analyst', }); + // The 17.7.0 driver alias still replays; the ≤16.x `uri` alias does not. + const mongo = await svc.getDatasource('legacy_mongo'); + expect(mongo?.driver).toBe('mongodb'); + expect(mongo?.config).toEqual({ uri: 'mongodb://mongo.internal:27017/events' }); }); it('removes the durable sys_metadata row when a datasource is deleted', async () => { diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 6828216ccc9..18207f6fb14 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -66,6 +66,7 @@ "ObjectStackRawIssue (type)", "P (const)", "PLURAL_TO_SINGULAR (const)", + "PROTOCOL_UPGRADE_GUIDE_URL (const)", "Predicate (type)", "PredicateInput (type)", "PredicateInputSchema (const)", @@ -73,6 +74,7 @@ "Protection (type)", "ProtectionSchema (const)", "QUALIFIED_ITEM_NAME_PATTERN (const)", + "RETIRED_KEY_UPGRADE_GUIDE_SENTENCE (const)", "RateLimitConfig (type)", "RateLimitConfigParsed (type)", "RateLimitConfigSchema (const)", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index 63ab9814a7a..37f29b34faa 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -24,7 +24,6 @@ "ai/Agent:avatar", "ai/Agent:guardrails", "ai/Agent:instructions", - "ai/Agent:knowledge [RETIRED]", "ai/Agent:label", "ai/Agent:lifecycle [RETIRED]", "ai/Agent:memory", @@ -37,7 +36,6 @@ "ai/Agent:skills", "ai/Agent:structuredOutput", "ai/Agent:surface", - "ai/Agent:tools [RETIRED]", "ai/BlueprintApp:icon", "ai/BlueprintApp:label", "ai/BlueprintApp:name", @@ -317,7 +315,6 @@ "ai/Skill:surface", "ai/Skill:tools", "ai/Skill:triggerConditions", - "ai/Skill:triggerPhrases [RETIRED]", "ai/SkillTriggerCondition:field", "ai/SkillTriggerCondition:operator", "ai/SkillTriggerCondition:value", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index b518780b24e..c9023d7c766 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -71,12 +71,10 @@ "api/AnalyticsMetadataResponse:success", "api/AnalyticsQueryRequest:cube", "api/AnalyticsQueryRequest:dimensions", - "api/AnalyticsQueryRequest:format [RETIRED]", "api/AnalyticsQueryRequest:limit", "api/AnalyticsQueryRequest:measures", "api/AnalyticsQueryRequest:offset", "api/AnalyticsQueryRequest:order", - "api/AnalyticsQueryRequest:query [RETIRED]", "api/AnalyticsQueryRequest:timeDimensions", "api/AnalyticsQueryRequest:timezone", "api/AnalyticsQueryRequest:where", @@ -352,7 +350,6 @@ "api/CreateFlowRequest:_packageId", "api/CreateFlowRequest:_packageVersion", "api/CreateFlowRequest:_provenance", - "api/CreateFlowRequest:active [RETIRED]", "api/CreateFlowRequest:description", "api/CreateFlowRequest:edges", "api/CreateFlowRequest:errorHandling", @@ -365,7 +362,6 @@ "api/CreateFlowRequest:runAs", "api/CreateFlowRequest:status", "api/CreateFlowRequest:successMessage", - "api/CreateFlowRequest:template [RETIRED]", "api/CreateFlowRequest:type", "api/CreateFlowRequest:variables", "api/CreateFlowRequest:version", @@ -1267,7 +1263,6 @@ "api/PackageInstallManifest:id", "api/PackageInstallManifest:installModes", "api/PackageInstallManifest:integrity", - "api/PackageInstallManifest:loading [RETIRED]", "api/PackageInstallManifest:main", "api/PackageInstallManifest:name", "api/PackageInstallManifest:namespace", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 7347885224e..ff55e5f347a 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -157,7 +157,6 @@ "automation/Flow:_packageId", "automation/Flow:_packageVersion", "automation/Flow:_provenance", - "automation/Flow:active [RETIRED]", "automation/Flow:description", "automation/Flow:edges", "automation/Flow:errorHandling", @@ -170,7 +169,6 @@ "automation/Flow:runAs", "automation/Flow:status", "automation/Flow:successMessage", - "automation/Flow:template [RETIRED]", "automation/Flow:type", "automation/Flow:variables", "automation/Flow:version", @@ -189,7 +187,6 @@ "automation/FlowNode:id", "automation/FlowNode:inputSchema", "automation/FlowNode:label", - "automation/FlowNode:outputSchema [RETIRED]", "automation/FlowNode:position", "automation/FlowNode:timeoutMs", "automation/FlowNode:type", @@ -286,7 +283,6 @@ "automation/RetryPolicy:jitter", "automation/RetryPolicy:maxRetries", "automation/RetryPolicy:maxRetryDelayMs", - "automation/RetryPolicy:retryDelayMs [RETIRED]", "automation/ScreenConfig:defaults", "automation/ScreenConfig:description", "automation/ScreenConfig:fields", @@ -308,14 +304,9 @@ "automation/ScreenFieldConfig:required", "automation/ScreenFieldConfig:type", "automation/ScreenFieldConfig:visibleWhen", - "automation/ScriptConfig:actionType [RETIRED]", "automation/ScriptConfig:function", "automation/ScriptConfig:inputs", "automation/ScriptConfig:outputVariable", - "automation/ScriptConfig:recipients [RETIRED]", - "automation/ScriptConfig:script [RETIRED]", - "automation/ScriptConfig:template [RETIRED]", - "automation/ScriptConfig:variables [RETIRED]", "automation/SubflowConfig:flowName", "automation/SubflowConfig:input", "automation/SubflowConfig:outputVariable", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index f4a99d2a94e..dc6c87af478 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -327,7 +327,6 @@ "data/Field:allowCreate", "data/Field:autonumberFormat", "data/Field:conditionalFormatting", - "data/Field:conditionalRequired [RETIRED]", "data/Field:currencyConfig", "data/Field:defaultValue", "data/Field:deleteBehavior", @@ -503,8 +502,6 @@ "data/ImportFieldMapping:transform", "data/Index:fields", "data/Index:name", - "data/Index:partial [RETIRED]", - "data/Index:type [RETIRED]", "data/Index:unique", "data/InlineGridColumn:accept", "data/InlineGridColumn:autofill", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index a7df5d9aa3d..5a9747c566a 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -272,7 +272,6 @@ "kernel/Manifest:id", "kernel/Manifest:installModes", "kernel/Manifest:integrity", - "kernel/Manifest:loading [RETIRED]", "kernel/Manifest:main", "kernel/Manifest:name", "kernel/Manifest:namespace", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 83730c36570..13da8402f23 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -163,7 +163,6 @@ "security/RowLevelSecurityPolicy:object", "security/RowLevelSecurityPolicy:operation", "security/RowLevelSecurityPolicy:positions", - "security/RowLevelSecurityPolicy:priority [RETIRED]", "security/RowLevelSecurityPolicy:tags [RETIRED]", "security/RowLevelSecurityPolicy:using", "security/SharingRule:_lock", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 7841b2ae9d0..25b039d86c2 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -984,7 +984,6 @@ "system/RetryPolicy:jitter", "system/RetryPolicy:maxRetries", "system/RetryPolicy:maxRetryDelayMs", - "system/RetryPolicy:retryDelayMs [RETIRED]", "system/RouteHandlerMetadata:handler", "system/RouteHandlerMetadata:metadata", "system/RouteHandlerMetadata:method", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 3529ea0da68..607e06186fb 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -14,13 +14,11 @@ "ui/Action:body", "ui/Action:bodyExtra", "ui/Action:bodyShape", - "ui/Action:bulkEnabled [RETIRED]", "ui/Action:component", "ui/Action:confirmText", "ui/Action:description", "ui/Action:disabled", "ui/Action:errorMessage", - "ui/Action:execute [RETIRED]", "ui/Action:execution", "ui/Action:icon", "ui/Action:label", @@ -45,7 +43,6 @@ "ui/Action:requiresFeature", "ui/Action:requiresMembershipReach", "ui/Action:resultDialog", - "ui/Action:shortcut [RETIRED]", "ui/Action:successMessage", "ui/Action:target", "ui/Action:type", @@ -173,27 +170,19 @@ "ui/App:_provenance", "ui/App:_unpublished", "ui/App:active", - "ui/App:apis [RETIRED]", "ui/App:areas", - "ui/App:aria [RETIRED]", "ui/App:branding", "ui/App:contextSelectors", "ui/App:defaultAgent", "ui/App:description", - "ui/App:embed [RETIRED]", "ui/App:hidden", - "ui/App:homePageId [RETIRED]", "ui/App:icon", "ui/App:isDefault", "ui/App:label", - "ui/App:mobileNavigation [RETIRED]", "ui/App:name", "ui/App:navigation", - "ui/App:objects [RETIRED]", "ui/App:protection", "ui/App:requiredPermissions", - "ui/App:sharing [RETIRED]", - "ui/App:version [RETIRED]", "ui/AppBranding:accentColor", "ui/AppBranding:favicon", "ui/AppBranding:logo", @@ -315,7 +304,6 @@ "ui/Dashboard:_packageId", "ui/Dashboard:_packageVersion", "ui/Dashboard:_provenance", - "ui/Dashboard:aria [RETIRED]", "ui/Dashboard:columns", "ui/Dashboard:dateRange", "ui/Dashboard:description", @@ -324,7 +312,6 @@ "ui/Dashboard:header", "ui/Dashboard:label", "ui/Dashboard:name", - "ui/Dashboard:performance [RETIRED]", "ui/Dashboard:protection", "ui/Dashboard:refreshInterval [RETIRED]", "ui/Dashboard:refreshIntervalSeconds", @@ -350,10 +337,6 @@ "ui/DashboardNavItem:requiresService", "ui/DashboardNavItem:type", "ui/DashboardNavItem:visible", - "ui/DashboardWidget:actionIcon [RETIRED]", - "ui/DashboardWidget:actionType [RETIRED]", - "ui/DashboardWidget:actionUrl [RETIRED]", - "ui/DashboardWidget:aria [RETIRED]", "ui/DashboardWidget:chartConfig", "ui/DashboardWidget:colorVariant", "ui/DashboardWidget:compareTo", @@ -367,7 +350,6 @@ "ui/DashboardWidget:options", "ui/DashboardWidget:requiresObject", "ui/DashboardWidget:requiresService", - "ui/DashboardWidget:responsive [RETIRED]", "ui/DashboardWidget:suppressWarnings", "ui/DashboardWidget:title", "ui/DashboardWidget:type", @@ -490,16 +472,13 @@ "ui/ElementNumberProps:prefix", "ui/ElementNumberProps:suffix", "ui/ElementRecordPickerProps:aria", - "ui/ElementRecordPickerProps:displayField [RETIRED]", "ui/ElementRecordPickerProps:emptyText", "ui/ElementRecordPickerProps:filter [RETIRED]", "ui/ElementRecordPickerProps:label", "ui/ElementRecordPickerProps:labelField", "ui/ElementRecordPickerProps:limit [RETIRED]", - "ui/ElementRecordPickerProps:multiple [RETIRED]", "ui/ElementRecordPickerProps:object [RETIRED]", "ui/ElementRecordPickerProps:placeholder", - "ui/ElementRecordPickerProps:searchFields [RETIRED]", "ui/ElementRecordPickerProps:sort [RETIRED]", "ui/ElementRecordPickerProps:targetVariable [RETIRED]", "ui/ElementRecordPickerProps:valueField", @@ -576,11 +555,9 @@ "ui/FormSelectOption:value", "ui/FormSelectOption:visibleWhen", "ui/FormView:allowSkip", - "ui/FormView:aria [RETIRED]", "ui/FormView:buttons", "ui/FormView:columns", "ui/FormView:data", - "ui/FormView:defaultSort [RETIRED]", "ui/FormView:defaultTab", "ui/FormView:defaults", "ui/FormView:description", @@ -746,7 +723,6 @@ "ui/ListView:allowPrinting", "ui/ListView:appearance", "ui/ListView:aria", - "ui/ListView:bordered [RETIRED]", "ui/ListView:bulkActionDefs", "ui/ListView:bulkActions", "ui/ListView:calendar", @@ -773,10 +749,8 @@ "ui/ListView:navigation", "ui/ListView:pageName [RETIRED]", "ui/ListView:pagination", - "ui/ListView:performance [RETIRED]", "ui/ListView:requiredPermissions", "ui/ListView:resizable", - "ui/ListView:responsive [RETIRED]", "ui/ListView:rowActions", "ui/ListView:rowColor", "ui/ListView:rowHeight", @@ -785,14 +759,12 @@ "ui/ListView:sharing", "ui/ListView:showRecordCount", "ui/ListView:sort", - "ui/ListView:striped [RETIRED]", "ui/ListView:tabs [RETIRED]", "ui/ListView:timeline", "ui/ListView:tree", "ui/ListView:type", "ui/ListView:userActions", "ui/ListView:userFilters", - "ui/ListView:virtualScroll [RETIRED]", "ui/NavigationArea:description", "ui/NavigationArea:icon", "ui/NavigationArea:id", @@ -933,7 +905,6 @@ "ui/ObjectListView:allowPrinting", "ui/ObjectListView:appearance", "ui/ObjectListView:aria", - "ui/ObjectListView:bordered [RETIRED]", "ui/ObjectListView:bulkActionDefs", "ui/ObjectListView:bulkActions", "ui/ObjectListView:calendar", @@ -960,10 +931,8 @@ "ui/ObjectListView:navigation", "ui/ObjectListView:pageName [RETIRED]", "ui/ObjectListView:pagination", - "ui/ObjectListView:performance [RETIRED]", "ui/ObjectListView:requiredPermissions", "ui/ObjectListView:resizable", - "ui/ObjectListView:responsive [RETIRED]", "ui/ObjectListView:rowActions", "ui/ObjectListView:rowColor", "ui/ObjectListView:rowHeight", @@ -972,14 +941,12 @@ "ui/ObjectListView:sharing", "ui/ObjectListView:showRecordCount", "ui/ObjectListView:sort", - "ui/ObjectListView:striped [RETIRED]", "ui/ObjectListView:tabs [RETIRED]", "ui/ObjectListView:timeline", "ui/ObjectListView:tree", "ui/ObjectListView:type", "ui/ObjectListView:userActions", "ui/ObjectListView:userFilters", - "ui/ObjectListView:virtualScroll [RETIRED]", "ui/ObjectMapProps:data", "ui/ObjectMapProps:enableClustering", "ui/ObjectMapProps:filter", @@ -1092,9 +1059,7 @@ "ui/PageAccordionProps:aria", "ui/PageAccordionProps:items", "ui/PageAccordionProps:variant", - "ui/PageCardProps:actions [RETIRED]", "ui/PageCardProps:aria", - "ui/PageCardProps:body [RETIRED]", "ui/PageCardProps:bordered", "ui/PageCardProps:children", "ui/PageCardProps:footer", @@ -1116,7 +1081,6 @@ "ui/PageHeaderProps:actions", "ui/PageHeaderProps:aria", "ui/PageHeaderProps:breadcrumb [RETIRED]", - "ui/PageHeaderProps:icon [RETIRED]", "ui/PageHeaderProps:maxVisible", "ui/PageHeaderProps:mobileMaxVisible", "ui/PageHeaderProps:recordChrome", @@ -1158,7 +1122,6 @@ "ui/PageTabsProps:items", "ui/PageTabsProps:position", "ui/PageTabsProps:tabStyle", - "ui/PageTabsProps:type [RETIRED]", "ui/PageVariable:defaultValue", "ui/PageVariable:name", "ui/PageVariable:source", @@ -1201,7 +1164,6 @@ "ui/RecordDetailsProps:fields", "ui/RecordDetailsProps:hideFields", "ui/RecordDetailsProps:inlineEdit", - "ui/RecordDetailsProps:layout [RETIRED]", "ui/RecordDetailsProps:redactFields", "ui/RecordDetailsProps:requiredPermissions", "ui/RecordDetailsProps:sections", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 7dd8c969ad6..c8fa87d4c2b 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -61,6 +61,7 @@ "ObjectStackRawIssue": "src/shared/error-map.zod.ts#ObjectStackRawIssue (type)", "P": "src/shared/expression.zod.ts#P (const)", "PLURAL_TO_SINGULAR": "src/meta-spelling/manifest-collection-spelling.ts#PLURAL_TO_SINGULAR (const)", + "PROTOCOL_UPGRADE_GUIDE_URL": "src/shared/protocol-upgrade-guide.ts#PROTOCOL_UPGRADE_GUIDE_URL (const)", "Predicate": "src/shared/expression.zod.ts#Predicate (type)", "PredicateInput": "src/shared/expression.zod.ts#PredicateInput (type)", "PredicateInputSchema": "src/shared/expression.zod.ts#PredicateInputSchema (const)", @@ -68,6 +69,7 @@ "Protection": "src/shared/protection.zod.ts#Protection (type)", "ProtectionSchema": "src/shared/protection.zod.ts#ProtectionSchema (const)", "QUALIFIED_ITEM_NAME_PATTERN": "src/shared/identifiers.zod.ts#QUALIFIED_ITEM_NAME_PATTERN (const)", + "RETIRED_KEY_UPGRADE_GUIDE_SENTENCE": "src/shared/protocol-upgrade-guide.ts#RETIRED_KEY_UPGRADE_GUIDE_SENTENCE (const)", "RateLimitConfig": "src/shared/http.zod.ts#RateLimitConfig (type)", "RateLimitConfigParsed": "src/shared/http.zod.ts#RateLimitConfigParsed (type)", "RateLimitConfigSchema": "src/shared/http.zod.ts#RateLimitConfigSchema (const)", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 3eead2d1341..70b6a516ea9 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -76,11 +76,6 @@ "evidence": "packages/runtime/src/sandbox/body-runner.ts#actionBodyRunnerFactory (`const raw = action.body`, in the factory whose own header calls it \"the ONE choke point where an `action.body` becomes an executable handler\" — both bind paths reach it: AppPlugin's collectBundleActions walk and engine.setDefaultActionRunner); packages/runtime/src/action-execution.ts#isHeadlessInvokableAction (gates headless dispatch on `action?.target || action?.body`)", "note": "server script (L1/L2) via engine.executeAction→body-runner. 2026-08-26: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which reads this key nowhere. Its 68 word-`body` occurrences are the inbound HTTP request body — the `body: any` parameter threaded through the handleX delegates (:890, :961, :1732, :1969 …) and the result envelope at :728 — plus the file's own \"Thin delegate — body extracted to ./domains/…\" extraction comments, where `body` means a function body. The consumer this note already NAMED in prose (body-runner) is where the read has always been; the surrounding action path was extracted into domains/actions.ts + action-execution.ts, and http-dispatcher.ts now only delegates (handleActions → handleActionsRequest at :1969-1970). WHY THE GATE COULD NOT SEE THE ROT: `body` is both a common English word and the name of the HTTP request member this dispatcher is built around, so the word-bounded key-mention check added in #11457 anchors on 68 unrelated hits and passes — the designed, honest limit of that signal, which is why this needed a hand call-graph read. Note the shape the repoint exposes: the NOTE's pointer (\"via engine.executeAction→body-runner\") stayed true the whole time while the EVIDENCE pointer rotted — the same split recorded on `requiredPermissions`, one layer over. 2026-08-28: RE-ANCHORED (commit 93ea19bca) — re-closed by hand against c459da6bc; both consumers stand where the 08-26 repoint put them, so the lines had not yet rotted and this is the grammar migration, not a second repair. The anchor is what makes that claim re-testable next time: `actionBodyRunnerFactory` is the symbol the header already calls the ONE choke point, so a consumer that moves inside this 798-line file keeps the pointer true and one that is deleted turns it red." }, - "execute": { - "status": "dead", - "verifiedAt": "2026-08-29", - "note": "REMOVED 2026-07-28 in protocol 17 (#3855, PR #3883) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-17 conversion `action-execute-to-target`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `target`, the only handler slot — rename the key, the value (a handler / flow / URL ref) is unchanged, and `os migrate meta --from 16` lists the mechanical edits. The tombstone is packages/spec/src/ui/action.zod.ts#execute, and packages/cli/src/utils/lower-callables.ts#lowerActionCallable deliberately declines to bind a function-valued `execute` so the tombstone fires instead of the alias silently working in one authoring style and being rejected in every other. LEDGER CORRECTED 2026-08-29 (commit cae2169cf) — the VERDICT was falsified, not the citation. This row read `live` on the claim that a .transform lowers execute -> target and DROPS the alias; no such transform exists. action.zod.ts has exactly two .transform calls (:526 and :1668) and both are lowerRequiresFeature, and the docblock above `target` (:559-562) says the alias was removed in protocol 17. The old pointer packages/spec/src/ui/action.zod.ts:581 was IN RANGE in an 1802-line file that names the key, so existence, line bound and key-mention were all green on it — what :581 holds today is a comment about the `global_nav` enum-VALUE retirement's declaration style, unrelated in every respect — and the entry carried no verifiedAt, so the re-verification clock never asked (the #12516 class in its purest form). HISTORY PRESERVED, because it is the argument for `target` being the single handler slot: DIVERGENCE RESOLVED in #3713 — before that fix three readers disagreed in two directions, the parse kept `target`, objectui ActionRunner did `execute || target`, and the CLI compile step (packages/cli/src/utils/lower-callables.ts) preferred a function on `execute`; #3713 made all three prefer `target`, and protocol 17 then removed the alias outright, so the both-declared conflict is unrepresentable rather than merely agreed-upon (mirrors agent.knowledge.topics -> sources, #1891). The server runtime never read `execute` at all — packages/runtime/src/action-execution.ts:525 gates on `target || body` and dispatches on `target`/`name` — so with the parse-time lowering gone there is no reader on any layer." - }, "params": { "status": "live", "verifiedAt": "2026-09-15", @@ -233,16 +228,6 @@ "status": "dead", "verifiedAt": "2026-09-28", "note": "REMOVED 2026-09-28 (#20323, ADR-0049 enforce-or-remove) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 conversion action-aria-removed. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); write the accessible name as the action's required `label`, and put a region name in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). The `live` grade this row replaces rested on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it: re-measured at the `.objectui-sha` pin f8a9d0fb05, none of the action surfaces (action:button/icon/menu/group/bar, the grid row and bulk action menus, record:quick_actions, the declared-actions bar) reads an action's `aria`; the only `schema.aria` readers there are the placing nodes' own blocks (the record:* page components, the list view, element:button's props). Measured at objectui @f8a9d0fb05 (moved out of the packages/spec/src/ui/action.zod.ts tombstone comment, #23434): none of the surfaces that render an action — `action:button`, `action:icon`, `action:menu`, `action:group`, `action:bar`, the grid's row and bulk action menus, `record:quick_actions`, the declared-actions bar — reads an action's `aria`; the only `schema.aria` readers are the placing nodes' own blocks." - }, - "shortcut": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); register keys in the Console keyboard stack and invoke the action by name." - }, - "bulkEnabled": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); declare the action in the view's `bulkActions` instead." } } } diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json index d95345e24c9..af0e6d0e835 100644 --- a/packages/spec/liveness/agent.json +++ b/packages/spec/liveness/agent.json @@ -51,11 +51,6 @@ "evidence": "cloud @cb8ee7ff: packages/service-ai/src/agent-runtime.ts#resolveActiveSkills — the name list handed to SkillRegistry.listActiveSkills, and the only tool-bearing slot under ADR-0064.", "note": "Agent→Skill→Tool." }, - "tools": { - "status": "dead", - "verifiedAt": "2026-09-15", - "note": "REMOVED in protocol 17 (#3894) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error before any parse, and a parse error after) and dropped from legacy sources by the ADR-0087 conversion `agent-tools-to-skills`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); an agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so re-declare each entry in a skill — the `skills` row on this type is that slot. LEDGER CORRECTED 2026-09-18 (#18304) — the VERDICT moved `live` -> `dead`, a truth correction and not a relaxation: the row asserted `live` on a key this repo's own AgentSchema had tombstoned, which the published vocabulary cannot mean. `live` is defined as \"Has a runtime consumer\", and retiredKey types the key `never`, so no author can put a value behind it and no consumer in any repo can read one. THE LOAD-BEARING EVIDENCE IS LOCAL AND RE-MEASURABLE HERE, which is the half the old citation never had: the tombstone packages/spec/src/ai/agent.zod.ts#tools, the strip packages/spec/src/conversions/registry.ts#agentToolsToSkills — whose own docblock records that the cloud runtime stopped reading the field entirely (cloud#910) — and that conversion's D3 chain step in packages/spec/src/migrations/registry.ts. Both populations are covered without leaving this checkout: an authored source is refused at tsc and at parse, and a stored row or built artifact has the key DELETED by the replayed conversion chain at rehydration (ADR-0087 addendum, retired entries included) before any consumer sees it. The verdict is also read off this ledger's own convention rather than chosen: measured 2026-09-18 over the 36 governed types the gate walks, 40 top-level keys carry a `[REMOVED]` tombstone description and all 40 are never-typed — 39 of them said `dead` and this one said `live`, the single outlier in the class. ⚠ THE CROSS-REPO HALF IS NOT THIS SEAT'S READING. The zero-consumer census in cloud @cb8ee7ff60c097cc21a584fe9caf8ef4391cc0e8 (the only two mentions there are comments recording the branch's removal) and its lit positive control (`agent.skills`, four real reads in the same tree, same path shape) were taken by the #13272 re-verification on 2026-09-15 — the date stamped here, the same one its ten stamped siblings carry. Cloud is not reachable from an open-source checkout, so the seat that landed this correction re-derived the in-repo half only and cites the cloud half as attributed, never as its own measurement. `live-elsewhere` is REFUSED rather than undeclared: that status requires a genuine foreign enforcer, and a key nothing can carry a value for has nothing to enforce — the same read that would have to support it is the read that found zero. The old `evidence` string (`cloud: packages/service-ai/src/agent-runtime.ts`) is DELETED rather than repointed: a dead row's pointer lives in this note by the gate's own design (EVIDENCE_UNSCANNED_STATUSES in scripts/liveness/check-liveness.mts), and that string named the one file whose remaining mentions of the key are the comments recording its deletion. ⚠ WHY IT SAT `live` SINCE THE 2026-06 AUDIT — the row was exempt from scrutiny rather than scrutinised. Its citation spelled `packages/services/service-ai/...` until #13309, and `FOREIGN_PATH_PREFIXES` in scripts/liveness/evidence.mts classifies that prefix foreign and never resolves it; after the repoint the explicit `cloud` realm marker classifies it foreign too. Both spellings are unresolvable BY DESIGN, so no gate could fail on this row in either direction — only a human, or a seat with cloud access, ever could. NEXT STEP IS A SEPARATE CARD: with the row `dead`, the key is an ADR-0049 enforce-or-remove input (playbook `.claude/skills/spec-property-retirement`). ⛔ Not done here." - }, "active": { "status": "live", "verifiedAt": "2026-09-15", @@ -110,11 +105,6 @@ "evidenceScope": "cross-repo", "evidence": "cloud @cb62c3ea: packages/service-ai/src/agent-runtime.ts#compileStructuredOutput — the reader: it compiles the agent's structuredOutput into the turn's output contract, reached through AgentRuntime.resolveTurnGuardrails; cloud @cb62c3ea: packages/service-ai/src/ai-service.ts#AIService.settleFinalAnswer — the enforcement, on every final answer in chatWithToolsImpl and streamChatWithTools, the forced final call included: format json_object / json_schema parsed and then checked against schema, strict, retryOnValidationFailure and maxRetries (each re-ask a guarded model round whose tokens are metered), fallbackFormat checked against the last answer once the primary's retries are spent, and transformPipeline trim / parse_json / validate. The refusal before the first round, TurnGovernor.unsupportedOutput, answered the members this spec has since retired (regex / grammar / xml and coerce_types) with AI_AGENT_STRUCTURED_OUTPUT_UNSUPPORTED.", "note": "Flipped from experimental on the cloud seat's reading, not on a reading taken from this repo — no seat in the flipping session could read cloud. The reading: the repo:cloud seat repo:cloud#1 (session_01Wxo1xhh2bU66T73q23jzE4), comment 5943158888 on objectstack-ai/objectstack#20274, of cloud main @cb62c3ea (the merge of objectstack-ai/cloud#2570), read 2026-10-02; verifiedAt is that reading's date, and every evidence pointer above is quoted from it. The blanket verdict covers every child: format, schema, strict, retryOnValidationFailure, maxRetries, fallbackFormat and transformPipeline. The same change retired the four members that reading found refused rather than enforced — the regex, grammar and xml formats and the coerce_types step (ruling record 5945617233, letter A): each is refused at parse with a prescription (enumWithRetiredValues in packages/spec/src/ai/agent.zod.ts), and the ADR-0087 conversion agent-structured-output-refused-members-removed repairs stored and existing sources — so every value an author can still write is one the runtime enforces. Producer: none in cloud at that ref; tenant-authored agents are NOT MEASURED. These keys are live because the cloud runtime consumes them; the OPEN framework edition does not run agents (this file's _note). ⛔ Re-attest by re-reading cloud, never by re-stamping the date." - }, - "knowledge": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); restrict retrieval at the knowledge-service/source level; describe grounding in `instructions`. AIKnowledgeSchema removed; the topics→sources rename absorbed pre-release." } } } diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 2e2b75cda72..e5758b3eb9f 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -314,12 +314,6 @@ }, "note": "Drilled because includeAll/placement diverge (dead) from the live core." }, - "homePageId": { - "status": "dead", - "verifiedAt": "2026-08-09", - "evidenceScope": "cross-repo", - "note": "CROSS-REPO RE-VERIFIED 2026-08-09 against objectui @c2fd1223 (#4895 task 1): the retirement holds, and the renderer side now says so in code rather than by absence — OuiAppSchema REJECTS the key (with a pin test asserting it is not accepted again), AppPreview ignores it (pin test), AppContent.resolveLandingRoute() documents the removal, and RootLandingRedirect routes on isDefault. No reader remains. The verdict below was first reached by searching this repo alone and was wrong for two months on exactly that account, so this row now records how wide the last look was. REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). PREMISE CORRECTED 2026-08-03 (#4709), removal upheld: #4667 justified this row with \"no shell ever read it\", which was FALSE and contradicted this repo's own 2026-06 AppSchema liveness audit (docs/audits/2026-06-appschema-property-liveness.md), which listed homePageId LIVE. objectui's console DID read it — resolveLandingRoute(), packages/app-shell/src/console/AppContent.tsx @785b8a5d — and it was the only thing deciding where an app opened. The key is retired for its SHAPE: an ID cross-reference into navigation with no referential integrity, silently falling back to the first item when it dangled. Post-v17 the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). Reorder navigation, or set isDefault, instead; if the capability returns it belongs on the navigation item itself (navigation[].landing), enforce-first. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance. Process lesson: a retirement citing liveness MUST reconcile against existing audit records — this contradiction went unnoticed for two months." - }, "requiredPermissions": { "status": "live", "verifiedAt": "2026-09-27", @@ -332,41 +326,6 @@ "evidence": "ui/packages/app-shell/src/hooks/surfaceAgent.ts (:81 at objectui @940ba24), ui/packages/app-shell/src/layout/ChatDock.tsx (:253 at objectui @940ba24)", "note": "bounded surface-binding knob (ADR-0063): resolved to the two platform agents (ask/build), alias-aware, anything else rejected — exactly as the schema documents." }, - "version": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142, 2026-06 audit) — authoring it is parse-rejected with the prescription (an app is versioned by its package's manifest.version). Row stays while the tombstone keeps the key in the walked shape." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — app-level ARIA was never read; declare aria on the rendering page component (`page.components[].aria`, live in page.json). NOT on a dashboard widget: `dashboard.widgets[].aria` is `dead` in dashboard.json, retired by #5010 in this same major — corrected 2026-08-08 (#6756)." - }, - "objects": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — objects belong to the stack; the ambient chatbot derives an app's object list from its nav items (collectNavObjects), never from App.objects." - }, - "apis": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — declarative endpoints belong to the stack." - }, - "sharing": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142, ADR-0049) — a declared-but-unenforced security surface; the live sharing path is FormView.sharing." - }, - "embed": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142, ADR-0049) — no iframe route ever read it; embedding is per form view." - }, - "mobileNavigation": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — fully unimplemented; returns if/when a real mobile navigation ships." - }, "_unpublished": { "status": "live", "verifiedAt": "2026-09-27", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index abf1975d9e6..c7138a73c92 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -166,21 +166,6 @@ "evidence": "packages/rest/src/meta-item-read-gate.ts#filterDashboardForUser (ADR-0057 D10 — the widget is stripped from the payload when the named kernel service is not registered; fail-open when the kernel cannot be probed); packages/rest/src/rest.test.ts#filterDashboardForUser (the pin: drops widgets whose gate reports the service absent, keeps them when it is present)", "note": "server-side capability gate: the widget is stripped from the payload when the named kernel service is not registered (fail-open when the kernel cannot be probed). The authoritative half of the pair — read it as the counter-example to judging a widget key from the renderer repo alone. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED, both legs — `rest-server.ts:1921-1931` had rotted onto the auth-gate normalization comment in `computeExecCtx`, ~380 lines above the widget gate, and `rest.test.ts:3271-3305` onto a NAV-level case (\"ITEM level: nav entries the caller cannot satisfy are stripped\") ~600 lines above the widget suite — a different gate's tests, still in range and still plausible. Re-closed by hand against 8cb96ec41." }, - "actionUrl": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 (objectui @91757a7). All 14 `actionUrl` occurrences in DashboardRenderer.tsx are scoped to `schema.header.actions[]` (:242-245 ActionDef build, :282-284 label i18n, :767-792 dispatch) — that is the DashboardHeaderAction schema, a different shape. Nothing anywhere read `widget.actionUrl`. The second-order cost is settled in the same change: packages/lint/src/validate-dashboard-action-refs.ts enforced ERROR-severity reference integrity on this key while its docblock called it 'the per-widget button', so a dangling target failed the build for an affordance that never rendered — that widget branch is deleted, with a pin test asserting it stays silent. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." - }, - "actionType": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "Same absence as `actionUrl`, same measurement (objectui @91757a7): every `actionType` read in the dashboard renderer belongs to `header.actions[]`. Its only consumer was the action-ref lint, picking which resolution rule to apply to the (unrendered) `actionUrl`; that branch is gone too. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." - }, - "actionIcon": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "The starkest of the three: zero references in either repo outside this schema declaration and one objectui type comment listing spec-derived keys (packages/types/src/complex.ts:676). Not even the action-ref lint looked at it. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." - }, "filter": { "status": "live", "verifiedAt": "2026-08-03", @@ -234,16 +219,6 @@ "verifiedAt": "2026-08-28", "evidence": "packages/lint/src/validate-widget-bindings.ts#validateWidgetBindings (the per-widget `suppressed(rule)` closure — `Array.isArray(w.suppressWarnings) && w.suppressWarnings.includes(rule)` — consulted by every WARNING-severity diagnostic in this rule set; errors are deliberately not suppressible)", "note": "build-time only, and that IS its contract — the key exists to silence a named build diagnostic, so the lint reading it is the whole feature (contrast `actionUrl`, where a lint reads the key but the promised runtime affordance does not exist). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:367` had rotted onto a closing brace in the measure-aggregate branch, ~18 lines past the read. Recorded because it is a class this batch met four times: the four rule-id positions the old citation listed (`:346, :417, :610, :640`) were BARE line suffixes with no path in front of them, so `PATH_RE` never matched them and they were prose no check has ever resolved, bounded or key-checked. Re-closed by hand against 8cb96ec41." - }, - "responsive": { - "status": "dead", - "verifiedAt": "2026-08-24", - "note": "The key this drill exists for. Nothing in either repo ever read `widget.responsive` — DashboardRenderer / DashboardEditor / plugin-designer name it only in comments. Its literal namesake `view.list.responsive` was judged dead and removed in the 2026-07-30 #3896 sweep precisely because `view` IS drilled through `children`; this one was never asked, and survived on that silence alone. Retired 2026-08-03 via #4876 / PR #4995 (retiredKey tombstone + the protocol-17 `dashboard-widget-responsive-removed` conversion). CORRECTED 2026-08-24 (#11027): this note used to call `useResponsiveConfig` the 'one real per-breakpoint consumer', fed by `page.components[].responsive` — measured false: that hook (and `ResponsiveProtocol`, the second implementation) had zero callers, so the page key was retired too (protocol-18 `page-component-responsive-removed`) and the tombstone here no longer prescribes it. The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 across both repos: no consumer of `widget.aria` anywhere. The `aria-*` attributes in DashboardRenderer / DatasetWidget are the renderer's own DOM attributes, and objectui's one `.aria` read (plugin-view/src/ObjectView.tsx:989) is the VIEW's. Same false-compliance shape as the dashboard-level `aria` removed in the #3896 sweep — an accessibility guarantee an author can declare and nothing honours. The shared AriaProps shape is untouched and stays live on `page.aria` / `page.components[].aria` and the list view `aria` (this ledger's own `page.props.aria` and `view.props.list.children.aria` rows) — corrected 2026-08-08 (#6756): it does NOT stay live on `app.aria`, whose `app.json` row is `dead` and which the protocol-17 `app-dead-authoring-keys-removed` conversion strips. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." } } }, @@ -278,16 +253,6 @@ "evidenceScope": "in-repo", "evidence": "packages/rest/src/meta-item-read-gate.ts#holdsRequiredPermissions is the ONE predicate the `/meta` read gate asks of every carrier of the key (an app, its navigation entries, a list view, a dashboard): the caller must hold every capability named, and an absent or empty list is no gate. The `dashboard` arm of packages/rest/src/meta-item-read-gate.ts#createMetaItemReadGate refuses a dashboard whose capabilities the caller does not all hold, 403 PERMISSION_DENIED (the app's whole refusal), on every door and before the ADR-0057 D10 widget gate — so a dashboard's direct URL is refused by the server — and packages/rest/src/meta-item-read-gate.ts#createMetaListReadGate leaves it out of `GET /meta/dashboard`. Pinned per audience (holder served, non-holder not served, no key served to all) on every `/meta/:type/:name` door by packages/rest/src/meta-alternate-door-read-gates.test.ts, and across both transports by packages/runtime/src/domains/meta-item-read-gate-parity.test.ts and packages/runtime/src/domains/meta-list-read-gate-parity.test.ts", "note": "planned → live 2026-10-10 (#22639, the rest half of the ruling of record 6095014058, letter A): the `/meta` read gate enforces the key on the list read and the by-name read of `dashboard`. The prior PLANNED note (#22611) is superseded and its flip instruction discharged in this same edit: `authorWarn` / `authorHint` dropped, and the describe's not-enforced clause deleted (packages/spec/src/ui/audience-required-permissions.ts). Boundaries: the refusal is WHOLE and reaches an author as anyone (the app's whole refusal, ruling 5856774816); a caller with no signed-in user is not gated (the app arm's rule — an anonymous caller is refused before any `/meta` read of this type). What the console shows a refused direct URL is objectui's half, filed when this one is accepted. The lint resolves the capability names (packages/lint/src/validate-capability-references.ts), since a misspelled one now hides the dashboard from everyone." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); no dashboard renderer applied it." - }, - "performance": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); dashboard performance tuning was never implemented." } } } diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index aa761d7f5a3..a827a4fbc7d 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -138,11 +138,6 @@ "status": "live", "note": "renderer CEL." }, - "conditionalRequired": { - "status": "dead", - "verifiedAt": "2026-08-29", - "note": "REMOVED 2026-07-28 in protocol 17 (#3855, PR #3883 — the commit that retired the last three deprecated authorable aliases) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-17 conversion `field-conditionalRequired-to-requiredWhen`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `requiredWhen`, the only predicate slot — rename the key, the value (a CEL predicate) is unchanged, and `os migrate meta --from 16` lists the mechanical edits. The tombstone is packages/spec/src/data/field.zod.ts#conditionalRequired. The consumer-side refusal is packages/objectql/src/validation/rule-validator.ts#ConditionalFieldDef, which declares `requiredWhen` THE ONLY predicate slot and states PD #12 — no consumer-side dialect fallback returns here; that absence is PINNED by the case named `does NOT read the retired conditionalRequired alias (PD #12 — no dialect fallback)` in packages/objectql/src/validation/rule-validator.test.ts, which sits beside a positive case asserting the rule DOES fire once the stored-conversion chain has lowered the alias. LEDGER CORRECTED 2026-08-29 (commit e25e8397a) — the VERDICT was falsified, not the citation, and in BOTH halves of its evidence. (a) This row read `live` on the claim that the FieldSchema .transform lowers conditionalRequired -> requiredWhen and DROPS the alias. No such transform exists: field.zod.ts contains ZERO .transform calls, its only two occurrences of that token are comments recording that `.overwrite()` was chosen over `.transform()` per the measured #6926 precedent, and the describe() on `requiredWhen` says out loud that the alias was removed in protocol 17. (b) The note's own stated reason for the verdict — that objectql rule-validator.ts keeps its `requiredWhen ?? conditionalRequired` fallback on purpose because raw (unparsed) metadata still reaches evaluateValidationRules — was retired by #3903: every path that hands the validator a STORED field definition now replays the ADR-0087 conversion chain at rehydration (applyConversionsToStoredItem), so a pre-17 row arrives with the alias already lowered, and a caller handing raw unconverted legacy input is off-contract. WHY NOTHING SAW THE ROT: the old pointer packages/spec/src/data/field.zod.ts:639 was IN RANGE in a 2,132-line file that names the key five times, so the existence check, the line bound and the key-mention check were all green on it — what :639 holds today is the tail of FieldMaskingRuleSchema, unrelated in every respect — and the entry carried no verifiedAt, so the re-verification clock never asked (the #12516 class). It was also the LAST `path:NNN` citation left in any ledger, which is why retiring it here closes the anchor-adoption worklist. HISTORY PRESERVED, because it is the argument for `requiredWhen` being the single slot: #3754 folded the alias at parse time and removed it from the parsed output (same shape as action.execute -> target in #3742, agent.knowledge.topics -> sources in #1891); before that fix BOTH keys stayed live in the output with no canonicalization, so each consumer re-derived the precedence — the condition that produced the #3713 divergence. That note called removal of the input key the endgame, and protocol 17 is where the endgame landed. RETIREMENT CHAIN, COMPLETE — the close-out machinery is what remains, and it is not a consumer: the ADR-0087 conversion `field-conditionalRequired-to-requiredWhen` in packages/spec/src/conversions/registry.ts, its `RETIRED_KEYS_BY_MAJOR` entry and D3 chain step in packages/spec/src/migrations/registry.ts, and the explicit `NOTE: no entry for conditionalRequired` in packages/spec/src/data/authoring-key-lint.ts. Two INERT survivors are recorded so the next reader does not mistake either for a reader that gives the key meaning: packages/lint/src/validate-expressions.ts still walks the key, and its own docblock calls that branch inert on the parsed compile path because strict FieldSchema rejects the key by name (it gets a slot-agnostic consequence clause rather than a fabricated fourth measurement); packages/drivers/driver-sql/src/builtin-column-collision.ts lists it in a key-classification map as `presentation`, which classifies a name, not a value anyone reads. Re-closed by hand against cae2169cf." - }, "dueLike": { "status": "live", "verifiedAt": "2026-10-09", diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index fce69c02afc..20f40e7912d 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -114,11 +114,6 @@ "status": "live", "evidence": "ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts (:480 in objectui, commit not pinned)", "note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file." - }, - "outputSchema": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); node outputs were never validated; downstream nodes read prior outputs via expressions regardless." } } }, @@ -140,11 +135,6 @@ "evidence": "packages/services/service-automation/src/engine.ts", "note": "Was `retryDelayMs` until 17.0.0 (#4964). This block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name — the dual-source instrument judges duplicated export NAMES. It now builds from `retryPolicyShape()`, so the base delay is spelled `backoffMs` here exactly as on `job.retryPolicy` and a `try_catch` node's `retry`; `retryDelayMs` is tombstoned and rewritten by `retry-policy-converged`. `retryExecution` destructures the parsed key." }, - "retryDelayMs": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "RENAMED to `backoffMs` 2026-08-04 (#4964) — the flow-level retry keys converged onto the shared `RetryPolicySchema`, whose tombstone this key now is (it arrives here via `retryPolicyShape()`, so authoring it is a tsc error and a parse error carrying the rename). Rewritten by the `retry-policy-converged` conversion wherever stored documents are replayed; it left the load path at 18 (#23543 F2). The row stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Note this key was LIVE right up to the rename — `retryExecution` read it — so `dead` here means retired, not never-read." - }, "backoffMultiplier": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" @@ -156,23 +146,8 @@ "jitter": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" - }, - "fallbackNodeId": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); faults route via per-node fault edges — draw a fault edge to the handler node." } } - }, - "active": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the real switch is `status` ('obsolete'/'invalid' disable, 'active' arms)." - }, - "template": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); shared logic is invoked via a subflow NODE referencing the flow by name." } } } diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index 82299a528ff..49a66bc0b3c 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -252,12 +252,6 @@ } } }, - "loading": { - "status": "dead", - "verifiedAt": "2026-08-23", - "evidenceScope": "cross-repo", - "note": "RETIRED, and the row must STAY: `retiredKey()` keeps the key in the walked shape (a tombstone, not a strict removal), so the orphans direction expects a row here — see the ledger README's asymmetry note. Removed in @objectstack/spec 17.0.0 (#4914, ADR-0049), ADR-0087 D3 entry `17.plugin-manifest-loading-retired`. The whole block — strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring — had no runtime reader in ANY of the three repos, so authoring it configured nothing. `loading.sandboxing` is the reason this is recorded as a security finding rather than tidying: it ran nothing in a process, vm, iframe or worker, and its `allowedServices` gated no call, so an author relying on it for isolation had none. This retirement is also what put the manifest on the ledger's agenda — it was found and paid by hand, because no gate could ask." - }, "engine": { "children": { "objectstack": { diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 90c94968c79..f6938e730a7 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -228,12 +228,6 @@ "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#getApplicablePolicies (`(policy as { enabled?: boolean }).enabled === false` excluded BEFORE object / positions / operation matching; absent = active per the schema default) — pinned by the enabled-gate cases in packages/plugins/plugin-security/src/security-plugin.test.ts", "note": "ENFORCED 2026-07-30, by the security-subset re-verification this stamp records. Until then the entry claimed live citing rls-compiler.ts, and NOTHING read the property: policies OR-combine (any match allows access), so a policy an admin disabled kept CONTRIBUTING ITS GRANT — the schema's own describe ('Disabled policies are not evaluated') was false in the over-share direction, the #3896 shape. Same enforce-or-remove resolution as `positions` (ADR-0049). 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — path-only citation upgraded to an anchor, on the entry whose whole history is that its citation once named a file nothing in it read. Re-closed by hand against c459da6bc." }, - "priority": { - "status": "dead", - "verifiedAt": "2026-07-30", - "evidence": "packages/spec/src/security/rls.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)", - "note": "REMOVED 2026-07-30, same day the audit corrected it (was live citing rls-compiler.ts, which never reads it — confirmed across the load site, the projection round-trip and the compiler). Not merely unimplemented: under OR-combination the promised 'conflict resolution' semantics cannot exist, so it went straight to the #3715/#3950 remove disposition inside the v17 breaking window. Tombstoned rather than deleted so the removal is audible; drop the tombstone (and this entry) ~two majors out." - }, "tags": { "status": "dead", "verifiedAt": "2026-09-27", diff --git a/packages/spec/liveness/skill.json b/packages/spec/liveness/skill.json index ce712f179bd..ea8476231e4 100644 --- a/packages/spec/liveness/skill.json +++ b/packages/spec/liveness/skill.json @@ -57,11 +57,6 @@ "evidenceScope": "cross-repo", "evidence": "cloud @cb8ee7ff: packages/service-ai/src/skill-registry.ts#listSkills — an inactive record never enters the pool that listActiveSkills filters; packages/mcp/src/skill-prompts.ts#projectSkillPrompt — an inactive row is dropped from the MCP prompt list.", "note": "inactive skills dropped — by the cloud registry, and not projected as an MCP prompt either (#3905)." - }, - "triggerPhrases": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); routing intent belongs in `triggerConditions`; describe intent in `description`/`instructions`." } } } diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 06343af3cec..d70333646f0 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 47 | 0 | 0 | 4 | 0 | 51 | +| `action` | 47 | 0 | 0 | 1 | 0 | 48 | diff --git a/packages/spec/liveness/state-counts/agent.md b/packages/spec/liveness/state-counts/agent.md index 579d29c20e7..6140b33d72d 100644 --- a/packages/spec/liveness/state-counts/agent.md +++ b/packages/spec/liveness/state-counts/agent.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `agent` | 23 | 0 | 0 | 3 | 0 | 26 | +| `agent` | 23 | 0 | 0 | 1 | 0 | 24 | diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index d7d2151ac5e..80ea528a4fb 100644 --- a/packages/spec/liveness/state-counts/app.md +++ b/packages/spec/liveness/state-counts/app.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `app` | 51 | 0 | 0 | 8 | 4 | 63 | +| `app` | 51 | 0 | 0 | 0 | 4 | 55 | diff --git a/packages/spec/liveness/state-counts/dashboard.md b/packages/spec/liveness/state-counts/dashboard.md index b78a210f296..fa8b7e19b60 100644 --- a/packages/spec/liveness/state-counts/dashboard.md +++ b/packages/spec/liveness/state-counts/dashboard.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `dashboard` | 43 | 0 | 0 | 13 | 0 | 56 | +| `dashboard` | 43 | 0 | 0 | 6 | 0 | 49 | diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 48598f1aa23..624e6039786 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 6 | 107 | +| `field` | 100 | 0 | 0 | 0 | 6 | 106 | diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md index 3840cade61f..b72830085ef 100644 --- a/packages/spec/liveness/state-counts/flow.md +++ b/packages/spec/liveness/state-counts/flow.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `flow` | 36 | 0 | 0 | 5 | 0 | 41 | +| `flow` | 36 | 0 | 0 | 0 | 0 | 36 | diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index d14767a0008..0535ecaaaeb 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 27 | 0 | 1 | 15 | 0 | 43 | +| `manifest` | 27 | 0 | 1 | 14 | 0 | 42 | diff --git a/packages/spec/liveness/state-counts/permission.md b/packages/spec/liveness/state-counts/permission.md index 6527c960c9e..729ecb82e76 100644 --- a/packages/spec/liveness/state-counts/permission.md +++ b/packages/spec/liveness/state-counts/permission.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `permission` | 38 | 0 | 0 | 5 | 0 | 43 | +| `permission` | 38 | 0 | 0 | 4 | 0 | 42 | diff --git a/packages/spec/liveness/state-counts/skill.md b/packages/spec/liveness/state-counts/skill.md index 3fbf014c6bf..6fd748e36c8 100644 --- a/packages/spec/liveness/state-counts/skill.md +++ b/packages/spec/liveness/state-counts/skill.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `skill` | 16 | 0 | 0 | 1 | 0 | 17 | +| `skill` | 16 | 0 | 0 | 0 | 0 | 16 | diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md index 49b06966f2e..11a6aac0aad 100644 --- a/packages/spec/liveness/state-counts/view.md +++ b/packages/spec/liveness/state-counts/view.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `view` | 78 | 0 | 0 | 12 | 0 | 90 | +| `view` | 78 | 0 | 0 | 5 | 0 | 83 | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 87904106853..f3b690913cc 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -51,7 +51,24 @@ "status": "live", "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less." }, - "sort": {"status": "live", "note": "objectui: ListView.tsx (audit L15). The console's sort rows carry the same React list key (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the sort entry's docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped by objectui @b9814b334 packages/components/src/custom/sort-builder.tsx#SortBuilder (its `normalize`, for a row without one, and its `addItem`), and again on a stored sort read back by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#toSortItems; re-read 2026-10-15, first cited by line, sha-less.", "children": {"field": {"status": "live", "verifiedAt": "2026-09-13", "evidenceScope": "in-repo", "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468, `fromElement`), which reads `el.field` off every sort entry and is the funnel every metadata ingress passes through; it lowers onto the engine `orderBy`. Drilled here rather than inherited because #17053 retired the bare string arm of this union, which is what made the entry's own keys visible to the walk."}, "order": {"status": "live", "verifiedAt": "2026-09-13", "evidenceScope": "in-repo", "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468) alongside `field`, and validated by its `direction()` helper (:3447) which refuses anything but 'asc'/'desc'. Divergence from `field` would be the signal worth recording; there is none \u2014 a sort entry is inert without both."}}}, + "sort": { + "status": "live", + "note": "objectui: ListView.tsx (audit L15). The console's sort rows carry the same React list key (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the sort entry's docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped by objectui @b9814b334 packages/components/src/custom/sort-builder.tsx#SortBuilder (its `normalize`, for a row without one, and its `addItem`), and again on a stored sort read back by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#toSortItems; re-read 2026-10-15, first cited by line, sha-less.", + "children": { + "field": { + "status": "live", + "verifiedAt": "2026-09-13", + "evidenceScope": "in-repo", + "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468, `fromElement`), which reads `el.field` off every sort entry and is the funnel every metadata ingress passes through; it lowers onto the engine `orderBy`. Drilled here rather than inherited because #17053 retired the bare string arm of this union, which is what made the entry's own keys visible to the walk." + }, + "order": { + "status": "live", + "verifiedAt": "2026-09-13", + "evidenceScope": "in-repo", + "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468) alongside `field`, and validated by its `direction()` helper (:3447) which refuses anything but 'asc'/'desc'. Divergence from `field` would be the signal worth recording; there is none — a sort entry is inert without both." + } + } + }, "searchableFields": { "status": "live", "note": "objectui: ListView.tsx (audit L15)." @@ -66,16 +83,6 @@ "status": "live", "note": "objectui: ObjectGrid.tsx (audit L15)." }, - "striped": { - "status": "dead", - "verifiedAt": "2026-08-12", - "note": "REMOVED 2026-08-12 (#7176, ADR-0049 enforce-or-remove) — the L15 citation was pass-through-only: every measured reader (spec-bridge, plugin-list, plugin-view/app-shell) copied the key forward and ObjectGrid, where the chain ends, never spells it. Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 view-list-passthrough-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); there is no authorable striped-rows switch — delete the key." - }, - "bordered": { - "status": "dead", - "verifiedAt": "2026-08-12", - "note": "REMOVED 2026-08-12 (#7176, ADR-0049 enforce-or-remove) — the L15 citation was pass-through-only: every measured reader copied the key forward and ObjectGrid never spells it (the grid frame is the renderer's own borderless constant; DataTable's only 'bordered' hit is a comment). Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 view-list-passthrough-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); delete the key." - }, "compactToolbar": { "status": "live", "note": "objectui: ListView.tsx:370 + toolbar gating at :1776-1988 (View-settings popover collapse). Post-audit key, verified objectui@fb35e48." @@ -190,11 +197,6 @@ "evidence": "packages/spec/src/ui/bulk-action.zod.ts", "note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Dispatch: useBulkExecutor.ts run() — per-record fan-out by default; execution:'aggregate' defs go through the ONE-call bulkCall branch injecting params._selectedIds (ObjectGrid.runBulkActionAggregate, objectui#3139). Verified objectui@4bf612c. #4457 gave the def a SHAPE (it was z.record(z.any())): the entry schema is BulkActionDefSchema, strict, and it refuses the combinations that parse but the executor never reads — so this row's liveness now covers the keys inside a def, not just the array. A PROMOTED def's field-backed params (packages/spec/src/ui/bulk-action.zod.ts; moved out of spec source, #23434): resolved when the dialog opens by objectui main @dca25aff2 packages/plugin-grid/src/resolveBulkActions.ts#resolvePromotedBulkParams through `resolveActionParams` (objectui 29b99490), then renamed onto the bulk vocabulary by the same file's #toBulkParam, which carries the field's type, translated label, options, required, description, placeholder, defaultValue, multiple, `dependsOn`, `accept` and `maxSize` (plus a reference-bearing field's reference and display field) and drops the lookup settings. An authored param's `options[]` entries (packages/spec/src/ui/bulk-action.zod.ts, the `.passthrough()` rationale; moved out of spec source, #23434): objectui @b9814b334 packages/plugin-grid/src/components/bulkParamToField.ts#bulkParamToField spreads each entry into the field metadata (`options?.map(o => ({ ...o, value: String(o.value) }))`), so every extra key reaches the widget, whose option vocabulary is objectui @b9814b334 packages/types/src/field-types.ts#SelectOptionMetadata, extending objectui @b9814b334 packages/types/src/select-option.ts#SelectOptionBase (`color`, `visibleWhen`, `disabled` and `icon` beyond the pair); the select cell reads `option?.color` in objectui @b9814b334 packages/fields/src/index.tsx#SelectCellRenderer. The renderer's own param type now agrees: objectui @b9814b334 packages/types/src/objectql.ts#BulkActionParam types an `options` entry open (an index signature, citing objectstack#4001), where the source's sha-less reading had it closed (`Array<{ label; value }>`). Re-read 2026-10-15, first cited by line, sha-less." }, - "virtualScroll": { - "status": "dead", - "verifiedAt": "2026-08-12", - "note": "REMOVED 2026-08-12 (#7176, ADR-0049 enforce-or-remove) — the L15 citation was pass-through-only: every measured reader copied the key forward and ObjectGrid never spells it; VirtualGrid genuinely virtualizes but is only exported, never instantiated, and its props have no virtualScroll member. Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 view-list-passthrough-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); large datasets page via pagination — delete the key." - }, "conditionalFormatting": { "status": "live", "note": "objectui: ObjectGrid.tsx (audit L15)." @@ -249,16 +251,6 @@ "userFilters": { "status": "live", "note": "objectui: UserFilters.tsx — element/fields/tabs/showAllRecords wired (audit L15); object views narrow to ObjectUserFiltersSchema (ADR-0047 amendment, #2679/objectui#2338). `allowAddTab` (packages/spec/src/ui/view.zod.ts, the #5073 close record; moved out of spec source, #23434): read as `config.allowAddTab` by objectui @b9814b334 packages/plugin-list/src/UserFilters.tsx#UserFilters and handed to objectui @b9814b334 packages/plugin-list/src/UserFilters.tsx#TabFilters, which draws the add-tab affordance and holds the added tabs in component state only; re-read 2026-10-15, first cited by line, sha-less." - }, - "responsive": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the grid is responsive by its own layout rules." - }, - "performance": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); list performance tuning was never implemented." } } }, @@ -369,16 +361,6 @@ "defaults": { "status": "live", "note": "objectui: ObjectForm.tsx foldFormButtons() reads schema.defaults and folds it into create-mode initialValues (framework#1894 / #2998); an explicit initialValues (URL prefill) still wins." - }, - "defaultSort": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); a related list sorts by its own list view's `sort`." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); no form renderer applied it; report gaps as renderer issues." } } }, diff --git a/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts b/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts index 1b5634b808a..b7adcbb5bb8 100644 --- a/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts @@ -821,7 +821,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(output).toContain("waives THIS file's tombstone requirement only"); // The aged-out tombstone survives as A route, no longer as THE reason. - expect(output).toContain('aged out (~two majors)'); + expect(output).toContain('aged out (one major, #25767)'); expect(output).toContain('RETIRED_KEYS_BY_MAJOR'); expect(output).not.toContain('ONE legitimate reason'); diff --git a/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts b/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts index 7e39fe8bddc..c6368c88fc6 100644 --- a/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts @@ -483,6 +483,17 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)' // not archaeology. Undated ⇒ undeletable, and the after-count is 0 of 97 by // design. The gate below is what that costs and what it buys. // +// #25767 (ADR-0087 amendment, 2026-10-26) shortened the clock to ONE major and +// added a second dating source that is not the registry: a line that was already +// `[RETIRED]` on the authorable surface at the previous major's release tag +// (`@objectstack/spec@.0.0`) was retired before that cut, so +// its kit has lived its one major. That is what dates the historical rows the +// table never named — not a backfill, a read of an immutable tag — and it is +// what lets a region's rows leave the registry without the clock losing them. +// The tag tests below create that tag inside this box; where the tag cannot be +// read (this box has no remote), the gate falls back to the registry alone and +// says so, which can only refuse more. +// // This block needs its own sandbox because the aged-out branch is now a claim // about `src/migrations/registry.ts`, which the main sandbox symlinks. @@ -494,6 +505,20 @@ const UNAGED_DECLARED_KEY = 'data/Object:zzDeclaredThisMajor5898'; const UNAGED_DECLARED = `${UNAGED_DECLARED_KEY} [RETIRED]`; +/** A leaf an OLD clause still ends in (`page.kind`, protocol 11), on a def that + * does not carry it — the leaf-collision pin needs the collision to exist. */ +const COLLIDER_LEAF = 'kind'; + +const DELETED_COLLIDER = `data/Object:${COLLIDER_LEAF} [RETIRED]`; + +/** The tag the gate dates an unregistered tombstone by (#25767). */ +const TOMBSTONE_DATING_TAG = `@objectstack/spec@${CURRENT_MAJOR - 1}.0.0`; + +/** A tombstone no table names, dated only by the tag. */ +const TAG_DATED_KEY = 'data/Object:zzRetiredBeforeTheCut25767'; + +const TAG_DATED = `${TAG_DATED_KEY} [RETIRED]`; + describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#5898)', () => { let box: string; let boxScript: string; @@ -550,14 +575,15 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 // clause match — that is the whole point — so its leaf is checked to be // registered no earlier than the major we declare it at is irrelevant; what // matters is that the key is not already in the baseline or the real table. - for (const injected of [DELETED_AGED, UNAGED_DECLARED]) { + for (const injected of [DELETED_AGED, UNAGED_DECLARED, DELETED_COLLIDER, TAG_DATED]) { expect( pristineSurface.includes(injected) || pristineSurface.includes(injected.replace(RETIRED_MARK, '')), `fixture ${injected} already exists in the committed baseline — pick another`, ).toBe(false); } - expect(CURRENT_MAJOR - AGED_DECLARED_MAJOR).toBeGreaterThanOrEqual(2); + // One major is the clock since #25767; the aged fixture is declared well past it. + expect(CURRENT_MAJOR - AGED_DECLARED_MAJOR).toBeGreaterThanOrEqual(1); box = fixtureTree('build-schemas-tombstone-age-'); fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); @@ -601,10 +627,11 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 'a tombstone DECLARED at an aged major may have its baseline line deleted', { timeout: SPAWN_TIMEOUT_MS }, () => { - // The surviving positive route. Note what supplies the major: the table, - // not the leaf — `compactLayout` happens to have an old clause too, so - // this case alone cannot tell the two mechanisms apart. The pin that can - // is in the main sandbox (`data/Object:type`) and in the last test here. + // The registry route. Note what supplies the major: the table, not the + // leaf (`compactLayout`'s own old clause left with its conversion on + // #25767, so nothing else could). The pin that tells the two mechanisms + // apart is in the main sandbox (`data/Object:type`) and in the collider + // test here. seedRetiredKeys({ [AGED_DECLARED_MAJOR]: [DELETED_AGED.replace(RETIRED_MARK, '')] }); seedBoxBase(DELETED_AGED); @@ -635,7 +662,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 expect(output).toMatch( new RegExp(`${UNAGED_DECLARED_KEY} — .*tombstone registered at major ${CURRENT_MAJOR}`), ); - expect(output).toMatch(/must age ≥ 2 majors/); + expect(output).toMatch(/must age ≥ 1 major before its line is deleted/); }, ); @@ -643,24 +670,99 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 'the pin: with the table EMPTY, a leaf collision proves nothing — the deletion is refused', { timeout: SPAWN_TIMEOUT_MS }, () => { - // `compactLayout` is registered at protocol 11 by an ADR-0087 clause, so - // the pre-#5898 matcher dated it there and allowed the deletion — exit 0, - // "tombstone aged out". With the declaration removed and nothing else - // changed, the only thing that can still supply major 11 is leaf matching. - expect(minRegisteredMajorForLeaf(DELETED_AGED_LEAF)).toBe(AGED_DECLARED_MAJOR); + // `page.kind` is registered at protocol 11 by an ADR-0087 clause, so the + // pre-#5898 matcher would date a `data/Object:kind` tombstone there and + // allow the deletion — exit 0, "tombstone aged out". With no declaration + // and (in this box, which has no remote) no readable release tag, the only + // thing that could still supply major 11 is leaf matching. + expect(minRegisteredMajorForLeaf(COLLIDER_LEAF)).toBe(AGED_DECLARED_MAJOR); seedRetiredKeys({}); - seedBoxBase(DELETED_AGED); + seedBoxBase(DELETED_COLLIDER); const { status, output } = runBox(['--check']); expect(status).toBe(1); expect(output).toContain('deleted without proof (#4650)'); expect(output).toMatch( - new RegExp(`data/Object:${DELETED_AGED_LEAF} — .*no entry in RETIRED_KEYS_BY_MAJOR`), + new RegExp(`data/Object:${COLLIDER_LEAF} — .*no entry in RETIRED_KEYS_BY_MAJOR`), ); + // The second source was asked and could not answer here — said, not skipped. + expect(output).toContain(`release tag ${TOMBSTONE_DATING_TAG} could not be read here to date it`); expect(output).not.toContain('tombstone aged out'); }, ); + + /** Commit a surface carrying `lines` and name it the previous major's cut. */ + const tagPreviousMajorCut = (...lines: string[]): string => { + writeSurfaceShards(boxSurfaceDir, [...pristineSurface, ...lines].sort()); + boxGit('add', AUTHORABLE_SURFACE_DIR_NAME, AUTHORABLE_DEFAULTS_DIR_NAME); + boxGit('commit', '-q', '--allow-empty', '-m', `release ${TOMBSTONE_DATING_TAG}`); + boxGit('tag', TOMBSTONE_DATING_TAG); + writeSurfaceShards(boxSurfaceDir, pristineSurface); + return boxGit('rev-parse', `refs/tags/${TOMBSTONE_DATING_TAG}^{commit}`); + }; + + it( + 'a tombstone NO table names, but already [RETIRED] at the previous major\'s release tag, may have its line deleted (#25767)', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The second dating source. The table is EMPTY — the registry rows for a + // region leave with it on the major after — and the only thing that dates + // this line is the release tag's surface, which already carried it marked. + seedRetiredKeys({}); + const cut = tagPreviousMajorCut(TAG_DATED); + try { + seedBoxBase(TAG_DATED); + + const { status, output } = runBox(['--check']); + + expect(output).toContain('carry their own proof (#4650)'); + expect(output).toMatch( + new RegExp( + `${TAG_DATED_KEY} — \\[RETIRED\\] at [0-9a-f]+ and already \\[RETIRED\\] at release tag ` + + `${TOMBSTONE_DATING_TAG.replace(/[.@/]/g, '\\$&')} \\(${cut.slice(0, 12)}`, + ), + ); + expect(output).toContain('tombstone aged out'); + expect(output).not.toContain('could not be read here'); + expect(status).toBe(0); + } finally { + boxGit('tag', '-d', TOMBSTONE_DATING_TAG); + } + }, + ); + + it( + 'the tag dates by its MARK, not by its presence: a key still LIVE at the cut was retired after it, and is refused', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The negative half of the same source. The key existed at the previous + // major's cut — unmarked, i.e. still authorable — so its tombstone is a + // post-cut retirement: the current major's kit, which has not lived its + // one major. The tag is readable here; it just does not say what the + // deletion needs it to say. + seedRetiredKeys({}); + const cut = tagPreviousMajorCut(TAG_DATED_KEY); + try { + seedBoxBase(TAG_DATED); + + const { status, output } = runBox(['--check']); + + expect(status).toBe(1); + expect(output).toContain('deleted without proof (#4650)'); + expect(output).toMatch( + new RegExp(`${TAG_DATED_KEY} — .*no entry in RETIRED_KEYS_BY_MAJOR`), + ); + expect(output).toContain( + `it was not [RETIRED] at release tag ${TOMBSTONE_DATING_TAG} (${cut.slice(0, 12)})`, + ); + expect(output).not.toContain('could not be read here'); + expect(output).not.toContain('tombstone aged out'); + } finally { + boxGit('tag', '-d', TOMBSTONE_DATING_TAG); + } + }, + ); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts b/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts index 3bd36b9bdbe..1c4fadf5e2c 100644 --- a/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts @@ -57,16 +57,27 @@ const ROOT_SCRIPTS = fileURLToPath(new URL('../../../scripts', import.meta.url)) useSharedSandbox(ROOT_SCRIPTS); /** Retired keys whose ONLY registered clause names a DIFFERENT def — the defect. */ -const LEAF_COLLIDER_A = 'api/HttpFindQueryParams:distinct'; +const LEAF_COLLIDER_A = 'api/PackageInstallManifest:capabilities'; -/** …matched by this clause, which registers `data/Query`'s key, not this one. */ -const LEAF_COLLIDER_A_CLAUSE = 'data.query.distinct'; +/** + * …matched by this clause, which registers `kernel/Manifest`'s key, not this + * one: `PackageInstallManifestSchema` extends `ManifestSchema`, so the same + * tombstone sits on a second def with no row of its own. Re-picked at spec 18.2 + * (#25767): the fixture was `api/HttpFindQueryParams:distinct` against + * `data.query.distinct` until that clause left with the 16.x conversions. + */ +const LEAF_COLLIDER_A_CLAUSE = 'manifest.capabilities'; -/** The issue's own `.type` shape: an index type dated by a flow node rename. */ -const LEAF_COLLIDER_B = 'data/Index:type'; +/** + * A book group's i18n block: a surviving tombstone (`book.zod.ts`, no row of + * its own) whose leaf a manifest retirement also spells. Re-picked at spec + * 18.2 (#25767) with A: the issue's own fixture, `data/Index:type` against + * `flow.node.type`, aged out with the 16.x tombstones. + */ +const LEAF_COLLIDER_B = 'system/BookGroup:translations'; -/** …registered at protocol 11 by `flow-node-http-callout-rename`. Unrelated. */ -const LEAF_COLLIDER_B_CLAUSE = 'flow.node.type'; +/** …registered by `plugin-manifest-contributes-dead-members-retired` for the manifest's `contributes` block. Unrelated. */ +const LEAF_COLLIDER_B_CLAUSE = 'manifest.contributes.translations'; /** A key no build emits: the aged-out steady state a stale entry decays into. */ const AGED_OUT_KEY = 'data/Object:zzAgedOutTombstone4659'; @@ -242,7 +253,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its 'registering one key registers exactly that key — its neighbour with the same leaf still fails', { timeout: SPAWN_TIMEOUT_MS }, () => { - // `data.query.distinct` covered A only by leaf; now even a REAL entry for + // `manifest.capabilities` covered A only by leaf; now even a REAL entry for // B cannot cover A. This is the whole change in one run: membership is per // key, and nothing radiates from one registration to another. seedRetiredKeys({ [CURRENT_MAJOR]: [LEAF_COLLIDER_B] }); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 11120c05fa5..6abda63e0bd 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -768,6 +768,34 @@ const generatedKeys = new Set(generatedSchemas.keys()); /** The protocol major this build is: the major any retirement it makes belongs to. */ const CURRENT_MAJOR = Number.parseInt(SPEC_VERSION, 10); +/** + * A tombstone may be deleted once it is this many majors old: one, since the + * 2026-10-26 ruling on #25767 (D2 = A) — a retirement kit lives through the + * protocol major AFTER the one it retired in, and leaves when the next major is + * cut. At major 18 that keeps the 17.x and 18.x kits and removes everything + * retired at or below 16.x; major 19 repeats the move one major on. Two sources + * date a deleted `[RETIRED]` line, read in this order by check (c)'s proof 1 + * (the deletion gate further down): + * + * - the registry: an EXACT `${defKey}:${name}` row in RETIRED_KEYS_BY_MAJOR, + * whose major is the one the retirement happened in (the row's region); + * - the previous major's release tag (`@objectstack/spec@.0.0`): + * a line that was ALREADY `[RETIRED]` on the authorable surface the day that + * major was cut predates the cut, so its kit has lived its one major. The + * tag is immutable and lives on origin, so neither the registry row a PR can + * write nor the one it can delete moves this clock — which is also why the + * tombstones that predate the table (46 of the 61 lines #25767 deleted) are + * datable without a backfill. + */ +const TOMBSTONE_AGE_MAJORS = 1; + +/** + * The release tag whose surface dates a tombstone the registry no longer names. + * `TOMBSTONE_AGE_MAJORS` behind the current major, at its `.0.0` cut — the + * commit where every retirement of the previous major's kit was already marked. + */ +const TOMBSTONE_DATING_TAG = `@objectstack/spec@${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}.0.0`; + // ─── Where a registration is WRITTEN ────────────────────────────────── // RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR and every step's `semantic` // list are GENERATED into src/migrations/registry.ts from one file per entry @@ -1148,9 +1176,10 @@ if (surfaceDoc) { ` the manifest deletion gate, and refuses the deletion unless it holds (#4650 proofs\n` + ` 2 and 3). ⚠️ It waives THIS file's tombstone requirement only — plugin manifests,\n` + ` connector configs and other non-metadata authoring go through their own gates.\n\n` + - ` Separately, a tombstone that has aged out (~two majors) may have its line deleted\n` + + ` Separately, a tombstone that has aged out (one major, #25767) may have its line deleted\n` + ` too — check (c) proof 1, which requires the exact key be declared in\n` + - ` RETIRED_KEYS_BY_MAJOR. Do it in the same PR, deliberately.\n\n` + + ` RETIRED_KEYS_BY_MAJOR or already [RETIRED] at ${TOMBSTONE_DATING_TAG}. Do it in the\n` + + ` same PR, deliberately.\n\n` + ` The routes and their preconditions are the spec-property-retirement playbook's\n` + ` (.claude/skills/); ADR-0104 and #4650 are the decisions behind them.`, ); @@ -1263,8 +1292,8 @@ if (surfaceDoc) { // (b3) A nested entry whose path this build does not emit at all. For a // TOP-LEVEL key that state is the aged-out steady state (b2) exempts // above — the tombstone carried `[RETIRED]` in `authorable-surface/`, - // aged two majors, and check (c) let the line go, with evidence at every - // step. A nested key reaches NONE of that: it has no baseline line (0 + // aged one major (#25767), and check (c) let the line go, with evidence + // at every step. A nested key reaches NONE of that: it has no baseline line (0 // dotted entries on the shipped surface), so checks (a0)/(a)/(b)/(c) are // structurally blind to it and no gated route can produce a registered // nested row whose path is absent. The one origin left is that the row is @@ -1330,15 +1359,17 @@ if (surfaceDoc) { // A deletion is legitimate on exactly one of FOUR proofs, each computed // inside this gate — never argued in a PR description: // -// 1. aged-out tombstone — the base entry carried `[RETIRED]` AND its EXACT -// `${defKey}:${name}` is declared in RETIRED_KEYS_BY_MAJOR at a major -// ≥ TOMBSTONE_AGE_MAJORS behind the current one (the "~two majors" this -// file's description has always promised, now enforced). Since #5898 this +// 1. aged-out tombstone — the base entry carried `[RETIRED]` AND either its +// EXACT `${defKey}:${name}` is declared in RETIRED_KEYS_BY_MAJOR at a major +// ≥ TOMBSTONE_AGE_MAJORS behind the current one (one major since the +// 2026-10-26 ruling on #25767; "~two majors" before it), or the line was +// already `[RETIRED]` on the authorable surface at the previous major's +// release tag (TOMBSTONE_DATING_TAG above), which dates the retirement +// before that cut without a registry row. Since #5898 the registry half // reads the same exact-key table check (b) reads, not a leaf-name match -// against the conversion registry — see TOMBSTONE_AGE_MAJORS below for the -// two false positives that matching produced and why the 97 pre-existing -// tombstones are left undeclared (and therefore undeletable) rather than -// backfilled from a source that cannot date them; +// against the conversion registry — see TOMBSTONE_AGE_MAJORS above and the +// note below it for the two false positives that matching produced; the tag half is what dates +// the tombstones that table never named (#25767); // 2. the def is not reachable from the metadata-type roots (2026-08-02 // ruling on #4650): no metadata document is ever parsed by it, so its // entry was over-collection and there is no author to tombstone for. @@ -1374,8 +1405,62 @@ if (surfaceDoc) { // — "reachable enough", "prescribed enough" — waives a deletion silently, and // this gate's whole history (#4638, #4643, #4662) is that direction of error. -/** A tombstone may be deleted once its registration is this many majors old. */ -const TOMBSTONE_AGE_MAJORS = 2; +/** + * The authorable keys that were `[RETIRED]` at {@link TOMBSTONE_DATING_TAG}, + * carried through declared def renames, or null when the tag cannot be read + * here (an offline container, or a checkout whose remote does not carry the + * tag). Resolved at most once per run and only when a deleted line asks for it, + * so a build that deletes nothing never pays the probe — and in a shallow + * checkout the fetch is `--depth=1` of the one tag, guarded by the probe, + * exactly as `resolveSurfaceBase()` fetches origin/main (#6359): it buys the + * ability to NAME the tag and nothing else. + * + * Null is not a pass. The caller falls back to the registry alone and refuses + * what the registry cannot date, so an environment that cannot see the tag can + * only be STRICTER than one that can. + */ +let tombstoneDatingSurface: { rev: string; retired: Set } | null | undefined; +function retiredAtTombstoneDatingTag(git: GitRun): { rev: string; retired: Set } | null { + if (tombstoneDatingSurface !== undefined) return tombstoneDatingSurface; + const ref = `refs/tags/${TOMBSTONE_DATING_TAG}`; + let probe = git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`); + if (probe.status !== 0) { + git('fetch', '--quiet', '--depth=1', 'origin', `+${ref}:${ref}`); + probe = git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`); + } + if (probe.status !== 0) { + console.log( + `ℹ️ authorable-surface deletion check (#4650): release tag ${TOMBSTONE_DATING_TAG} is not\n` + + ` readable in this checkout and could not be fetched, so deleted tombstone lines are dated\n` + + ` by RETIRED_KEYS_BY_MAJOR alone here (#25767). That can only refuse more, never less.`, + ); + tombstoneDatingSurface = null; + return null; + } + const rev = probe.stdout.trim(); + const surface = readSurfaceKeysAtRev( + git, + rev, + AUTHORABLE_SURFACE_DIR_NAME, + 'keys', + `authorable-surface deletion check (#4650) — tombstone dating at ${TOMBSTONE_DATING_TAG} (#25767)`, + ); + if (!surface) { + console.log( + `ℹ️ authorable-surface deletion check (#4650): no ${SURFACE_FILE_NAME} at ${TOMBSTONE_DATING_TAG}\n` + + ` (${rev.slice(0, 12)}), so deleted tombstone lines are dated by RETIRED_KEYS_BY_MAJOR alone here.`, + ); + tombstoneDatingSurface = null; + return null; + } + const retired = new Set(); + for (const entry of surface.entries) { + if (!entry.endsWith(RETIRED_MARK)) continue; + retired.add(carryAuthorableKey(entry.replace(RETIRED_MARK, ''))); + } + tombstoneDatingSurface = { rev, retired }; + return tombstoneDatingSurface; +} // The aging clock reads RETIRED_KEYS_BY_MAJOR — `registeredRetiredKeys()` above, // the same exact-key map check (b) reads. There is no leaf-name matching left in @@ -1400,17 +1485,20 @@ const TOMBSTONE_AGE_MAJORS = 2; // conversion `stack.api.requireAuth` (#3963). The clock was started from a // different KIND of change to the same surface. // -// Why the historical mapping is not backfilled, and what happens instead: a -// tombstone with no entry in RETIRED_KEYS_BY_MAJOR cannot prove its age, so its -// baseline line stays. That is fail-closed by construction rather than by -// estimate — see the table's "Historical tombstones" section for why neither -// available source could date the 97 pre-existing rows honestly (leaf-matching -// the conversion registry is the very inference #4659 removed; this file's own -// git history begins at 17.0.0-rc.0, so it dates every one of them at major 17 -// — an artifact of the baseline's birth, not archaeology). Deleting one of those -// lines is therefore a deliberate, reviewable act: declare its exact key under -// its true major in the table, and check (b2) verifies the entry still names a -// key this build tombstones. +// Why the historical mapping is not backfilled, and what dates it instead: a +// tombstone with no entry in RETIRED_KEYS_BY_MAJOR is dated by the previous +// major's release tag — `[RETIRED]` on the surface at `@objectstack/spec@17.0.0` +// means retired at or before the 17 cut, i.e. a ≤16.x kit, which at major 18 has +// lived its one major (#25767). Leaf-matching the conversion registry is the +// very inference #4659 removed, and this file's own git history begins at +// 17.0.0-rc.0, so it dates every pre-existing tombstone at major 17 — an +// artifact of the baseline's birth, not archaeology; the tag reads the SAME +// artifact but asks a different question of it, "was it already marked when +// the major was cut", which the birth of the file cannot fake. A tombstone the +// registry does not name AND the tag does not carry stays: fail-closed by +// construction rather than by estimate. Deleting such a line is a deliberate, +// reviewable act: declare its exact key under its true major in the table, and +// check (b2) verifies the entry still names a key this build tombstones. interface SurfaceReachability { /** The metadata-type roots the BFS started from. */ @@ -2850,19 +2938,37 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; } const registeredAt = declaredRetired.get(key); if (registeredAt === undefined) { + // The second dating source (#25767): a line that was already marked + // when the previous major was cut belongs to the kit that major + // retired, and that kit has lived its one major. Read lazily — only a + // deleted tombstone the registry does not name asks for the tag. + const dated = retiredAtTombstoneDatingTag(gitInPackage); + if (dated !== null && dated.retired.has(key)) { + allowed.push( + `${key} — [RETIRED] at ${baseRev} and already [RETIRED] at release tag ` + + `${TOMBSTONE_DATING_TAG} (${dated.rev.slice(0, 12)}; current major ${CURRENT_MAJOR}): ` + + `tombstone aged out (#25767).`, + ); + continue; + } + const tagVerdict = + dated === null + ? `and release tag ${TOMBSTONE_DATING_TAG} could not be read here to date it` + : `and it was not [RETIRED] at release tag ${TOMBSTONE_DATING_TAG} (${dated.rev.slice(0, 12)})`; violations.push( `${key} — def ${how}; tombstoned, but no entry in RETIRED_KEYS_BY_MAJOR names this\n` + - ` EXACT key, so there is nothing that dates the retirement and its aging clock\n` + - ` has no start. Until #5898 a leaf-name match against unrelated ADR-0087\n` + - ` clauses supplied one by coincidence (a flow node's '.type' dated an index\n` + - ` type's tombstone) — always erring early, since it took the Math.min.`, + ` EXACT key, ${tagVerdict}, so there is\n` + + ` nothing that dates the retirement and its aging clock has no start. Until\n` + + ` #5898 a leaf-name match against unrelated ADR-0087 clauses supplied one by\n` + + ` coincidence (a flow node's '.type' dated an index type's tombstone) — always\n` + + ` erring early, since it took the Math.min.`, ); continue; } if (CURRENT_MAJOR - registeredAt < TOMBSTONE_AGE_MAJORS) { violations.push( `${key} — def ${how}; tombstone registered at major ${registeredAt}, current major is\n` + - ` ${CURRENT_MAJOR} — a tombstone must age ≥ ${TOMBSTONE_AGE_MAJORS} majors before its line is deleted.`, + ` ${CURRENT_MAJOR} — a tombstone must age ≥ ${TOMBSTONE_AGE_MAJORS} major before its line is deleted (#25767).`, ); continue; } @@ -2893,15 +2999,16 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ` compared against the baseline at merge base ${baseRev} with origin/main,\n` + ` which this commit cannot rewrite.\n\n` + ` A line may only leave this file when:\n` + - ` 1. its key was tombstoned (\`retiredKey()\` → "[RETIRED]") AND that key is\n` + + ` 1. its key was tombstoned (\`retiredKey()\` → "[RETIRED]") AND either that key is\n` + ` declared — EXACTLY, as '\${defKey}:\${name}' — in RETIRED_KEYS_BY_MAJOR\n` + - ` under a major ≥ ${TOMBSTONE_AGE_MAJORS} behind this one (≤ v${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}). Tombstones that\n` + - ` predate that table are deliberately undeclared: nothing could date them\n` + - ` honestly, so they are NOT deletable until someone establishes the true\n` + - ` major and writes it down (#5898) — as one entry file under\n` + - ` ${entryDirOf('retired-key')}, named for that major per\n` + - ` ${MIGRATION_ENTRIES_README}, then \`${MIGRATION_REGISTRY_COMMAND}\`;\n` + - ` the table is generated, ⛔ never typed. The\n` + + ` under a major ≥ ${TOMBSTONE_AGE_MAJORS} behind this one (≤ v${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}), or its line was\n` + + ` already [RETIRED] on the authorable surface at release tag\n` + + ` ${TOMBSTONE_DATING_TAG} — retired before that cut, so its kit has lived the one\n` + + ` major it is owed (#25767). A tombstone that neither source dates is NOT\n` + + ` deletable until someone establishes the true major and writes it down\n` + + ` (#5898) — as one entry file under ${entryDirOf('retired-key')}, named for\n` + + ` that major per ${MIGRATION_ENTRIES_README}, then\n` + + ` \`${MIGRATION_REGISTRY_COMMAND}\`; the table is generated, ⛔ never typed. The\n` + ` D2 conversion (src/conversions/registry.ts) naming the surface stays\n` + ` required — it is the prescription consumers follow — but it is no longer\n` + ` what dates the clock; or\n` + diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index 989c8d08c74..8d0dc27c218 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -67,7 +67,8 @@ export const RENAMED_DEFS: Readonly> = { // longer emits, which is precisely the decay guard that made this table // trustworthy. The removal is carried by the real retirement kit instead — // a `retiredKey()` tombstone on `ConnectorSchema.rateLimitConfig`, the - // `connector-rate-limit-config-removed` D2 conversion, and the deliberate + // `connector-rate-limit-config-removed` D2 conversion (a ≤16.x kit, retired + // on #25767; the tombstone stays because its container strips), and the deliberate // manifest deletion of both `integration/ConnectorRateLimitConfig` and // `integration/RateLimitStrategy`. A retirement must never ride this table // (same ruling as `automation/ConflictResolution` below). diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 76a8a80d01c..f562637750f 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -530,22 +530,24 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { // scanning `dead.evidence` would hold 6 rows to a standard, read nothing of // the other 74, and publish that as coverage of the class. // - // The carrier is `flow.active`, a `retiredKey()` tombstone, because the gate + // The carrier is `action.aria`, a `retiredKey()` tombstone, because the gate // itself holds a tombstoned row at `dead` (the #19062 join) — so it cannot be - // re-graded out from under this case. The carrier used to be `flow.description`, - // a docs-shaped row that WAS re-graded `live` (#20299): a borrowed `dead` row is - // a claim with a timestamp, and the precondition below says so if it moves. + // re-graded out from under this case. It was `flow.active` until that ≤16.x + // kit aged out (#25767) and its row left the ledger; before that + // `flow.description`, a docs-shaped row that WAS re-graded `live` (#20299): + // a borrowed `dead` row is a claim with a timestamp, and the precondition + // below says so if it moves. it('stays GREEN when a `dead` entry carries the SAME rotted pointer', () => { - const shipped = JSON.parse(readFileSync(path.join(LEDGERS, 'flow.json'), 'utf8')); - expect(shipped.props.active.status, 'the carrier row must be `dead` in the shipped ledger').toBe('dead'); + const shipped = JSON.parse(readFileSync(path.join(LEDGERS, 'action.json'), 'utf8')); + expect(shipped.props.aria.status, 'the carrier row must be `dead` in the shipped ledger').toBe('dead'); const root = path.join(tmp, 'dead-excluded'); cpSync(LEDGERS, root, { recursive: true }); - setEvidence(root, 'flow', 'active', `${ROTTED} (rotted by the self-test)`); + setEvidence(root, 'action', 'aria', `${ROTTED} (rotted by the self-test)`); const { status, output } = runGate(root); expect(status, output).toBe(0); - expect(output).not.toContain(`flow/active → ${ROTTED}`); + expect(output).not.toContain(`action/aria → ${ROTTED}`); }); // The partition itself, pinned at the source — the precedent is the diff --git a/packages/spec/scripts/liveness/tombstoned-row-status.test.ts b/packages/spec/scripts/liveness/tombstoned-row-status.test.ts index d55229aea7e..51d9b0eb83c 100644 --- a/packages/spec/scripts/liveness/tombstoned-row-status.test.ts +++ b/packages/spec/scripts/liveness/tombstoned-row-status.test.ts @@ -90,7 +90,7 @@ const TOMBSTONE_MARKER = '[REMOVED]'; * The non-vacuity floor: about two thirds of the measured population, so retiring * a tombstoned key (ADR-0087's normal end of a window) does not red it (#26541). */ -const TOMBSTONE_FLOOR = 32; // measured 48 on 2026-10-27 (pinned at 40); floor = floor(48 × 2/3) +const TOMBSTONE_FLOOR = 18; // measured 48 on main 2026-10-27, 28 once #25767 aged out the ≤16.x kits; floor = floor(28 × 2/3) function defOf(s: any): any { return s && (s._zod?.def ?? s._def); @@ -188,12 +188,14 @@ describe('shipped ledgers — a tombstoned key is `dead`', () => { expect(writable).toEqual([]); }); - it('`agent.tools` is the #18304 row: tombstoned in AgentSchema, `dead` in the ledger, with no `evidence` pointer', () => { - const shape = shapeOf(getMetadataTypeSchema('agent'))!; - expect(descOf(shape.tools)).toContain(TOMBSTONE_MARKER); - expect(defOf(unwrap(shape.tools))?.type).toBe('never'); + // #18304's carrier was `agent.tools`; that ≤16.x kit aged out on #25767 and + // its row left the ledger, so a 17.x tombstone carries the same pin. + it('`action.aria` is the carrier row: tombstoned in ActionSchema, `dead` in the ledger, with no `evidence` pointer', () => { + const shape = shapeOf(getMetadataTypeSchema('action'))!; + expect(descOf(shape.aria)).toContain(TOMBSTONE_MARKER); + expect(defOf(unwrap(shape.aria))?.type).toBe('never'); - const row = ledgerOf('agent').props.tools; + const row = ledgerOf('action').props.aria; expect(row.status).toBe('dead'); // A `dead` row's pointer lives in `note` by the gate's own design // (EVIDENCE_UNSCANNED_STATUSES): an `evidence` string here would be a claim diff --git a/packages/spec/scripts/pure-schema-construction.test.ts b/packages/spec/scripts/pure-schema-construction.test.ts index 4b3ea02fa2b..b28641605ef 100644 --- a/packages/spec/scripts/pure-schema-construction.test.ts +++ b/packages/spec/scripts/pure-schema-construction.test.ts @@ -134,7 +134,9 @@ describe('a consumer of the built ./shared entry keeps the conversion table only return ids.filter((id) => text.includes(`'${id}'`) || text.includes(`"${id}"`)); }; - expect(ids.length).toBeGreaterThan(100); + // Anti-vacuity floor, not a count: 100 measured after #25767 retired the + // ≤16.x kits (the table shrinks by one major's worth each protocol major). + expect(ids.length).toBeGreaterThan(50); expect(await kept('normalizeStackInput', 'shared-reader')).toEqual(ids); expect( await kept( diff --git a/packages/spec/src/ai/agent-lifecycle-retirement.test.ts b/packages/spec/src/ai/agent-lifecycle-retirement.test.ts index 83ac9faba7c..1bb072212dd 100644 --- a/packages/spec/src/ai/agent-lifecycle-retirement.test.ts +++ b/packages/spec/src/ai/agent-lifecycle-retirement.test.ts @@ -296,6 +296,10 @@ describe('tree-scoped absence: nothing inside the declared radius still authors const EXCLUDED = new Set([ // This pin authors the key to assert its refusal and its conversion. THIS_FILE, + // The CLI write door's pin (#25767): its fixture authors the pre-retirement + // agent on purpose, to assert `os migrate meta --from 17 --write` rewrites + // it through this kit's D2 conversion — the kit's fixture at the CLI door. + 'packages/cli/test/migrate-meta-write.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement agent on purpose. diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 586c8ab0bc8..8aa8cc61594 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -71,12 +71,12 @@ describe('AIModelConfigSchema', () => { }); }); -describe('agent.tools retirement (ADR-0064) — tombstoned; tools move into skills', () => { - it('REJECTS a legacy inline tools array, with the fix in the message', () => { - // Tombstoned, not deleted: AgentSchema is `strictObject`, so a plain - // deletion would reject the key with a generic unknown-key error. - // `retiredKey()` is what makes the rejection carry the prescription — - // which is the payload, and what this assertion pins. +describe('agent.tools retirement (ADR-0064) — aged out; tools move into skills', () => { + it('REFUSES a legacy inline tools array as an unknown key naming the upgrade guide', () => { + // The tombstone that carried the prescription was a 16.x retirement kit + // and left the shape at 18 (#25767, ADR-0087 D2 = A). AgentSchema is + // `strictObject`, so the key is still refused loudly — the unknown-key + // refusal names it and the guide the `skills` prescription now lives in. expect(() => AgentSchema.parse({ name: 'legacy', @@ -85,7 +85,7 @@ describe('agent.tools retirement (ADR-0064) — tombstoned; tools move into skil instructions: 'x', tools: [{ type: 'action', name: 'create_ticket' }], }), - ).toThrow(/agent\.tools.*removed.*use `skills`/s); + ).toThrow(/`tools`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('parses cleanly once the capability moves into skills', () => { @@ -203,15 +203,17 @@ describe('AgentSchema', () => { })).toThrow(/knowledge/); }); - it('the rejection names where retrieval scoping actually lives', () => { + it('the refusal is the strict unknown-key one, naming the guide where the scoping prescription lives', () => { + // The `knowledge` tombstone aged out at 18 with its kit (#25767). let message = ''; try { AgentSchema.parse({ name: 'a', label: 'A', instructions: 'x', knowledge: { sources: ['s'] } }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/knowledge-service|source level/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toMatch(/`knowledge`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should accept agent with skills (Agent→Skill→Tool architecture)', () => { diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index d1d8b9badd9..d3f194962f1 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -384,57 +384,6 @@ export const AgentSchema = lazySchema(() => strictObject({ /** Capabilities — Skill-based (primary) */ skills: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).optional().describe('Skill names to attach (Agent→Skill→Tool architecture)'), - /** - * [REMOVED in protocol 17 — #3894] The legacy inline - * `{type,name,description}[]` fallback. - * - * ADR-0064's central invariant is "an agent's tool set is the union of its - * surface-compatible skills' tools; nothing falls through to the global - * registry", and this field was the one seam that broke it: the runtime - * resolved `agent.tools[].name` against the FULL registry with no surface - * check, so an `ask`-surface agent could name an authoring tool and get it. - * - * Tombstoned rather than deleted: `AgentSchema` is `strictObject`, so a plain - * deletion would already REJECT the key — but only with a generic unknown-key - * error. The prescription is the payload. An author who wrote `tools` has to - * be told the specific thing this key's removal means: the capability moves - * into a skill, and ADR-0064's union is the only path from an agent to a tool - * — which this tombstone says and an unknown-key rejection cannot. It also - * types the key `never`, so the same mistake fails `tsc` at the authoring site - * before any parse runs. Those are the two channels an upgrading author, very - * often an AI (ADR-0033), actually reads (`shared/retired-key.ts`). - */ - tools: retiredKey( - '`agent.tools` was removed in @objectstack/spec 17 — 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. ' + - 'ADR-0064 itself still reads `Proposed` and is cloud-owned — that scopes its ' + - 'RUNTIME half (tool resolution, which lives in cloud `service-ai`), not this ' + - 'rejection: the authoring invariant binds you here, and ADR-0109 ' + - '(Accepted — implemented) is the in-repo record that carries it. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - /** Knowledge */ - // `knowledge` REMOVED (#3896 audit close-out) — a GROUNDING claim nothing - // enforced: the RAG path never reads the agent record; `search_knowledge` - // takes `sourceIds` from the LLM's own tool-call arguments (cloud - // knowledge-tools.ts:96). An author who "scoped" retrieval here scoped - // nothing — the dangerous direction, same shape as tool.permissions. - knowledge: retiredKey( - '`agent.knowledge` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** Interface */ active: z.boolean().default(true), access: z.array(z.string()).optional().describe('Who can chat with this agent'), diff --git a/packages/spec/src/ai/skill.test.ts b/packages/spec/src/ai/skill.test.ts index e0325a06fbe..860f7240aaf 100644 --- a/packages/spec/src/ai/skill.test.ts +++ b/packages/spec/src/ai/skill.test.ts @@ -193,15 +193,18 @@ describe('defineSkill', () => { }); describe('retired `triggerPhrases` — phrases never routed a skill; triggerConditions do', () => { - it('REJECTS the retired key with the routing prescription', () => { + it('REFUSES the aged-out key as an unknown key naming the upgrade guide (#25767)', () => { + // The tombstone that carried the routing prescription was a 16.x kit and + // left the shape at 18; the strict refusal names the key and the guide. let message = ''; try { SkillSchema.parse({ name: 'case_mgmt', label: 'Cases', triggerPhrases: ['open a ticket'] }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/triggerConditions/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toMatch(/`triggerPhrases`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('does not offer the retired key as the fix for a near-miss of it', () => { diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index f989ac415de..7f66c79a744 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -20,7 +20,6 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; // whichever export happens to be declared first (#7113). import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; -import { retiredKey } from '../shared/retired-key'; /** * The trigger-condition operators whose `value` is a LIST rather than a scalar @@ -374,24 +373,6 @@ export const SkillSchema = lazySchema(() => strictObject({ */ tools: z.array(z.string().regex(/^[a-z_][a-z0-9_]*\*?$/)).describe('Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only'), - /** - * Natural language phrases that trigger skill activation. - * Used for intent matching and skill routing. - */ - // `triggerPhrases` REMOVED (#3896 audit close-out): phrases were NEVER - // matched against the user's message — activation is `triggerConditions` ∩ - // the agent's `skills[]` allowlist (+ explicit /skill-name pinning). The - // cloud API served the field back to clients, a dead-end projection that - // made the false capability look extra real. - triggerPhrases: retiredKey( - '`skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Programmatic conditions for skill activation. * Evaluated against the runtime context (object name, user role, etc.). diff --git a/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts b/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts index 452d7937b2f..3374f9a4e8e 100644 --- a/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts +++ b/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts @@ -4,17 +4,20 @@ * [#17487] The shipped confirmation-gate prescriptions are held to the door * that actually runs — in BOTH directions. * - * Three customer-facing prescriptions tell an author what + * One customer-facing prescription tells an author what * `action.ai.requiresConfirmation: true` does: the `requiresConfirmation` entry * of `TOOL_RETIRED_KEY_GUIDANCE` (reached as a parse error on the `.strict()` * `ToolSchema`, the one channel every consumer bumping `@objectstack/spec` is - * guaranteed to hit) and the ADR-0087 D3 entry's `replacement` and - * `acceptanceCriteria`, which are what `spec-changes.json`, the upgrade guide - * and `os migrate meta` project to consumers. + * guaranteed to hit). The ADR-0087 D3 entry whose `replacement` and + * `acceptanceCriteria` carried the same sentence into `spec-changes.json`, the + * upgrade guide and `os migrate meta` was a 16.x retirement kit and aged out + * at 18 (#25767, ADR-0087 amendment); the guidance entry is an unknown-key + * prescription on the strict surface rather than a ledger tombstone, so it + * stays, and this pin now ties that one carrier to the door. * * ## Why this pin exists, and why it reads the RUNTIME * - * All three used to carry a hedge — "the declaration is the contract, not yet + * All of them used to carry a hedge — "the declaration is the contract, not yet * the behaviour … until that door lands, such a call simply RUNS" — written * while the runtime door was a separate, unlanded card. The door landed. The * hedge then DENIED a door that exists, and denied it in the dangerous @@ -66,7 +69,6 @@ import { describe, it, expect } from 'vitest'; import { ToolSchema } from './tool.zod'; import { ActionSchema } from '../ui/action.zod'; -import { entry as toolConfirmationEntry } from '../migrations/entries/semantic/17.tool-requires-confirmation-retired'; const HERE = dirname(fileURLToPath(import.meta.url)); /** …/packages/spec/src/ai → repo root */ @@ -89,20 +91,19 @@ function toolRejectionMessage(): string { } /** - * The three carriers, each named. A prescription reaching consumers through a - * different projection is the same text, so one set of assertions covers all. + * The carriers, each named — one since #25767 (the D3 entry's two projections + * aged out with its kit). Kept as a list so a carrier that returns is added + * here and judged by the same assertions. */ function shippedPrescriptions(): Array<[string, string]> { return [ ['ToolSchema rejection (TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation)', toolRejectionMessage()], - ['ADR-0087 D3 entry — replacement', toolConfirmationEntry.replacement], - ['ADR-0087 D3 entry — acceptanceCriteria', toolConfirmationEntry.acceptanceCriteria], ]; } /** * Claims that the refusal is not performed. A closed list, every member taken - * verbatim from a sentence one of these three carriers really shipped — so the + * verbatim from a sentence one of the carriers really shipped — so the * predicate answers "did the retired shape come back", never "does this read * hedged to me". */ @@ -143,7 +144,7 @@ function aiDoorBody(): string { } describe('the confirmation-gate prescriptions match the door that runs', () => { - it('anchors on real text in all three carriers', () => { + it('anchors on real text in every carrier', () => { // Anti-vacuity for every assertion below: an empty carrier would pass // "carries no denial" by reading nothing. for (const [name, text] of shippedPrescriptions()) { @@ -174,7 +175,7 @@ describe('the confirmation-gate prescriptions match the door that runs', () => { expect( aiDoorBody(), '`invokeBusinessAction` no longer calls `actionConfirmationRefusal` — the prescriptions ' - + 'in `ai/tool.zod.ts` and the ADR-0087 D3 entry now describe a door that does not run', + + 'in `ai/tool.zod.ts` now describes a door that does not run', ).toMatch(/actionConfirmationRefusal\(/); }); diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 9923f029ced..8f4dd425ef0 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -207,9 +207,10 @@ export const ToolSchema = lazySchema(() => strictObject({ // promised a capability gate on invocation that nothing enforced, and // `active: false` read as "withdrawn" while the tool kept reaching the LLM // set and `POST /ai/tools/:name/execute` kept running it. The `.strict()` - // parse rejects each with its prescription (TOOL_RETIRED_KEY_GUIDANCE), and - // the `tool-inert-authoring-keys-removed` conversion strips them from - // authored sources. + // parse rejects each with its prescription (TOOL_RETIRED_KEY_GUIDANCE); the + // `tool-inert-authoring-keys-removed` conversion that stripped them from + // authored sources was a ≤16.x kit and retired on #25767 — the prescription + // is the surviving route. /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here, in a code package's source; diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 956f40db77a..429d1a52c90 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -6,7 +6,6 @@ import { FilterConditionSchema } from '../data/filter.zod'; import { AggregationFunction, DateGranularity } from '../data/query.zod'; import { strictObject } from '../shared/strict-object'; import { BaseResponseSchema } from './contract.zod'; -import { retiredKey } from '../shared/retired-key'; /** * Analytics API Protocol @@ -51,17 +50,6 @@ export const AnalyticsEndpoint = z.enum([ export const AnalyticsQueryRequestSchema = lazySchema(() => AnalyticsQuerySchema.extend({ cube: z.string().describe('Target cube name'), - query: retiredKey( - '`query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. ' + - 'The { cube, query: {...} } envelope was the dialect of the retired degraded analytics shim — ' + - 'the real engine never understood it. Move the query.* fields to the body top level: ' + - '{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }.', - ), - format: retiredKey( - '`format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. ' + - 'It was never implemented — every response is the JSON envelope. Delete the key; ' + - 'for CSV/XLSX use the export surface instead.', - ), }).strict() ); diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 8efe368c655..e908fb55b87 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -196,7 +196,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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/automation/control-flow.test.ts b/packages/spec/src/automation/control-flow.test.ts index 9ce5411ed73..b357f946f35 100644 --- a/packages/spec/src/automation/control-flow.test.ts +++ b/packages/spec/src/automation/control-flow.test.ts @@ -115,17 +115,18 @@ describe('TryCatchConfigSchema', () => { }); // #4661: `retryDelayMs` was the automation spelling of `backoffMs` before the - // retry policy converged onto one declaration. It is TOMBSTONED rather than - // deleted precisely because this schema is not `.strict()` — a plain removal - // would have Zod swallow the authored number and silently fall back to the - // 1000ms default. Assert the loud rejection, not merely its absence. - it('rejects the retired `retryDelayMs` spelling with the rename prescription', () => { + // retry policy converged onto one declaration. Its tombstone was a 16.x + // retirement kit and aged out at 18 (#25767); the retry policy is strict, so + // an authored number is still refused loudly — as an unknown key naming the + // upgrade guide — rather than swallowed into the 1000ms default. Assert the + // loud rejection, not merely its absence. + it('refuses the aged-out `retryDelayMs` spelling as an unknown key naming the upgrade guide', () => { const parse = () => TryCatchConfigSchema.parse({ try: { nodes: [node('t')] }, retry: { maxRetries: 3, retryDelayMs: 500 }, }); - expect(parse).toThrow(/backoffMs/); - expect(parse).toThrow(/retryDelayMs/); + expect(parse).toThrow(/Unrecognized key\(s\) on this retry policy: `retryDelayMs`/); + expect(parse).toThrow(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('retry is opt-in: a declared but empty retry block does not retry', () => { diff --git a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts index 57b18269536..16dbec3c12d 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts @@ -298,15 +298,22 @@ describe('what stays as it was (lit controls)', () => { expect(flowNodeConfigRefusals('decision', { condition: 'a == b', whateverElse: 1 })).toEqual([]); }); - it('CONTROL: a tombstoned retired script key keeps its existing path', () => { + it('an aged-out retired script key is an unknown key now, so the key arm refuses it at its own path (#25767)', () => { + // `actionType`'s tombstone was a 16.x retirement kit and left the shape at + // 18 (ADR-0087 D2 = A). The contract's own parse refuses it as an unknown + // key naming the upgrade guide, and this arm — which used to hold back + // because the tombstone answered at its own path — now draws its one + // refusal at the key, like any other undeclared key. const config = { ...MEASURED, actionType: 'email' }; const own = getBuiltinNodeConfigContracts().get('script')!.schema.safeParse(config); expect(own.success ? [] : own.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.') }))).toEqual([ - { code: 'invalid_type', path: 'actionType' }, + { code: 'unrecognized_keys', path: '' }, ]); - expect(own.success ? '' : own.error!.issues[0]!.message).toMatch(/was removed in @objectstack\/spec 17/); - expect(flowNodeConfigRefusals('script', config)).toEqual([]); - expect(issuesOf(flowWith('script', config))).toEqual([]); + expect(own.success ? '' : own.error!.issues[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(flowNodeConfigRefusals('script', config).map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'node-config-refused-by-contract', path: 'actionType' }, + ]); + expect(issuesOf(flowWith('script', config)).map(({ path }) => path)).toEqual(['nodes.1.config.actionType']); }); }); @@ -337,18 +344,20 @@ describe('try_catch: its retry is closed, so the spec judges its keys like every .map(({ path }) => path).sort()).toEqual(['bogusKey', 'retry.other']); }); - it('a retryDelayMs the conversion leaves behind is refused at its own path, in the tombstone\'s words', () => { + it('a retryDelayMs the conversion leaves behind is refused at its own path, as the unknown key it is now', () => { // `retry-policy-converged` keeps both spellings when the values differ, and - // leaves a `null`: registration's walk refused both as undeclared keys. + // leaves a `null`: registration's walk refused both as undeclared keys. The + // spelling's tombstone aged out at 18 (#25767), so the refusal is the + // strict unknown-key one — naming the key, the policy, and the upgrade + // guide the rename now lives in — closed by the arm's own "rename or remove". for (const retry of [{ backoffMs: 500, retryDelayMs: 700 }, { retryDelayMs: null }]) { const config = { try: TRY_REGION, retry }; const refusals = flowNodeConfigRefusals('try_catch', config); expect(refusals.map(({ code, path }) => ({ code, path })), JSON.stringify(retry)).toEqual([ { code: 'node-config-refused-by-contract', path: 'retry.retryDelayMs' }, ]); - // The tombstone's upgrade is the remedy; no "undeclared key" closing on a key the contract declares retired. - expect(refusals[0]!.message).toContain('Rename the key to `backoffMs`'); - expect(refusals[0]!.message).not.toMatch(/Rename the key to one/); + expect(refusals[0]!.message).toContain('Unrecognized key(s) on this retry policy: `retryDelayMs`'); + expect(refusals[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); expect(issuesOf(flowWith('try_catch', config)).map(({ path }) => path)).toEqual(['nodes.1.config.retry.retryDelayMs']); } }); @@ -417,7 +426,8 @@ describe('try_catch: its retry is closed, so the spec judges its keys like every } expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); expect(refusal!.issues!.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.retry.retryDelayMs']); - expect(refusal!.issues![0]!.message).toContain('Rename the key to `backoffMs`'); + expect(refusal!.issues![0]!.message).toContain('Unrecognized key(s) on this retry policy: `retryDelayMs`'); + expect(refusal!.issues![0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts index 5470205f902..7cb07cc38c4 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts @@ -413,21 +413,21 @@ describe('what stays accepted (lit controls)', () => { expect(flowNodeConfigRefusals('http', config).map(({ path }) => path)).toEqual(['timeoutMs']); }); - it('CONTROL: key membership is not this arm\'s — an undeclared key and a tombstoned one', () => { + it('CONTROL: key membership is not this arm\'s — an undeclared key and an aged-out retired one', () => { // [#21982] An undeclared key on these builtins draws the KEY arm's one // refusal at its location; this arm adds nothing beside it. [#22343] So - // does a tombstoned key on a type the key arm judges it for — `try_catch`'s - // `retry.retryDelayMs` — while a retired `script` key keeps the scope it - // had (the lint names it): neither draws a second refusal from this arm. + // does a retired key the key arm judges — `try_catch`'s `retry.retryDelayMs` + // and, since its tombstone aged out at 18 (#25767), `script`'s `actionType` + // too: each is an unknown key now, and neither draws a second refusal here. for (const [type, config, key] of [ ['http', { ...VALID.http, bogusKey: 1 }, 'bogusKey'], ['screen', { fields: [{ name: 'qty', visible: 'x' }] }, 'fields[0].visible'], ['try_catch', { ...VALID.try_catch, retry: { retryDelayMs: 500 } }, 'retry.retryDelayMs'], + ['script', { ...VALID.script, actionType: 'email' }, 'actionType'], ] as const) { expect(flowNodeConfigRefusals(type, config).map(({ code, path }) => ({ code, path })), type) .toEqual([{ code: 'node-config-refused-by-contract', path: key }]); } - expect(flowNodeConfigRefusals('script', { ...VALID.script, actionType: 'email' })).toEqual([]); // CONTROL: the contracts refuse all four — the arm holds back, the contract does not. for (const [type, config] of [ ['http', { ...VALID.http, bogusKey: 1 }], diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index 3c32b8e6adc..a216b20f119 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -370,7 +370,7 @@ describe('FlowSchema', () => { }); }); - it('REJECTS the retired `active` with the status prescription', () => { + it('REFUSES the aged-out `active` as an unknown key naming the upgrade guide (#25767)', () => { let message = ''; try { FlowSchema.parse({ @@ -379,8 +379,9 @@ describe('FlowSchema', () => { } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/status/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toMatch(/`active`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should default runAs to user', () => { @@ -938,7 +939,7 @@ describe('FlowSchema - errorHandling', () => { }); }); - it('REJECTS the retired errorHandling.fallbackNodeId — faults route via fault edges', () => { + it('REFUSES the aged-out errorHandling.fallbackNodeId as an unknown key (faults route via fault edges; #25767)', () => { expect(() => FlowSchema.parse({ name: 'fallback_flow', label: 'Fallback', @@ -949,7 +950,7 @@ describe('FlowSchema - errorHandling', () => { ], edges: [], errorHandling: { strategy: 'continue', fallbackNodeId: 'fallback' }, - })).toThrow(/fault edge/); + })).toThrow(/`fallbackNodeId`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('should accept flow without errorHandling (optional)', () => { @@ -1304,7 +1305,10 @@ describe('BPMN — Wait Event Configuration', () => { * (#4158). Retiring the pair is what flipped them, which is the point — the * schema now says what `wait` actually does. */ - it('rejects the retired timeout keys instead of stripping them', () => { + it('refuses the retired timeout keys instead of stripping them', () => { + // Their tombstones were 16.x retirement kits and aged out at 18 (#25767); + // the block is strict, so each is an unknown-key refusal naming the key and + // the upgrade guide, where the `timerDuration` prescription now lives. for (const retired of [{ timeoutMs: 7_200_000 }, { onTimeout: 'fail' }]) { const result = FlowNodeSchema.safeParse({ id: 'wait_timer', @@ -1314,22 +1318,25 @@ describe('BPMN — Wait Event Configuration', () => { }); const key = Object.keys(retired)[0]; expect(result.success, `${key} must be rejected, not silently dropped`).toBe(false); - // The prescription names the issue and the replacement (or its absence). - expect(JSON.stringify(result.error?.issues), `${key} guidance`).toMatch(/was removed in @objectstack\/spec 17/); + const issues = JSON.stringify(result.error?.issues); + expect(issues, `${key} refusal`).toContain('unrecognized_keys'); + expect(issues, `${key} refusal`).toContain(`\`${key}\``); + expect(issues, `${key} refusal`).toContain('https://objectstack.ai/docs/protocol-upgrade'); } }); /** * #6758 — a prescription that does not parse is WORSE than no prescription: - * the author lands in the exact rejection the tombstone exists to spare them, - * and the second one is a bare `invalid_type` with no guidance attached. Both - * author-facing channels on this block print a `timerDuration` value to copy, - * and `timerDuration` is `z.string()` — so every value they print must be - * QUOTED. Until this test they printed the bare number `60000`, which is - * TS2322 at the authoring site and `expected string, received number` at the - * parse (the ADR-0087 conversion has always known better: it writes - * `String(next.timeoutMs)`, `conversions/registry.ts` — "Moving the number - * unstringified would produce a block that no longer parses"). + * the author lands in the exact rejection the guidance exists to spare them, + * and the second one is a bare `invalid_type` with no guidance attached. The + * author-facing channel on this block prints a `timerDuration` value to copy, + * and `timerDuration` is `z.string()` — so every value it prints must be + * QUOTED. Until this test the channels printed the bare number `60000`, which + * is TS2322 at the authoring site and `expected string, received number` at + * the parse (the ADR-0087 conversion that moved `timeoutMs` knew better: it + * wrote `String(next.timeoutMs)`). The `timeoutMs` tombstone that was the + * other channel aged out at 18 with its kit (#25767); the `timeout` + * misspelling guidance is the channel that remains. * * The value is EXTRACTED from the message rather than compared to a copy: a * hard-coded `'60000'` here would go green the moment someone reworded the @@ -1346,12 +1353,9 @@ describe('BPMN — Wait Event Configuration', () => { }; const channels = { - // Channel 1 — `retiredKey()`'s `z.never` message, raised when an upgrading - // author still writes the removed key. - 'the `timeoutMs` tombstone': messageFor({ eventType: 'timer', timeoutMs: 60_000 }, 'invalid_type'), - // Channel 2 — the `strictObject` `guidance` entry for the `timeout` - // misspelling. Worse than channel 1: there is no first failure to learn - // from, so a bad prescription here is the FIRST thing the schema ever says. + // The `strictObject` `guidance` entry for the `timeout` misspelling: there + // is no first failure to learn from, so a bad prescription here is the + // FIRST thing the schema ever says. 'the `timeout` misspelling guidance': messageFor({ eventType: 'timer', timeout: 60_000 }, 'unrecognized_keys'), }; @@ -1976,19 +1980,20 @@ describe('unknown keys are rejected, not stripped', () => { expect(result.data!.errorHandling!.backoffMs).toBe(5000); }); - it('errorHandling: `retryDelayMs` is the tombstone and carries the rename', () => { + it('errorHandling: `retryDelayMs` is an unknown key now, refused on THIS surface with the upgrade guide', () => { + // Its tombstone aged out at 18 (#25767). The refusal still names the + // surface it was written on — naming a scope narrower than the truth is + // what let the #4661 divergence read as reviewed for a whole release. const result = FlowSchema.safeParse({ ...minimalFlow, errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }, }); expect(result.success).toBe(false); const message = JSON.stringify(result.error!.issues); - expect(message).toContain('was removed in @objectstack/spec 17.0.0'); - expect(message).toContain('backoffMs'); - // The prescription must name THIS surface, not only the two #4661 knew - // about — naming a scope narrower than the truth is what let this - // divergence read as reviewed for a whole release. - expect(message).toContain('flow.errorHandling'); + expect(message).toContain('unrecognized_keys'); + expect(message).toContain('`retryDelayMs`'); + expect(message).toContain('`errorHandling`'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('errorHandling: `maxAttempts` gets the off-by-one, not a rename', () => { diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index eb01b00f699..7e0d87b2702 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -20,7 +20,6 @@ import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { NON_BLANK_STRING } from '../shared/refinement-projection'; -import { retiredKey } from '../shared/retired-key'; import { retryPolicyShape } from '../shared/retry-policy.zod'; import { strictObject } from '../shared/strict-object'; import { collectFlowGraphs, parseFlowNodeRegions } from './control-flow.zod'; @@ -728,16 +727,6 @@ function flowNodeObject() { return strictObject( }, )).optional().describe('Input parameter schema for this node').meta({ title: 'Input Schema' }), - // `outputSchema` REMOVED (#3896 audit close-out): declared, never validated — - // no engine path checked node outputs against it (ledger: dead). - outputSchema: retiredKey( - '`flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Wait Event Configuration (for 'wait' nodes) * Defines what external event or condition should resume the paused execution. @@ -776,49 +765,6 @@ function flowNodeObject() { return strictObject( /** Signal name to listen for — for signal/webhook events */ signalName: z.string().optional().describe('Named signal or webhook event to wait for'), - /** - * `wait` never had a timeout. Both keys below described one and neither - * delivered it (#4158) — the pair is retired in 17 rather than left standing - * as a promise the runtime does not keep (PD #10). (Both tombstones below say - * 17 and the ADR-0087 conversion is `toMajor: 17`; this line said 18, the - * #4350 class — a tombstone naming a major that never shipped it.) - * - * `timeoutMs` said "maximum wait time" and its ONLY reader used it as the - * timer *duration* when `timerDuration` was absent — so it did something, just - * not what it said. `timerDuration` already expresses that (`parseIsoDuration` - * reads a bare numeric *string* as milliseconds — the number must be quoted, - * because `timerDuration` is `z.string()` and the schema is what the author - * meets), which is why the conversion can move it losslessly, stringifying on - * the way, instead of dropping it. - * - * `onTimeout` had ZERO readers anywhere. Setting it changed nothing, and the - * showcase set it — a declared default (`'fail'`) stamped on every wait node - * that no code ever consulted. - * - * Real timeout semantics — resume the run at a deadline and either fail the - * node or continue past it — remain unimplemented. If they are wanted, they - * should be built to a requirement, not retrofitted to fit two keys that - * happened to be declared. - */ - timeoutMs: retiredKey( - '`waitEventConfig.timeoutMs` was removed in @objectstack/spec 17. It documented a ' - + 'timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its ' - + 'only reader treated it as the timer DURATION when `timerDuration` was absent, so use ' - + '`timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is ' - + "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 list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - onTimeout: retiredKey( - '`waitEventConfig.onTimeout` was removed in @objectstack/spec 17. 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 list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }).superRefine((wec, ctx) => { // A timer with nothing to count is the forever-hang in its second // spelling. `parseIsoDuration` is the executor's only source for the wake @@ -1089,14 +1035,6 @@ export const FlowSchema = lazySchema(() => strictObject( /** Metadata & Versioning */ version: z.number().int().default(1).describe('Version number'), status: z.enum(['draft', 'active', 'obsolete', 'invalid']).default('draft').describe('Deployment status'), - // `template` REMOVED (#3896 audit close-out): no reader in designer or engine. - template: retiredKey( - '`flow.template` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** Trigger Type */ type: z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api']).describe('Flow type'), @@ -1108,19 +1046,6 @@ export const FlowSchema = lazySchema(() => strictObject( nodes: z.array(FlowNodeSchema).describe('Flow nodes'), edges: z.array(FlowEdgeSchema).describe('Flow connections'), - // `active` REMOVED (#3896 audit close-out) — the rls.enabled shape, in the - // over-permissive direction: the spec default was `false` while the engine - // treated an unset flow as ENABLED, and `active: false` never stopped a flow - // (`status` is the enforced lifecycle). An author who "disabled" a flow here - // left it firing. - active: retiredKey( - '`flow.active` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest 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 // context afterward: `system` runs elevated (a full-access, RLS-bypassing @@ -1355,17 +1280,6 @@ export const FlowSchema = lazySchema(() => strictObject( maxRetries: retryPolicyShape().maxRetries .describe("Retry attempts after the initial one. Read only under strategy: 'retry', which requires >= 1; 0 (the default) means no retry."), - // `fallbackNodeId` REMOVED (#3896 audit close-out): the engine routes - // unrecoverable errors via per-node FAULT EDGES, never this — an author - // who configured a fallback here had none. - fallbackNodeId: retiredKey( - '`flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (' + - 'audit close-out) — the engine routes unrecoverable node errors via per-node fault ' + - "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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }).superRefine((eh, ctx) => { // `strategy: 'retry'` with 0 attempts is `strategy: 'fail'` wearing a // different label — the flow runs once and stops. That is a declared diff --git a/packages/spec/src/automation/schemaless-node-config.test.ts b/packages/spec/src/automation/schemaless-node-config.test.ts index 8ccce8c314d..0191a253ce8 100644 --- a/packages/spec/src/automation/schemaless-node-config.test.ts +++ b/packages/spec/src/automation/schemaless-node-config.test.ts @@ -49,12 +49,13 @@ function unknownKeyMessage(schema: Parseable, value: unknown): string | undefine return result.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message; } -/** Every key the contract still declares, tombstones included. */ -const SCRIPT_SHAPE_KEYS = [ - 'actionType', 'function', 'inputs', 'outputVariable', - 'recipients', 'script', 'template', 'variables', -]; -/** The keys #4343 retired — each must reject with its own prescription. */ +/** Every key the contract declares. */ +const SCRIPT_SHAPE_KEYS = ['function', 'inputs', 'outputVariable']; +/** + * The keys #4343 retired. Their tombstones were a 16.x retirement kit and aged + * out of the shape at 18 (#25767, ADR-0087 D2 = A): each is refused as a strict + * unknown key naming the upgrade guide, where the per-branch prescriptions now live. + */ const SCRIPT_RETIRED: ReadonlyArray<[string, unknown]> = [ ['actionType', 'email'], ['template', 'task_done'], @@ -90,16 +91,15 @@ describe('ScriptConfigSchema (converged to a function call)', () => { expect(ScriptConfigSchema.safeParse({ function: '' }).success).toBe(false); }); - it.each(SCRIPT_RETIRED)('rejects the retired `%s` with its own prescription', (key, value) => { + it.each(SCRIPT_RETIRED)('refuses the aged-out `%s` as an unknown key naming the upgrade guide', (key, value) => { const result = ScriptConfigSchema.safeParse({ function: 'score_lead', [key]: value }); expect(result.success).toBe(false); - const message = result.error!.issues.map((i) => i.message).join('\n'); - // The tombstone's payload is the prescription, not "unrecognized key" — - // this string IS the upgrade doc for whoever hits it (retired-key.ts). - expect(message).toContain(`\`script.config.${key}\``); - expect(message).toMatch(/was removed in @objectstack\/spec 17/); - expect(message).toMatch(/os migrate meta --from 16/); - expect(result.error!.issues[0]!.path).toEqual([key]); + const issue = result.error!.issues[0] as { code: string; keys?: string[]; path: PropertyKey[]; message: string }; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.keys).toEqual([key]); + expect(issue.message).toContain(`\`${key}\``); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(issue.message).not.toMatch(/os migrate meta --from 16/); }); it('names every violated key at once, so one refusal lists the whole job', () => { @@ -107,18 +107,9 @@ describe('ScriptConfigSchema (converged to a function call)', () => { function: 'score_lead', actionType: 'email', template: 't', recipients: ['a'], }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.path[0]).sort()) - .toEqual(['actionType', 'recipients', 'template']); - }); - - it('prescribes a different mechanism per branch — the retirement is not one rename', () => { - const messageFor = (key: string, value: unknown) => - ScriptConfigSchema.safeParse({ function: 'f', [key]: value }).error!.issues[0]!.message; - // Mail has a real delivery path; Slack does not go through it (no slack - // channel exists — that is a connector), and an inline body is a function. - expect(messageFor('actionType', 'email')).toMatch(/`notify` node/); - expect(messageFor('actionType', 'email')).toMatch(/connector_action/); - expect(messageFor('script', 'return 1;')).toMatch(/defineStack\(\{ functions \}\)/); + const issues = result.error!.issues as Array<{ code: string; keys?: string[] }>; + expect(issues).toHaveLength(1); + expect([...(issues[0]!.keys ?? [])].sort()).toEqual(['actionType', 'recipients', 'template']); }); }); @@ -438,14 +429,14 @@ describe('DecisionConfigSchema — `mode` beside a non-empty `conditions` list i }); describe('structural contract — what the downstream walkers require', () => { - it('keeps the tombstoned keys IN the shape, so the ratchet can see them retired', () => { - // A `retiredKey()` is still a property. Deleting it outright would read as - // "the key vanished" to the authorable-surface gate, which is the hard - // failure the tombstone route exists to avoid. + it('the aged-out keys are OUT of the shape — the ratchet saw them retired for one major, then dated their exit', () => { + // A `retiredKey()` stayed a property for the major after its retirement so + // the authorable-surface gate could see it retired rather than vanished; + // at 18 the gate dated each line's exit by the 17.0.0 release tag (#25767) + // and the keys left the shape. None may come back as a declared key. expect(Object.keys(ScriptConfigSchema.shape).sort()).toEqual(SCRIPT_SHAPE_KEYS); for (const [key] of SCRIPT_RETIRED) { - expect(ScriptConfigSchema.shape[key as keyof typeof ScriptConfigSchema.shape].description) - .toMatch(/^\[REMOVED\]/); + expect(ScriptConfigSchema.shape).not.toHaveProperty(key); } }); diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index fe3d6c680ab..f8b0e357488 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -293,10 +293,13 @@ function decisionModeWithConditionsRefusal(mode: unknown): string { * spelling of `function`, and the `invoke_function` marker named nothing on * its own. * - * All five keys are tombstoned below; the ADR-0087 D2 conversion - * `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. + * All five keys are refused below as strict unknown keys: their tombstones + * were a ≤16.x retirement kit and aged out on #25767 (ADR-0087 D2 = A), together + * with the `flow-node-script-branch-keys-removed` conversion that rewrote stored + * metadata rows (moving a shorthand `actionType` into `function`, where that is + * what it meant). A stored row still carrying one of them is canonicalized on a + * release that carries the step (`os migrate meta --stored --apply` on 18.1 or + * earlier); the refusal's trailing sentence names the protocol upgrade guide. */ export const ScriptConfigSchema = lazySchema(() => strictObject({ surface: 'this script node config', @@ -333,54 +336,6 @@ export const ScriptConfigSchema = lazySchema(() => strictObject({ outputVariable: flowBoundVariableNameSchema('outputVariable').optional() .describe("Flow variable the function's return value is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}`"), - // The four retired dispatch branches (#4343). Each tombstone carries its own - // prescription because the three replacements are different mechanisms, not - // one rename: real messaging is `notify`, Slack is a connector, and inline - // logic belongs in a registered function. - actionType: retiredKey( - '`script.config.actionType` was removed in @objectstack/spec 17 — 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`; `--write` applies the ones it can prove, and the stub and ' - + 'marker values are removed.', - ), - template: retiredKey( - '`script.config.template` was removed in @objectstack/spec 17 — 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. For templated content use a `notify` node and name an ' - + 'email template (`email_template`) in its own `template` key, with `templateData` filling its ' - + 'placeholders: the email and in-app inbox channels render that template in each recipient\'s ' - + 'locale. Otherwise the node carries inline `title`/`message`. The messaging service\'s ' - + '`sys_notification_template` rows are read only by the `email` and `sms` channels, `email` ' - + 'only when the node names no template. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - recipients: retiredKey( - '`script.config.recipients` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - variables: retiredKey( - '`script.config.variables` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - script: retiredKey( - '`script.config.script` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), })); export type ScriptConfig = z.input; diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 7afc0e3b859..37a2ba34e83 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest'; import { CreateRecordConfigSchema } from '../automation/builtin-node-config.zod.js'; import { FlowSchema } from '../automation/flow.zod.js'; import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types.js'; -import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; import { DatasourceSchema } from '../data/datasource.zod.js'; import { getDriverConfigSchema, @@ -13,7 +12,7 @@ import { validateDriverConfig, } from '../data/driver/config-registry.zod.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; -import { ElementButtonPropsSchema, PageHeaderProps, PageTabsProps } from '../ui/component.zod.js'; +import { ElementButtonPropsSchema, PageHeaderProps } from '../ui/component.zod.js'; import { PageSchema } from '../ui/page.zod.js'; import { JobSchema, RetryPolicySchema } from '../system/job.zod.js'; import { defineStack } from '../stack.zod.js'; @@ -45,10 +44,10 @@ import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './types.js'; * same-major pair is absorbed instead (`spec-property-retirement` §0). */ const SUPERSEDED_FIXTURES: Readonly> = { - // `connector.fieldMappings[].transform` (17) lives only inside - // `connector.fieldMappings`, which protocol 18 retired whole with the rest of - // connector-attached sync (ADR-0049). - 'field-mapping-transform-removed': { by: 'connector-sync-keys-removed', stripped: ['fieldMappings'] }, + // Empty since #25767: the one entry here (`field-mapping-transform-removed`, + // 17, whose only surface protocol 18 retired whole with + // `connector-sync-keys-removed`) aged out with the 16.x kits. The mechanism + // stays for the next such pair. }; describe('conversion layer (ADR-0087 D2)', () => { @@ -683,202 +682,28 @@ describe('conversion layer (ADR-0087 D2)', () => { * the test that fails first when either half moves. */ it('⛔ does NOT replay a RETIRED entry, while the stored-row door does', () => { - const entry = ALL_CONVERSIONS.find((c) => c.id === 'app-hidden-to-unpublished'); + const entry = ALL_CONVERSIONS.find((c) => c.id === 'object-external-sharing-model-removed'); // Premise: without this the assertions below would pass vacuously. expect(entry?.retiredFromLoadPath, 'premise: the entry under test is retired').toBe(true); - const authored = { name: 'account', label: 'Account', hidden: true, navigation: [] }; + const authored = { name: 'portal_case', label: 'Case', sharingModel: 'public_read', externalSharingModel: 'private' }; // Authoring funnel: the old shape survives verbatim, nothing is reported. const notices: ConversionNotice[] = []; const out = normalizeStackInput( - { apps: [structuredClone(authored)] }, + { objects: [structuredClone(authored)] }, { onConversionNotice: (n) => notices.push(n) }, ); - expect((out.apps as any[])[0]).toEqual(authored); + expect((out.objects as any[])[0]).toEqual(authored); expect(notices).toEqual([]); // Data at rest: the SAME bytes through the stored-row door DO convert. - const stored = applyConversionsToStoredItem('app', structuredClone(authored)) as Record< + const stored = applyConversionsToStoredItem('object', structuredClone(authored)) as Record< string, unknown >; - expect(stored.hidden).toBeUndefined(); - expect(stored._unpublished).toBe(true); - }); - }); - - describe('flow-node-wait-timeout-keys-removed', () => { - const wecFlow = (waitEventConfig: Record) => ({ - flows: [ - { - name: 'settle', - label: 'Settle', - type: 'autolaunched', - edges: [], - nodes: [ - { id: 'n1', type: 'start', label: 'Start' }, - { id: 'w', type: 'wait', label: 'Wait', waitEventConfig }, - ], - }, - ], - }); - const wecOf = (stack: Record) => (stack.flows as any[])[0].nodes[1].waitEventConfig; - // Retired from the load path, so the default `applyConversions` skips it — - // exactly like `stack-api-require-auth-removed`. - const convert = (stack: Record) => collectConversionNotices(stack, { includeRetired: true }); - - it('moves `timeoutMs` to `timerDuration` as a STRING the schema accepts', () => { - const { stack, notices } = convert(wecFlow({ eventType: 'timer', timeoutMs: 60_000 })); - expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: '60000' }); - expect(notices).toHaveLength(1); - // The move is only lossless if the result still parses — `timerDuration` is - // `z.string()`, so carrying the number across would have broken the block. - expect(() => FlowSchema.parse((stack.flows as any[])[0])).not.toThrow(); - }); - - it('drops `timeoutMs` instead of moving it when `timerDuration` already won', () => { - const { stack, notices } = convert( - wecFlow({ eventType: 'timer', timerDuration: 'PT5M', timeoutMs: 999 }), - ); - expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: 'PT5M' }); - expect(notices).toHaveLength(1); - }); - - it('drops `onTimeout` — it never had a reader, so there is nothing to preserve', () => { - const { stack, notices } = convert(wecFlow({ eventType: 'timer', timerDuration: 'PT1M', onTimeout: 'continue' })); - expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: 'PT1M' }); - expect(notices).toHaveLength(1); - }); - - it('leaves a block carrying neither key untouched', () => { - const { stack, notices } = convert(wecFlow({ eventType: 'signal', signalName: 'paid' })); - expect(wecOf(stack)).toEqual({ eventType: 'signal', signalName: 'paid' }); - expect(notices).toHaveLength(0); - }); - - it('tombstones both keys so a source that skipped conversion is rejected, not stripped', () => { - for (const bad of [{ timeoutMs: 60_000 }, { onTimeout: 'continue' }]) { - const flow = (wecFlow({ eventType: 'timer', timerDuration: 'PT1M', ...bad }).flows as any[])[0]; - expect(() => FlowSchema.parse(flow), `${Object.keys(bad)[0]} must be rejected`) - .toThrow(/was removed in @objectstack\/spec 17/); - } - }); - }); - - describe('flow-node-script-branch-keys-removed', () => { - /** One `script` node in a flow shaped the way the conversion walks it. */ - const scriptFlow = (config: Record) => ({ - flows: [ - { - name: 'task_lifecycle', - label: 'Task lifecycle', - type: 'autolaunched', - edges: [], - nodes: [ - { id: 'n1', type: 'start', label: 'Start' }, - { id: 's', type: 'script', label: 'Script', config }, - ], - }, - ], - }); - const cfgOf = (stack: Record) => (stack.flows as any[])[0].nodes[1].config; - // Retired from the load path (the keys misdescribed themselves), so the - // default `applyConversions` skips it — only `os migrate meta` replays it. - const convert = (stack: Record) => collectConversionNotices(stack, { includeRetired: true }); - - it('moves a shorthand `actionType` into `function` — that is what it named', () => { - const { stack, notices } = convert(scriptFlow({ actionType: 'score_lead', outputVariable: 'score' })); - expect(cfgOf(stack)).toEqual({ function: 'score_lead', outputVariable: 'score' }); - expect(notices).toHaveLength(1); - expect(notices[0]!.to).toBe('config.function'); - }); - - it('drops a shorthand `actionType` instead of moving it when `function` already won', () => { - const { stack, notices } = convert(scriptFlow({ actionType: 'stale_name', function: 'score_lead' })); - expect(cfgOf(stack)).toEqual({ function: 'score_lead' }); - expect(notices).toHaveLength(1); - }); - - it('drops the built-in ids and the bare marker — neither was ever a function name', () => { - for (const actionType of ['email', 'slack', 'invoke_function']) { - const { stack, notices } = convert(scriptFlow({ actionType, function: 'score_lead' })); - expect(cfgOf(stack), actionType).toEqual({ function: 'score_lead' }); - expect(notices, actionType).toHaveLength(1); - expect(notices[0]!.to, actionType).toMatch(/removed/); - } - }); - - it('drops the stub payload keys — nothing ever read them, so there is nothing to preserve', () => { - const { stack, notices } = convert(scriptFlow({ - actionType: 'email', - template: 'task_done', - recipients: ['{record.owner}'], - variables: { taskName: '{record.name}' }, - })); - expect(cfgOf(stack)).toEqual({}); - expect(notices).toHaveLength(4); - }); - - it('drops an inline `script` body the runtime never executed', () => { - const { stack, notices } = convert(scriptFlow({ script: 'return { ok: true };' })); - expect(cfgOf(stack)).toEqual({}); - expect(notices).toHaveLength(1); - }); - - it('leaves an already-converged node untouched', () => { - const { stack, notices } = convert(scriptFlow({ function: 'score_lead', inputs: { id: '{record.id}' } })); - expect(cfgOf(stack)).toEqual({ function: 'score_lead', inputs: { id: '{record.id}' } }); - expect(notices).toHaveLength(0); - }); - - it('leaves a non-script node carrying the same key names alone', () => { - // Unlike the wait retirement, these tombstones live on the script config - // contract — no other node type is parsed against it, so a `template` key - // elsewhere is that node's own business. - const stack0 = { - flows: [{ - name: 'f', - nodes: [{ id: 'n', type: 'notify', config: { template: 'x', recipients: ['a'] } }], - }], - }; - const { stack, notices } = convert(stack0); - expect(stack).toEqual(stack0); - expect(notices).toHaveLength(0); - }); - - it('tombstones every retired key so a source that skipped conversion is rejected, not stripped', () => { - // NOTE the channel: unlike `waitEventConfig`, a node's `config` is - // `z.record(z.unknown())` on `FlowNodeSchema`, so `FlowSchema.parse` does - // NOT reach these tombstones — they answer whoever AUTHORS the key (`tsc` - // types it `never`; this parse raises the prescription). A stored flow is - // reached by the other half: `registerFlow` replays this conversion even - // though it is retired (#3903), and the execute-time parse then refuses - // what is left over for naming no callable. - for (const bad of [ - { actionType: 'email' }, - { template: 't' }, - { recipients: ['a'] }, - { variables: { x: 1 } }, - { script: 'return 1;' }, - ]) { - const key = Object.keys(bad)[0]!; - expect( - () => ScriptConfigSchema.parse({ function: 'score_lead', ...bad }), - `${key} must be rejected`, - ).toThrow(/was removed in @objectstack\/spec 17/); - } - // The flow-level parse is deliberately blind to the TOMBSTONES — pinned so - // the note above stays true if `FlowNodeSchema.config` is ever tightened. - // What it does see since #20316 is the one key the script contract - // requires, left out: a stripped node naming no callable is refused at - // the build doors now, not only at execute — and by that, never by a - // tombstone. - expect(() => FlowSchema.parse((scriptFlow({ function: 'score_lead', actionType: 'email' }).flows as any[])[0])).not.toThrow(); - const stripped = FlowSchema.safeParse((scriptFlow({ actionType: 'email' }).flows as any[])[0]); - expect(stripped.success).toBe(false); - expect(stripped.error!.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.function']); - expect(stripped.error!.issues[0].message).not.toMatch(/was removed in @objectstack\/spec 17/); + expect(stored.externalSharingModel).toBeUndefined(); + expect(stored.sharingModel).toBe('public_read'); }); }); @@ -1204,7 +1029,7 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(JobSchema.safeParse(converted).success).toBe(true); }); - it('the flow half needed nothing more: an authored `retryDelayMs` meets its tombstone naming `backoffMs`', () => { + it('the flow half needed nothing more: an authored `retryDelayMs` meets the strict refusal (its tombstone aged out, #25767)', () => { const issues = refusalOf({ flows: [{ name: 'sync_orders', @@ -1215,8 +1040,9 @@ describe('conversion layer (ADR-0087 D2)', () => { edges: [{ id: 'e1', source: 'start', target: 'end' }], }], }); - expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.errorHandling.retryDelayMs']); - expect(issues[0]!.message).toContain('Rename the key to `backoffMs`'); + expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.errorHandling']); + expect(issues[0]!.message).toContain('`retryDelayMs`'); + expect(issues[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('CONTROL — a job with no block, and a block stating `maxRetries: 1`, both parse; an omitted multiplier keeps its default', () => { @@ -1356,159 +1182,6 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); - // #4456 — the driver-factory `??` fallback graduation. The mappings are - // driver-scoped by construction; these pin the two edges the flat fixture - // pair cannot express as sharply: the same key converting under one driver - // and not another, and the load-path posture. - describe('datasource-config-driver-key-aliases', () => { - const convert = (datasources: unknown[]) => - collectConversionNotices({ datasources }, { includeRetired: true }); - - it('renames `database` → `filename` ONLY under sqlite — it is canonical for the SQL/mongo drivers', () => { - const { stack, notices } = convert([ - { name: 'a', driver: 'sqlite', config: { database: './a.db' } }, - { name: 'b', driver: 'postgres', config: { host: 'db', database: 'analytics' } }, - { name: 'c', driver: 'mysql', config: { host: 'db', database: 'orders' } }, - { name: 'd', driver: 'mongo', config: { host: 'db', database: 'events' } }, - ]); - const [a, b, c, d] = stack.datasources as Array<{ config: Record }>; - expect(a!.config).toEqual({ filename: './a.db' }); - expect(b!.config).toEqual({ host: 'db', database: 'analytics' }); - expect(c!.config).toEqual({ host: 'db', database: 'orders' }); - expect(d!.config).toEqual({ host: 'db', database: 'events' }); - expect(notices.filter((n) => n.conversionId === 'datasource-config-driver-key-aliases')).toHaveLength(1); - }); - - it('still lands for a row whose driver id is ITSELF being renamed', () => { - // The pairs are keyed by CANONICAL driver id, and commit e2798fab7 renamed mongo's. - // A stored `driver: 'mongo'` must therefore still find the mongo pairs - // (through the alias) even as the sibling conversion rewrites its id — - // otherwise the rename would quietly un-convert every legacy mongo config. - const { stack, notices } = convert([ - { name: 'events', driver: 'mongo', config: { uri: 'mongodb://db/x', user: 'svc' } }, - ]); - const [events] = stack.datasources as Array<{ driver: string; config: Record }>; - expect(events!.config).toEqual({ url: 'mongodb://db/x', username: 'svc' }); - expect(events!.driver).toBe('mongodb'); - // Two key renames (`uri` → `url`, `user` → `username`) plus the id rename. - expect(notices.filter((n) => n.conversionId === 'datasource-config-driver-key-aliases')).toHaveLength(2); - expect(notices.filter((n) => n.conversionId === 'datasource-driver-mongo-to-mongodb')).toHaveLength(1); - }); - - it('does not touch a plugin-contributed driver id — no contract, no rewrite', () => { - const before = { datasources: [{ name: 'x', driver: 'com.vendor.snowflake', config: { user: 'svc' } }] }; - const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - - it('is retired from the load path — an AUTHORED legacy spelling is not silently absorbed', () => { - // Without includeRetired (the normalizeStackInput posture) the stack is - // untouched: the authoring gate rejects `file:` with a rename hint, and - // this conversion must not run ahead of it and defeat #4410. - const before = { datasources: [{ name: 'a', driver: 'sqlite', config: { file: './a.db' } }] }; - const { stack, notices } = collectConversionNotices(structuredClone(before)); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - }); - /** - * #5011 — `dashboard.widgets[].compareTo` converged on the executor contract. - * - * The fixture pair above already proves the three mechanical rewrites. What it - * cannot express is the entry's real judgement: the durations it deliberately - * does NOT touch, and the fact that it takes no acceptance window. Both are - * asserted here, because a conversion that quietly rewrote `{ offset: '7d' }` - * into a kind would turn a loud rejection into a wrong comparison — the exact - * failure class #5011 exists to end. - */ - describe('dashboard-widget-compareto-converged', () => { - const dash = (compareTo: unknown) => ({ - dashboards: [{ - name: 'revenue_review', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo }], - }], - }); - const convert = (compareTo: unknown) => - collectConversionNotices(structuredClone(dash(compareTo)), { includeRetired: true }); - const widgetOf = (stack: Record) => - (stack.dashboards as Array<{ widgets: Array> }>)[0]!.widgets[0]!; - - it('rewrites both bare strings, value verbatim, container changed', () => { - for (const kind of ['previousPeriod', 'previousYear'] as const) { - const { stack, notices } = convert(kind); - expect(widgetOf(stack).compareTo).toEqual({ kind }); - expect(notices).toHaveLength(1); - expect(notices[0]!.from).toBe(`'${kind}'`); - } - }); - - it("rewrites `{ offset: '1y' }` — the one duration with an exact equivalent", () => { - const { stack, notices } = convert({ offset: '1y' }); - expect(widgetOf(stack).compareTo).toEqual({ kind: 'previousYear' }); - expect(notices).toHaveLength(1); - }); - - /** - * The deliberate non-rewrite, and the assertion this whole describe exists - * for. `previousPeriod` shifts by the resolved window's own length, which - * equals `7d` only when the window happens to be seven days — so a - * mechanical rewrite would silently change which rows the comparison column - * counts. Left untouched, the strict schema meets the author with the - * prescription instead. - */ - it('leaves every other duration UNTOUCHED and silent, rather than guessing', () => { - for (const offset of ['7d', '1M', '2w', '30d', '3y']) { - const before = dash({ offset }); - const { stack, notices } = convert({ offset }); - expect(stack, `${offset} must not be rewritten`).toEqual(before); - expect(notices, `${offset} must emit no notice`).toHaveLength(0); - } - }); - - it('never synthesises a `dimension` — the executor resolves it, the chain cannot', () => { - // The conversion sees a stack, not a dataset: it cannot know which time - // dimension carries the window. Writing a guess here would convert a loud - // runtime error into a wrong comparison. - for (const input of ['previousPeriod', 'previousYear', { offset: '1y' }]) { - expect(widgetOf(convert(input).stack)).not.toHaveProperty('compareTo.dimension'); - } - }); - - it('is retired from the load path — the old spellings get NO acceptance window', () => { - // Without `includeRetired` (the normalizeStackInput posture) nothing is - // rewritten: the strict schema must be what an AUTHORED legacy spelling - // meets, so the prescription is delivered instead of the shape being - // silently absorbed (PD #12). - for (const input of ['previousPeriod', 'previousYear', { offset: '1y' }]) { - const before = dash(input); - const { stack, notices } = collectConversionNotices(structuredClone(before)); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - } - }); - - it('touches only `compareTo` — a widget without one passes through by reference', () => { - const before = { - dashboards: [{ - name: 'other', widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }; - const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - - it('is idempotent by construction — the converged shape is not a match', () => { - // No test replays a conversion twice for us (the skill's note), so this - // asserts it directly: `{ kind }` is neither a string arm nor an `offset`. - const before = dash({ kind: 'previousYear' }); - const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - }); - /** * `page-header-subtitle-alias` (#4827, objectui#3226). * @@ -1823,294 +1496,6 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); - /** - * `page-tabs-type-to-tab-style` (#6776). - * - * The fixture pair above pins before → after and the notice count. What needs - * its own cover here is everything the fixture cannot show: - * - * - the **discriminator** — this entry keys on the component's `type`, and - * the key it rewrites is also called `type`, so "which `type`" is the - * whole correctness question; - * - the **reach** — `page:tabs` is one of the seven named slots, and all - * four in-repo authoring sites are `slots.tabs`, so a region-only walk - * would rewrite nothing that actually exists; - * - **idempotence** and the #4923 both-keys rule; - * - the **acceptance face in both directions**, since a rename moves what - * the schema accepts as well as what it refuses. - */ - describe('page-tabs-type-to-tab-style', () => { - const regionPage = (properties: Record) => ({ - pages: [{ name: 'sys_position_detail', regions: [{ name: 'main', components: [{ type: 'page:tabs', properties }] }] }], - }); - const slottedPage = (properties: Record) => ({ - pages: [{ name: 'sys_user_detail', regions: [], slots: { tabs: { type: 'page:tabs', properties } } }], - }); - const convert = (stack: Record) => { - const notices: ConversionNotice[] = []; - const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); - return { out, notices }; - }; - type Comp = { type: string; properties: Record }; - type Pg = { regions: { components: Comp[] }[]; slots?: { tabs: Comp | Comp[] } }; - const pageOf = (stack: Record) => (stack.pages as Pg[])[0]!; - const propsOf = (stack: Record, where: 'region' | 'slot') => { - const page = pageOf(stack); - return where === 'region' - ? page.regions[0]!.components[0]!.properties - : (page.slots!.tabs as Comp).properties; - }; - - it('rewrites `properties.type` → `tabStyle` on a region-level page:tabs', () => { - const { out, notices } = convert(regionPage({ type: 'card', items: [] })); - expect(propsOf(out, 'region')).toEqual({ tabStyle: 'card', items: [] }); - expect(notices.map((n) => n.conversionId)).toEqual(['page-tabs-type-to-tab-style']); - expect(notices[0]!.path).toBe('pages[0].regions[0].components[0].properties.tabStyle'); - }); - - it('reaches `slots.tabs` — the shape every in-repo site actually uses', () => { - // Region-only reach was the pre-#6776 behaviour of `mapPageComponents`, - // and for THIS key it would have converted nothing: `page:tabs` is a - // named slot, and all four sites in this repo are slotted record pages. - // A conversion that cannot reach the corpus makes the tombstone's - // "run `os migrate meta`" prescription a false promise. - const { out, notices } = convert(slottedPage({ type: 'pill', position: 'top', items: [] })); - expect(propsOf(out, 'slot')).toEqual({ tabStyle: 'pill', position: 'top', items: [] }); - expect(notices[0]!.path).toBe('pages[0].slots.tabs.properties.tabStyle'); - }); - - it('reaches an ARRAY-valued slot too, indexing the path', () => { - const stack = { - pages: [{ - name: 'sys_user_detail', - regions: [], - slots: { tabs: [{ type: 'page:tabs', properties: { type: 'card', items: [] } }] }, - }], - }; - const { out, notices } = convert(stack); - const slot = pageOf(out).slots!.tabs as Comp[]; - expect(slot[0]!.properties).toEqual({ tabStyle: 'card', items: [] }); - expect(notices[0]!.path).toBe('pages[0].slots.tabs[0].properties.tabStyle'); - }); - - it('never touches the node\'s OWN `type` — the dispatch key is not the prop', () => { - // The one confusion this entry has to be immune to: `component.type` is - // `'page:tabs'` and stays that way; only `properties.type` moves. - const { out } = convert(regionPage({ type: 'card', items: [] })); - expect(pageOf(out).regions[0]!.components[0]!.type).toBe('page:tabs'); - }); - - it('leaves a non-tabs component alone, including a nested `type` in its props', () => { - const stack = { - pages: [{ - name: 'p', - regions: [{ - name: 'main', - components: [{ type: 'element:button', properties: { label: 'Open', action: { type: 'url', target: '/x' } } }], - }], - }], - }; - // Identity, not just equality: nothing converted, so copy-on-write shares. - expect(applyConversions(stack, { includeRetired: true })).toBe(stack); - }); - - it('keeps BOTH when `tabStyle` already says something different', () => { - const stack = regionPage({ tabStyle: 'pill', type: 'card', items: [] }); - const { out, notices } = convert(stack); - expect(out).toBe(stack); - expect(notices).toEqual([]); - }); - - it('drops the redundant twin when both spellings agree', () => { - const { out, notices } = convert(regionPage({ tabStyle: 'pill', type: 'pill', items: [] })); - expect(propsOf(out, 'region')).toEqual({ tabStyle: 'pill', items: [] }); - expect(notices).toHaveLength(1); - }); - - it('is idempotent — the converted result replays to itself with no second notice', () => { - const once = applyConversions(regionPage({ type: 'card', items: [] }), { includeRetired: true }); - const notices: ConversionNotice[] = []; - const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); - expect(twice).toBe(once); - expect(notices).toEqual([]); - }); - - /** - * The acceptance face, both directions — a KEY verdict, so the criterion is - * the props schema's own judgement of the key (#5046's distinction), not a - * full-parse-green demand on a value. - * - * `PageSchema` stays green on both spellings because - * `PageComponent.properties` is an open bag that never judged the key at - * all; that gap is the defect's mechanism, and the #5068 props gate is the - * only place either verdict is visible. - */ - it('the props schema refuses `type` BY NAME and accepts `tabStyle`; PageSchema accepts both', () => { - expect(PageTabsProps.safeParse({ type: 'card', items: [] }).success).toBe(false); - // The refusal carries the prescription, not a bare "unrecognized key" — - // a `retiredKey` tombstone rather than an undeclared key, which is what - // makes the removal audible to an upgrading (often AI) author. - expect(() => PageTabsProps.parse({ type: 'card', items: [] })) - .toThrow(/`type`.*removed.*`tabStyle`/s); - expect(PageTabsProps.safeParse({ tabStyle: 'card', items: [] }).success).toBe(true); - - const page = (properties: Record) => ({ - name: 'sys_position_detail', - label: 'Position', - type: 'record' as const, - object: 'sys_position', - regions: [{ name: 'main', components: [{ type: 'page:tabs', properties }] }], - }); - expect(PageSchema.safeParse(page({ type: 'card', items: [] })).success).toBe(true); - expect(PageSchema.safeParse(page({ tabStyle: 'card', items: [] })).success).toBe(true); - }); - }); - - /** - * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09). - * - * The fixture pair pins before → after and the notice count. What needs its - * own cover here is the property the fixture cannot express, and the one this - * entry would be dangerous without: **its reach is the stored population and - * nothing else.** - * - * `hidden` is not a retired key. It keeps its birth contract — navigation - * presentation — so an author writing `hidden: true` (the Account / - * personal-settings case, which the platform itself does) must come out of the - * load path with `hidden: true` still on the app. A conversion firing there - * would rewrite that into an unpublished app and reproduce #4829 one layer - * down, through the very machinery meant to repair it. `retiredFromLoadPath` - * is what buys that, so it is asserted directly rather than inferred. - */ - describe('app-hidden-to-unpublished', () => { - const storedApp = (app: Record) => ({ apps: [{ name: 'production_management', ...app }] }); - const convertStored = (stack: Record) => { - const notices: ConversionNotice[] = []; - const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); - return { out, notices }; - }; - const appOf = (stack: Record) => (stack.apps as Record[])[0]!; - - it('rewrites a stored `hidden: true` app to `_unpublished: true`, dropping `hidden`', () => { - const { out, notices } = convertStored(storedApp({ hidden: true, label: 'PM', navigation: [] })); - expect(appOf(out)).toEqual({ name: 'production_management', _unpublished: true, label: 'PM', navigation: [] }); - expect(notices.map((n) => n.conversionId)).toEqual(['app-hidden-to-unpublished']); - expect(notices[0]!.path).toBe('apps[0]._unpublished'); - expect(notices[0]!.from).toBe('hidden'); - expect(notices[0]!.to).toBe('_unpublished'); - }); - - it('leaves `hidden: false` alone — it meant "listed" under both regimes', () => { - const before = storedApp({ hidden: false, navigation: [] }); - const { out, notices } = convertStored(before); - expect(out).toBe(before); - expect(notices).toEqual([]); - }); - - it('a row that already carries `_unpublished` keeps BOTH keys, untouched', () => { - // The machine has already spoken about this row. Reconciling a - // disagreeing pair is a human's call, not the loader's — and it is what - // makes the pass idempotent on a row that has already converted. - const before = storedApp({ hidden: true, _unpublished: false, navigation: [] }); - const { out, notices } = convertStored(before); - expect(out).toBe(before); - expect(notices).toEqual([]); - }); - - it('is idempotent — the converted result replays to itself with no second notice', () => { - const once = applyConversions(storedApp({ hidden: true, navigation: [] }), { includeRetired: true }); - const notices: ConversionNotice[] = []; - const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); - expect(twice).toBe(once); - expect(notices).toEqual([]); - }); - - it('replays on the STORED seam, which is the population it exists for', () => { - const converted = applyConversionsToStoredItem('app', { name: 'edu_admin', hidden: true, navigation: [] }); - expect(converted).toEqual({ name: 'edu_admin', _unpublished: true, navigation: [] }); - }); - - // ---- the reach, asserted in the negative ---- - - it('⛔ does NOT fire on the LOAD path — an authored `hidden: true` survives verbatim', () => { - // `includeRetired` defaults to false, which IS the load seam - // (`normalizeStackInput` for defineStack / validate / lint). This is the - // assertion that keeps the built-in Account app — authored `hidden: true` - // for the avatar-menu reason — from being converted into an app no normal - // user may reach, which is the #4829 defect itself. - const account = { apps: [{ name: 'account', label: 'Account', hidden: true, navigation: [] }] }; - const notices: ConversionNotice[] = []; - const out = applyConversions(account, { onNotice: (n) => notices.push(n) }); - expect(out).toBe(account); - expect(appOf(out)).toEqual({ name: 'account', label: 'Account', hidden: true, navigation: [] }); - expect(notices).toEqual([]); - }); - - it('is declared `retiredFromLoadPath` — the flag IS the load-path exclusion', () => { - const entry = ALL_CONVERSIONS.find((c) => c.id === 'app-hidden-to-unpublished'); - expect(entry).toBeDefined(); - expect(entry!.retiredFromLoadPath).toBe(true); - expect(entry!.toMajor).toBe(17); - }); - - // ---- the seam's own refusal (`excludeConversionIds`, #17885) ---- - - /** - * A seam that has opened the retired window may still refuse a named - * entry. The artifact-ingestion door does exactly this for the DEFAULT-FLIP - * class: its evidence is the artifact's DECLARED `engines.protocol` floor, - * which is a dependency range and not an age, so "this input predates the - * flip" is a guess there — while at the stored-row seam above it is a fact. - * Pinned on the primitive so the option cannot quietly stop working while - * the door's own suite still reads green for another reason. - */ - it('a seam can refuse a named entry by id even with `includeRetired: true`', () => { - const before = storedApp({ hidden: true, label: 'PM', navigation: [] }); - const notices: ConversionNotice[] = []; - const out = applyConversions(before, { - includeRetired: true, - excludeConversionIds: ['app-hidden-to-unpublished'], - onNotice: (n) => notices.push(n), - }); - expect(out).toBe(before); - expect(appOf(out)).toEqual({ name: 'production_management', hidden: true, label: 'PM', navigation: [] }); - expect(notices).toEqual([]); - }); - - /** - * ⭐ FIRING CONTROL for the refusal above — it excludes ONE id, not the - * window. `page-kind-jsx-to-html` (retired too, at 18) and the retired app - * entry are driven through the SAME call: the named one is refused, the other - * still fires. - */ - it('refuses only the named id — the rest of the chain still runs', () => { - const notices: ConversionNotice[] = []; - const out = applyConversions( - { - apps: [{ name: 'production_management', hidden: true, navigation: [] }], - pages: [{ name: 'landing', kind: 'jsx', source: '
hi
' }], - }, - { - includeRetired: true, - excludeConversionIds: ['app-hidden-to-unpublished'], - onNotice: (n) => notices.push(n), - }, - ); - expect(appOf(out).hidden, 'the refused entry did not fire').toBe(true); - expect(appOf(out)._unpublished).toBeUndefined(); - expect((out.pages as Record[])[0]!.kind, 'the rest of the chain did').toBe('html'); - expect(notices.map((n) => n.conversionId)).toEqual(['page-kind-jsx-to-html']); - }); - - it('an absent or empty exclusion list changes nothing', () => { - const shape = () => storedApp({ hidden: true, navigation: [] }); - const withEmpty = applyConversions(shape(), { includeRetired: true, excludeConversionIds: [] }); - const without = applyConversions(shape(), { includeRetired: true }); - expect(appOf(withEmpty)._unpublished).toBe(true); - expect(appOf(without)._unpublished).toBe(true); - }); - }); - /** * `field-reference-to-alias` (#13700 — ui#6837 half 1). * diff --git a/packages/spec/src/conversions/page-component-walk.test.ts b/packages/spec/src/conversions/page-component-walk.test.ts index 6f2520da21c..375329d64a3 100644 --- a/packages/spec/src/conversions/page-component-walk.test.ts +++ b/packages/spec/src/conversions/page-component-walk.test.ts @@ -64,11 +64,6 @@ describe('a conversion reaches a component nested in another component', () => { properties: (child) => ({ tabStyle: 'line', items: [{ label: 'Tab', children: [child] }] }), path: 'properties.items[0].children[0]', }, - { - label: 'properties.body[] — a page:card body', - properties: (child) => ({ body: [child] }), - path: 'properties.body[0]', - }, { label: 'properties.footer[] — a page:card footer', properties: (child) => ({ footer: [child] }), @@ -100,12 +95,13 @@ describe('a conversion reaches a component nested in another component', () => { }); } - it('reaches every position `pageComponentSlotPositions()` names, the retired spelling included', () => { + it('reaches every position `pageComponentSlotPositions()` names', () => { const positions = pageComponentSlotPositions(); // Guards the case against passing vacuously on an empty list, and ties it - // to the four explicit shapes above. + // to the three explicit shapes above. (`body`, the retired page:card + // spelling, left the list with its tombstone at 18 — #25767.) expect(positions.map(({ key, panelKey }) => (panelKey === undefined ? key : `${key}[].${panelKey}`)).sort()) - .toEqual(['body', 'children', 'footer', 'items[].children']); + .toEqual(['children', 'footer', 'items[].children']); for (const { key, panelKey } of positions) { const properties = panelKey === undefined ? { [key]: [staleHeader('Nested')] } @@ -163,27 +159,6 @@ describe('a conversion reaches a component nested in another component', () => { ]); }); - it('visits a moved sub-tree ONCE — the descent reads the mapped component', () => { - // `page-card-body-to-children` renames `properties.body` → `children` on the - // outer card. Because the descent happens after the mapper, the inner card - // is walked under the canonical key and emits one notice, not one per - // spelling — and its own path is spelled with `children`, not `body`. - const { stack, notices } = collectConversionNotices( - regionPage({ - type: 'page:card', - properties: { title: 'Outer', body: [{ type: 'page:card', properties: { title: 'Inner', body: [] } }] }, - }), - { includeRetired: true }, - ); - expect(notices.map((n) => n.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.children', - 'pages[0].regions[0].components[0].properties.children[0].properties.children', - ]); - expect(componentAt(stack, 'regions', 0, 'components', 0)).toEqual({ - type: 'page:card', - properties: { title: 'Outer', children: [{ type: 'page:card', properties: { title: 'Inner', children: [] } }] }, - }); - }); }); describe('the page-component walk stays copy-on-write and shape-gated', () => { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 6a91e8e762f..17499ed9dc1 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -26,7 +26,7 @@ import { renameConfigKey, renameKey, } from './walk.js'; -import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js'; +import { resolveDriverId } from '../data/driver/config-registry.zod.js'; import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js'; import { ClockTimeValueSchema } from '../data/field-value.zod.js'; import { @@ -257,242 +257,6 @@ const flowNodeFilterAlias: MetadataConversion = { }, }; -/** - * Object `compactLayout` → `highlightFields` (spec 11.7.0, ADR-0085; alias - * retired at authoring in 11.9.1, #2536). - * - * A pure key rename — the value (ordered field-name list) is unchanged. - * **Retired from the load path**: the schema tombstones `compactLayout` with a - * fix-it error, so the loader must NOT quietly accept it. Protocol 11 is below - * `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this conversion any - * more and `migrate meta --from 10|11` refuses before it would ever reach it - * (backfilled per the ADR-0087 true-up — the rename shipped before the - * conversion layer existed). Its one remaining reader is the stored-row replay - * (`applyConversionsToStoredItem`, `conversions/stored.ts`), which is not - * floor-scoped and still walks it for rows at rest. - */ -const objectCompactLayoutRename: MetadataConversion = { - id: 'object-compactLayout-to-highlightFields', - toMajor: 11, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'object.compactLayout', - summary: "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", - apply(stack, emit) { - return mapCollection(stack, 'objects', (obj, path) => { - const renamed = renameKey(obj, 'compactLayout', 'highlightFields'); - if (!renamed) return obj; - emit({ from: 'compactLayout', to: 'highlightFields', path: `${path}.highlightFields` }); - return renamed; - }); - }, - fixture: { - before: { - objects: [{ name: 'crm_lead', label: 'Lead', compactLayout: ['name', 'status'] }], - }, - after: { - objects: [{ name: 'crm_lead', label: 'Lead', highlightFields: ['name', 'status'] }], - }, - expectedNotices: 1, - }, -}; - -/** - * Stack collection `roles:` → `positions:` (protocol 13, ADR-0090 D3). - * - * The distribution concept was renamed Role → Position across the platform; - * the stack-definition collection key renamed with it. A pure key move — the - * item shapes migrate separately (`position.parent` removal is semantic, see - * the step-13 TODOs, historically). **Retired from the load path**: ADR-0090 - * shipped this as a pre-launch one-step rename with no alias window. Protocol - * 13 is below `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this - * conversion any more; the entry now survives only as the stored-row replay - * (`applyConversionsToStoredItem`, `conversions/stored.ts`), which is not - * floor-scoped. - */ -const stackRolesToPositions: MetadataConversion = { - id: 'stack-roles-to-positions', - toMajor: 13, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'stack.roles', - summary: "stack collection key 'roles' → 'positions' (ADR-0090 D3)", - apply(stack, emit) { - const renamed = renameKey(stack, 'roles', 'positions'); - if (!renamed) return stack; - emit({ from: 'roles', to: 'positions', path: 'positions' }); - return renamed; - }, - fixture: { - before: { - roles: [{ name: 'sales_rep', label: 'Sales Rep' }], - }, - after: { - positions: [{ name: 'sales_rep', label: 'Sales Rep' }], - }, - expectedNotices: 1, - }, -}; - -/** - * OWD legacy aliases `read` / `read_write` → canonical (protocol 13, - * ADR-0090 D4). - * - * The two aliases with an unambiguous canonical spelling convert mechanically; - * the third legacy alias `'full'` has NO lossless target (full access includes - * transfer/delete — wider than `public_read_write`) and is delegated to the - * step-13 semantic TODO instead (D2 scope guard: lossless only). Handles both - * `object.sharingModel` and the nested `object.security.sharingModel` spot. - * **Retired from the load path** (one-step removal; authoring rejects with a - * fix-it). - */ -const owdLegacyReadAliases: MetadataConversion = { - id: 'owd-legacy-read-aliases', - toMajor: 13, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'object.sharingModel', - summary: "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", - apply(stack, emit) { - const CANONICAL: Record = { - read: 'public_read', - read_write: 'public_read_write', - }; - return mapCollection(stack, 'objects', (obj, path) => { - let next = obj; - const direct = next.sharingModel; - if (typeof direct === 'string' && CANONICAL[direct]) { - emit({ from: direct, to: CANONICAL[direct]!, path: `${path}.sharingModel` }); - next = { ...next, sharingModel: CANONICAL[direct] }; - } - const security = next.security; - if (security && typeof security === 'object' && !Array.isArray(security)) { - const nested = (security as Record).sharingModel; - if (typeof nested === 'string' && CANONICAL[nested]) { - emit({ from: nested, to: CANONICAL[nested]!, path: `${path}.security.sharingModel` }); - next = { ...next, security: { ...(security as Record), sharingModel: CANONICAL[nested] } }; - } - } - return next; - }); - }, - fixture: { - before: { - objects: [ - { name: 'crm_deal', label: 'Deal', sharingModel: 'read' }, - { name: 'crm_note', label: 'Note', security: { sharingModel: 'read_write' } }, - ], - }, - after: { - objects: [ - { name: 'crm_deal', label: 'Deal', sharingModel: 'public_read' }, - { name: 'crm_note', label: 'Note', security: { sharingModel: 'public_read_write' } }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * Sharing-rule recipient type `'role'` → `'position'` (protocol 13, - * ADR-0090 D3). - * - * Applies to both `sharedWith.type` and the owner-rule `ownedBy.type`. The - * removed `'role_and_subordinates'` recipient is NOT converted — its v2 - * replacement (`unit_and_subordinates`) expands a *different* tree (business - * units, not the retired role hierarchy), so it is a step-13 semantic TODO. - * **Retired from the load path** (one-step rename, no alias window). - */ -const sharingRecipientRoleToPosition: MetadataConversion = { - id: 'sharing-recipient-role-to-position', - toMajor: 13, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'sharingRule.sharedWith.type', - summary: "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", - apply(stack, emit) { - const renameRecipient = (rule: Record, key: string, path: string) => { - const recipient = rule[key]; - if (!recipient || typeof recipient !== 'object' || Array.isArray(recipient)) return rule; - const dict = recipient as Record; - if (dict.type !== 'role') return rule; - emit({ from: 'role', to: 'position', path: `${path}.${key}.type` }); - return { ...rule, [key]: { ...dict, type: 'position' } }; - }; - return mapCollection(stack, 'sharingRules', (rule, path) => { - let next = renameRecipient(rule, 'sharedWith', path); - next = renameRecipient(next, 'ownedBy', path); - return next; - }); - }, - fixture: { - before: { - sharingRules: [ - { - name: 'share_sales', - type: 'owner', - object: 'crm_deal', - sharedWith: { type: 'role', value: 'sales_mgr' }, - ownedBy: { type: 'role', value: 'sales_rep' }, - }, - ], - }, - after: { - sharingRules: [ - { - name: 'share_sales', - type: 'owner', - object: 'crm_deal', - sharedWith: { type: 'position', value: 'sales_mgr' }, - ownedBy: { type: 'position', value: 'sales_rep' }, - }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * Book audience gated arm `{ profile }` → `{ permissionSet }` (protocol 14, - * ADR-0090 D2 fallout; shipped in 14.0.0 as a pre-launch one-step rename). - * - * Packages own permission sets but never positions (ADR-0090 D9), so the - * gate is a capability reference. Value carried over 1:1. **Retired from the - * load path** — the zod union rejects `{ profile }` at parse. Protocol 14 is - * below `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this - * conversion any more; this entry now survives only as the stored-row replay - * (`applyConversionsToStoredItem`, `conversions/stored.ts`) the one-step ship - * skipped, which is not floor-scoped. - */ -const bookAudienceProfileToPermissionSet: MetadataConversion = { - id: 'book-audience-profile-to-permission-set', - toMajor: 14, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'book.audience', - summary: "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", - apply(stack, emit) { - return mapCollection(stack, 'books', (book, path) => { - const audience = book.audience; - if (!audience || typeof audience !== 'object' || Array.isArray(audience)) return book; - const dict = audience as Record; - if (typeof dict.profile !== 'string' || dict.permissionSet != null) return book; - emit({ from: 'profile', to: 'permissionSet', path: `${path}.audience.permissionSet` }); - const { profile, ...rest } = dict; - return { ...book, audience: { ...rest, permissionSet: profile } }; - }); - }, - fixture: { - before: { - books: [{ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } }], - }, - after: { - books: [{ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } }], - }, - expectedNotices: 1, - }, -}; - /** Rename a visibility alias key on a dict, emitting with the given path. */ function renameVisibilityAlias( dict: Record, @@ -737,8 +501,9 @@ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { * canonical key by a schema transform and dropped from the parsed output. They * are now removed from the spec outright, and each schema TOMBSTONES its key * with a fix-it error (`retiredKey`, `shared/retired-key.ts`) so the loader - * cannot quietly accept it — the same shape as - * `object-compactLayout-to-highlightFields` above. + * cannot quietly accept it — the same shape the retired + * `object-compactLayout-to-highlightFields` conversion had (a ≤16.x kit, + * removed with its region on #25767). * * All three are therefore `retiredFromLoadPath: true` from the day they land: * there is no alias window, deliberately. What the entries buy is the two @@ -789,82 +554,6 @@ function mapObjectFieldsKey(stack: Dict, collection: string, from: string, to: s }); } -/** - * Action `execute` → `target` (protocol 17, #3713 / #3742 / #3855). - * - * A pure key rename — the value (a handler/flow/URL ref) is unchanged. Actions - * appear both top-level and nested under their object, so both are walked. - */ -const actionExecuteToTarget: MetadataConversion = { - id: 'action-execute-to-target', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'action.execute', - summary: "action key 'execute' → 'target' (the deprecated handler alias; the spec and the renderer had resolved the pair in opposite directions, so one key now names the handler)", - apply(stack, emit) { - const renameOn = (action: Dict, path: string): Dict => { - const renamed = renameKey(action, 'execute', 'target'); - if (!renamed) return action; - emit({ from: 'execute', to: 'target', path: `${path}.target` }); - return renamed; - }; - const withTopLevel = mapCollection(stack, 'actions', renameOn); - return mapCollection(withTopLevel, 'objects', (obj, path) => { - const nested = mapCollection(obj, 'actions', (action, actionPath) => - renameOn(action, `${path}.${actionPath}`), - ); - return nested; - }); - }, - fixture: { - before: { - actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], - }, - after: { - actions: [{ name: 'convert', label: 'Convert', type: 'script', target: 'convertHandler' }], - }, - expectedNotices: 1, - }, -}; - -/** - * Field `conditionalRequired` → `requiredWhen` (protocol 17, #3754 / #3855). - * - * A pure key rename — the value (a CEL predicate, bare or enveloped) is - * unchanged. Covers object fields and object-extension fields: the same - * `FieldSchema`, so the same alias. - */ -const fieldConditionalRequiredToRequiredWhen: MetadataConversion = { - id: 'field-conditionalRequired-to-requiredWhen', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'field.conditionalRequired', - summary: "field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, folded into the canonical key so no reader picks its own precedence)", - apply(stack, emit) { - const withObjects = mapObjectFieldsKey(stack, 'objects', 'conditionalRequired', 'requiredWhen', emit); - return mapObjectFieldsKey(withObjects, 'objectExtensions', 'conditionalRequired', 'requiredWhen', emit); - }, - fixture: { - before: { - objects: [{ - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, - }], - }, - after: { - objects: [{ - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } }, - }], - }, - expectedNotices: 1, - }, -}; - /* * ABSORBED — `agent-knowledge-topics-to-sources` (protocol 17, #3855). The * rename lived inside the same unreleased major that now REMOVES the whole @@ -875,58 +564,6 @@ const fieldConditionalRequiredToRequiredWhen: MetadataConversion = { * prescription covers the historical `topics` spelling. */ -/** - * Agent `tools` → dropped (protocol 17, #3894 / #3820). - * - * NOT a rename — there is no key to move the value to. ADR-0064 says an - * agent's tool set is exactly the union of its surface-compatible skills' - * tools, and `agent.tools[]` was the seam that broke it (it resolved names - * against the FULL registry with no surface check). Each entry has to become - * a reference inside a SKILL, which needs a human decision about which skill - * — so this conversion drops the dead key and emits one notice per agent - * naming what was lost, rather than guessing a destination. - * - * Dropping is safe: the cloud runtime stopped reading the field entirely - * (cloud#910), so by protocol 17 it contributes nothing at load time. What - * the notice preserves is the AUTHOR's knowledge of which tools they meant. - */ -const agentToolsToSkills: MetadataConversion = { - id: 'agent-tools-to-skills', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'agent.tools', - summary: "agent key 'tools' removed — declare capability in a skill (ADR-0064: an agent's tools are exactly its skills' tools, and this inline slot resolved names against the whole registry with no surface check)", - apply(stack, emit) { - return mapCollection(stack, 'agents', (agent, path) => { - if (!('tools' in agent) || agent.tools == null) return agent; - const next: Dict = { ...agent }; - delete next.tools; - // The notice carries `tools → skills` at the agent's path: the author - // sees WHICH agent lost inline references and where the capability has - // to be re-declared. The tool names themselves stay in their git - // history, which is where a judgement call should be read from. - emit({ from: 'tools', to: 'skills', path: `${path}.skills` }); - return next; - }); - }, - fixture: { - before: { - agents: [ - { - name: 'support_bot', - skills: ['case_management'], - tools: [{ type: 'action', name: 'create_ticket' }], - }, - ], - }, - after: { - agents: [{ name: 'support_bot', skills: ['case_management'] }], - }, - expectedNotices: 1, - }, -}; - /** * Sharing-rule `accessLevel: 'full'` → `'edit'` (protocol 17, #3865). * @@ -1901,4228 +1538,513 @@ const flowNodeScriptConfigAliases: MetadataConversion = { }; /** - * App dead authoring keys removed (protocol 17, #4001 app step; 2026-06 - * AppSchema liveness audit). - * - * Seven keys were authorable and never read by any consumer in framework or - * objectui: `version` (an app is versioned by its package's - * `manifest.version`), `aria` (no renderer read app-level ARIA), `objects` / - * `apis` (the spec's own "config file convenience" — objects register via - * `defineStack`, and the chatbot derives an app's object list from its nav - * items), `sharing` / `embed` (declared-but-unenforced security surface — the - * only live path is `FormView.sharing`; ADR-0049 class), and - * `mobileNavigation` (fully unimplemented — even `packages/mobile` ignored - * it). Pure lossless deletes: none ever had a runtime effect. - * - * `retiredFromLoadPath`: the schema tombstones each key (`retiredKey`, tsc - * `never` + a parse-time prescription), same posture as its step-17 siblings. - * - * ## Extended by #4509 — the two context-selector keys - * - * `contextSelectors[].includeAll` and `contextSelectors[].placement` join the - * same conversion rather than opening a second `app` entry: both target major - * 17, both are pure deletes on the same collection, and a separate entry would - * have to keep its fixture disjoint from this one's for no gain. - * - * They differ from the seven above in why they went out. Both carried schema - * defaults, which the liveness advisory lint cannot warn on — a default - * materialises at parse time, so an authored value is indistinguishable from a - * supplied one. Removal was the only channel that could reach an author. - * - * `includeAll` was the sharper of the two: not unread but deliberately - * DISOBEYED. Context selectors are mandatory-scope, so the shell never rendered - * an "All" row — on Studio's package selector an All row would undo the - * selector's own `filter` and list the platform's system/cloud kernel packages. - * `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it, - * which is what a flag reading alive while doing nothing looks like from the - * inside. + * ⛔ WITHDRAWN — there is deliberately NO `field-required-notnull-explicit` + * conversion in this registry, and re-adding one is the mistake this comment + * exists to stop (#16693; maintainer ruling, decision batch #85, 2026-09-08, + * option A). + * + * It read `required: true` and wrote `storage: { notNull: true }` beside it, + * on the argument that pre-17 `field.required` bound three meanings to one + * knob — the write contract, the physical NOT NULL, and the drift + * expectation — so writing the constraint down preserved an old source's + * full meaning. ADR-0113 split those axes, and two facts retired the argument: + * + * 1. Adding `storage.notNull` wherever `required: true` appears IS the + * implication ADR-0113 abolished. `required` is the write-time contract; + * `storage.notNull` alone binds the column. A conversion cannot be what + * decides a column constraint — that is the author's explicit act. + * 2. `retiredFromLoadPath: true` did NOT hold the transform to + * `os migrate meta`, whatever this entry's docblock used to claim. The + * artifact-ingestion door replays the whole chain with `includeRetired: + * true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`), + * keyed off the artifact's declared `engines.protocol` FLOOR rather than + * its age — and `^17.0.0` is the range `create-objectstack` stamps. So + * every scaffolded app was handed NOT NULL columns it never asked for, plus + * a boot warning telling its author to write the same tightening into the + * source. On a populated database that instruction is a `tighten_not_null` + * / `severity: error` / `category: destructive` migration, prescribed as + * the remedy for a deprecation notice. + * + * No migration is owed to anyone: genuinely pre-ADR-0113 artifacts are not + * measured to exist, and an app that wants NOT NULL columns declares + * `storage.notNull` deliberately. Existing columns are left exactly as they + * are. + * + * ⚠️ Fact 2 is about the MECHANISM, not about this entry, and it outlived the + * entry: `retiredFromLoadPath` still holds nothing back at three runtime seams + * (ADR-0087's 2026-09-13 addendum names all three). Before setting that flag on a DEFAULT FLIP — as opposed to a + * lossless delete or a rename — read that addendum, because the flag does not mean + * what its name says: its own docblock (`RetiredConversionState` in `types.ts`) has + * stated the authoring-only reach since commit 29dd1a6dd. + */ + +/** + * The #3896 close-out sweep, part 2 (protocol 17): the remaining inert + * authoring keys leave the surface, one conversion per metadata type. All are + * 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` lists the mechanical edits for existing sources. */ -const appDeadAuthoringKeysRemoved: MetadataConversion = { - id: 'app-dead-authoring-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / ' - + 'app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / ' - + 'app.homePageId / app.areas.order', - summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits found each one unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (the first retirement record said nothing read it, a premise since corrected; the retirement stands), and no renderer ever sorted areas)", - apply(stack, emit) { - const RETIRED = [ - 'version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', - 'homePageId', - ]; - const RETIRED_SELECTOR = ['includeAll', 'placement']; - return mapCollection(stack, 'apps', (app, path) => { - const next = stripKeys(app, RETIRED, emit, path); - // `contextSelectors` and `areas` are ARRAYS one level down, so stripKeys - // (top-level only) cannot reach them. Drill in and copy-on-write at both - // levels, so an app with nothing to strip keeps its identity for change - // detection. - const stripInArray = (key: string, retired: string[], src: Record) => { - const arr = src[key]; - if (!Array.isArray(arr)) return src; - let touched = false; - const mapped = arr.map((el, i) => { - if (!el || typeof el !== 'object' || Array.isArray(el)) return el; - const stripped = stripKeys( - el as Record, - retired, - emit, - `${path}.${key}[${i}]`, - ); - if (stripped !== el) touched = true; - return stripped; - }); - return touched ? { ...src, [key]: mapped } : src; - }; - return stripInArray('areas', ['order'], stripInArray('contextSelectors', RETIRED_SELECTOR, next)); - }); - }, - fixture: { - before: { - apps: [{ - name: 'portal', - label: 'Portal', - version: '1.0.0', - sharing: { enabled: true }, - embed: { enabled: true }, - mobileNavigation: { mode: 'bottom_nav' }, - homePageId: 'nav_home', - contextSelectors: [{ - id: 'active_package', - label: 'Package', - optionsSource: { endpoint: '/api/v1/packages', valueKey: 'id', labelKey: 'name' }, - includeAll: true, - placement: 'sidebar_header', - }], - areas: [{ id: 'area_sales', label: 'Sales', order: 1, navigation: [] }], - navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], - }], - }, - after: { - apps: [{ - name: 'portal', - label: 'Portal', - contextSelectors: [{ - id: 'active_package', - label: 'Package', - optionsSource: { endpoint: '/api/v1/packages', valueKey: 'id', labelKey: 'name' }, - }], - areas: [{ id: 'area_sales', label: 'Sales', navigation: [] }], - navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], - }], - }, - // Eight notices: five top-level keys (`version`, `sharing`, `embed`, - // `mobileNavigation`, `homePageId`), the two on the single context - // selector, and `order` on the single area. - expectedNotices: 8, - }, +const stripKeys = ( + item: Record, + keys: readonly string[], + emit: (n: { from: string; to: string; path: string }) => void, + path: string, +): Record => { + let touched = false; + const next: Record = { ...item }; + for (const key of keys) { + if (!(key in next)) continue; + delete next[key]; + emit({ from: key, to: '(removed)', path: `${path}.${key}` }); + touched = true; + } + return touched ? next : item; }; /** - * `app.areas[].visible` / `app.areas[].requiredPermissions` removed - * (protocol 17, #4651, ADR-0049). - * - * Deliberately its own conversion rather than two more keys on - * `app-dead-authoring-keys-removed` above. That entry's summary is a list of - * inert authoring keys; these two are a **security** finding, and the summary - * string is what `spec-changes.json`, the generated upgrade guide and the - * `spec_changes` MCP tool serve to an upgrading consumer. Folded into the - * catch-all, "a gate that never gated has been removed" would arrive buried in - * a sentence about `version` and `mobileNavigation` — and it is the one line of - * this release an author with a gated area has to read. - * - * The defect: `filterAppForUser` (`packages/rest/src/rest-server.ts`) checks the - * APP's `requiredPermissions`, then walks ONLY `item.navigation` — it never - * reads `item.areas` — while the client renders every area in the switcher. An - * area declaring `requiredPermissions: ['sales.admin']` parsed, stored, served - * and rendered for every user. Fail open, on the surface whose per-ITEM and - * per-APP siblings of the same name ARE enforced (ADR-0078 false compliance). - * - * Stripping is lossless in the only sense that matters: the keys changed no - * outcome, so metadata behaves identically with or without them. What the - * author loses is the BELIEF that the area was gated — which is the point of - * the removal, and why the strict schema's `guidance` prescription (see - * `ui/app.zod.ts`) names the two layers that do enforce instead of just saying - * "removed". - * - * `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 list the mechanical edits for authored sources. + * All conversions, keyed by the protocol major that introduced the canonical + * shape. Newest majors last; ordering within a major is application order. */ -const appAreaFailOpenGatesRemoved: MetadataConversion = { - id: 'app-area-fail-open-gates-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'app.areas.visible / app.areas.requiredPermissions', - summary: "navigation-area keys 'visible'/'requiredPermissions' removed (ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app)", - apply(stack, emit) { - const RETIRED_AREA_GATES = ['visible', 'requiredPermissions']; - return mapCollection(stack, 'apps', (app, path) => { - // `areas` is an ARRAY one level down, so `stripKeys` (top-level only) - // cannot reach it. Copy-on-write at both levels, so an app with nothing - // to strip keeps its identity for change detection. - const areas = app.areas; - if (!Array.isArray(areas)) return app; - let touched = false; - const mapped = areas.map((el, i) => { - if (!isDict(el)) return el; - const stripped = stripKeys(el, RETIRED_AREA_GATES, emit, `${path}.areas[${i}]`); - if (stripped !== el) touched = true; - return stripped; - }); - return touched ? { ...app, areas: mapped } : app; - }); - }, - fixture: { - // DISJOINT from `app-dead-authoring-keys-removed`'s fixture above and from - // every other entry: this app carries none of the keys another conversion - // strips, and that fixture's area carries neither gate — so each replays - // through the whole table hitting only its own entry. - before: { - apps: [{ - name: 'sales_portal', - label: 'Sales Portal', - areas: [ - { - id: 'area_admin', - label: 'Admin', - visible: "'sales_admin' in current_user.positions", - requiredPermissions: ['sales.admin'], - navigation: [{ id: 'nav_forecast', label: 'Forecast', type: 'object', objectName: 'forecast' }], - }, - // Untouched on purpose: the per-ITEM gate inside this area is the - // ENFORCED layer the prescription points at, and it spells the two - // key names identically. A sweep by key name alone would delete the - // working gate along with the fake one. - { - id: 'area_sales', - label: 'Sales', - navigation: [{ - id: 'nav_leads', label: 'Leads', type: 'object', objectName: 'lead', - visible: "'sales' in current_user.positions", - requiredPermissions: ['sales.access'], - }], - }, - ], - }], - }, - after: { - apps: [{ - name: 'sales_portal', - label: 'Sales Portal', - areas: [ - { - id: 'area_admin', - label: 'Admin', - navigation: [{ id: 'nav_forecast', label: 'Forecast', type: 'object', objectName: 'forecast' }], - }, - { - id: 'area_sales', - label: 'Sales', - navigation: [{ - id: 'nav_leads', label: 'Leads', type: 'object', objectName: 'lead', - visible: "'sales' in current_user.positions", - requiredPermissions: ['sales.access'], - }], - }, - ], - }], - }, - // Two notices: both gates on the ONE area that declared them. The second - // area and the nav item inside it must produce none. - expectedNotices: 2, - }, -}; /** - * RLS-policy `priority` removed (protocol 17, #3896 security audit). - * - * A pure DELETE with no rename target, because the promised semantics never - * existed: no outcome depends on an order. Applicable policies OR-combine on - * reads (any match allows access), and a write's check is chosen once per - * operation across the applicable policies, then OR-combined - * (`RowLevelSecurityPolicySchema.check`), so there is no conflict for a - * priority to resolve and evaluation order cannot change an outcome. The 2026-07-30 security-subset - * liveness re-verification closed the call graph — collection site, projection - * round-trip, compiler — and found NO reader, ever. Dropping the key is - * therefore strictly lossless: outcomes are identical with or without it. + * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, commit e2798fab7; + * retired from the load path at 18, #23543 F1). * - * `retiredFromLoadPath`: the schema tombstones the key (`retiredKey`, tsc - * `never` + a parse-time prescription), same posture as its step-17 siblings. + * ## Why a stored value has to move at all + * + * `mongo` and `mongodb` were both accepted spellings from #4410 through 17 — + * this conversion never rescued a broken boot, and a stored row it has not + * reached still resolves the same contract and builds the same driver. What moved is the + * CANONICAL id: commit e2798fab7 landed the ruling that renamed it to `mongodb`, the spelling both boot + * hosts, the driver package (`@objectstack/driver-mongodb`) and every URL scheme + * already used, so that the id which selects a driver and the id which selects + * its config contract are one string with no mapping layer between them. + * + * That rename is visible in data because the canonical id is PUBLISHED as + * `DRIVER_CATALOG.id` (`@objectstack/service-datasource`), documented as "used + * as `datasource.driver`" — it is literally what Studio's connection form writes + * into a datasource row. After the rename the form emits `mongodb`, while every + * row written before it carries `mongo`. Left alone, one deployment's datasource + * list holds two spellings of one driver, and any surface that matches a stored + * `driver` against the published catalog id (a form pre-selecting the current + * driver, a grouped list, an equality filter) silently fails to match the older + * rows. So the stored value converges here rather than each reader learning to + * accept both. + * + * ## Why the data repair is D2 + * + * There is a concrete stored value with a lossless, behaviour-preserving + * rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the + * same contract and build the same driver, before and after, so replaying this + * cannot change where any data lives — contrast the retired + * `datasource-config-driver-key-aliases` (#25767), whose scope guard existed + * because rewriting a sqlite `path:` WOULD have moved a database. + * + * Losslessness decides only that the data repair is D2. It does not decide + * whether the family ALSO owes a D3 entry — every retirement family does + * (`SemanticMigration`, `migrations/types.ts`). The rename into protocol 17 + * shipped before that rule and was not back-filled; the value's retirement at + * 18 is a family of its own, and its D3 entry is + * `datasource-driver-mongo-alias-retired`. + * + * ## Retired from the load path at 18 (#23543, ruling F1 A) + * + * Through 17 it stayed live because `mongo` was still a legal alias, so there + * was no refusal for a retirement to hand the author to (the verdict #23487 + * recorded). The ruling retired the value: `mongo` left the selection face, and + * `DriverType` refuses an authored `datasource.driver: 'mongo'` by name with + * the prescription naming `mongodb` (`DriverVocabularyEntry.retiredAliases`, + * `data/driver/config-registry.zod.ts`). So the authoring funnel no longer + * replays this entry, and an author is taught the canonical id instead of + * being rewritten. Stored rows are the `includeRetired` seams: stored-row + * rehydration (`applyConversionsToStoredItem`) and the artifact door still + * rewrite them, and `os migrate meta --from 16` lists the edit for existing + * sources, as step 17's mechanical member. The spelling keeps resolving the + * mongodb CONTRACT, which is what let the retired + * `datasource-config-driver-key-aliases` — ordered ahead of this entry while + * it existed (#25767) — reach a stored `driver: 'mongo'` row's legacy config + * keys. That D3 + * entry names what the chain cannot reach: an environment variable, a REST + * caller, a script. */ -const permissionRlsPriorityRemoved: MetadataConversion = { - id: 'permission-rls-priority-removed', +const datasourceDriverMongoToMongodb: MetadataConversion = { + id: 'datasource-driver-mongo-to-mongodb', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'permission.rowLevelSecurity.priority', - summary: "RLS-policy key 'priority' removed (a security audit found no reader: policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", + retiredAfter: '17.7.0', + surface: 'datasource.driver', + summary: + "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver " + + 'package and the published DRIVER_CATALOG already used, so the id that selects a driver ' + + 'and the id that selects its config contract are one string with no mapping between them', apply(stack, emit) { - return mapCollection(stack, 'permissions', (ps, path) => { - const rls = (ps as { rowLevelSecurity?: unknown }).rowLevelSecurity; - if (!Array.isArray(rls)) return ps; - let touched = false; - const next = rls.map((policy, i) => { - if (!isDict(policy) || !('priority' in policy)) return policy; - const { priority: _dropped, ...rest } = policy; - emit({ from: 'priority', to: '(removed)', path: `${path}.rowLevelSecurity[${i}].priority` }); - touched = true; - return rest; - }); - return touched ? { ...ps, rowLevelSecurity: next } : ps; + return mapDatasources(stack, (ds, path) => { + // Only the exact legacy canon, trimmed and lower-cased the same way + // `resolveDriverId` reads it. `mongodb` is already canonical, and any other + // spelling (a plugin driver, a typo) is not this conversion's business. + if (typeof ds.driver !== 'string' || ds.driver.trim().toLowerCase() !== 'mongo') return ds; + emit({ from: 'mongo', to: 'mongodb', path: `${path}.driver` }); + return { ...ds, driver: 'mongodb' }; }); }, fixture: { before: { - permissions: [{ - name: 'contributor', - label: 'Contributor', - rowLevelSecurity: [{ - name: 'own_tasks', - object: 'crm_task', - operation: 'select', - using: 'assignee == current_user.email', - enabled: true, - priority: 10, - }], - }], + datasources: [ + { name: 'events', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/events' } }, + // Already canonical — untouched, and emits nothing. + { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, + // A different driver whose id merely CONTAINS the string: never rewritten. + { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], }, after: { - permissions: [{ - name: 'contributor', - label: 'Contributor', - rowLevelSecurity: [{ - name: 'own_tasks', - object: 'crm_task', - operation: 'select', - using: 'assignee == current_user.email', - enabled: true, - }], - }], + datasources: [ + { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, + { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, + { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], }, expectedNotices: 1, }, }; /** - * Tool inert authoring keys removed (protocol 17, #3896 audit close-out). - * - * `category`, `permissions`, `active` and `builtIn` were authorable and inert — - * none is part of `AIToolDefinition` and no execution path read them. Two were - * misleading in the dangerous direction: `permissions` promised a capability - * gate on invocation that nothing enforced (a tool "requiring" capabilities ran - * for everyone), and `active: false` read as "withdrawn" while the tool kept - * reaching the LLM tool set and `POST /ai/tools/:name/execute` kept running it. - * A pure lossless delete: dropping the keys changes no runtime behaviour, - * because they never had any. - * - * `retiredFromLoadPath`: ToolSchema is `.strict()` and rejects each key with - * its prescription (`TOOL_RETIRED_KEY_GUIDANCE`), the #3715 pattern. + * The retry policy converges to one declaration (protocol 17, #4661 — the + * #4535 C8 dual-source cluster). + * + * `@objectstack/spec/automation` and `@objectstack/spec/system` both exported a + * `RetryPolicy` / `RetryPolicySchema`, resolving to DIFFERENT declarations — so + * which shape a consumer got depended only on the import path (#4411). They + * were never two concepts: `try_catch`'s `retry` region and `job.retryPolicy` + * both compute `delay = base * multiplier^(retry-1)`, and the two executors + * implemented that identical formula. What differed was cosmetic and + * accidental, and this conversion pays for both halves of the merge: + * + * 1. **The base delay had two spellings.** automation said `retryDelayMs`, + * system said `backoffMs`. `backoffMs` wins — it is what the *enforced* + * retry policies already spell it (`job.retryPolicy` and `hook.retryPolicy`; + * see the `datasource-inert-blocks-removed` note above, which leans on + * exactly that distinction), so the platform drops from three spellings to + * two rather than four. `retryDelayMs` is tombstoned (`retiredKey`) — NOT + * deleted — because two of the three owning shapes are not `.strict()`: a + * plain deletion would have Zod silently swallow the authored number and + * fall back to the 1000ms default, which is the quiet-failure class + * ADR-0049 removes. + * + * 2. **The defaults were opposite, and no gate can see a default.** Pre-17, + * `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` + * while the automation shape defaulted 0 / 1. The merged declaration takes + * 0 / 1 (retry is opt-in: a retry replays whatever the attempt already did, + * and an implicit replay of side effects is the failure mode hardest to + * catch in tests — the same reading already recorded for flow-level retry + * in `flow-retry-max-retries-required`, #4247). Taken alone that would + * SILENTLY stop existing jobs from retrying, and the authorable-surface + * gate would never notice: it compares key sets, and a default is not a key. + * So this conversion writes the pre-17 numbers into every existing + * `job.retryPolicy` that omitted them. Deployed stacks keep their exact + * behaviour; only a newly authored omission means "no retry". + * + * Jobs with no `retryPolicy` block at all are left alone — absence already + * meant a single attempt on both sides of the change. + * + * ## The ONE further surface this entry grew to cover (#4964 / #4962) + * + * The convergence above was driven by the dual-source instrument, whose + * question is "how many declarations share one exported NAME?". Two further + * encodings of the identical policy were invisible to it because they were + * anonymous inline `z.object`s with no exported name at all — and after a + * convergence lands, a surviving dialect reads as reviewed-and-kept rather + * than missed. One of the two is still a surface; the other went with its + * layer: + * + * - **`flow.errorHandling`** (#4964) spelled the base delay `retryDelayMs`; + * every other key, bound and default already matched. Step 0 below renames + * it, so the ONE authorable casualty of the whole convergence is still just + * that word — now retired everywhere it was ever legal rather than only on + * the two shapes #4661's instrument could see. + * - **`ETLPipeline.retry`** (#4962) spelled the count `maxAttempts` and + * defaulted it to 3. It got **no step here, deliberately** — and never will. + * An ETL pipeline was not a `defineStack` collection and `etl.zod.ts` had no + * parse site in objectstack / objectui / cloud (批 12's measurement), so + * there was no stored or authored document a walker could reach: a branch + * for it would have been dead code claiming migration coverage that does not + * exist, which is the ADR-0049 failure this registry is supposed to prevent, + * not commit. #6414 then retired the whole L2 layer, and #4962's own entry + * (`etl-retry-converged-onto-retry-policy`) was ABSORBED into + * `etl-pipeline-layer-retired` inside the same unreleased major. There is no + * `maxAttempts` tombstone left to carry the rename or the default change: it + * went with the shape that carried it, which is strictly stronger, because + * no `retry` block survives to author the key into. `tsc` (TS2724/TS2305 on + * the removed ETL names) is the whole channel. That is also why the ETL + * default flip 3 → 0 never needed a materialization step while the job one + * did: nothing was ever deployed under the old reading. + * + * ## Retired from the load path at 18 (#23543, ruling F2 A) + * + * The base-delay rename never needed the load path: every surface that carried + * `retryDelayMs` refuses it with the rename to `backoffMs` (the + * `RetryPolicySchema` tombstone, which a `try_catch` node's `config.retry` is, + * and `flow.errorHandling`'s own). It stayed for the job half alone (the + * verdict #23487 recorded): retired, the authoring funnel would stop writing + * `maxRetries: 3` into a source block that omits it, and such a job would + * silently drop from three retries to none — a default flip no gate can see. + * The ruling made that drop loud instead: the job parse refuses a + * `retryPolicy` whose `maxRetries` is omitted or 0, naming the fix + * (`JOB_RETRY_POLICY_NO_ATTEMPTS`, `system/job.zod.ts`), so no authored job + * changes its retry count without its author being told. An omitted + * `backoffMultiplier` is not refused and now keeps its declared default (1): + * it shapes the delay curve, not whether the job retries. + * + * The authoring funnel (`defineStack`, `os validate` / `os build` / `os lint`) + * no longer replays it. Stored documents are the `includeRetired` seams — + * stored-row rehydration (`applyConversionsToStoredItem`), the automation + * engine's flow rehydration, the metadata save door's flow fallback, the + * artifact-ingestion door inside its window — so a job stored before 17 keeps + * the three retries it ran with. `os migrate meta --from 16` replays it too (a + * step-17 member, carried by the D3 entry `job-retry-policy-without-attempts-refused`). */ -const toolInertAuthoringKeysRemoved: MetadataConversion = { - id: 'tool-inert-authoring-keys-removed', +const retryPolicyConverged: MetadataConversion = { + id: 'retry-policy-converged', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'tool.category / tool.permissions / tool.active / tool.builtIn', - summary: "tool keys 'category'/'permissions'/'active'/'builtIn' removed (authorable and inert, so removed under ADR-0049 enforce-or-remove; permissions gated nothing, active:false withdrew nothing)", + retiredAfter: '17.7.0', + surface: 'flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier', + summary: + "retry policy unified across job.retryPolicy, try_catch retry and flow.errorHandling: base delay 'retryDelayMs' → 'backoffMs', " + + "and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1: two declarations that differed only by accident became one, and retry is opt-in because a retry replays whatever the attempt already did", apply(stack, emit) { - const RETIRED = ['category', 'permissions', 'active', 'builtIn'] as const; - return mapCollection(stack, 'tools', (tool, path) => { - let touched = false; - const next: Record = { ...tool }; - for (const key of RETIRED) { - if (!(key in next)) continue; - delete next[key]; - emit({ from: key, to: '(removed)', path: `${path}.${key}` }); - touched = true; - } - return touched ? next : tool; + // ── 0. flows: errorHandling.retryDelayMs → errorHandling.backoffMs ─ + // + // The FLOW-LEVEL retry policy (#4964). Same rename as the try_catch node + // below and for the same reason, reached one level up: `errorHandling` + // hangs off the flow document, not off a node, so `mapFlowNodes` walks + // straight past it — which is a small echo of why this divergence survived + // #4661 at all. `renameKey` leaves an already-canonical `backoffMs` alone; + // when BOTH spellings are present it now (#4923) deletes `retryDelayMs` + // only if it repeats the same number, and otherwise keeps both rather than + // guessing which delay the author meant — the strict block then rejects + // naming both. This entry relied on the shared helper's semantics rather + // than open-coding its own precisely so it would move with that ruling, + // and it did: no change was needed here. + const withErrorHandling = mapCollection(stack, 'flows', (flow, path) => { + const eh = flow.errorHandling; + if (!eh || typeof eh !== 'object' || Array.isArray(eh)) return flow; + const renamed = renameKey(eh as Record, 'retryDelayMs', 'backoffMs'); + if (renamed === null) return flow; + emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.errorHandling.backoffMs` }); + return { ...flow, errorHandling: renamed }; }); - }, - fixture: { - before: { - tools: [{ - name: 'create_case', - label: 'Create Case', - description: 'Creates a support case', - parameters: { type: 'object' }, - category: 'action', - permissions: ['case.create'], - active: true, - builtIn: false, - }], - }, - after: { - tools: [{ - name: 'create_case', - label: 'Create Case', - description: 'Creates a support case', - parameters: { type: 'object' }, - }], - }, - expectedNotices: 4, - }, -}; -/** - * ⛔ WITHDRAWN — there is deliberately NO `field-required-notnull-explicit` - * conversion in this registry, and re-adding one is the mistake this comment - * exists to stop (#16693; maintainer ruling, decision batch #85, 2026-09-08, - * option A). - * - * It read `required: true` and wrote `storage: { notNull: true }` beside it, - * on the argument that pre-17 `field.required` bound three meanings to one - * knob — the write contract, the physical NOT NULL, and the drift - * expectation — so writing the constraint down preserved an old source's - * full meaning. ADR-0113 split those axes, and two facts retired the argument: - * - * 1. Adding `storage.notNull` wherever `required: true` appears IS the - * implication ADR-0113 abolished. `required` is the write-time contract; - * `storage.notNull` alone binds the column. A conversion cannot be what - * decides a column constraint — that is the author's explicit act. - * 2. `retiredFromLoadPath: true` did NOT hold the transform to - * `os migrate meta`, whatever this entry's docblock used to claim. The - * artifact-ingestion door replays the whole chain with `includeRetired: - * true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`), - * keyed off the artifact's declared `engines.protocol` FLOOR rather than - * its age — and `^17.0.0` is the range `create-objectstack` stamps. So - * every scaffolded app was handed NOT NULL columns it never asked for, plus - * a boot warning telling its author to write the same tightening into the - * source. On a populated database that instruction is a `tighten_not_null` - * / `severity: error` / `category: destructive` migration, prescribed as - * the remedy for a deprecation notice. - * - * No migration is owed to anyone: genuinely pre-ADR-0113 artifacts are not - * measured to exist, and an app that wants NOT NULL columns declares - * `storage.notNull` deliberately. Existing columns are left exactly as they - * are. - * - * ⚠️ Fact 2 is about the MECHANISM, not about this entry, and it outlived the - * entry: `retiredFromLoadPath` still holds nothing back at three runtime seams - * (ADR-0087's 2026-09-13 addendum names all three). Before setting that flag on a DEFAULT FLIP — as opposed to a - * lossless delete or a rename — read that addendum, because the flag does not mean - * what its name says: its own docblock (`RetiredConversionState` in `types.ts`) has - * stated the authoring-only reach since commit 29dd1a6dd. - */ - -/** - * The #3896 close-out sweep, part 2 (protocol 17): the remaining inert - * authoring keys leave the surface, one conversion per metadata type. All are - * 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` lists the mechanical edits for existing sources. - */ -const stripKeys = ( - item: Record, - keys: readonly string[], - emit: (n: { from: string; to: string; path: string }) => void, - path: string, -): Record => { - let touched = false; - const next: Record = { ...item }; - for (const key of keys) { - if (!(key in next)) continue; - delete next[key]; - emit({ from: key, to: '(removed)', path: `${path}.${key}` }); - touched = true; - } - return touched ? next : item; -}; - -/** action.shortcut / action.bulkEnabled — capability claims nothing enforced. */ -const actionInertKeysRemoved: MetadataConversion = { - id: 'action-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'action.shortcut / action.bulkEnabled', - summary: "action keys 'shortcut'/'bulkEnabled' removed (inert, removed under ADR-0049 enforce-or-remove: no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions)", - apply(stack, emit) { - return mapCollection(stack, 'actions', (a, path) => stripKeys(a, ['shortcut', 'bulkEnabled'], emit, path)); - }, - fixture: { - before: { actions: [{ name: 'close_case', label: 'Close', type: 'script', shortcut: 'Ctrl+K', bulkEnabled: true }] }, - after: { actions: [{ name: 'close_case', label: 'Close', type: 'script' }] }, - expectedNotices: 2, - }, -}; + // ── 1. try_catch nodes: retry.retryDelayMs → retry.backoffMs ────── + const withFlows = mapFlowNodes(withErrorHandling, (node, path) => { + if (node.type !== 'try_catch') return node; + const config = node.config; + if (!config || typeof config !== 'object' || Array.isArray(config)) return node; + const configDict = config as Record; + const retry = configDict.retry; + if (!retry || typeof retry !== 'object' || Array.isArray(retry)) return node; + const renamed = renameKey(retry as Record, 'retryDelayMs', 'backoffMs'); + if (renamed === null) return node; + emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.config.retry.backoffMs` }); + return { ...node, config: { ...configDict, retry: renamed } }; + }); -/** - * flow.active / flow.template / nodes[].outputSchema / - * errorHandling.fallbackNodeId — `active` is the rls.enabled shape (writing - * `active: false` never stopped a flow; `status` is the enforced lifecycle), - * the rest were never read. Deleting `active` preserves behavior exactly — - * the flow was governed by `status` all along; the notice tells the author - * where the real switch is. - */ -const flowInertKeysRemoved: MetadataConversion = { - id: 'flow-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId', - summary: "flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (inert, removed under ADR-0049 enforce-or-remove: active:false never stopped a flow; status is the enforced lifecycle)", - apply(stack, emit) { - let out = mapCollection(stack, 'flows', (f, path) => { - let next = stripKeys(f, ['active', 'template'], emit, path); - const eh = next.errorHandling; - if (eh && typeof eh === 'object' && !Array.isArray(eh) && 'fallbackNodeId' in (eh as Record)) { - const nextEh = { ...(eh as Record) }; - delete nextEh.fallbackNodeId; - emit({ from: 'fallbackNodeId', to: '(removed)', path: `${path}.errorHandling.fallbackNodeId` }); - next = next === f ? { ...f } : next; - next.errorHandling = nextEh; + // ── 2. jobs: materialize the pre-17 implicit defaults ───────────── + return mapCollection(withFlows, 'jobs', (job, path) => { + const policy = job.retryPolicy; + if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return job; + const policyDict = policy as Record; + let next = policyDict; + if (next.maxRetries === undefined) { + next = { ...next, maxRetries: 3 }; + emit({ + from: 'maxRetries unset (implied 3)', + to: 'maxRetries: 3', + path: `${path}.retryPolicy.maxRetries`, + }); } - return next; - }); - out = mapFlowNodes(out, (node, path) => { - if (!('outputSchema' in node)) return node; - const next = { ...node }; - delete next.outputSchema; - emit({ from: 'outputSchema', to: '(removed)', path: `${path}.outputSchema` }); - return next; + if (next.backoffMultiplier === undefined) { + next = { ...next, backoffMultiplier: 2 }; + emit({ + from: 'backoffMultiplier unset (implied 2)', + to: 'backoffMultiplier: 2', + path: `${path}.retryPolicy.backoffMultiplier`, + }); + } + return next === policyDict ? job : { ...job, retryPolicy: next }; }); - return out; }, fixture: { before: { flows: [{ - name: 'escalate', - type: 'autolaunched', - active: false, - template: true, - errorHandling: { strategy: 'retry', fallbackNodeId: 'n9' }, - nodes: [{ id: 'n1', type: 'start', outputSchema: { ok: { type: 'boolean' } } }], - edges: [], + name: 'sync_orders', + // Flow-LEVEL policy (#4964) — one level up from the nodes, so the node + // walk below never sees it. + errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'try_catch', + config: { + try: { nodes: [], edges: [] }, + retry: { maxRetries: 3, retryDelayMs: 500, jitter: true }, + }, + }, + // Already canonical — left alone, contributes no notice. + { + id: 'n3', + type: 'try_catch', + config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, + }, + ], + }, { + // Flow-level block already canonical — left alone, no notice. + name: 'roll_up', + errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, + nodes: [{ id: 'n1', type: 'start' }], }], + jobs: [ + // Omits both defaults — both get written out. + { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000 } }, + // States both — untouched. + { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, + // No policy block at all — absence already meant one attempt. + { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, + ], }, after: { flows: [{ - name: 'escalate', - type: 'autolaunched', - errorHandling: { strategy: 'retry' }, + name: 'sync_orders', + errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'try_catch', + config: { + try: { nodes: [], edges: [] }, + retry: { maxRetries: 3, jitter: true, backoffMs: 500 }, + }, + }, + { + id: 'n3', + type: 'try_catch', + config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, + }, + ], + }, { + name: 'roll_up', + errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, nodes: [{ id: 'n1', type: 'start' }], - edges: [], - }], - }, - expectedNotices: 4, - }, -}; - -/** - * View container: list-shaped entries lose `responsive`/`performance`, - * form-shaped entries lose `data`/`defaultSort`/`aria`. Deliberately - * SHAPE-SCOPED — the LIST view's `aria` and `data` are live and untouched. - */ -const viewInertKeysRemoved: MetadataConversion = { - id: 'view-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria', - summary: "view keys removed as inert (ADR-0049 enforce-or-remove): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live)", - apply(stack, emit) { - const LIST_KEYS = ['responsive', 'performance'] as const; - // NOT 'data': the sweep's removal attempt was refuted by the build — - // defineForm writes data.provider='schema' on every metadata form. - const FORM_KEYS = ['defaultSort', 'aria'] as const; - // The per-family key sets are why {@link mapViewPayloads} labels every - // payload: `aria` is retired on a form and LIVE on a list, `data` the - // reverse, so a walk that could not tell the two apart would delete a live - // key on whichever family it guessed wrong. - return mapViewPayloads(stack, (payload, kind, path) => - stripKeys(payload, kind === 'list' ? LIST_KEYS : FORM_KEYS, emit, path)); - }, - fixture: { - before: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], responsive: { sm: {} }, performance: { lazyLoad: true }, aria: { label: 'Leads' } }, - form: { type: 'simple', data: { provider: 'object', object: 'crm_lead' }, defaultSort: [{ field: 'created_at', order: 'desc' }], aria: { label: 'Lead form' } }, // data survives - }], - }, - after: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], aria: { label: 'Leads' } }, - form: { type: 'simple', data: { provider: 'object', object: 'crm_lead' } }, }], + jobs: [ + { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000, maxRetries: 3, backoffMultiplier: 2 } }, + { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, + { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, + ], }, + // sync_orders' flow-level rename, n2's node-level rename, plus + // nightly_sync's two materialized defaults. expectedNotices: 4, }, }; /** - * View container: list-shaped entries lose `striped`/`bordered`/`virtualScroll` - * (#7176, ADR-0049 enforce-or-remove, maintainer ruling 2026-08-10). Every - * measured reader was a forwarding copy — react spec-bridge → plugin-list → - * plugin-view/app-shell — and the chains end at ObjectGrid, which never spells - * any of the three: copy-without-apply is dead in effect. List-shaped slots - * only (`list` + named `listViews`); form-shaped entries never declared them. + * The two spellings of the page-header node, both converted by + * {@link pageHeaderSubtitleAlias}. + * + * `page:header` is the protocol type (`PageComponentType`, `ComponentPropsMap`); + * `page-header` is objectui's kebab legacy alias (`@object-ui/layout`), which is + * authorable here because `PageComponentSchema.type` is deliberately + * `z.union([PageComponentType, z.string()])` — an open namespace for custom + * components. */ -const viewListPassthroughKeysRemoved: MetadataConversion = { - id: 'view-list-passthrough-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'view.list.striped / view.list.bordered / view.list.virtualScroll', - summary: "view list keys removed: 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (a key that is only passed through is dead in effect; ADR-0049 enforce-or-remove)", - apply(stack, emit) { - const LIST_KEYS = ['striped', 'bordered', 'virtualScroll'] as const; - return mapViewPayloads(stack, (payload, kind, path) => - kind === 'list' ? stripKeys(payload, LIST_KEYS, emit, path) : payload); - }, - fixture: { - before: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], resizable: true, striped: true, bordered: true, virtualScroll: true }, // resizable survives - listViews: { - all: { type: 'grid', columns: ['name'], striped: false }, - }, - }], - }, - after: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], resizable: true }, - listViews: { - all: { type: 'grid', columns: ['name'] }, - }, - }], - }, - expectedNotices: 4, - }, -}; +const PAGE_HEADER_COMPONENT_TYPES = new Set(['page:header', 'page-header']); /** - * The `'pdf'` export format leaves `view.exportOptions` (protocol 17, #8010 — - * maintainer ruling 2026-08-12). - * - * PDF export was declined platform-side (#1301 NOT_PLANNED), so the enum - * member was declared-but-unrenderable: ObjectGrid dropped the format from the - * export menu with only a runtime `console.warn`, so `exportOptions: - * ['xlsx', 'pdf']` type-checked, validated, and silently rendered a menu - * without PDF. The same ruling adopted the OBJECT form for `exportOptions` - * (`{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }` — - * the key set the renderer actually reads); the legacy bare array stays - * accepted and lifts to `{ formats: [...] }` at parse, so this conversion does - * NOT rewrite the array form to the object form — the array is back-compat, - * not retired. - * - * This is an enum-VALUE retirement, so there is no `retiredKey()` tombstone to - * hang the prescription on — the format enum's own error map carries it - * (`LIST_VIEW_EXPORT_PDF_RETIRED`, ui/view.zod.ts), keyed on `issue.input` so - * only the value which used to be legal gets the "was removed" message, plus a - * union-level dispatch so the refusal is the TOP-level message in either - * authored spelling (the `hook-body-crypto-hash-removed` precedent, one level - * deeper again: the value sits inside either a bare array or an object's - * `formats`, so `stripKeys` cannot reach it). - * - * `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` 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). - */ -const viewExportOptionsPdfRemoved: MetadataConversion = { - id: 'view-export-options-pdf-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'view.list.exportOptions / view.listViews.*.exportOptions', - summary: - "list-view export format 'pdf' removed (PDF export was declined as not planned, and ObjectGrid " - + 'dropped the declared format from the menu with only a runtime console.warn; an honest enum replaces that warning)', - apply(stack, emit) { - const stripPdf = (slot: unknown, path: string): unknown => { - if (!slot || typeof slot !== 'object' || Array.isArray(slot)) return slot; - const eo = (slot as Dict).exportOptions; - if (Array.isArray(eo) && eo.includes('pdf')) { - emit({ from: 'pdf', to: '(removed)', path: `${path}.exportOptions` }); - return { ...(slot as Dict), exportOptions: eo.filter((f) => f !== 'pdf') }; - } - if (eo && typeof eo === 'object' && !Array.isArray(eo)) { - const formats = (eo as Dict).formats; - if (Array.isArray(formats) && formats.includes('pdf')) { - emit({ from: 'pdf', to: '(removed)', path: `${path}.exportOptions.formats` }); - return { - ...(slot as Dict), - exportOptions: { ...(eo as Dict), formats: formats.filter((f) => f !== 'pdf') }, - }; - } - } - return slot; - }; - return mapViewPayloads(stack, (payload, kind, path) => - kind === 'list' ? (stripPdf(payload, path) as Dict) : payload); - }, - fixture: { - before: { - views: [{ - object: 'crm_contract', - // Legacy array spelling: 'pdf' is stripped, the survivors stay an array - // (the array form is back-compat, not retired — no lift here). - list: { type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }, - listViews: { - // Object spelling: 'pdf' leaves `formats`; the sibling keys survive. - archive: { - type: 'grid', - columns: ['name'], - exportOptions: { formats: ['csv', 'pdf'], maxRecords: 100 }, - }, - // No 'pdf' declared → untouched. - all: { type: 'grid', columns: ['name'], exportOptions: ['csv', 'json'] }, - }, - }], - }, - after: { - views: [{ - object: 'crm_contract', - list: { type: 'grid', columns: ['name'], exportOptions: ['xlsx'] }, - listViews: { - archive: { - type: 'grid', - columns: ['name'], - exportOptions: { formats: ['csv'], maxRecords: 100 }, - }, - all: { type: 'grid', columns: ['name'], exportOptions: ['csv', 'json'] }, - }, - }], - }, - expectedNotices: 2, - }, -}; - -/** dashboard.aria / dashboard.performance / widgets[].performance. */ -const dashboardInertKeysRemoved: MetadataConversion = { - id: 'dashboard-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dashboard.aria / dashboard.performance / dashboard.widgets[].performance', - summary: "dashboard keys 'aria'/'performance' and widget 'performance' removed (inert, removed under ADR-0049 enforce-or-remove: no renderer applied any of them)", - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - let next = stripKeys(d, ['aria', 'performance'], emit, path); - const widgets = next.widgets; - if (Array.isArray(widgets)) { - let subTouched = false; - const rebuilt = widgets.map((w, i) => { - if (w && typeof w === 'object' && !Array.isArray(w) && 'performance' in w) { - const cleaned = { ...(w as Record) }; - delete cleaned.performance; - emit({ from: 'performance', to: '(removed)', path: `${path}.widgets[${i}].performance` }); - subTouched = true; - return cleaned; - } - return w; - }); - if (subTouched) { - next = next === d ? { ...d } : next; - next.widgets = rebuilt; - } - } - return next; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'sales_kpis', - aria: { label: 'Sales' }, - performance: { lazyLoad: true }, - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], performance: { prefetch: true } }], - }], - }, - after: { - dashboards: [{ - name: 'sales_kpis', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }, - expectedNotices: 3, - }, -}; - -/** - * dashboard.widgets[].responsive (#4876) — the same-named `view.responsive` - * went in the #3896 close-out above; this one escaped that sweep through a - * liveness drill gap rather than on evidence (`dashboard.json` declares no - * `children` on `widgets`, so no widget-level key was ever classified — filed - * as #4956). Re-measured 2026-08-03: no objectui code reads - * `widget.responsive`, and zero authored instances exist repo-wide. - * - * Deliberately a SEPARATE entry rather than another key on - * `dashboard-inert-keys-removed`: that entry's identity is the #3896 sweep, and - * folding a differently-evidenced removal into it would misattribute this one - * in `spec-changes.json` and the upgrade guide — the two places an upgrading - * author actually reads. Both are `toMajor: 17`, so a stored dashboard carrying - * both keys is cleaned by both in one replay. - * - * Strips ONLY the widget embed. At the time the shared `ResponsiveConfig` - * shape was believed to stay live on `page.components[].responsive`; #11027 - * measured that carrier equally unread and retired it and the shape at - * protocol 18 (`page-component-responsive-removed` below). - */ -const dashboardWidgetResponsiveRemoved: MetadataConversion = { - id: 'dashboard-widget-responsive-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dashboard.widgets[].responsive', - summary: "dashboard widget key 'responsive' removed (no renderer ever applied per-widget breakpoint overrides; the page.components[].responsive key this entry once deferred to was measured equally unread and retired at protocol 18)", - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - const widgets = d.widgets; - if (!Array.isArray(widgets)) return d; - let touched = false; - const rebuilt = widgets.map((w, i) => { - if (!w || typeof w !== 'object' || Array.isArray(w)) return w; - const cleaned = stripKeys(w as Record, ['responsive'], emit, `${path}.widgets[${i}]`); - if (cleaned !== w) touched = true; - return cleaned; - }); - if (!touched) return d; - return { ...d, widgets: rebuilt }; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ - id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], - responsive: { columns: { xs: 12, lg: 4 }, order: { xs: 2, lg: 1 }, hiddenOn: ['xs'] }, - }], - }], - }, - after: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }, - expectedNotices: 1, - }, -}; - -/** - * dashboard.widgets[].actionUrl / actionType / actionIcon / aria (#5010) — the - * four widget-level keys the #4956 drill judged dead on a closed call graph. - * - * Two affordances, one removal, because both fail the same way and an upgrading - * source carries them together: - * - * - the action TRIO described a per-widget button. No renderer in either repo - * draws one; every action a dashboard dispatches comes from - * `header.actions[]`. Its only reader was `packages/lint`'s reference- - * integrity rule, which failed builds when a button that cannot render - * pointed at an action that did not exist — that widget branch is deleted in - * the same change. - * - `aria` promised ARIA attributes that never reached the DOM: the same false - * compliance the DASHBOARD-level `aria` was retired for above, one level - * down. `dashboard-inert-keys-removed` took the parent key in the #3896 - * sweep and left this one, because `widgets` had no ledger drill then. - * - * A SEPARATE entry rather than more keys on `dashboard-inert-keys-removed`, for - * the reason `dashboard-widget-responsive-removed` gives just above: that entry's - * identity is the #3896 sweep, and folding a differently-evidenced removal into - * it would misattribute this one in `spec-changes.json` and the upgrade guide — - * the two places an upgrading author actually reads. All are `toMajor: 17`, so a - * stored dashboard carrying keys from several of them is cleaned in one replay. - * - * `colorVariant`, the fifth key #5010 lists, is deliberately NOT here — and the - * reason has since been SETTLED, in the other direction. When this entry was - * written its disposition was open: the rewrite target its triage assumed, - * `options.colorVariant`, measured dead on the ADR-0021 dataset path too, and - * 16 authored sites depended on the answer. #5010 ruling B answered ENFORCE — - * the declaration stays and objectui implements it — which objectui#3359 / - * PR objectui#3799 did, absorbed here by the `.objectui-sha` pin `09987b68`. - * The key is `live` in `liveness/dashboard.json` as of #6774, so nothing is - * owed: no retirement, no later entry. Read this paragraph as "not being - * removed", not as "not removed yet". - */ -const dashboardWidgetActionAriaRemoved: MetadataConversion = { - id: 'dashboard-widget-action-aria-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / ' - + 'dashboard.widgets[].actionIcon / dashboard.widgets[].aria', - summary: - "dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed " - + '(no renderer ever drew a per-widget action button, and widget ARIA attributes never ' - + 'reached the DOM; use header.actions[] and the widget title/description)', - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - const widgets = d.widgets; - if (!Array.isArray(widgets)) return d; - let touched = false; - const rebuilt = widgets.map((w, i) => { - if (!w || typeof w !== 'object' || Array.isArray(w)) return w; - const cleaned = stripKeys( - w as Record, - ['actionUrl', 'actionType', 'actionIcon', 'aria'], - emit, - `${path}.widgets[${i}]`, - ); - if (cleaned !== w) touched = true; - return cleaned; - }); - if (!touched) return d; - return { ...d, widgets: rebuilt }; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ - id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], - actionUrl: 'export_dashboard_pdf', - actionType: 'script', - actionIcon: 'download', - aria: { ariaLabel: 'Total orders' }, - }], - }], - }, - after: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }, - // One notice per KEY, not per widget — four keys on one widget. - expectedNotices: 4, - }, -}; - -/** - * dashboard.widgets[].compareTo (#5011) — a VOCABULARY convergence, not a - * removal: the widget's three declared arms are replaced by the one shape the - * analytics executor implements, `{ kind, dimension? }` - * (`DatasetSelection.compareTo`). - * - * Why a conversion rather than a plain tombstone: two of the three arms have an - * exact target, so leaving them for the author to retype would be make-work on - * a rewrite a machine can prove. - * - * 'previousPeriod' → { kind: 'previousPeriod' } value verbatim, container changed - * 'previousYear' → { kind: 'previousYear' } value verbatim, container changed - * { offset: '1y' } → { kind: 'previousYear' } '1y' IS previousYear, by definition - * - * `dimension` is deliberately NOT synthesised. The conversion sees a stack, not - * a dataset — it cannot know which time dimension carries the window — and it - * does not need to: `dimension` is optional precisely so the executor can - * resolve it (one dated candidate) or refuse loudly (zero, or several). Writing - * a guess here would convert a loud runtime error into a wrong comparison. - * - * Every OTHER `{ offset }` (`'7d'`, `'1M'`, `'2w'`, …) is left UNTOUCHED, on - * purpose. There is no faithful target: `previousPeriod` shifts by the resolved - * window's own length, which equals `7d` only when the window happens to be - * seven days. Rewriting it would silently change which rows the comparison - * column counts — the failure class this issue exists to end. So the source - * keeps the key, the strict schema rejects it with `COMPARE_TO_OFFSET_RETIRED` - * (which prescribes kind + filter-window), and the residue is declared as the - * `dashboard-widget-compareto-offset` semantic migration. - * - * Measured before writing (#5011's adjudication asked for stored/authored - * instances first): repo-wide, four authored instances, all of them the string - * form, all in `examples/app-crm`; ZERO `{ offset }` instances in either repo. - * Expected — a canonical-path author who tried `{ offset }` got a thrown widget, - * so it could never accumulate there. - * - * `retiredFromLoadPath: true`: the old spellings get NO acceptance window. An - * auto-converting loader would let `compareTo: 'previousPeriod'` keep parsing - * clean, which is the lenient-consumer shape PD #12 forbids and the exact - * dynamic that let the widget and executor vocabularies drift apart unnoticed - * for a whole major. Stored `sys_metadata` rows are still covered — every - * rehydration seam replays retired entries via `applyConversionsToStoredItem` - * (#3903). - */ -const dashboardWidgetCompareToConverged: MetadataConversion = { - id: 'dashboard-widget-compareto-converged', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dashboard.widgets[].compareTo', - summary: - "dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract " - + "(the shape the dataset executor implements; the bare strings and { offset: '1y' } rewrite mechanically; other { offset } " - + 'durations have no faithful target and are reported, not guessed)', - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - const widgets = d.widgets; - if (!Array.isArray(widgets)) return d; - let touched = false; - const rebuilt = widgets.map((w, i) => { - if (!w || typeof w !== 'object' || Array.isArray(w)) return w; - const widget = w as Record; - if (!('compareTo' in widget)) return w; - const cmp = widget.compareTo; - const at = `${path}.widgets[${i}].compareTo`; - - // Arm 1/2 — the bare string form. The value survives verbatim. - if (cmp === 'previousPeriod' || cmp === 'previousYear') { - emit({ from: `'${cmp}'`, to: `{ kind: '${cmp}' }`, path: at }); - touched = true; - return { ...widget, compareTo: { kind: cmp } }; - } - - // Arm 3 — `{ offset }`, and only the one duration with an exact target. - if (cmp && typeof cmp === 'object' && !Array.isArray(cmp)) { - const offset = (cmp as Record).offset; - if (offset === '1y') { - emit({ from: "{ offset: '1y' }", to: "{ kind: 'previousYear' }", path: at }); - touched = true; - return { ...widget, compareTo: { kind: 'previousYear' } }; - } - } - return w; - }); - if (!touched) return d; - return { ...d, widgets: rebuilt }; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'revenue_review', - widgets: [ - { id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: 'previousPeriod' }, - { id: 'w2', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: 'previousYear' }, - { id: 'w3', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { offset: '1y' } }, - ], - }], - }, - after: { - dashboards: [{ - name: 'revenue_review', - widgets: [ - { id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { kind: 'previousPeriod' } }, - { id: 'w2', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { kind: 'previousYear' } }, - { id: 'w3', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { kind: 'previousYear' } }, - ], - }], - }, - expectedNotices: 3, - }, -}; - -/** - * agent.knowledge — a grounding claim nothing enforced (the RAG path reads - * `sourceIds` from the LLM's tool-call arguments, never the agent record). - * Absorbs the former `agent-knowledge-topics-to-sources` rename (#3855): - * both spellings of the block end deleted, in one notice. - */ -const agentKnowledgeRemoved: MetadataConversion = { - id: 'agent-knowledge-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'agent.knowledge', - summary: "agent key 'knowledge' removed (inert, removed under ADR-0049 enforce-or-remove: declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level)", - apply(stack, emit) { - return mapCollection(stack, 'agents', (a, path) => stripKeys(a, ['knowledge'], emit, path)); - }, - fixture: { - before: { agents: [{ name: 'support_agent', label: 'Support', knowledge: { sources: ['faq'], indexes: ['docs'] } }] }, - after: { agents: [{ name: 'support_agent', label: 'Support' }] }, - expectedNotices: 1, - }, -}; - -/** skill.triggerPhrases — phrases were never matched against the user's message. */ -const skillTriggerPhrasesRemoved: MetadataConversion = { - id: 'skill-trigger-phrases-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'skill.triggerPhrases', - summary: "skill key 'triggerPhrases' removed (inert, removed under ADR-0049 enforce-or-remove: activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection)", - apply(stack, emit) { - return mapCollection(stack, 'skills', (sk, path) => stripKeys(sk, ['triggerPhrases'], emit, path)); - }, - fixture: { - before: { skills: [{ name: 'case_mgmt', label: 'Cases', triggerPhrases: ['open a ticket'] }] }, - after: { skills: [{ name: 'case_mgmt', label: 'Cases' }] }, - expectedNotices: 1, - }, -}; - -/** - * All conversions, keyed by the protocol major that introduced the canonical - * shape. Newest majors last; ordering within a major is application order. - */ -/** - * Stack `api.requireAuth` → dropped (protocol 17, #3963). - * - * NOT a rename — there is no key to move the value to. The deployment-wide - * anonymous-access opt-out is retired: auth is a kernel concern, and anonymous - * access to object data is now always denied. A surface that legitimately - * serves a session-less caller derives its own narrow authorization from a - * declaration (a public form view, a share link, or `book.audience: 'public'`), - * so there is nothing for the old boolean to control. - * - * Dropping is safe at load time: the runtime no longer reads the key (its - * plumbing was removed in the same change), so a surviving `api.requireAuth` - * would otherwise be silently stripped by the non-strict schema — the exact - * quiet-failure this conversion + the `retiredKey` tombstone exist to prevent. - * The notice tells the author their intent was dropped and where to re-declare - * public access. - */ -const stackApiRequireAuthRemoved: MetadataConversion = { - id: 'stack-api-require-auth-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'stack.api.requireAuth', - summary: "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (a public form, a share link or `book.audience: 'public'`), which replaced the deployment-wide opt-out", - apply(stack, emit) { - const api = stack.api; - if (!isDict(api) || !('requireAuth' in api)) return stack; - const nextApi: Dict = { ...api }; - delete nextApi.requireAuth; - emit({ from: 'requireAuth', to: '(removed)', path: 'api' }); - return { ...stack, api: nextApi }; - }, - fixture: { - before: { - api: { requireAuth: false, enableProjectScoping: false }, - }, - after: { - api: { enableProjectScoping: false }, - }, - expectedNotices: 1, - }, -}; - -/** - * `waitEventConfig.timeoutMs` / `.onTimeout` removed — `wait` never had a timeout - * (protocol 17, #4158). - * - * Both keys described a timeout and neither delivered one. `onTimeout` had **zero** - * readers: no path ever inspected it, so neither `'fail'` nor `'continue'` ever - * happened, and its `.default('fail')` stamped a decision nothing made onto every - * wait node. `timeoutMs` said "maximum wait time" while its only reader used it as - * the timer *duration* when `timerDuration` was absent — it did something, just not - * what it claimed. - * - * **Retired from the load path**, like every other key retired for lying rather than - * for being renamed (`api.requireAuth`, the tool/app/flow inert keys, RLS `priority`). - * The distinction the registry draws: a key that was merely *renamed* keeps a load - * window, because punishing an author for a spelling nobody warned them about is - * pointless. A key that **misdescribed itself** does not — silently absorbing it - * would let the author keep believing they configured a timeout. The chain converts - * it mechanically; the schema tombstone tells them what actually happened. - * - * `timeoutMs` moves to `timerDuration` rather than being dropped, because that IS - * what it did. It is stringified on the way: `timerDuration` is `z.string()` while - * `timeoutMs` was `z.number()`, and `parseIsoDuration` reads a bare numeric string as - * milliseconds — so `timeoutMs: 60000` and `timerDuration: '60000'` are the same - * wait. Moving the number unstringified would produce a block that no longer parses. - * With `timerDuration` already set it is dropped instead: the executor's `??` never - * looked past the duration, so it was already dead metadata. - */ -function removeWaitTimeoutKeys(stack: Dict, emit: Emit): Dict { - // Deliberately not filtered to `node.type === 'wait'`: the tombstones live on the - // block, so a non-wait node carrying one would fail to parse and never be cleaned. - return mapFlowNodes(stack, (node, path) => { - const wec = node.waitEventConfig; - if (!isDict(wec)) return node; - const next: Dict = { ...wec }; - let changed = false; - - if (next.timeoutMs != null) { - if (next.timerDuration == null) { - next.timerDuration = String(next.timeoutMs); - emit({ from: 'waitEventConfig.timeoutMs', to: 'waitEventConfig.timerDuration', path: `${path}.waitEventConfig.timerDuration` }); - } else { - emit({ from: 'waitEventConfig.timeoutMs', to: '(removed — `timerDuration` already set, so it was never read)', path: `${path}.waitEventConfig` }); - } - delete next.timeoutMs; - changed = true; - } - if (next.onTimeout != null) { - emit({ from: 'waitEventConfig.onTimeout', to: '(removed — no reader ever existed)', path: `${path}.waitEventConfig` }); - delete next.onTimeout; - changed = true; - } - return changed ? { ...node, waitEventConfig: next } : node; - }); -} - -const flowNodeWaitTimeoutKeysRemoved: MetadataConversion = { - id: 'flow-node-wait-timeout-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'flow.node.waitEventConfig', - summary: - "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) " + - "and 'onTimeout' (removed — zero readers, so no timeout ever fired): wait never had a timeout, so its timeout contract is withdrawn rather than built", - apply(stack, emit) { - return removeWaitTimeoutKeys(stack, emit); - }, - fixture: { - before: { - flows: [ - { - name: 'settlement', - nodes: [ - { id: 'n1', type: 'start' }, - // The shape that actually did something: `timeoutMs` standing in for a - // duration. It must survive as a working wait, hence the move. - { id: 'n2', type: 'wait', waitEventConfig: { eventType: 'timer', timeoutMs: 60000, onTimeout: 'continue' } }, - // `timerDuration` already set → `timeoutMs` was dead; dropped, not moved. - { - id: 'n3', - type: 'wait', - waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M', timeoutMs: 999 }, - }, - // Nothing retired here — left byte-identical. - { id: 'n4', type: 'wait', waitEventConfig: { eventType: 'signal', signalName: 'paid' } }, - ], - }, - ], - }, - after: { - flows: [ - { - name: 'settlement', - nodes: [ - { id: 'n1', type: 'start' }, - { id: 'n2', type: 'wait', waitEventConfig: { eventType: 'timer', timerDuration: '60000' } }, - { id: 'n3', type: 'wait', waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M' } }, - { id: 'n4', type: 'wait', waitEventConfig: { eventType: 'signal', signalName: 'paid' } }, - ], - }, - ], - }, - // n2: `timeoutMs` moved + `onTimeout` dropped. n3: `timeoutMs` dropped (shadowed). - expectedNotices: 3, - }, -}; - -/** - * The remaining inert datasource blocks removed (protocol 17, #4583 B/C/D). - * - * Three clusters, one finding each — declared, `.strict()`-guarded, read by no - * runtime path: - * - * - `retryPolicy` (4 keys): no connect or query path ever retried on it. - * Connection failure is the boot policy in the datasource connection service - * (degraded boot / `bootCritical` fail-fast), which does not retry on a - * schedule. - * - `healthCheck` (3 keys): nothing scheduled a probe, so `enabled` enabled - * nothing and the timeouts bounded nothing. Liveness is probed ON DEMAND via - * the driver handle's `ping()` / `checkHealth()`. - * - `external.label` / `external.requirePermission`: the federation block's own - * label was never read (the top-level `label` is what Setup renders), and no - * authorization check ever consulted the permission — naming one gated - * nothing, which is the false-compliance shape ADR-0049 removes. - * - * `retryPolicy` is the one with a booby trap, and it is a NAME collision rather - * than a behaviour question: `hook.retryPolicy` and `job.retryPolicy` ARE - * enforced. They are different keys on different types and spell the delay - * `backoffMs`, not `baseDelayMs` — which is itself the evidence nothing read - * the datasource one, since no code reads both spellings. The conversion - * therefore touches ONLY `datasources`, and the schema's rejection message - * spells the distinction out rather than offering a rename. - * - * `retiredFromLoadPath`: every affected shape is `.strict()` and rejects with - * its prescription (`RETIRED_DATASOURCE_BLOCKS`). - */ -const datasourceInertBlocksRemoved: MetadataConversion = { - id: 'datasource-inert-blocks-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission', - summary: "datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody; each of those jobs already has a live mechanism)", - apply(stack, emit) { - return mapCollection(stack, 'datasources', (ds, path) => { - const next = stripKeys(ds, ['retryPolicy', 'healthCheck'], emit, path); - // `external.*` sits one level down, so stripKeys (top-level only) cannot - // reach it — drill in, and copy-on-write so an untouched datasource keeps - // its identity for the caller's change detection. - const external = next.external; - if (!external || typeof external !== 'object' || Array.isArray(external)) return next; - const strippedExternal = stripKeys( - external as Record, - ['label', 'requirePermission'], - emit, - `${path}.external`, - ); - if (strippedExternal === external) return next; - return { ...next, external: strippedExternal }; - }); - }, - fixture: { - before: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'db.internal', database: 'analytics' }, - healthCheck: { enabled: true, intervalMs: 30000, timeoutMs: 5000 }, - retryPolicy: { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000, backoffMultiplier: 2 }, - schemaMode: 'external', - external: { - label: 'Warehouse — ANALYTICS / PROD', - allowWrites: false, - requirePermission: 'analytics_admin', - }, - }], - }, - // Four notices: one per removed key, counting the two nested ones. - after: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'db.internal', database: 'analytics' }, - schemaMode: 'external', - external: { allowWrites: false }, - }], - }, - expectedNotices: 4, - }, -}; - -/** - * `mapping.extractQuery` / `errorPolicy` / `batchSize` removed (protocol 17, - * #4509, ADR-0049). - * - * Three keys on the import/export mapping artifact that parsed, stored, and - * controlled nothing: - * - * - `extractQuery` — "Query to run for export only" against an export path that - * does not exist: no exporter reads a mapping artifact at all. A whole - * `QuerySchema` subtree hung off it, which is what made it read as a designed - * feature rather than an aspiration. - * - `errorPolicy` — `skip` / `abort` / `retry` selecting between three - * behaviours that were one behaviour. Error handling on the import path is - * the import REQUEST's own options; nothing consults the mapping. - * - `batchSize` — the write path sizes its own batches and never asked. - * - * Two of the three were **unwarnable**, which is why they went out now rather - * than after a deprecation window: `errorPolicy` and `batchSize` carried schema - * defaults, and a default materialises at parse time, so the liveness advisory - * lint could not distinguish an authored value from a supplied one - * (`_authorWarnSkipped` in `liveness/mapping.json`). For a key in that state, - * removal is not the escalation after a warning — it is the only channel that - * ever reaches the author. - * - * `batchSize` is also the name most likely to be "fixed" by relocation, so the - * schema prescription says outright that `bulkActionDef` / `connector` / `sync` - * / `offline` / seed-loader / NoSQL-cursor `batchSize` are live and are all - * different keys sizing different paths. Same shape as the `retryPolicy` / - * `backoffMs` trap #4583 had to defuse on `datasource`. - * - * `retiredFromLoadPath`: the schema is `.strict()`, so the keys are gone from - * the shape and rejected with a `guidance` prescription rather than tombstoned. - */ -const mappingInertKeysRemoved: MetadataConversion = { - id: 'mapping-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'mapping.extractQuery / mapping.errorPolicy / mapping.batchSize', - summary: "mapping keys 'extractQuery'/'errorPolicy'/'batchSize' removed (no exporter reads a mapping, error handling belongs to the import request, and the write path sizes its own batches)", - apply(stack, emit) { - const RETIRED = ['extractQuery', 'errorPolicy', 'batchSize']; - // Scoped to the `mappings` collection deliberately: `batchSize` is live on - // connector, sync, bulk-action and offline shapes, and a stack-wide strip - // would delete an enforced key from all of them. - return mapCollection(stack, 'mappings', (m, path) => stripKeys(m, RETIRED, emit, path)); - }, - fixture: { - before: { - mappings: [{ - name: 'csv_import_contacts', - targetObject: 'contact', - fieldMapping: [{ source: 'Email', target: 'email' }], - extractQuery: { object: 'contact', fields: ['email'] }, - errorPolicy: 'abort', - batchSize: 500, - }], - }, - after: { - mappings: [{ - name: 'csv_import_contacts', - targetObject: 'contact', - fieldMapping: [{ source: 'Email', target: 'email' }], - }], - }, - expectedNotices: 3, - }, -}; - -/** - * Inline book translation maps removed (protocol 17, #4667, ADR-0049). - * - * `book.translations` and `book.groups[].translations` were parsed, stored and - * round-tripped, and read by nothing: the tree endpoint and the docs portal - * render `label` / `description` verbatim, and the generic bundle translator - * covers view / action / object / app / dashboard / page only. A localized book - * therefore shipped its authoring-locale strings to every reader. - * - * The trap was PROXIMITY: `doc.translations` — same name, same shape, two files - * over — is live on every doc render path, so the book-level map read as the - * same feature one level up. - * - * Split routes, because the two schemas differ: `BookSchema` is `strictObject` - * (key deleted, `guidance` carries the prescription, ledger row deleted), while - * `BookGroupSchema` is a plain `z.object` with no `.strict()` — a bare delete - * there would have zod silently strip the key, so it is tombstoned with - * `retiredKey` and its ledger row STAYS. - */ -const bookTranslationsRemoved: MetadataConversion = { - id: 'book-translations-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'book.translations / book.groups.translations', - summary: "book keys 'translations' (book-level and group-level) removed (no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live", - apply(stack, emit) { - return mapCollection(stack, 'books', (book, path) => { - const next = stripKeys(book, ['translations'], emit, path); - const groups = next.groups; - if (!Array.isArray(groups)) return next; - let touched = false; - const mapped = groups.map((g, i) => { - if (!g || typeof g !== 'object' || Array.isArray(g)) return g; - const stripped = stripKeys( - g as Record, - ['translations'], - emit, - `${path}.groups[${i}]`, - ); - if (stripped !== g) touched = true; - return stripped; - }); - return touched ? { ...next, groups: mapped } : next; - }); - }, - fixture: { - before: { - books: [{ - name: 'crm_guide', - label: 'CRM Guide', - translations: { 'zh-CN': { label: 'CRM 指南' } }, - groups: [{ - key: 'basics', - label: 'Basics', - translations: { 'zh-CN': { label: '基础' } }, - }], - }], - }, - after: { - books: [{ - name: 'crm_guide', - label: 'CRM Guide', - groups: [{ key: 'basics', label: 'Basics' }], - }], - }, - // Two notices: the book-level map and the one on its single group. - expectedNotices: 2, - }, -}; - -/** - * `job.id` removed (protocol 17, #4667, ADR-0049). - * - * Nothing read it, and its own `describe()` — "defaults to `name` when omitted" - * — advertised an identity override that never existed. `name` is the identity - * at every layer that has one: the scheduling key, the `sys_job` row key (the - * DB adapter upserts by `name` and mints its own row id), and the - * `JobExecution.jobId` stamp. Two jobs differing only in `id` were never two - * jobs — they were one job declared twice. - */ -const jobIdRemoved: MetadataConversion = { - id: 'job-id-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'job.id', - summary: "job key 'id' removed (nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)", - apply(stack, emit) { - return mapCollection(stack, 'jobs', (job, path) => stripKeys(job, ['id'], emit, path)); - }, - fixture: { - before: { - jobs: [{ - name: 'nightly_sync', - id: 'job_nightly', - schedule: { type: 'cron', expression: '0 0 * * *' }, - handler: 'syncAll', - }], - }, - after: { - jobs: [{ - name: 'nightly_sync', - schedule: { type: 'cron', expression: '0 0 * * *' }, - handler: 'syncAll', - }], - }, - expectedNotices: 1, - }, -}; - -/** - * `translation.validationMessages` removed (protocol 17, #4667, ADR-0049). - * - * A translation group with no reader — objectui's spec-translations transform - * passed it through to the client tree and nothing downstream consumed it. The - * platform's own signature was on it twice: the schema example showed a - * concrete override, and #3778's legacy-key migration table steered retired - * `errors:` authors straight into it. Retiring one dead key by pointing authors - * at another is the failure this conversion also fixes — that guidance entry is - * rewritten in the same change to say rule messages are authored on the rule - * (`object.validations[].message`), not translated through a group. - * - * Since 17.3.0 (#14381, #14253) there is a group again and that same guidance - * entry names it: `objects.._validations..message`, - * which the write path resolves. It is not `validationMessages` returning — - * that one was keyed by rule name at the TOP level, so it could not tell two - * objects' rules apart, and nothing read it; this one is object-scoped and the - * rule evaluator reads it through the existing `i18nService` channel. The - * sentence above is what 17.0.0 said and it stays true of the retired key; an - * author arriving at this conversion needs both halves, so the summary below - * carries the route too. - * - * Removed from the shared `translationDataShape()`, so it retires at BOTH doors - * at once — the bundle entry and the registered item. #3778's original guard - * ran on the item door only, which is exactly how the key survived this long in - * file-authored bundles. - */ -const translationValidationMessagesRemoved: MetadataConversion = { - id: 'translation-validation-messages-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'translation.validationMessages', - summary: "translation key 'validationMessages' removed (no resolver read it, so a translated rule message was stored and never shown; the legacy-key table of the translation-bundle migration had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`), and translate it under the object-scoped group `objects.._validations..message`, which the write path resolves (17.3.0, a translation key shipped together with its reader)", - apply(stack, emit) { - return mapCollection(stack, 'translations', (t, path) => - stripKeys(t, ['validationMessages'], emit, path)); - }, - fixture: { - before: { - translations: [{ - name: 'zh_cn', - locale: 'zh-CN', - validationMessages: { discount_limit: '折扣不能超过40%' }, - messages: { 'common.save': '保存' }, - }], - }, - after: { - translations: [{ - name: 'zh_cn', - locale: 'zh-CN', - messages: { 'common.save': '保存' }, - }], - }, - expectedNotices: 1, - }, -}; - -/** - * `datasource.capabilities` removed (protocol 17, #4583). - * - * Eleven boolean flags, declared and strict-guarded, read by nothing. Pushdown - * is decided by the runtime driver's own `supports.*` object — a different - * mechanism entirely — so a datasource declaring `queryAggregations: false` - * never once changed which engine path ran. - * - * `readOnly` is why this one is not merely tidy-up. It reads as a safety - * property and was authored as one: the shipped CRM example labelled a - * datasource "Read Replica" on the strength of it, while the datasource - * accepted writes exactly like the primary. The key had already been MOVED - * twice toward somewhere it might be enforced — out of `config` in #4410, into - * `capabilities` in #4465 — and was inert at every address. This removes it - * instead of moving it a third time. - * - * Deliberately NOT converted to `external.allowWrites: false`, which is the - * enforced gate and the obvious-looking target: it applies only to FEDERATED - * datasources (`schemaMode` other than `managed`), so rewriting a managed - * datasource that way would produce a key that is equally inert for that author - * — the exact defect being retired, laundered through a migration. A managed - * datasource has no read-only gate at all (#4584); the honest conversion is a - * delete plus a rejection message that says so. - * - * `retiredFromLoadPath`: both shapes are `.strict()` and reject the key with - * its prescription (`RETIRED_CAPABILITIES`). - */ -const datasourceCapabilitiesRemoved: MetadataConversion = { - id: 'datasource-capabilities-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.capabilities', - summary: "datasource key 'capabilities' removed (eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only)", - apply(stack, emit) { - return mapCollection(stack, 'datasources', (ds, path) => stripKeys(ds, ['capabilities'], emit, path)); - }, - fixture: { - before: { - datasources: [{ - name: 'analytics', - driver: 'sqlite', - config: { filename: ':memory:' }, - capabilities: { readOnly: true, queryAggregations: true }, - }], - }, - // One notice per datasource, not per flag: the block is what was removed. - after: { - datasources: [{ - name: 'analytics', - driver: 'sqlite', - config: { filename: ':memory:' }, - }], - }, - expectedNotices: 1, - }, -}; - -/** - * `datasource.readReplicas` — replica connections nothing ever opened (#4468). - * - * A lossless delete, and an unusually clear one: read/write splitting does not - * exist anywhere in the platform. `ConnectableDatasource` and - * `DatasourceConnectionSpec` carry no replicas field, the driver factory never - * reads the key, and no query path distinguishes a read from a write — so every - * statement always went to the primary regardless of what was declared here. - * - * Retired from the load path like every other key retired for *lying* rather - * than for being renamed. The distinction the registry draws (see - * `flow-node-wait-timeout-keys-removed`): a merely renamed key keeps a load - * window, because punishing an author for a spelling nobody warned them about - * is pointless. A key that misdescribed itself does not — silently absorbing it - * would let the author keep believing they had configured replica reads. - * - * Worth recording *why* this needed a conversion at all rather than passing - * unnoticed: #4410 had just taught the schema to validate each entry against the - * declared driver's config contract. Sources written between #4410 and here - * carry replica blocks that were *checked* — precise host names, correct port - * types, no typos — which is exactly the shape an author trusts most. The - * notice is what tells them the well-formed thing they wrote was never wired to - * anything. - */ -const datasourceReadReplicasRemoved: MetadataConversion = { - id: 'datasource-read-replicas-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.readReplicas', - summary: "datasource key 'readReplicas' removed (no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)", - apply(stack, emit) { - return mapCollection(stack, 'datasources', (ds, path) => stripKeys(ds, ['readReplicas'], emit, path)); - }, - fixture: { - before: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'primary.internal', port: 5432, database: 'analytics' }, - readReplicas: [ - { host: 'replica-a.internal', port: 5432, database: 'analytics' }, - { host: 'replica-b.internal', port: 5432, database: 'analytics' }, - ], - }], - }, - // One notice per datasource, not per replica: the key is what was removed. - after: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'primary.internal', port: 5432, database: 'analytics' }, - }], - }, - expectedNotices: 1, - }, -}; - -/** - * The legacy `datasource.config` spellings the driver factory used to read via - * undeclared `??` fallbacks, per canonical driver id — exactly the set that - * HAPPENED TO WORK before #4456, nothing more. - * - * Deliberately narrower than the schemas' rename-hint alias tables: an alias - * that only ever produced a rejection hint (`path:`, `dsn:`, `hostname:`, …) - * never worked at run time, so "converting" it would CHANGE behaviour — e.g. a - * stored sqlite `path:` fell back to `:memory:`, and rewriting it to - * `filename` would silently move the database. D2 scope guard: lossless and - * behaviour-preserving only. - * - * Driver-awareness is load-bearing: `database` means "rename to `filename`" - * ONLY under sqlite — for postgres/mysql/mongo it is a canonical key and must - * not be touched. - */ -const DATASOURCE_CONFIG_KEY_ALIASES: Readonly< - Partial>> -> = { - // `filename ?? file ?? database` was the factory's precedence; pair order - // mirrors it, so with both aliases present `file` wins and `database` is - // left shadowed — exactly what the `??` chain resolved to. - sqlite: [['file', 'filename'], ['database', 'filename']], - 'sqlite-wasm': [['file', 'filename'], ['database', 'filename']], - postgres: [['connectionString', 'url'], ['user', 'username']], - mysql: [['connectionString', 'url'], ['user', 'username']], - // `mongodb` since commit e2798fab7 — the canonical id was renamed from `mongo` so the - // contract canon matches what both boot hosts, the driver package and every - // URL scheme already said. Keyed by the CANONICAL id `resolveDriverId` - // returns, so a stored `driver: 'mongo'` still lands here through the alias. - mongodb: [['uri', 'url'], ['user', 'username']], -}; - -/** - * Datasource `config` legacy key aliases → canonical, per driver (protocol 17, - * #4456 — the #4410 close-out). - * - * #4410 gave `datasource.config` its per-driver zod gate, so the AUTHORING - * surface has exactly one spelling per key and rejects the legacy ones with a - * rename hint. What the gate could not fix is data at rest: a runtime - * datasource stored in `sys_metadata` before the gate may carry `file:` - * (sqlite), `connectionString:`/`user:` (postgres/mysql), or `uri:`/`user:` - * (mongo), and until now those kept working only because - * `createDefaultDatasourceDriverFactory` carried undeclared read-side `??` - * fallbacks — the exact PD #12 debt {@link flowNodeFilterAlias} pioneered the - * retirement path for. Deleting the fallbacks without this entry would - * silently change where a stored datasource's data lives (a sqlite `file:` - * row would fall back to `:memory:`). - * - * So the tolerance graduates here: every stored-row rehydration seam replays - * the full chain (`applyConversionsToStoredItem`, #3903), hands the factory - * the canonical key, and the factory reads ONE spelling. Precedence follows - * {@link renameKey}: a canonical key already present wins, which is also what - * the factory's `??` chains resolved to — and since #4923 the alias beside it - * is deleted when it merely repeats that value, or kept (for the authoring - * gate to refuse naming both) when the two disagree. - * - * **Retired from the load path** — not because the keys misdescribed - * themselves (they were honest spellings, merely undeclared), but because the - * authoring gate ALREADY rejects each of them with a rename hint - * (`strictUnknownKeyError` alias tables), and a live-window entry at - * `normalizeStackInput` would run before that gate and silently absorb the - * spelling #4410 deliberately made loud. Stored rows and `migrate meta` are - * exactly the `includeRetired` seams. - */ -const datasourceConfigDriverKeyAliases: MetadataConversion = { - id: 'datasource-config-driver-key-aliases', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.config', - summary: - "datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', " - + "postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' " - + "and 'user' → 'username' (undeclared driver-factory `??` fallbacks, graduated into this layer and deleted from the reader)", - apply(stack, emit) { - return mapDatasources(stack, (ds, path) => { - const kind = resolveDriverId(ds.driver); - const pairs = kind ? DATASOURCE_CONFIG_KEY_ALIASES[kind] : undefined; - if (!pairs) return ds; - const config = ds.config; - if (!isDict(config)) return ds; - let nextConfig = config; - for (const [from, to] of pairs) { - const renamed = renameKey(nextConfig, from, to); - if (!renamed) continue; // absent, or an ambiguous pair `renameKey` refuses (#4923) - emit({ from, to, path: `${path}.config.${to}` }); - nextConfig = renamed; - } - return nextConfig === config ? ds : { ...ds, config: nextConfig }; - }); - }, - fixture: { - before: { - datasources: [ - { name: 'app_db', driver: 'sqlite', config: { file: './data/app.db' } }, - // driver-id aliases resolve too — `sqlite3` selects the sqlite contract - { name: 'archive_db', driver: 'sqlite3', config: { database: './data/archive.db' } }, - { - name: 'warehouse', - driver: 'pg', - config: { connectionString: 'postgresql://db.internal:5432/analytics', user: 'analyst' }, - }, - // `database` is CANONICAL for mysql — only `user` converts - { name: 'orders', driver: 'mysql', config: { host: 'db.internal', database: 'orders', user: 'svc_orders' } }, - { name: 'events', driver: 'mongodb', config: { uri: 'mongodb://mongo.internal:27017/events' } }, - // Both spellings, DIFFERENT files (#4923): kept, so the authoring gate - // can refuse naming both rather than the loader picking a database. - { name: 'scratch', driver: 'sqlite', config: { filename: ':memory:', file: './scratch.db' } }, - ], - }, - after: { - datasources: [ - { name: 'app_db', driver: 'sqlite', config: { filename: './data/app.db' } }, - { name: 'archive_db', driver: 'sqlite3', config: { filename: './data/archive.db' } }, - { - name: 'warehouse', - driver: 'pg', - config: { url: 'postgresql://db.internal:5432/analytics', username: 'analyst' }, - }, - { name: 'orders', driver: 'mysql', config: { host: 'db.internal', database: 'orders', username: 'svc_orders' } }, - { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, - { name: 'scratch', driver: 'sqlite', config: { filename: ':memory:', file: './scratch.db' } }, - ], - }, - // app_db 1 + archive_db 1 + warehouse 2 + orders 1 + events 1 + scratch 0. - expectedNotices: 6, - }, -}; - -/** - * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, commit e2798fab7; - * retired from the load path at 18, #23543 F1). - * - * ## Why a stored value has to move at all - * - * `mongo` and `mongodb` were both accepted spellings from #4410 through 17 — - * this conversion never rescued a broken boot, and a stored row it has not - * reached still resolves the same contract and builds the same driver. What moved is the - * CANONICAL id: commit e2798fab7 landed the ruling that renamed it to `mongodb`, the spelling both boot - * hosts, the driver package (`@objectstack/driver-mongodb`) and every URL scheme - * already used, so that the id which selects a driver and the id which selects - * its config contract are one string with no mapping layer between them. - * - * That rename is visible in data because the canonical id is PUBLISHED as - * `DRIVER_CATALOG.id` (`@objectstack/service-datasource`), documented as "used - * as `datasource.driver`" — it is literally what Studio's connection form writes - * into a datasource row. After the rename the form emits `mongodb`, while every - * row written before it carries `mongo`. Left alone, one deployment's datasource - * list holds two spellings of one driver, and any surface that matches a stored - * `driver` against the published catalog id (a form pre-selecting the current - * driver, a grouped list, an equality filter) silently fails to match the older - * rows. So the stored value converges here rather than each reader learning to - * accept both. - * - * ## Why the data repair is D2 - * - * There is a concrete stored value with a lossless, behaviour-preserving - * rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the - * same contract and build the same driver, before and after, so replaying this - * cannot change where any data lives — contrast - * {@link datasourceConfigDriverKeyAliases}, whose scope guard exists because - * rewriting a sqlite `path:` WOULD have moved a database. - * - * Losslessness decides only that the data repair is D2. It does not decide - * whether the family ALSO owes a D3 entry — every retirement family does - * (`SemanticMigration`, `migrations/types.ts`). The rename into protocol 17 - * shipped before that rule and was not back-filled; the value's retirement at - * 18 is a family of its own, and its D3 entry is - * `datasource-driver-mongo-alias-retired`. - * - * ## Retired from the load path at 18 (#23543, ruling F1 A) - * - * Through 17 it stayed live because `mongo` was still a legal alias, so there - * was no refusal for a retirement to hand the author to (the verdict #23487 - * recorded). The ruling retired the value: `mongo` left the selection face, and - * `DriverType` refuses an authored `datasource.driver: 'mongo'` by name with - * the prescription naming `mongodb` (`DriverVocabularyEntry.retiredAliases`, - * `data/driver/config-registry.zod.ts`). So the authoring funnel no longer - * replays this entry, and an author is taught the canonical id instead of - * being rewritten. Stored rows are the `includeRetired` seams: stored-row - * rehydration (`applyConversionsToStoredItem`) and the artifact door still - * rewrite them, and `os migrate meta --from 16` lists the edit for existing - * sources, as step 17's mechanical member. The spelling keeps resolving the - * mongodb CONTRACT, which is what lets - * {@link datasourceConfigDriverKeyAliases} — ordered ahead of this entry — - * still reach a stored `driver: 'mongo'` row's legacy config keys. That D3 - * entry names what the chain cannot reach: an environment variable, a REST - * caller, a script. - */ -const datasourceDriverMongoToMongodb: MetadataConversion = { - id: 'datasource-driver-mongo-to-mongodb', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'datasource.driver', - summary: - "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver " - + 'package and the published DRIVER_CATALOG already used, so the id that selects a driver ' - + 'and the id that selects its config contract are one string with no mapping between them', - apply(stack, emit) { - return mapDatasources(stack, (ds, path) => { - // Only the exact legacy canon, trimmed and lower-cased the same way - // `resolveDriverId` reads it. `mongodb` is already canonical, and any other - // spelling (a plugin driver, a typo) is not this conversion's business. - if (typeof ds.driver !== 'string' || ds.driver.trim().toLowerCase() !== 'mongo') return ds; - emit({ from: 'mongo', to: 'mongodb', path: `${path}.driver` }); - return { ...ds, driver: 'mongodb' }; - }); - }, - fixture: { - before: { - datasources: [ - { name: 'events', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/events' } }, - // Already canonical — untouched, and emits nothing. - { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, - // A different driver whose id merely CONTAINS the string: never rewritten. - { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, - ], - }, - after: { - datasources: [ - { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, - { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, - { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * `script` node config — the four retired dispatch branches (protocol 17, #4343). - * - * A `script` node had four ways to name what it ran and only one of them ran - * anything. `actionType: 'email' | 'slack'` were logger-backed stubs: they wrote - * a line and reported success, and `template` / `recipients` / `variables` fed a - * message no channel ever sent — under any configuration, with or without the - * messaging service installed. Inline `config.script` was recognized and never - * executed (the built-in runtime has no server-side JS sandbox), so the node - * warned and no-op'd. Every remaining `actionType` value was shorthand for a - * registered-function name — a second spelling of `config.function` — and the - * `invoke_function` marker named nothing on its own. - * - * So the node converges on its one real path (call the function named by - * `config.function`), and the five keys leave the surface. This is what let the - * contract be parsed at execute time at all: while the legal key set depended on - * `actionType`, a flat parse would either reject valid shapes or wave everything - * through — see the module header of `automation/schemaless-node-config.zod.ts`. - * - * **Retired from the load path**, like every other key retired for lying rather - * than for being renamed (see `flow-node-wait-timeout-keys-removed` for the - * distinction the registry draws): silently absorbing `actionType: 'email'` - * would let an author keep believing the flow sends mail. - * - * A **shorthand `actionType` moves into `function`** rather than being dropped, - * because that is what it meant (#1870) — the same reasoning that moves - * `timeoutMs` into `timerDuration`. It moves only when `function` is not already - * set: with both present the executor always took `function`, so the shorthand - * was already dead metadata. The built-in ids and the `invoke_function` marker - * are never function names, so they are dropped, not moved. - * - * The other four keys are dropped outright: no reader ever consumed them, so - * there is no value to preserve. Rebuilding the intent is an authoring decision - * the tombstones prescribe per branch (`notify` for mail, a `connector_action` - * with the Slack connector — or `http` to a webhook — for Slack, a registered - * function for an inline body), not something a mechanical rewrite can guess. - * - * Ordering note: this runs AFTER `flow-node-script-config-aliases`, so the - * `functionName` → `function` rename has already happened when the shorthand - * rule asks whether `function` is set. - */ -const SCRIPT_RETIRED_BUILTIN_ACTION_TYPES = new Set(['email', 'slack']); -const SCRIPT_RETIRED_INVOKE_FUNCTION_MARKER = 'invoke_function'; - -function removeScriptBranchKeys(stack: Dict, emit: Emit): Dict { - return mapFlowNodes(stack, (node, path) => { - // Filtered to `script`, unlike the wait retirement: these tombstones live on - // the script config contract, which no other node type is parsed against. - if (node.type !== 'script') return node; - const cfg = node.config; - if (!isDict(cfg)) return node; - - const next: Dict = { ...cfg }; - let changed = false; - - if (next.actionType != null) { - const actionType = typeof next.actionType === 'string' ? next.actionType.trim() : ''; - const hasFunction = typeof next.function === 'string' && next.function.trim() !== ''; - const isShorthand = - actionType !== '' - && actionType !== SCRIPT_RETIRED_INVOKE_FUNCTION_MARKER - && !SCRIPT_RETIRED_BUILTIN_ACTION_TYPES.has(actionType); - - if (isShorthand && !hasFunction) { - next.function = actionType; - emit({ from: 'config.actionType', to: 'config.function', path: `${path}.config.function` }); - } else if (isShorthand) { - emit({ from: 'config.actionType', to: '(removed — `config.function` already named the callable)', path: `${path}.config` }); - } else { - emit({ from: 'config.actionType', to: '(removed — logger-backed stub or bare marker; nothing was delivered)', path: `${path}.config` }); - } - delete next.actionType; - changed = true; - } - - for (const key of ['template', 'recipients', 'variables'] as const) { - if (next[key] == null) continue; - emit({ from: `config.${key}`, to: '(removed — fed a side effect that never delivered)', path: `${path}.config` }); - delete next[key]; - changed = true; - } - - if (next.script != null) { - emit({ from: 'config.script', to: '(removed — inline JS was never executed)', path: `${path}.config` }); - delete next.script; - changed = true; - } - - return changed ? { ...node, config: next } : node; - }); -} - -const flowNodeScriptBranchKeysRemoved: MetadataConversion = { - id: 'flow-node-script-branch-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'flow.node.script.config.actionType / flow.node.script.config.template / ' - + 'flow.node.script.config.recipients / flow.node.script.config.variables / ' - + 'flow.node.script.config.script', - summary: - "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — " - + "'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / " - + "'variables' (fed those stubs) and 'script' (inline JS the runtime never executed); script is now a pure function-call node, the only path that ran real logic", - apply(stack, emit) { - return removeScriptBranchKeys(stack, emit); - }, - fixture: { - before: { - flows: [ - { - name: 'task_lifecycle', - nodes: [ - { id: 'n1', type: 'start' }, - // The logger-backed stub in full: nothing here was ever delivered. - { - id: 'n2', - type: 'script', - config: { - actionType: 'email', - template: 'task_done', - recipients: ['{record.owner}'], - variables: { taskName: '{record.name}' }, - }, - }, - // Shorthand for a registered function — the one value that MOVES. - { id: 'n3', type: 'script', config: { actionType: 'score_lead', outputVariable: 'score' } }, - // Inline body: recognized, never executed. Dropped; the node is left - // naming no callable, which the execute-time parse now says out loud. - { id: 'n4', type: 'script', config: { script: 'return { ok: true };' } }, - // The marker alongside the canonical key: marker dropped, key kept. - { id: 'n5', type: 'script', config: { actionType: 'invoke_function', function: 'score_lead' } }, - // Already converged — left byte-identical. - { id: 'n6', type: 'script', config: { function: 'notify_owner', inputs: { id: '{record.id}' } } }, - ], - }, - ], - }, - after: { - flows: [ - { - name: 'task_lifecycle', - nodes: [ - { id: 'n1', type: 'start' }, - { id: 'n2', type: 'script', config: {} }, - { id: 'n3', type: 'script', config: { outputVariable: 'score', function: 'score_lead' } }, - { id: 'n4', type: 'script', config: {} }, - { id: 'n5', type: 'script', config: { function: 'score_lead' } }, - { id: 'n6', type: 'script', config: { function: 'notify_owner', inputs: { id: '{record.id}' } } }, - ], - }, - ], - }, - // n2: actionType + template + recipients + variables. n3: the move. - // n4: script. n5: the marker. n6: nothing. - expectedNotices: 7, - }, -}; - -/** - * `object.managedBy: 'system'` → `'system-data'` (protocol 17, #3355 — the v17 - * close-out of ADR-0103's v16 enum split). - * - * v16 split the overloaded `system` bucket ADDITIVELY: the 20 engine-owned - * objects moved to the new explicit `engine-owned`, and the 8 admin/user-writable - * ones stayed on `system`. The value that remained therefore labelled the exact - * opposite of what its name said — writable platform DATA under the word - * "system" — and left an author choosing between `system` and `engine-owned` with - * nothing in the vocabulary to choose on. v17 renames the residue to - * `system-data` and retires the bare value. - * - * Because v16 already drained the engine side, this is a ONE-TO-ONE mechanical - * replacement with no judgement call: every remaining `system` declaration is, - * by construction, writable platform data. - * - * **Retired from the load path** — the enum rejects `'system'` with the - * {@link MANAGED_BY_SYSTEM_RETIRED} prescription, and that rejection is the - * whole point: a live-window entry at `normalizeStackInput` would run BEFORE the - * enum and silently absorb the value, so an author (or a model) would keep - * writing the name the rename exists to unteach. Stored `sys_metadata` rows and - * `os migrate meta --from 16` are exactly the `includeRetired` seams, so data at - * rest is CONVERTED rather than reinterpreted. - * - * Note the affordance side-effect, which is deliberate and is why this is a - * major-window change rather than a docs fix: `system` defaulted LOCKED and each - * object opened its writes via `userActions`, while `system-data` defaults - * WRITABLE. A converted row that carried no `userActions` therefore gains the - * generic affordances — which is the honest reading of the bucket it is being - * moved into, and changes no enforcement: the write guard, the delegated-admin - * gate, RLS and permission sets all adjudicate independently of the bucket name. - */ -const objectManagedBySystemToSystemData: MetadataConversion = { - id: 'object-managed-by-system-to-system-data', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'object.managedBy', - summary: - "object managedBy 'system' → 'system-data' (ADR-0103's residual bucket named the " - + 'engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the ' - + 'name describing what the bucket actually holds: admin/user-writable platform data)', - apply(stack, emit) { - return mapCollection(stack, 'objects', (obj, path) => { - if (obj.managedBy !== 'system') return obj; - emit({ from: 'system', to: 'system-data', path: `${path}.managedBy` }); - return { ...obj, managedBy: 'system-data' }; - }); - }, - fixture: { - before: { - objects: [ - { - name: 'sys_user_position', - label: 'User Position', - managedBy: 'system', - userActions: { create: true, edit: true, delete: true }, - }, - // every other bucket passes through untouched — including `engine-owned`, - // the value v16 already moved the engine side to - { name: 'sys_automation_run', label: 'Automation Run', managedBy: 'engine-owned' }, - { name: 'crm_deal', label: 'Deal' }, - ], - }, - after: { - objects: [ - { - name: 'sys_user_position', - label: 'User Position', - managedBy: 'system-data', - userActions: { create: true, edit: true, delete: true }, - }, - { name: 'sys_automation_run', label: 'Automation Run', managedBy: 'engine-owned' }, - { name: 'crm_deal', label: 'Deal' }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * The dead object capability flags leave the surface (protocol 17, #3207 — - * the #2377 ADR-0049 close-out slice PR #3414 removed from the schema). - * - * `enable.trash` promised a recycle bin and `enable.mru` promised - * Most-Recently-Used tracking; neither ever had a behavior-changing reader — - * every delete has always been a hard delete, and no MRU state was ever - * written. Both defaulted `true`, the ADR-0078 silent-failure shape: authors - * wrote `trash: false // never soft-delete audit logs` believing they were - * opting out of a soft-delete that never ran. Soft delete stays parked at - * #3146; if built it returns as a live enforced flag (ADR-0049). - * - * `retiredFromLoadPath`: the capabilities block is `.strict()` and rejects - * both keys with the prescription (`CAPABILITIES_RETIRED_KEY_GUIDANCE`), so a - * 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` lists the mechanical edits for existing sources. - */ -const objectEnableTrashMruRemoved: MetadataConversion = { - id: 'object-enable-trash-mru-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'object.enable.trash / object.enable.mru', - summary: - "object capability flags 'enable.trash'/'enable.mru' removed (the last slice of the dead author-facing property removals: no " - + 'recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)', - apply(stack, emit) { - return mapCollection(stack, 'objects', (obj, path) => { - // `enable.*` sits one level down, so stripKeys (top-level only) cannot - // reach it — drill in, and copy-on-write so an untouched object keeps - // its identity (pattern of `datasource-inert-blocks-removed`). - const enable = obj.enable; - if (!enable || typeof enable !== 'object' || Array.isArray(enable)) return obj; - const stripped = stripKeys( - enable as Record, - ['trash', 'mru'], - emit, - `${path}.enable`, - ); - if (stripped === enable) return obj; - return { ...obj, enable: stripped }; - }); - }, - fixture: { - before: { - objects: [ - { - name: 'task', - label: 'Task', - enable: { trash: false, mru: true, searchable: true }, - }, - // an object without the retired keys passes through untouched - { name: 'crm_account', label: 'Account', enable: { searchable: true } }, - ], - }, - // Two notices: one per removed key; the surviving `searchable` proves the - // strip is surgical, not a block-level delete. - after: { - objects: [ - { - name: 'task', - label: 'Task', - enable: { searchable: true }, - }, - { name: 'crm_account', label: 'Account', enable: { searchable: true } }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * `indexes[].type` / `indexes[].partial` leave the surface (protocol 17, - * #5248 + #4943, ADR-0049 enforce-or-remove). - * - * Neither key had a single DDL consumer. `SqlDriver.syncDeclaredIndexes` builds - * every declared index through knex's `table.index(fields, name)` / - * `table.unique(fields, { indexName })`, and the differ's `DeclaredIndexInput` - * declares `name` / `fields` / `unique` / `nullSafeColumns` — so an authored - * `type` selected no access method and an authored `partial` produced a FULL - * index with the predicate silently discarded. `type` additionally carried - * `.default('btree')`, so it was materialized into *every* parse output: the - * ADR-0078 shape where an inert knob reads as live configuration. - * - * The maintainer chose remove over enforce (#5248, 2026-08-06): enforcing means - * per-dialect algorithm mapping, raw-SQL `CREATE INDEX … WHERE` (MySQL has no - * partial index), and reworking how `isSyncReproducibleIndex` excludes partial - * indexes from incremental sync — design cost for a capability with no demand. - * - * ⚠️ NOT the same `partial` as the driver's: `schema-drift.ts` carries a - * `partial: boolean` parsed back out of the DATABASE's own `CREATE INDEX` DDL - * (`parseIndexDdl`), consumed by drift detection. That is untouched here, and - * the exemption it grants DB-authored partial indexes still stands. - * - * `retiredFromLoadPath`: both keys are tombstoned at `IndexSchema`, so a live - * 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` lists the mechanical edits for existing sources. - */ -const objectIndexTypePartialRemoved: MetadataConversion = { - id: 'object-index-type-partial-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'object.indexes[].type / object.indexes[].partial', - summary: - "object index keys 'indexes[].type'/'indexes[].partial' removed (no driver " - + 'ever read either: the index method is the dialect\'s choice and a partial index is built ' - + 'by a database-layer migration, not declared)', - apply(stack, emit) { - // `indexes` is an ARRAY one level down, so `stripKeys` (top-level only) - // cannot reach it — drill in and copy-on-write, so an object whose indexes - // carry neither key keeps its identity. Both `objects[]` and - // `objectExtensions[]` embed `IndexSchema`, so both are walked. - const stripIndexes = (owner: Dict, path: string): Dict => { - const indexes = owner.indexes; - if (!Array.isArray(indexes)) return owner; - let changed = false; - const next = indexes.map((idx, i) => { - if (!isDict(idx)) return idx; - const stripped = stripKeys(idx, ['type', 'partial'], emit, `${path}.indexes[${i}]`); - if (stripped !== idx) changed = true; - return stripped; - }); - return changed ? { ...owner, indexes: next } : owner; - }; - let out = mapCollection(stack, 'objects', stripIndexes); - out = mapCollection(out, 'objectExtensions', stripIndexes); - return out; - }, - fixture: { - before: { - objects: [ - { - name: 'crm_invoice', - label: 'Invoice', - indexes: [ - // both retired keys on one index, alongside surviving ones - { - name: 'idx_invoice_active_no', - fields: ['invoice_no'], - unique: 'global', - type: 'btree', - partial: "state = 'active'", - }, - // an index carrying neither key passes through untouched - { name: 'idx_invoice_customer', fields: ['customer'] }, - ], - }, - ], - objectExtensions: [ - { - extend: 'crm_invoice', - indexes: [{ fields: ['tags'], type: 'gin' }], - }, - ], - }, - // Three notices: two keys on the object's first index, one on the - // extension's. The surviving `name`/`fields`/`unique` prove the strip is - // surgical rather than an index-level delete. - after: { - objects: [ - { - name: 'crm_invoice', - label: 'Invoice', - indexes: [ - { name: 'idx_invoice_active_no', fields: ['invoice_no'], unique: 'global' }, - { name: 'idx_invoice_customer', fields: ['customer'] }, - ], - }, - ], - objectExtensions: [ - { - extend: 'crm_invoice', - indexes: [{ fields: ['tags'] }], - }, - ], - }, - expectedNotices: 3, - }, -}; - -/** - * The retry policy converges to one declaration (protocol 17, #4661 — the - * #4535 C8 dual-source cluster). - * - * `@objectstack/spec/automation` and `@objectstack/spec/system` both exported a - * `RetryPolicy` / `RetryPolicySchema`, resolving to DIFFERENT declarations — so - * which shape a consumer got depended only on the import path (#4411). They - * were never two concepts: `try_catch`'s `retry` region and `job.retryPolicy` - * both compute `delay = base * multiplier^(retry-1)`, and the two executors - * implemented that identical formula. What differed was cosmetic and - * accidental, and this conversion pays for both halves of the merge: - * - * 1. **The base delay had two spellings.** automation said `retryDelayMs`, - * system said `backoffMs`. `backoffMs` wins — it is what the *enforced* - * retry policies already spell it (`job.retryPolicy` and `hook.retryPolicy`; - * see the `datasource-inert-blocks-removed` note above, which leans on - * exactly that distinction), so the platform drops from three spellings to - * two rather than four. `retryDelayMs` is tombstoned (`retiredKey`) — NOT - * deleted — because two of the three owning shapes are not `.strict()`: a - * plain deletion would have Zod silently swallow the authored number and - * fall back to the 1000ms default, which is the quiet-failure class - * ADR-0049 removes. - * - * 2. **The defaults were opposite, and no gate can see a default.** Pre-17, - * `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` - * while the automation shape defaulted 0 / 1. The merged declaration takes - * 0 / 1 (retry is opt-in: a retry replays whatever the attempt already did, - * and an implicit replay of side effects is the failure mode hardest to - * catch in tests — the same reading already recorded for flow-level retry - * in `flow-retry-max-retries-required`, #4247). Taken alone that would - * SILENTLY stop existing jobs from retrying, and the authorable-surface - * gate would never notice: it compares key sets, and a default is not a key. - * So this conversion writes the pre-17 numbers into every existing - * `job.retryPolicy` that omitted them. Deployed stacks keep their exact - * behaviour; only a newly authored omission means "no retry". - * - * Jobs with no `retryPolicy` block at all are left alone — absence already - * meant a single attempt on both sides of the change. - * - * ## The ONE further surface this entry grew to cover (#4964 / #4962) - * - * The convergence above was driven by the dual-source instrument, whose - * question is "how many declarations share one exported NAME?". Two further - * encodings of the identical policy were invisible to it because they were - * anonymous inline `z.object`s with no exported name at all — and after a - * convergence lands, a surviving dialect reads as reviewed-and-kept rather - * than missed. One of the two is still a surface; the other went with its - * layer: - * - * - **`flow.errorHandling`** (#4964) spelled the base delay `retryDelayMs`; - * every other key, bound and default already matched. Step 0 below renames - * it, so the ONE authorable casualty of the whole convergence is still just - * that word — now retired everywhere it was ever legal rather than only on - * the two shapes #4661's instrument could see. - * - **`ETLPipeline.retry`** (#4962) spelled the count `maxAttempts` and - * defaulted it to 3. It got **no step here, deliberately** — and never will. - * An ETL pipeline was not a `defineStack` collection and `etl.zod.ts` had no - * parse site in objectstack / objectui / cloud (批 12's measurement), so - * there was no stored or authored document a walker could reach: a branch - * for it would have been dead code claiming migration coverage that does not - * exist, which is the ADR-0049 failure this registry is supposed to prevent, - * not commit. #6414 then retired the whole L2 layer, and #4962's own entry - * (`etl-retry-converged-onto-retry-policy`) was ABSORBED into - * `etl-pipeline-layer-retired` inside the same unreleased major. There is no - * `maxAttempts` tombstone left to carry the rename or the default change: it - * went with the shape that carried it, which is strictly stronger, because - * no `retry` block survives to author the key into. `tsc` (TS2724/TS2305 on - * the removed ETL names) is the whole channel. That is also why the ETL - * default flip 3 → 0 never needed a materialization step while the job one - * did: nothing was ever deployed under the old reading. - * - * ## Retired from the load path at 18 (#23543, ruling F2 A) - * - * The base-delay rename never needed the load path: every surface that carried - * `retryDelayMs` refuses it with the rename to `backoffMs` (the - * `RetryPolicySchema` tombstone, which a `try_catch` node's `config.retry` is, - * and `flow.errorHandling`'s own). It stayed for the job half alone (the - * verdict #23487 recorded): retired, the authoring funnel would stop writing - * `maxRetries: 3` into a source block that omits it, and such a job would - * silently drop from three retries to none — a default flip no gate can see. - * The ruling made that drop loud instead: the job parse refuses a - * `retryPolicy` whose `maxRetries` is omitted or 0, naming the fix - * (`JOB_RETRY_POLICY_NO_ATTEMPTS`, `system/job.zod.ts`), so no authored job - * changes its retry count without its author being told. An omitted - * `backoffMultiplier` is not refused and now keeps its declared default (1): - * it shapes the delay curve, not whether the job retries. - * - * The authoring funnel (`defineStack`, `os validate` / `os build` / `os lint`) - * no longer replays it. Stored documents are the `includeRetired` seams — - * stored-row rehydration (`applyConversionsToStoredItem`), the automation - * engine's flow rehydration, the metadata save door's flow fallback, the - * artifact-ingestion door inside its window — and `os migrate meta --from 16` - * (a step-17 member, carried by the D3 entry `job-retry-policy-without-attempts-refused`), - * so a job stored or written before 17 keeps the three retries it ran with. - */ -const retryPolicyConverged: MetadataConversion = { - id: 'retry-policy-converged', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier', - summary: - "retry policy unified across job.retryPolicy, try_catch retry and flow.errorHandling: base delay 'retryDelayMs' → 'backoffMs', " + - "and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1: two declarations that differed only by accident became one, and retry is opt-in because a retry replays whatever the attempt already did", - apply(stack, emit) { - // ── 0. flows: errorHandling.retryDelayMs → errorHandling.backoffMs ─ - // - // The FLOW-LEVEL retry policy (#4964). Same rename as the try_catch node - // below and for the same reason, reached one level up: `errorHandling` - // hangs off the flow document, not off a node, so `mapFlowNodes` walks - // straight past it — which is a small echo of why this divergence survived - // #4661 at all. `renameKey` leaves an already-canonical `backoffMs` alone; - // when BOTH spellings are present it now (#4923) deletes `retryDelayMs` - // only if it repeats the same number, and otherwise keeps both rather than - // guessing which delay the author meant — the strict block then rejects - // naming both. This entry relied on the shared helper's semantics rather - // than open-coding its own precisely so it would move with that ruling, - // and it did: no change was needed here. - const withErrorHandling = mapCollection(stack, 'flows', (flow, path) => { - const eh = flow.errorHandling; - if (!eh || typeof eh !== 'object' || Array.isArray(eh)) return flow; - const renamed = renameKey(eh as Record, 'retryDelayMs', 'backoffMs'); - if (renamed === null) return flow; - emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.errorHandling.backoffMs` }); - return { ...flow, errorHandling: renamed }; - }); - - // ── 1. try_catch nodes: retry.retryDelayMs → retry.backoffMs ────── - const withFlows = mapFlowNodes(withErrorHandling, (node, path) => { - if (node.type !== 'try_catch') return node; - const config = node.config; - if (!config || typeof config !== 'object' || Array.isArray(config)) return node; - const configDict = config as Record; - const retry = configDict.retry; - if (!retry || typeof retry !== 'object' || Array.isArray(retry)) return node; - const renamed = renameKey(retry as Record, 'retryDelayMs', 'backoffMs'); - if (renamed === null) return node; - emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.config.retry.backoffMs` }); - return { ...node, config: { ...configDict, retry: renamed } }; - }); - - // ── 2. jobs: materialize the pre-17 implicit defaults ───────────── - return mapCollection(withFlows, 'jobs', (job, path) => { - const policy = job.retryPolicy; - if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return job; - const policyDict = policy as Record; - let next = policyDict; - if (next.maxRetries === undefined) { - next = { ...next, maxRetries: 3 }; - emit({ - from: 'maxRetries unset (implied 3)', - to: 'maxRetries: 3', - path: `${path}.retryPolicy.maxRetries`, - }); - } - if (next.backoffMultiplier === undefined) { - next = { ...next, backoffMultiplier: 2 }; - emit({ - from: 'backoffMultiplier unset (implied 2)', - to: 'backoffMultiplier: 2', - path: `${path}.retryPolicy.backoffMultiplier`, - }); - } - return next === policyDict ? job : { ...job, retryPolicy: next }; - }); - }, - fixture: { - before: { - flows: [{ - name: 'sync_orders', - // Flow-LEVEL policy (#4964) — one level up from the nodes, so the node - // walk below never sees it. - errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, - nodes: [ - { id: 'n1', type: 'start' }, - { - id: 'n2', - type: 'try_catch', - config: { - try: { nodes: [], edges: [] }, - retry: { maxRetries: 3, retryDelayMs: 500, jitter: true }, - }, - }, - // Already canonical — left alone, contributes no notice. - { - id: 'n3', - type: 'try_catch', - config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, - }, - ], - }, { - // Flow-level block already canonical — left alone, no notice. - name: 'roll_up', - errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, - nodes: [{ id: 'n1', type: 'start' }], - }], - jobs: [ - // Omits both defaults — both get written out. - { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000 } }, - // States both — untouched. - { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, - // No policy block at all — absence already meant one attempt. - { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, - ], - }, - after: { - flows: [{ - name: 'sync_orders', - errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, - nodes: [ - { id: 'n1', type: 'start' }, - { - id: 'n2', - type: 'try_catch', - config: { - try: { nodes: [], edges: [] }, - retry: { maxRetries: 3, jitter: true, backoffMs: 500 }, - }, - }, - { - id: 'n3', - type: 'try_catch', - config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, - }, - ], - }, { - name: 'roll_up', - errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, - nodes: [{ id: 'n1', type: 'start' }], - }], - jobs: [ - { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000, maxRetries: 3, backoffMultiplier: 2 } }, - { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, - { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, - ], - }, - // sync_orders' flow-level rename, n2's node-level rename, plus - // nightly_sync's two materialized defaults. - expectedNotices: 4, - }, -}; - -/** - * The `crypto.hash` capability token leaves `HookBodyCapability` (protocol 17, - * #4391 — ADR-0049 enforce-or-remove). - * - * Four layers advertised the token and none implemented it: the spec enum, the - * doc table beside it, the CLI's build-time extractor (which INFERRED the token - * from a `ctx.crypto.hash` call, so `os build` blessed the very body that was - * about to fail) and `ScriptContext.crypto.hash`. `installCtx` wired only - * `randomUUID`, so the single call the token authorised threw inside the VM. - * Removed rather than implemented: crypto in the sandbox widens its capability - * and security-review surface, and zero complaints against a capability that - * throws on every use is the strongest liveness evidence there is. - * - * This is an enum-VALUE retirement, so there is no `retiredKey()` tombstone to - * hang the prescription on — the enum's own error map carries it - * (`CRYPTO_HASH_RETIRED`, data/hook-body.zod.ts), keyed on `issue.input` so that - * only the value which used to be legal gets the "was removed" message. - * - * `retiredFromLoadPath`: the enum rejects the token 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` — 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` 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. - * - * Note what the strip deliberately does NOT do: it removes the dead grant, not - * the `ctx.crypto.hash(...)` call the body made under it. That call has never - * returned a value, so nothing regresses — but the author is still left a dead - * line to delete, which is exactly what the prescription tells them to do. - */ -const hookBodyCryptoHashRemoved: MetadataConversion = { - id: 'hook-body-crypto-hash-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'hook.body.capabilities / action.body.capabilities', - summary: - "script-body capability token 'crypto.hash' removed (the sandbox never installed " - + 'ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too)', - apply(stack, emit) { - const stripToken = (item: Dict, path: string): Dict => { - const body = item.body; - if (!body || typeof body !== 'object' || Array.isArray(body)) return item; - const caps = (body as Dict).capabilities; - if (!Array.isArray(caps) || !caps.includes('crypto.hash')) return item; - emit({ from: 'crypto.hash', to: '(removed)', path: `${path}.body.capabilities` }); - return { - ...item, - body: { ...(body as Dict), capabilities: caps.filter((c) => c !== 'crypto.hash') }, - }; - }; - const withHooks = mapCollection(stack, 'hooks', stripToken); - return mapCollection(withHooks, 'actions', stripToken); - }, - fixture: { - before: { - hooks: [ - { - name: 'fingerprint_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", - capabilities: ['crypto.hash', 'crypto.uuid'], - }, - }, - // a body without the retired token passes through untouched - { - name: 'stamp_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: 'ctx.input.trace = ctx.crypto.randomUUID();', - capabilities: ['crypto.uuid'], - }, - }, - ], - actions: [ - { - name: 'digest_deal', - type: 'script', - body: { - language: 'js', - source: "return ctx.crypto.hash('sha256', ctx.record.id);", - capabilities: ['crypto.hash'], - }, - }, - ], - }, - // Surgical: `crypto.uuid` survives beside the stripped token, and the - // `capabilities` key itself stays (an empty grant set is legal). - after: { - hooks: [ - { - name: 'fingerprint_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", - capabilities: ['crypto.uuid'], - }, - }, - { - name: 'stamp_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: 'ctx.input.trace = ctx.crypto.randomUUID();', - capabilities: ['crypto.uuid'], - }, - }, - ], - actions: [ - { - name: 'digest_deal', - type: 'script', - body: { - language: 'js', - source: "return ctx.crypto.hash('sha256', ctx.record.id);", - capabilities: [], - }, - }, - ], - }, - // One per rewritten body — the hook and the action; `stamp_lead` is untouched. - expectedNotices: 2, - }, -}; - -/** - * `array_agg` / `string_agg` leave `AggregationFunction` (protocol 17, #6188 — - * ADR-0049 enforce-or-remove). - * - * The enum declared eight functions; the SQL family compiles five. - * `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each - * lower `count`/`sum`/`avg`/`min`/`max` and route everything else to the same - * refusal, and `service-analytics` carried a hand-written `UNSUPPORTED_AGGREGATES` - * list naming exactly these two — a subtraction that existed to stop them - * reaching the Cube strategy's `default`, which returned `COUNT(*)`: a row count - * in place of the number the author asked for. So the declaration was not merely - * unenforced, it was the reason another package had to carry a denylist. - * - * The maintainer's 2026-08-07 ruling SPLIT the three unlowered functions rather - * than retiring them as a block, and the split is the substance of this entry. - * `count_distinct` stays: one portable lowering (`COUNT(DISTINCT x)`), a - * dashboard staple, and `service-analytics` already lowers it — so it takes - * ADR-0049's ENFORCE leg and the SQL implementation follows on its own card. - * These two take the REMOVE leg: display conveniences with no measured pull, - * and `string_agg` has no single shape to lower to at all (the delimiter is a - * second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently - * named function in SQL Server). - * - * Like `hook-body-crypto-hash-removed` above, this is an enum-VALUE retirement: - * there is no `retiredKey()` tombstone to hang the prescription on, so the enum's - * own error map carries it (`ARRAY_AGG_RETIRED` / `STRING_AGG_RETIRED`, - * `data/query.zod.ts`), keyed on `issue.input` so only the two spellings that - * used to be legal are told they "were removed". For the same reason nothing - * lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are expected to - * be byte-identical — no def and no authorable KEY changed. - * - * ## What this rewrites, and what it deliberately does not - * - * The retired values are authorable in two places and only one of them is stored - * metadata: - * - * - **`dataset.measures[].aggregate`** (`ui/dataset.zod.ts`, reusing this enum) - * is carried in the stack, so it is what this conversion walks. - * - **`QueryAST.aggregations[].function`** is a REQUEST surface — the client - * SDK's builder output and the `POST /data/:object/query` body, never stored - * (`liveness/query.json` records the same fact for `joins`/`cursor`/`distinct`). - * There is no source for the chain to rewrite, so it is a semantic TODO on the - * D3 step instead. - * - * The measure is DROPPED rather than stripped down to a bare `{ name, field }`. - * A measure with no `aggregate` and no `derived` fails the dataset's own - * `superRefine`, so stripping the key alone would hand back an item that cannot - * parse — a conversion whose output is invalid is worse than no conversion. And - * nothing is lost by dropping it: `compileDataset` has always refused these two - * by name with `datasetInvalidError`, so a stored dataset carrying one never - * produced a number on any backend. Every drop emits its own notice, so - * `os migrate meta` names the measure it removed rather than quietly shrinking - * the dataset. - * - * The cascade is part of that correctness, not extra: a `derived` measure whose - * `of` names a dropped measure would leave the dataset failing the "derived - * measures may only reference OTHER measures declared in this dataset" refinement. - * It is applied to a fixpoint because a derived measure may combine other derived - * measures. - * - * `retiredFromLoadPath`: the enum rejects both values 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` — 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` lists the mechanical edits for author sources. - */ -const datasetMeasureAggRemoved: MetadataConversion = { - id: 'dataset-measure-array-string-agg-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dataset.measures[].aggregate', - summary: - "dataset measure aggregates 'array_agg' / 'string_agg' removed (no SQL backend " - + 'compiled them and the v1 dataset runtime refused them by name, so a measure declaring ' - + 'one never produced a value; the measure is dropped, and with it any derived measure ' - + 'left referencing it)', - apply(stack, emit) { - const RETIRED = new Set(['array_agg', 'string_agg']); - return mapCollection(stack, 'datasets', (dataset, path) => { - const measures = dataset.measures; - if (!Array.isArray(measures)) return dataset; - - const dropped = new Set(); - const kept = measures.filter((m, i) => { - if (!isDict(m)) return true; - const aggregate = m.aggregate; - if (typeof aggregate !== 'string' || !RETIRED.has(aggregate)) return true; - emit({ from: aggregate, to: '(removed)', path: `${path}.measures[${i}].aggregate` }); - if (typeof m.name === 'string') dropped.add(m.name); - return false; - }); - if (kept.length === measures.length) return dataset; - - // Fixpoint: a derived measure may be built from another derived measure, - // so one pass can strand a reference the next pass has to answer for. - let survivors = kept; - for (;;) { - const next = survivors.filter((m) => { - if (!isDict(m)) return true; - const derived = m.derived; - if (!isDict(derived) || !Array.isArray(derived.of)) return true; - if (!derived.of.some((name) => typeof name === 'string' && dropped.has(name))) return true; - emit({ - from: `derived measure "${String(m.name)}"`, - to: '(removed)', - path: `${path}.measures`, - }); - if (typeof m.name === 'string') dropped.add(m.name); - return false; - }); - if (next.length === survivors.length) break; - survivors = next; - } - - return { ...dataset, measures: survivors }; - }); - }, - fixture: { - before: { - datasets: [{ - name: 'order_lines', - object: 'order_line', - dimensions: [{ name: 'status', field: 'status', type: 'string' }], - measures: [ - { name: 'total_amount', aggregate: 'sum', field: 'amount' }, - { name: 'product_ids', aggregate: 'array_agg', field: 'product_id' }, - { name: 'product_names', aggregate: 'string_agg', field: 'product_name' }, - // Derived measures: the first is stranded by the drop above, the - // second is stranded by the first — the reason the sweep runs to a - // fixpoint rather than once. - { name: 'name_list', derived: { op: 'sum', of: ['product_names'] } }, - { name: 'name_list_ratio', derived: { op: 'ratio', of: ['name_list', 'total_amount'] } }, - // Survives: derived from measures that are all still here. - { name: 'amount_share', derived: { op: 'ratio', of: ['total_amount', 'total_amount'] } }, - ], - }], - }, - after: { - datasets: [{ - name: 'order_lines', - object: 'order_line', - dimensions: [{ name: 'status', field: 'status', type: 'string' }], - measures: [ - { name: 'total_amount', aggregate: 'sum', field: 'amount' }, - { name: 'amount_share', derived: { op: 'ratio', of: ['total_amount', 'total_amount'] } }, - ], - }], - }, - // Two retired aggregates, plus the two derived measures the drops stranded. - expectedNotices: 4, - }, -}; - -/** - * `connector.rateLimitConfig` — OUTBOUND throttling for an engine that does not - * exist (#4911, ADR-0049). - * - * Not "declared but unread" — *declared but unimplemented*, which is a step - * worse. The platform's only token bucket is `packages/runtime/src/security/ - * rate-limit.ts`, and it is INBOUND: the dispatcher calls `consume(key)` on a - * request fingerprint and short-circuits with 429. There is no counterpart on - * the way out; no connector provider (`connector-rest`, `connector-openapi`, - * `connector-mcp`, `connector-slack`) reads the key, and no seam exists that - * could. So `strategy` / `maxRequests` / `windowSeconds` / `burstCapacity` / - * `respectUpstreamLimits` / `rateLimitHeaders` were six precise knobs an author - * could set — and would then believe capped their outbound call rate against a - * third-party API's quota. That belief is the defect: this is the false- - * compliance class ADR-0049 exists for, not cosmetic debt. - * - * The whole SHAPE goes, not just the key: `ConnectorRateLimitConfigSchema` and - * the `RateLimitStrategySchema` enum it embedded had no other consumer, and an - * exported schema with no consumer reads as a capability to whoever finds it - * (#3950). The vocabulary returns *with* an implementation, in one change — - * implementation-first, the ruling #4834 / PR #4878 set for the plugin-runtime - * family. - * - * Deliberately NOT converted to `shared/RateLimitConfig` (inbound API limiting, - * `windowMs`), the obvious-looking target: it limits the calls *others* make to - * us. Rewriting an outbound cap into an inbound one would throttle the wrong - * direction — a migration that silently changes behaviour, well outside D2's - * lossless scope. #4684 split their NAMES for this exact confusion; the honest - * conversion is a delete plus a prescription that says where throttling really - * lives (the connector provider / upstream gateway). - * - * `retiredFromLoadPath`: the key lies rather than being misspelled — the - * `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` lists the mechanical edits for author sources; live parses hit the `retiredKey()` tombstone instead. - */ -const connectorRateLimitConfigRemoved: MetadataConversion = { - id: 'connector-rate-limit-config-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'connector.rateLimitConfig', - summary: - "connector key 'rateLimitConfig' removed (no outbound rate-limiting engine exists; " - + "the runtime's only token bucket limits INBOUND requests, so every knob here was inert " - + 'while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)', - apply(stack, emit) { - return mapCollection(stack, 'connectors', (c, path) => - stripKeys(c, ['rateLimitConfig'], emit, path)); - }, - fixture: { - before: { - connectors: [ - { - name: 'billing_api', - label: 'Billing API', - type: 'api', - rateLimitConfig: { - strategy: 'token_bucket', - maxRequests: 100, - windowSeconds: 60, - burstCapacity: 150, - respectUpstreamLimits: true, - }, - }, - // A connector that never authored the key keeps its identity — the - // copy-on-write contract `stripKeys` / `mapCollection` are built on. - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - // One notice per connector, not per knob: the block is what was removed. - // `retryConfig` and the timeouts beside it are untouched by THIS conversion - // — a statement about its scope, not a liveness verdict. They are not live: - // declared, defaulted and documented, and read by nothing. No retry loop - // reads a strategy or a backoff; every occurrence of the timeouts outside - // `packages/spec` is a write of the literal `30000` so a def satisfies the - // post-parse type; and `ConnectorProviderContext` carries neither, so a - // provider factory cannot see them. `liveness/connector.json` classifies - // all of them `dead` and is the authority here — ADR-0049 owes them a - // decision, which this entry does not pre-empt. - after: { - connectors: [ - { name: 'billing_api', label: 'Billing API', type: 'api' }, - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * `fieldMappings[].transform` — five declared transforms, zero engines (#5552, - * ADR-0049). - * - * `shared/FieldMappingSchema.transform` was typed by a five-member - * discriminated union (`FieldMappingTransformSchema`: `constant` / `cast` / - * `lookup` / `javascript` / `map`), inherited by both schemas that extend it — - * `integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. The - * whole union goes; the key is tombstoned on the base, which retires all three - * authorable spellings in one edit. - * - * What was measured, and against what (2026-08-06, `origin/main` @ efedd28): - * - * - **Declarative**: `transform: { type: … }` in a field-mapping position is - * authored NOWHERE outside `packages/spec`'s own tests — not in `examples/`, - * not in `skills/`, not in objectui. The showcase's one mapping - * (`examples/app-showcase/src/data/mappings/index.ts`) writes `transform: - * 'map'` — a bare STRING, which is the *other* schema (see below). - * - **Parse**: reachable. `AutomationEngine.registerConnector` / - * `registerDegradedConnector` run `ConnectorSchema.parse`, which walks - * `fieldMappings[]`. So the tombstone's prescription has a live receiver, - * which is why this is a `retiredKey()` and not a bare deletion. - * - **Execution**: none. `fieldMappings` is spelled only inside - * `packages/spec` — the four connector packages, the automation engine, REST - * and objectui never read it, and nothing anywhere switches on - * `transform.type`. Falsification control for the scan: the member-specific - * words are findable in the tree (`targetType` 70 hits, `keyField` 66, - * `valueField` 260) — the scanner works, the consumers are absent. - * - **cloud**: NOT verified. This session has no read access to - * `objectstack-ai/cloud`, and GitHub code search does not index it (a control - * query scoped to that repo returns zero with `incomplete_results`). Recorded - * as unverified rather than claimed clean — the #5540 disposition. - * - * The `javascript` member is the one that got the defect filed (#5552): its - * `.describe()` recommended `dialect="js"` while `ExpressionDialect` retired - * `js` in #3278 (ADR-0058 addendum), so the envelope the doc taught was - * rejected by the enum, the only spelling that parsed was the bare string — - * which `ExpressionInputSchema` wraps as `dialect: 'cel'` — and the example it - * offered (`value.toUpperCase()`) is not valid CEL. The maintainer ruling - * (2026-08-06) rejected fixing the sentence alone as "gilding a member that - * cannot run" and ordered enforce-or-remove on the measurement; the - * measurement came back dead for all five. - * - * NOT touched, and the reason the word `transform` survives elsewhere: - * `data/mapping.zod.ts`'s `ImportFieldMappingSchema.transform` is a different - * declaration under the same name — a flat string enum steering `params`, - * applied row by row by `packages/rest/src/import-mapping.ts:115-167` and - * recorded live in `packages/spec/liveness/mapping.json`. It is also the - * counter-example that makes this retirement's shape clear: its own - * `javascript` value is rejected with a 400 because no server sandbox exists — - * implement-or-reject-loudly, rather than a member that parses and evaporates. - * - * Scope of the rewrite: `connectors[].fieldMappings[]` is the only stored - * source that can carry the key. `ExternalLookupSchema` is not referenced by - * any stack collection or metadata type, so there is no external-lookup - * document for the walker to visit; its authorable key is retired by the same - * tombstone and needs no transform. (That zero-consumer finding then became - * the whole family's verdict: #8075 retired `data/external-lookup.zod.ts` - * outright — route 3, D3 `external-lookup-message-queue-families-retired` — - * and the `data/ExternalFieldMapping:transform` retired-keys entry was - * subsumed by the def retirement, the WidgetManifest.performance way. The - * `externalLookup.…` clause in this entry's `surface` stays: it correctly - * documents what the v16→17 change list removed, and it composes with the def - * retirement into "delete the whole value".) - * - * `retiredFromLoadPath`: the key claims a transformation that never ran, the - * `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` lists the mechanical edits for author sources; live parses hit the tombstone. - */ -const fieldMappingTransformRemoved: MetadataConversion = { - id: 'field-mapping-transform-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform', - summary: - "field-mapping key 'transform' removed (the whole five-member " - + 'FieldMappingTransform union went with it: no runtime ever executed constant/cast/' - + 'lookup/javascript/map, and the javascript member advertised dialect="js", a dialect ' - + "already retired because JavaScript belongs in a script body. The enforced transform pipeline is the import mapping's string-enum " - + '`mapping.fieldMapping[].transform`, which is unaffected)', - apply(stack, emit) { - return mapCollection(stack, 'connectors', (c, path) => { - const mappings = c.fieldMappings; - if (!Array.isArray(mappings)) return c; - let changed = false; - const next = mappings.map((m, i) => { - if (!m || typeof m !== 'object' || Array.isArray(m)) return m; - const stripped = stripKeys( - m as Record, - ['transform'], - emit, - `${path}.fieldMappings[${i}]`, - ); - if (stripped !== m) changed = true; - return stripped; - }); - return changed ? { ...c, fieldMappings: next } : c; - }); - }, - fixture: { - before: { - connectors: [ - { - name: 'sap_erp', - label: 'SAP ERP', - type: 'saas', - fieldMappings: [ - // The member #5552 was filed about: `dialect: 'js'` was never - // parseable, so the only authored form is the bare string — which - // silently meant CEL. - { source: 'order_value', target: 'order_total', transform: { type: 'javascript', expression: 'value / 100' } }, - // A second member, to show the notice is per mapping entry and not - // per union member. - { source: 'Status', target: 'status', transform: { type: 'map', mappings: { Active: 'active' } } }, - // A mapping that never authored the key keeps its identity — the - // copy-on-write contract `stripKeys` / `mapCollection` are built on. - { source: 'E-mail', target: 'email', defaultValue: '' }, - ], - }, - // A connector with no field mappings at all is untouched. - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - after: { - connectors: [ - { - name: 'sap_erp', - label: 'SAP ERP', - type: 'saas', - fieldMappings: [ - { source: 'order_value', target: 'order_total' }, - { source: 'Status', target: 'status' }, - { source: 'E-mail', target: 'email', defaultValue: '' }, - ], - }, - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * The nine theme token groups that were EMITTED and read by nobody (#5021, - * ADR-0049). - * - * The distinguishing fact, and the reason #3494 could not reach these: that - * round's criterion was "the theme engine never emits a variable for this key", - * which retired `spacing` / `breakpoints` / `density` / `wcagContrast` and five - * more. These eight keys pass that test — objectui's `generateThemeVars` walks - * every one of them and puts `--font-size-*`, `--font-weight-*`, - * `--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, - * `--z-*`, `--font-heading` and `--font-mono` on the document, exactly as - * declared. What does not exist is a READER: measured against objectui `main` - * on 2026-08-04, all nine groups have zero consumers across `packages/**`, - * while `--font-sans`, `--radius*`, `--shadow*` and the colour variables come - * back live in the same run. So the author's type scale was real CSS that - * styled nothing. - * - * A CSS custom property is genuinely weaker evidence than a spec key here — a - * tenant's own stylesheet CAN read a variable the platform ignores — and that - * is precisely why the prescription is `customVars` rather than a bare "sorry". - * `customVars` emits `--: ` verbatim, so every one of these - * variables is reproducible byte for byte. The retirement removes a semantic - * vocabulary the platform never honoured; it removes no capability. - * - * ⚠️ This STRIPS rather than rewriting into `customVars`, and that is the - * deliberate half of the design. A mechanical rewrite is possible (stringify - * the numbers, reconstruct `--z-` / `--timing-` names) and was - * rejected: it would hand back ~25 `customVars` entries that still nothing - * reads, converting a dead semantic slot into a dead literal one and making the - * retirement invisible — the #4583 shape. The notice tells the author exactly - * which keys went and the tombstone tells them where to put back the ones they - * actually consume, which is a decision only they can make. Measured input to - * that choice: `examples/**` and `apps/**` author ZERO of these keys today (the - * showcase's two themes declare `colors` only), so there is no in-repo body of - * 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` 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 = { - id: 'theme-inert-token-scales-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight' - + ' / theme.typography.letterSpacing / theme.typography.fontFamily.heading' - + ' / theme.typography.fontFamily.mono / theme.animation / theme.zIndex', - summary: - "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', " - + "'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed " - + '(ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, ' - + '--letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono ' - + 'faithfully, and no first-party component or stylesheet has ever read one. ' - + 'Re-declare any variable you actually consume under customVars, which emits it verbatim)', - apply(stack, emit) { - return mapCollection(stack, 'themes', (theme, path) => { - let next = stripKeys(theme, ['animation', 'zIndex'], emit, path); - - // `typography` is one level down, and `fontFamily` two — `stripKeys` is - // top-level only, so each nesting level is walked explicitly and - // copy-on-write is preserved at every level (an untouched theme keeps its - // identity, which is what `mapCollection` tests for). - const typography = next.typography; - if (typography && typeof typography === 'object' && !Array.isArray(typography)) { - const typo = typography as Record; - let nextTypo = stripKeys( - typo, - ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'], - emit, - `${path}.typography`, - ); - - const fontFamily = nextTypo.fontFamily; - if (fontFamily && typeof fontFamily === 'object' && !Array.isArray(fontFamily)) { - const nextFamily = stripKeys( - fontFamily as Record, - ['heading', 'mono'], - emit, - `${path}.typography.fontFamily`, - ); - if (nextFamily !== fontFamily) nextTypo = { ...nextTypo, fontFamily: nextFamily }; - } - - if (nextTypo !== typo) next = { ...next, typography: nextTypo }; - } - - return next; - }); - }, - fixture: { - before: { - themes: [ - { - name: 'corporate', - label: 'Corporate', - colors: { primary: '#7C3AED' }, - typography: { - fontFamily: { base: 'Inter, sans-serif', heading: 'Georgia, serif', mono: 'ui-monospace' }, - fontSize: { base: '1rem', lg: '1.125rem' }, - fontWeight: { normal: 400, bold: 700 }, - lineHeight: { normal: '1.5' }, - letterSpacing: { wide: '0.025em' }, - }, - animation: { duration: { fast: '150ms' }, timing: { ease: 'cubic-bezier(0.4, 0, 0.2, 1)' } }, - zIndex: { modal: 1050 }, - }, - // A theme that authored none of them keeps its identity — the - // copy-on-write contract `stripKeys` / `mapCollection` are built on. - { name: 'minimal', label: 'Minimal', colors: { primary: '#000000' } }, - ], - }, - after: { - themes: [ - { - name: 'corporate', - label: 'Corporate', - colors: { primary: '#7C3AED' }, - // `fontFamily` SURVIVES with `base` alone: it is the one font-family - // key with a live consumer (`--font-sans`). The block is not removed, - // only narrowed — which is why the fixture keeps it rather than - // dropping `typography` wholesale. - typography: { fontFamily: { base: 'Inter, sans-serif' } }, - }, - { name: 'minimal', label: 'Minimal', colors: { primary: '#000000' } }, - ], - }, - // One notice per KEY, not per stop and not per theme: eight keys authored - // on the first theme, zero on the second. - expectedNotices: 8, - }, -}; - -/** - * The two spellings of the page-header node, both converted by - * {@link pageHeaderSubtitleAlias}. - * - * `page:header` is the protocol type (`PageComponentType`, `ComponentPropsMap`); - * `page-header` is objectui's kebab legacy alias (`@object-ui/layout`), which is - * authorable here because `PageComponentSchema.type` is deliberately - * `z.union([PageComponentType, z.string()])` — an open namespace for custom - * components. - */ -const PAGE_HEADER_COMPONENT_TYPES = new Set(['page:header', 'page-header']); - -/** - * Page-header component prop `description` → `subtitle` (protocol 17, - * objectui#3226 / #4827). - * - * One concept — the page's second line — carried two authorable spellings, one - * per renderer. `@objectstack/spec`'s `PageHeaderProps` (`ui/component.zod.ts`) - * declares `subtitle` and nothing else; objectui's kebab legacy alias - * `page-header` advertised `description` in its registration `inputs` and its - * renderer read a bare `subtitle ?? description`. That is the Prime Directive - * #12 shape exactly: a producer dialect held up by a consumer fallback, with - * the registry teaching the off-spec key on the way in. - * - * **Why a conversion and not a deletion** — the load-bearing half of this - * entry. The alias's entire reason to exist is out-of-repo consumer schemas, - * so "no in-repo author writes `description`" is not evidence that nobody - * does; on this key in particular, in-repo evidence has zero coverage. - * Deleting the consumer read would drop those pages' second line *silently* — - * the title still renders, one line vanishes, nothing errors — which is the - * least reportable failure shape there is. So the alias retires the ADR-0087 - * D2 way instead: rewritten to the canonical key at load, and at every stored - * row's rehydration (`applyConversionsToStoredItem` walks `pages` for free), - * declared, loud, tested and expiring. objectui#3226 deletes its `??` once - * this ships. - * - * **Both spellings of the node are converted**, deliberately. `page-header` is - * the alias the fallback served; `page:header` is the canonical type, where an - * authored `description` is *already* dropped on the floor today because that - * renderer only ever read `subtitle` — the same defect, one spelling over. The - * alias here is the KEY, not the type: this entry does **not** rewrite - * `page-header` → `page:header`. That registration is objectui's to retire, on - * its own schedule, and rewriting a type over an open namespace is the class of - * move {@link flowNodeHttpRename}'s conflict guard exists for. - * - * Precedence is the house rule {@link renameKey} encodes and nothing new: an - * already-canonical `subtitle` WINS, and the shadowed `description` is left - * exactly where it sits — unconverted, unreported, unremoved — as in - * {@link flowNodeCrudObjectAlias}. Only header nodes are touched: `description` - * is a live declared prop elsewhere on the same surface (`element:text_input` - * helper text), and those components are not this entry's business. - * - * **This entry is why the walker's reach had to grow (#6775).** Every other - * page-component entry leans on a tombstone to cover the sites a conversion - * cannot reach; this one has none to lean on, because `description` stays a - * live declared prop on other components, so `properties.description` parses - * green at ANY position. Until the walker descended into `slots.*` (#6776) and - * into container `properties` (#6775), a header on a `kind: 'slotted'` record - * page or inside a card/tab panel was rewritten by nobody and reported by - * nobody — and objectui's `subtitle ?? description` fallback could not retire - * without those pages silently losing their second line, the exact failure - * shape this entry exists to prevent. The fixture pins all three positions. - * - * **Retired from the load path at 18** — its one-major window (protocol 17) - * has run (#23421). The authoring funnel no longer rewrites the old key, so an - * author meets `PageHeaderProps`' own refusal, which names the rename - * (`aliases: { description: 'subtitle' }`), at every door that judges page - * props: `os validate` / `os build` / `os lint` and the metadata save door - * alike, where before the first two converted silently and the save door - * refused. Stored rows keep healing: stored-row rehydration replays the full - * chain, this entry included (`applyConversionsToStoredItem`), and so do the - * artifact-ingestion door (inside its window) and `os migrate meta`. The kebab - * `page-header` type stays in its walk for those replays only — objectui no - * longer registers that type, so a source node of it renders the unknown - * component panel whatever its props say. - */ -const pageHeaderSubtitleAlias: MetadataConversion = { - id: 'page-header-subtitle-alias', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'page.component.page-header.description', - summary: - "page-header component prop 'description' → 'subtitle' (the off-spec spelling a renderer tolerated through a bare `subtitle ?? description` fallback; `subtitle` is the declared key, and the fallback retires)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - const type = component.type; - if (typeof type !== 'string' || !PAGE_HEADER_COMPONENT_TYPES.has(type)) return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'description', 'subtitle'); - if (!renamed) return component; - emit({ from: 'description', to: 'subtitle', path: `${path}.properties.subtitle` }); - return { ...component, properties: renamed }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'crm_lead_detail', - regions: [ - { - name: 'header', - components: [ - // The kebab legacy alias — the spelling the `??` fallback served. - { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, - // The canonical type authored with the legacy key: converted too, - // because today this second line is dropped on the floor. - { type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }, - // Both spellings, DIFFERENT text (#4923): kept, so the author - // reconciles the two second lines rather than the loader picking. - { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, - // `description` is this component's OWN declared prop (helper text) — untouched. - { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, - ], - }, - ], - }, - // The slotted record page — `regions: []`, header in a named slot. This - // is the shape objectui's own guide prescribes for a customized record - // header, and a region-only walk visited none of it (#6776). - { - name: 'crm_account_detail', - kind: 'slotted', - regions: [], - slots: { - header: { type: 'page:header', properties: { title: '{name}', description: 'Account overview' } }, - }, - }, - // Container nesting (#6775): a header inside a card's `children`, one in - // its `footer`, one inside a tab panel, and one two levels down. All are - // spec-valid (`properties` is an open bag) and all were invisible to the - // region-only walk — with no tombstone to catch them at parse time. - { - name: 'crm_pipeline_dashboard', - regions: [ - { - name: 'main', - components: [ - { - type: 'page:card', - properties: { - title: 'Pipeline', - children: [ - { type: 'page-header', properties: { title: 'Open', description: 'This quarter' } }, - ], - footer: [ - { type: 'page:header', properties: { title: 'Closed', description: 'Last quarter' } }, - ], - }, - }, - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Activity', - children: [ - { type: 'page:header', properties: { title: 'Recent', description: 'Last 7 days' } }, - // Two levels down — the recursion, not just one hop. - { - type: 'page:card', - properties: { - children: [ - { type: 'page:header', properties: { title: 'Nested', description: 'Deep' } }, - ], - }, - }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - ], - }, - after: { - pages: [ - { - name: 'crm_lead_detail', - regions: [ - { - name: 'header', - components: [ - { type: 'page-header', properties: { title: 'Leads', subtitle: 'All open leads' } }, - { type: 'page:header', properties: { title: 'Lead', subtitle: 'One lead' } }, - { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, - { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, - ], - }, - ], - }, - { - name: 'crm_account_detail', - kind: 'slotted', - regions: [], - slots: { - header: { type: 'page:header', properties: { title: '{name}', subtitle: 'Account overview' } }, - }, - }, - { - name: 'crm_pipeline_dashboard', - regions: [ - { - name: 'main', - components: [ - { - type: 'page:card', - properties: { - title: 'Pipeline', - children: [ - { type: 'page-header', properties: { title: 'Open', subtitle: 'This quarter' } }, - ], - footer: [ - { type: 'page:header', properties: { title: 'Closed', subtitle: 'Last quarter' } }, - ], - }, - }, - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Activity', - children: [ - { type: 'page:header', properties: { title: 'Recent', subtitle: 'Last 7 days' } }, - { - type: 'page:card', - properties: { - children: [ - { type: 'page:header', properties: { title: 'Nested', subtitle: 'Deep' } }, - ], - }, - }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - ], - }, - expectedNotices: 7, - }, -}; - -/** - * The SDUI component-props reconciliation (protocol 17, #5775) — three entries - * below, one shared reason. - * - * #5068 wired the first parse `ComponentPropsMap` ever had and measured what - * the corpus actually authors against it. The measurement came back with - * divergence in BOTH directions: keys objectui's renderers honour that the - * schema never declared, and keys the schema declared (one of them REQUIRED) - * that no renderer has ever read. The maintainer's 2026-08-06 ruling took - * direction A — the #5611 rule, "the delivered and authorized shape is the - * contract" — so the honoured keys were declared and the unread ones retire - * here. - * - * **The reach is every position a picker can be authored in** (#6775). - * {@link mapPageComponents} walks `regions[].components[]`, `slots.` and - * the containers a component nests under its `properties` — the same set - * `walkPageComponents` lints — so a picker inside a card's `children` or a tab - * 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 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 `os validate`, `os build` and `os lint` refuse it - * with the prescription (the component-props gate, `@objectstack/lint`; the - * load path keeps the open `properties` bag as written) and only - * `os migrate meta` lists the mechanical edits for existing sources. - */ -const RECORD_PICKER_COMPONENT_TYPE = 'element:record_picker'; - -/** - * `element:record_picker.displayField` → `labelField` (protocol 17, #5775). - * - * Two spellings of one concept — "which field is the row's text" — of which the - * schema required the one nobody reads. `record-picker.tsx` resolves - * `props.labelField ?? 'name'` and renders `row[labelField]`; `displayField` - * appears nowhere in that renderer, and objectui's own component registry - * publishes `labelField` as the designer input. So an author who followed the - * schema and wrote `displayField: 'title'` got a picker listing `name`, with a - * success receipt and no diagnostic anywhere — the ADR-0078 shape. - * - * A rename rather than a deletion because the two keys are synonyms: the value - * (a field name) is exactly what `labelField` wants. Precedence is - * {@link renameKey}'s house rule (#4923) and nothing new — a redundant twin is - * dropped, a DISAGREEING pair is left for the author to reconcile rather than - * the loader picking a field. - */ -const recordPickerDisplayFieldToLabelField: MetadataConversion = { - id: 'record-picker-display-field-to-label-field', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.element:record_picker.displayField', - summary: - "record-picker component prop 'displayField' → 'labelField' (the required key no renderer read; `labelField ?? 'name'` is what renders the row, so the delivered spelling became the declared one)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== RECORD_PICKER_COMPONENT_TYPE) return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'displayField', 'labelField'); - if (!renamed) return component; - emit({ from: 'displayField', to: 'labelField', path: `${path}.properties.labelField` }); - return { ...component, properties: renamed }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'showcase_page_variables', - regions: [ - { - name: 'main', - components: [ - { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { displayField: 'title' } }, - // Both spellings, SAME value: the redundant twin goes (#4923). - { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name', displayField: 'name' } }, - // Both spellings, DIFFERENT fields: kept, so the author reconciles - // the two rather than the loader picking a column. - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, - // `displayField` is a live LOOKUP-FIELD key elsewhere on the - // surface — a different component's business, untouched here. - // (The neighbor was `element:form` until #9249 retired that - // element whole; its strip conversion would now touch the node, - // so the overlap rides on a type no conversion dispatches on.) - { type: 'element:text', properties: { displayField: 'title' } }, - // Nested one container down (#6775) — a picker inside a card is - // where a form-shaped page actually puts one. - { - type: 'page:card', - properties: { - title: 'Link a project', - children: [ - { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { displayField: 'code' } }, - ], - }, - }, - ], - }, - ], - }, - // A slotted page's named slot — same component, other authoring shape. - { - name: 'showcase_project_detail', - kind: 'slotted', - regions: [], - slots: { - details: [ - { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { displayField: 'label' } }, - ], - }, - }, - ], - }, - after: { - pages: [ - { - name: 'showcase_page_variables', - regions: [ - { - name: 'main', - components: [ - { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { labelField: 'title' } }, - { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name' } }, - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, - { type: 'element:text', properties: { displayField: 'title' } }, - { - type: 'page:card', - properties: { - title: 'Link a project', - children: [ - { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { labelField: 'code' } }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'showcase_project_detail', - kind: 'slotted', - regions: [], - slots: { - details: [ - { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { labelField: 'label' } }, - ], - }, - }, - ], - }, - expectedNotices: 4, - }, -}; - -/** - * `element:record_picker.searchFields` / `.multiple` — declared capabilities the - * control does not have (protocol 17, #5775, ADR-0049). - * - * The renderer is a shadcn `Select` over a `find()` result: no search input - * exists, so `searchFields` narrowed nothing, and the control is single-choice - * writing ONE id into the bound page variable, so `multiple: true` selected - * nothing extra while reporting success. Neither key has a reader anywhere in - * objectui. Enforce-or-remove: they are removed, not deprecated, and either - * returns the day the capability is implemented (#5021 / #4988 precedent). - * - * Pure lossless deletes — neither key ever had an effect to lose. - */ -const recordPickerInertKeysRemoved: MetadataConversion = { - id: 'record-picker-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple', - summary: - "record-picker component props 'searchFields'/'multiple' removed (the control is a plain single-select with no search box; neither key had a reader)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== RECORD_PICKER_COMPONENT_TYPE) return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const stripped = stripKeys(properties, ['searchFields', 'multiple'], emit, `${path}.properties`); - if (stripped === properties) return component; - return { ...component, properties: stripped }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'picker_gallery', - regions: [ - { - name: 'main', - components: [ - { - type: 'element:record_picker', - dataSource: { object: 'showcase_project' }, - properties: { searchFields: ['name', 'code'], multiple: true }, - }, - // `multiple` is a live FIELD key (lookup fields) — a different - // surface entirely, and not this entry's business. (The - // neighbor was `element:form` until #9249 retired that element - // whole; the overlap now rides on a type no conversion - // dispatches on.) - { type: 'element:text', properties: { multiple: true } }, - // Inside a tab panel (#6775): `page:tabs` hangs its sub-tree off - // `properties.items[].children`, which the walk now descends. - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Pick one', - children: [ - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { multiple: true } }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - // The named-slot shape, on a slotted record page. - { - name: 'picker_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: { searchFields: ['name'] } }, - }, - }, - ], - }, - after: { - pages: [ - { - name: 'picker_gallery', - regions: [ - { - name: 'main', - components: [ - { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: {} }, - { type: 'element:text', properties: { multiple: true } }, - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Pick one', - children: [ - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: {} }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'picker_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: {} }, - }, - }, - ], - }, - expectedNotices: 4, - }, -}; - -/** - * `page:card.body` → `children` (protocol 17, #5775). - * - * Every container on this surface composes through `children` — `grid`, - * `flex`, `page:section`, `page:accordion` items, `page:tabs` items — and the - * card renderer reads `schema.body ?? schema.children` with its own comment - * saying authors expect `children` to work here too. The showcase's two cards - * author `children`. Only the declaration said `body`, which made the #5068 - * gate report the showcase's own correct pages as authoring an unknown key. - * - * Converging on `children` rather than declaring both: one composition key, not - * two de-facto contracts (Prime Directive #12). `footer` is a genuinely - * distinct slot and is untouched. The renderer keeps its `body ??` fallback for - * stored documents — that is objectui's to retire on its own schedule, exactly - * as {@link pageHeaderSubtitleAlias} left the kebab `page-header` registration - * alone. - */ -const pageCardBodyToChildren: MetadataConversion = { - id: 'page-card-body-to-children', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.page:card.body', - summary: - "page:card component prop 'body' → 'children' (one composition key across every container; the card renderer already reads both)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== 'page:card') return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'body', 'children'); - if (!renamed) return component; - emit({ from: 'body', to: 'children', path: `${path}.properties.children` }); - return { ...component, properties: renamed }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'my_work', - regions: [ - { - name: 'sidebar', - components: [ - { - type: 'page:card', - properties: { title: 'Shortcuts', body: [{ type: 'element:text' }], footer: [{ type: 'element:text' }] }, - }, - // Both spellings, DIFFERENT content: kept, so the author picks - // which body the card should have. - { - type: 'page:card', - properties: { children: [{ type: 'element:text' }], body: [{ type: 'element:image' }] }, - }, - // `body` on a component that is not a card — not this entry's key. - { type: 'record:alert', properties: { body: 'Confirm the work before marking it done.' } }, - // A card nested in a card (#6775). The OUTER rename moves the - // sub-tree from `body` to `children`, and the descent reads the - // MAPPED component, so the inner card is visited exactly once — - // under the canonical key, not once per spelling. - { - type: 'page:card', - properties: { - title: 'Outer', - body: [ - { type: 'page:card', properties: { title: 'Inner', body: [{ type: 'element:text' }] } }, - ], - }, - }, - ], - }, - ], - }, - // The named-slot shape: a card authored into a slotted page's `details`. - { - name: 'my_work_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'page:card', properties: { title: 'Detail', body: [{ type: 'element:text' }] } }, - }, - }, - ], - }, - after: { - pages: [ - { - name: 'my_work', - regions: [ - { - name: 'sidebar', - components: [ - { - type: 'page:card', - properties: { title: 'Shortcuts', children: [{ type: 'element:text' }], footer: [{ type: 'element:text' }] }, - }, - { - type: 'page:card', - properties: { children: [{ type: 'element:text' }], body: [{ type: 'element:image' }] }, - }, - { type: 'record:alert', properties: { body: 'Confirm the work before marking it done.' } }, - { - type: 'page:card', - properties: { - title: 'Outer', - children: [ - { type: 'page:card', properties: { title: 'Inner', children: [{ type: 'element:text' }] } }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'my_work_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'page:card', properties: { title: 'Detail', children: [{ type: 'element:text' }] } }, - }, - }, - ], - }, - expectedNotices: 4, - }, -}; - -/** - * Inline `type:'api'` action: object-form `params` → `bodyExtra` (protocol 17, - * #5777). - * - * One key carried two fact-contracts. `InlineActionSchema.params` is picked - * from `ActionSchema` and is an `ActionParam[]` **definition array** — the - * fields a dialog collects before the action runs. What the showcase's - * pure-SDUI contact form authored on its submit button is a **request payload - * map** (`params: { name: '{{page.inquiryName}}', … }`), and objectui's - * `ActionRunner` took both: its own comment says "Accept both — when `params` - * is an array, treat it as the input-collection definition", discriminating on - * `Array.isArray`. A tolerant consumer fossilizing a wrong convention is Prime - * Directive #12's exact shape, and here the fossil was load-bearing: the - * generated reference could only describe the array, so an author following the - * docs could not write a working `api` submit button at all. - * - * The maintainer's 2026-08-06 ruling on #5777 took **direction A — a separate - * payload key**, explicitly refusing the same-name union of option B. The - * separate key is `bodyExtra`, which `ActionSchema` has declared all along for - * exactly this ("static body fragment merged into the outgoing request body for - * `type:'api'` actions"); #5777's spec half picks it onto the inline shape, so - * the rewrite target is a key the contract already owns rather than a third - * name. `payload` is already an alias pointing there (#5013) and `body` is - * already the `script` hook body, so those two spellings were never available. - * - * **Why a conversion and not a deletion.** The two shapes are disjoint — - * `Array.isArray` decides, with no value that could be read either way — so the - * rewrite is mechanical and lossless, which is the ADR-0087 D2 precondition. - * Deleting instead would strand every page authored the old way on a bare - * `expected array, received object`. - * - * **Scoped to `type:'api'`, deliberately.** Object-form `params` on an inline - * `type:'url'` action is a THIRD meaning again — `ActionRunner.interpolateTarget` - * reads it as the `${param.X}` interpolation scope, and `executeUrl` reads - * `params.newTab` — so rewriting those into an api request body would be lossy, - * not lossless. Nothing in the reachable corpus authors that shape; it stays - * refused by the array-only field, and this entry does not touch it. - * - * **Not extended to registered actions.** `ActionSchema` is parsed at - * `defineAction`, so its array-only `params` has always refused the object form - * at the authoring door — the defect existed only on the inline path, where - * `PageComponent.properties` is an open bag and nothing parsed the props until - * #5068. Registered actions already reach the payload through `bodyExtra`. - * - * Precedence is {@link renameKey}'s house rule and nothing new: an - * already-present `bodyExtra` WINS and a differing object-form `params` is left - * exactly where it sits, for the author to reconcile (#4923). + * Page-header component prop `description` → `subtitle` (protocol 17, + * objectui#3226 / #4827). * - * The reach is every position a button can be authored in, as for - * {@link pageHeaderSubtitleAlias} and {@link pageCardBodyToChildren} (#6775): - * `regions[].components[]`, `slots.`, and the containers a component - * nests under its `properties` — which is where a submit button most often - * sits, inside the card that holds the form. The array-only field still covers - * anything the rewrite declines to touch: it refuses the object form with a - * message naming `bodyExtra`, at any position. + * One concept — the page's second line — carried two authorable spellings, one + * per renderer. `@objectstack/spec`'s `PageHeaderProps` (`ui/component.zod.ts`) + * declares `subtitle` and nothing else; objectui's kebab legacy alias + * `page-header` advertised `description` in its registration `inputs` and its + * renderer read a bare `subtitle ?? description`. That is the Prime Directive + * #12 shape exactly: a producer dialect held up by a consumer fallback, with + * the registry teaching the off-spec key on the way in. * - * **Retired from the load path at 18** — its one-major window (protocol 17) - * has run (#23421). The authoring funnel no longer rewrites the object form, - * so an author meets that array-only field's refusal naming `bodyExtra` at - * every door that judges page props — `os validate` / `os build` / `os lint` - * and the metadata save door alike, where before the first two converted - * silently and the save door refused. Stored rows keep healing: stored-row - * rehydration replays the full chain, this entry included - * (`applyConversionsToStoredItem`), and so do the artifact-ingestion door - * (inside its window) and `os migrate meta`. - */ -const inlineActionApiParamsToBodyExtra: MetadataConversion = { - id: 'inline-action-api-params-to-body-extra', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'page.component.element:button.action.params', - summary: - "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (a static payload and a parameter definition are two things, so the payload gets its own key; `params` stays the ActionParam[] definition array)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== 'element:button') return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const action = properties.action; - if (!isDict(action)) return component; - // Only the payload meaning converts. `Array.isArray` is the whole - // discriminator — a definition array stays put — and the `api` guard - // keeps the url-action interpolation scope out of it. - if (action.type !== 'api') return component; - if (!isDict(action.params)) return component; - const renamed = renameKey(action, 'params', 'bodyExtra'); - if (!renamed) return component; - emit({ from: 'params', to: 'bodyExtra', path: `${path}.properties.action.bodyExtra` }); - return { ...component, properties: { ...properties, action: renamed } }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'showcase_contact_form', - regions: [ - { - name: 'main', - components: [ - // The measured site: a pure-SDUI form submit posting page vars. - { - type: 'element:button', - properties: { - label: 'Submit inquiry', - action: { - type: 'api', - target: '/api/v1/forms/contact-us/submit', - method: 'POST', - params: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, - }, - }, - }, - // A real ActionParam[] definition array — the other meaning of - // the same key, untouched. - { - type: 'element:button', - properties: { - label: 'Close order', - action: { - type: 'api', - target: '/api/v1/sales_order/close', - params: [{ name: 'reason', label: 'Reason', type: 'text' }], - }, - }, - }, - // Both spellings, DIFFERENT payloads: kept, so the author - // reconciles the two bodies rather than the loader picking. - { - type: 'element:button', - properties: { - label: 'Both', - action: { - type: 'api', - target: '/api/v1/x', - params: { a: 1 }, - bodyExtra: { b: 2 }, - }, - }, - }, - // `type:'url'` — object-form `params` is the `${param.X}` - // interpolation scope there, a third meaning. Not this entry's. - { - type: 'element:button', - properties: { - label: 'Open', - action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, - }, - }, - // The shape a pure-SDUI form is actually built in (#6775): the - // submit button sits in the card's `footer`, one container down. - { - type: 'page:card', - properties: { - title: 'Contact us', - footer: [ - { - type: 'element:button', - properties: { - label: 'Send', - action: { type: 'api', target: '/api/v1/forms/send', params: { note: '{{page.note}}' } }, - }, - }, - ], - }, - }, - ], - }, - ], - }, - // The named-slot shape: an action button in a slotted page's `actions`. - { - name: 'showcase_contact_detail', - kind: 'slotted', - regions: [], - slots: { - actions: [ - { - type: 'element:button', - properties: { - label: 'Resend', - action: { type: 'api', target: '/api/v1/forms/resend', params: { id: '{{record.id}}' } }, - }, - }, - ], - }, - }, - ], - }, - after: { - pages: [ - { - name: 'showcase_contact_form', - regions: [ - { - name: 'main', - components: [ - { - type: 'element:button', - properties: { - label: 'Submit inquiry', - action: { - type: 'api', - target: '/api/v1/forms/contact-us/submit', - method: 'POST', - bodyExtra: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, - }, - }, - }, - { - type: 'element:button', - properties: { - label: 'Close order', - action: { - type: 'api', - target: '/api/v1/sales_order/close', - params: [{ name: 'reason', label: 'Reason', type: 'text' }], - }, - }, - }, - { - type: 'element:button', - properties: { - label: 'Both', - action: { - type: 'api', - target: '/api/v1/x', - params: { a: 1 }, - bodyExtra: { b: 2 }, - }, - }, - }, - { - type: 'element:button', - properties: { - label: 'Open', - action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, - }, - }, - { - type: 'page:card', - properties: { - title: 'Contact us', - footer: [ - { - type: 'element:button', - properties: { - label: 'Send', - action: { type: 'api', target: '/api/v1/forms/send', bodyExtra: { note: '{{page.note}}' } }, - }, - }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'showcase_contact_detail', - kind: 'slotted', - regions: [], - slots: { - actions: [ - { - type: 'element:button', - properties: { - label: 'Resend', - action: { type: 'api', target: '/api/v1/forms/resend', bodyExtra: { id: '{{record.id}}' } }, - }, - }, - ], - }, - }, - ], - }, - expectedNotices: 3, - }, -}; - -/** - * `page:tabs.type` → `tabStyle` (protocol 17, #6776). - * - * The concept — the tab strip's visual style, `line` | `card` | `pill` — was - * declared all along. What was wrong is the **spelling**: a props key named - * `type` sits on the one name a page component cannot spare, because the node's - * own dispatch key is `type` too. Three independently-measured consequences on - * objectui `origin/main`, none of them cosmetic: - * - * - `SchemaRenderer.tsx:253,264` hoists `properties` onto the node but skips - * `type` and `id` explicitly, or the inner value would shadow which - * renderer to dispatch to. Its comment names this very case ("tab visual - * style: 'line' | 'card' | 'pill'"). - * - `sdui-parser`'s `BASE_PROPS` (`validate.ts:20-30`) contains `'type'`, and - * `validate.ts:68` `continue`s on any base prop — so a manifest input named - * `type` is skipped before the unknown/typed checks and is never validated. - * - In the flat and JSX carriers a node reads - * `{ type: 'page:tabs', items: […], tabStyle: 'card' }` — `type` is the tag - * name, and the spec's spelling has nowhere left to go. - * - * So `tabStyle` is not drift: it is the only spelling those carriers can - * express, it is what objectui's registry publishes as the designer input, and - * `containers.tsx:381` reads BOTH (`schema?.properties?.type || schema?.tabStyle`). - * Converging on the spelling the renderer reads — rather than the one that - * declares well — is exactly {@link recordPickerDisplayFieldToLabelField}'s - * shape (#5775), and one spelling rather than two is Prime Directive #12. The - * alternative considered and refused was declaring `tabStyle` as an alias of - * `type`: that fossilizes a second dialect for one concept, and the dialect - * that would survive is the one that silently fails to validate. - * - * A rename rather than a deletion because the two keys are synonyms down to the - * value vocabulary — the same three-value enum, no re-mapping. Precedence is - * {@link renameKey}'s house rule (#4923): a redundant twin is dropped, a - * DISAGREEING pair is left for the author to reconcile rather than the loader - * picking a look. - * - * The reach is every position, as for {@link pageCardBodyToChildren} (#6775): - * a `page:tabs` nested inside another component's `properties` is rewritten - * where it sits. The discriminator matters more here than anywhere else, since - * the key being renamed shares a name with the node's dispatch key — so the - * fixture pins that descending into a props bag does NOT turn some other - * component's inner `type` (an action's `type: 'url'`, a tab item's fields) - * into a rewrite target: only `properties.type` on a node whose own `type` is - * `page:tabs` moves. The tombstone still carries the refusal at the authoring - * site (`tsc`) and at `os validate` / `os build` / `os lint`, where the - * component-props gate refuses it as an `error` (the load path keeps the open - * `properties` bag as written), whatever the walk reached. - * - * `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 conversion. + * **Why a conversion and not a deletion** — the load-bearing half of this + * entry. The alias's entire reason to exist is out-of-repo consumer schemas, + * so "no in-repo author writes `description`" is not evidence that nobody + * does; on this key in particular, in-repo evidence has zero coverage. + * Deleting the consumer read would drop those pages' second line *silently* — + * the title still renders, one line vanishes, nothing errors — which is the + * least reportable failure shape there is. So the alias retires the ADR-0087 + * D2 way instead: rewritten to the canonical key at load, and at every stored + * row's rehydration (`applyConversionsToStoredItem` walks `pages` for free), + * declared, loud, tested and expiring. objectui#3226 deletes its `??` once + * this ships. + * + * **Both spellings of the node are converted**, deliberately. `page-header` is + * the alias the fallback served; `page:header` is the canonical type, where an + * authored `description` is *already* dropped on the floor today because that + * renderer only ever read `subtitle` — the same defect, one spelling over. The + * alias here is the KEY, not the type: this entry does **not** rewrite + * `page-header` → `page:header`. That registration is objectui's to retire, on + * its own schedule, and rewriting a type over an open namespace is the class of + * move {@link flowNodeHttpRename}'s conflict guard exists for. + * + * Precedence is the house rule {@link renameKey} encodes and nothing new: an + * already-canonical `subtitle` WINS, and the shadowed `description` is left + * exactly where it sits — unconverted, unreported, unremoved — as in + * {@link flowNodeCrudObjectAlias}. Only header nodes are touched: `description` + * is a live declared prop elsewhere on the same surface (`element:text_input` + * helper text), and those components are not this entry's business. + * + * **This entry is why the walker's reach had to grow (#6775).** Every other + * page-component entry leans on a tombstone to cover the sites a conversion + * cannot reach; this one has none to lean on, because `description` stays a + * live declared prop on other components, so `properties.description` parses + * green at ANY position. Until the walker descended into `slots.*` (#6776) and + * into container `properties` (#6775), a header on a `kind: 'slotted'` record + * page or inside a card/tab panel was rewritten by nobody and reported by + * nobody — and objectui's `subtitle ?? description` fallback could not retire + * without those pages silently losing their second line, the exact failure + * shape this entry exists to prevent. The fixture pins all three positions. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23421). The authoring funnel no longer rewrites the old key, so an + * author meets `PageHeaderProps`' own refusal, which names the rename + * (`aliases: { description: 'subtitle' }`), at every door that judges page + * props: `os validate` / `os build` / `os lint` and the metadata save door + * alike, where before the first two converted silently and the save door + * refused. Stored rows keep healing: stored-row rehydration replays the full + * chain, this entry included (`applyConversionsToStoredItem`), and so do the + * artifact-ingestion door (inside its window) and `os migrate meta`. The kebab + * `page-header` type stays in its walk for those replays only — objectui no + * longer registers that type, so a source node of it renders the unknown + * component panel whatever its props say. */ -const pageTabsTypeToTabStyle: MetadataConversion = { - id: 'page-tabs-type-to-tab-style', +const pageHeaderSubtitleAlias: MetadataConversion = { + id: 'page-header-subtitle-alias', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.page:tabs.type', + retiredAfter: '17.7.0', + surface: 'page.component.page-header.description', summary: - "page:tabs component prop 'type' → 'tabStyle' (a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", + "page-header component prop 'description' → 'subtitle' (the off-spec spelling a renderer tolerated through a bare `subtitle ?? description` fallback; `subtitle` is the declared key, and the fallback retires)", apply(stack, emit) { return mapPageComponents(stack, (component, path) => { - if (component.type !== 'page:tabs') return component; + const type = component.type; + if (typeof type !== 'string' || !PAGE_HEADER_COMPONENT_TYPES.has(type)) return component; const properties = component.properties; if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'type', 'tabStyle'); + const renamed = renameKey(properties, 'description', 'subtitle'); if (!renamed) return component; - emit({ from: 'type', to: 'tabStyle', path: `${path}.properties.tabStyle` }); + emit({ from: 'description', to: 'subtitle', path: `${path}.properties.subtitle` }); return { ...component, properties: renamed }; }); }, @@ -6130,35 +2052,55 @@ const pageTabsTypeToTabStyle: MetadataConversion = { before: { pages: [ { - name: 'sys_position_detail', + name: 'crm_lead_detail', + regions: [ + { + name: 'header', + components: [ + // The kebab legacy alias — the spelling the `??` fallback served. + { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, + // The canonical type authored with the legacy key: converted too, + // because today this second line is dropped on the floor. + { type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }, + // Both spellings, DIFFERENT text (#4923): kept, so the author + // reconciles the two second lines rather than the loader picking. + { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, + // `description` is this component's OWN declared prop (helper text) — untouched. + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + ], + }, + ], + }, + // The slotted record page — `regions: []`, header in a named slot. This + // is the shape objectui's own guide prescribes for a customized record + // header, and a region-only walk visited none of it (#6776). + { + name: 'crm_account_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'page:header', properties: { title: '{name}', description: 'Account overview' } }, + }, + }, + // Container nesting (#6775): a header inside a card's `children`, one in + // its `footer`, one inside a tab panel, and one two levels down. All are + // spec-valid (`properties` is an open bag) and all were invisible to the + // region-only walk — with no tombstone to catch them at parse time. + { + name: 'crm_pipeline_dashboard', regions: [ { name: 'main', components: [ - { type: 'page:tabs', properties: { type: 'card', items: [{ label: 'Holders' }] } }, - // Both spellings, SAME value: the redundant twin goes (#4923). - { type: 'page:tabs', properties: { tabStyle: 'pill', type: 'pill', items: [] } }, - // Both spellings, DIFFERENT looks: kept, so the author picks one - // rather than the loader picking for them. - { type: 'page:tabs', properties: { tabStyle: 'pill', type: 'card', items: [] } }, - // A `type` one level down inside another component's properties - // is a different key entirely: `action.type` is the action's - // discriminator, and the walk descends into CONTAINER keys - // (`children` / `items[].children` / `body` / `footer`), never - // into an arbitrary props value. - { - type: 'element:button', - properties: { label: 'Open', action: { type: 'url', target: '/x' } }, - }, - // Tabs nested in a card, and tabs inside a tab panel (#6775): - // the rewrite reaches both, and the outer strip's own `items` - // stay ordinary tab records, not components. { type: 'page:card', properties: { - title: 'Related', + title: 'Pipeline', children: [ - { type: 'page:tabs', properties: { type: 'pill', items: [{ label: 'Notes' }] } }, + { type: 'page-header', properties: { title: 'Open', description: 'This quarter' } }, + ], + footer: [ + { type: 'page:header', properties: { title: 'Closed', description: 'Last quarter' } }, ], }, }, @@ -6168,9 +2110,18 @@ const pageTabsTypeToTabStyle: MetadataConversion = { tabStyle: 'line', items: [ { - label: 'Nested', + label: 'Activity', children: [ - { type: 'page:tabs', properties: { type: 'card', items: [] } }, + { type: 'page:header', properties: { title: 'Recent', description: 'Last 7 days' } }, + // Two levels down — the recursion, not just one hop. + { + type: 'page:card', + properties: { + children: [ + { type: 'page:header', properties: { title: 'Nested', description: 'Deep' } }, + ], + }, + }, ], }, ], @@ -6180,40 +2131,47 @@ const pageTabsTypeToTabStyle: MetadataConversion = { }, ], }, - // The shape this key is really authored in: `page:tabs` IS one of the - // seven named slots, and all four in-repo sites are `slots.tabs` on a - // `kind: 'slotted'` record page. Region-only reach would have missed - // every one of them (#6776 — see `mapPageComponents`). + ], + }, + after: { + pages: [ + { + name: 'crm_lead_detail', + regions: [ + { + name: 'header', + components: [ + { type: 'page-header', properties: { title: 'Leads', subtitle: 'All open leads' } }, + { type: 'page:header', properties: { title: 'Lead', subtitle: 'One lead' } }, + { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + ], + }, + ], + }, { - name: 'sys_user_detail', + name: 'crm_account_detail', + kind: 'slotted', regions: [], slots: { - tabs: { type: 'page:tabs', properties: { type: 'line', position: 'top', items: [] } }, + header: { type: 'page:header', properties: { title: '{name}', subtitle: 'Account overview' } }, }, }, - ], - }, - after: { - pages: [ { - name: 'sys_position_detail', + name: 'crm_pipeline_dashboard', regions: [ { name: 'main', components: [ - { type: 'page:tabs', properties: { tabStyle: 'card', items: [{ label: 'Holders' }] } }, - { type: 'page:tabs', properties: { tabStyle: 'pill', items: [] } }, - { type: 'page:tabs', properties: { tabStyle: 'pill', type: 'card', items: [] } }, - { - type: 'element:button', - properties: { label: 'Open', action: { type: 'url', target: '/x' } }, - }, { type: 'page:card', properties: { - title: 'Related', + title: 'Pipeline', children: [ - { type: 'page:tabs', properties: { tabStyle: 'pill', items: [{ label: 'Notes' }] } }, + { type: 'page-header', properties: { title: 'Open', subtitle: 'This quarter' } }, + ], + footer: [ + { type: 'page:header', properties: { title: 'Closed', subtitle: 'Last quarter' } }, ], }, }, @@ -6223,9 +2181,17 @@ const pageTabsTypeToTabStyle: MetadataConversion = { tabStyle: 'line', items: [ { - label: 'Nested', + label: 'Activity', children: [ - { type: 'page:tabs', properties: { tabStyle: 'card', items: [] } }, + { type: 'page:header', properties: { title: 'Recent', subtitle: 'Last 7 days' } }, + { + type: 'page:card', + properties: { + children: [ + { type: 'page:header', properties: { title: 'Nested', subtitle: 'Deep' } }, + ], + }, + }, ], }, ], @@ -6235,126 +2201,208 @@ const pageTabsTypeToTabStyle: MetadataConversion = { }, ], }, - { - name: 'sys_user_detail', - regions: [], - slots: { - tabs: { type: 'page:tabs', properties: { tabStyle: 'line', position: 'top', items: [] } }, - }, - }, ], }, - expectedNotices: 5, + expectedNotices: 7, }, }; /** - * `page:header.icon` and `page:card.actions` — two declared page-component props - * with NO renderer read point at all (protocol 17, #6946, ADR-0049). - * - * Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」) on - * objectui#3829, **route (c)**: retire both upstream. That card had put three - * shapes on the table — wire the key, publish it with a KNOWN GAP marker (the - * `record:activity.showSubscriptionToggle` precedent), or retire it here — and - * the ruling took the third, so the platform contract loses two keys rather - * than growing two features nobody asked for. - * - * What "no read point" means, per key, measured against objectui at the - * `.objectui-sha` pin (`09987b68`) rather than inherited from the card: - * - * - **`page:header.icon`** — `PageHeaderRenderer` resolves `icon` only inside - * the ACTION pipeline (`action.icon`, per button); the header's own props - * bag is never asked for one. `@object-ui/layout`'s `` does - * draw an `icon`, but only from a REACT prop a host passes: unlike - * `actions`, which four lines away falls back to `schema?.actions ?? - * schema?.properties?.actions`, `icon` has no schema fallback, so an - * authored node cannot reach it. The registration publishes no `icon` - * input either. - * - **`page:card.actions`** — `PageCardRenderer` builds its `` from - * `title`, `bordered`, `body ?? children` and `footer`, full stop. There is - * no actions area in the markup and no `actions` input in the - * registration. Its sibling `page:header` DOES read `actions` off the bag, - * which is exactly why the divergence survived: the two declarations look - * identical. - * - * Both keys are in objectui's own `UNPUBLISHED_EXEMPTIONS` map as B-class - * ("spec declares it, NO renderer read point") entries pointing at objectui#3829 - * — an independent measurement of the same fact, taken in the other repo. - * - * **Pure lossless deletes.** Neither key ever had an effect to lose, and - * neither has a lossless rewrite target: the header has no second icon slot - * (its identity is the record chrome), and the card has no actions area to move - * a list into — buttons belong in `children`/`footer` as components, which is a - * page rewrite and not a mechanical one. So this strips, exactly as - * {@link recordPickerInertKeysRemoved} does for the picker's two inert keys. - * - * ⚠️ `page:header.actions` is NOT touched — it is read (`containers.tsx`, and - * `@object-ui/layout`'s header) and stays. One key name, two components, one - * of them live: the strip is scoped by component `type`, never by key name. - * - * objectui#3829 (drop the two exemptions) is Blocked-by #6946 and proceeds on - * the next pin bump. + * The SDUI component-props reconciliation (protocol 17, #5775) — three entries + * below, one shared reason. + * + * #5068 wired the first parse `ComponentPropsMap` ever had and measured what + * the corpus actually authors against it. The measurement came back with + * divergence in BOTH directions: keys objectui's renderers honour that the + * schema never declared, and keys the schema declared (one of them REQUIRED) + * that no renderer has ever read. The maintainer's 2026-08-06 ruling took + * direction A — the #5611 rule, "the delivered and authorized shape is the + * contract" — so the honoured keys were declared and the unread ones retire + * here. + * + * **The reach is every position a picker can be authored in** (#6775). + * {@link mapPageComponents} walks `regions[].components[]`, `slots.` and + * the containers a component nests under its `properties` — the same set + * `walkPageComponents` lints — so a picker inside a card's `children` or a tab + * 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 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 `os validate`, `os build` and `os lint` refuse it + * with the prescription (the component-props gate, `@objectstack/lint`; the + * load path keeps the open `properties` bag as written) and only + * `os migrate meta` lists the mechanical edits for existing sources. + */ +const RECORD_PICKER_COMPONENT_TYPE = 'element:record_picker'; + +/** + * Inline `type:'api'` action: object-form `params` → `bodyExtra` (protocol 17, + * #5777). + * + * One key carried two fact-contracts. `InlineActionSchema.params` is picked + * from `ActionSchema` and is an `ActionParam[]` **definition array** — the + * fields a dialog collects before the action runs. What the showcase's + * pure-SDUI contact form authored on its submit button is a **request payload + * map** (`params: { name: '{{page.inquiryName}}', … }`), and objectui's + * `ActionRunner` took both: its own comment says "Accept both — when `params` + * is an array, treat it as the input-collection definition", discriminating on + * `Array.isArray`. A tolerant consumer fossilizing a wrong convention is Prime + * Directive #12's exact shape, and here the fossil was load-bearing: the + * generated reference could only describe the array, so an author following the + * docs could not write a working `api` submit button at all. + * + * The maintainer's 2026-08-06 ruling on #5777 took **direction A — a separate + * payload key**, explicitly refusing the same-name union of option B. The + * separate key is `bodyExtra`, which `ActionSchema` has declared all along for + * exactly this ("static body fragment merged into the outgoing request body for + * `type:'api'` actions"); #5777's spec half picks it onto the inline shape, so + * the rewrite target is a key the contract already owns rather than a third + * name. `payload` is already an alias pointing there (#5013) and `body` is + * already the `script` hook body, so those two spellings were never available. + * + * **Why a conversion and not a deletion.** The two shapes are disjoint — + * `Array.isArray` decides, with no value that could be read either way — so the + * rewrite is mechanical and lossless, which is the ADR-0087 D2 precondition. + * Deleting instead would strand every page authored the old way on a bare + * `expected array, received object`. + * + * **Scoped to `type:'api'`, deliberately.** Object-form `params` on an inline + * `type:'url'` action is a THIRD meaning again — `ActionRunner.interpolateTarget` + * reads it as the `${param.X}` interpolation scope, and `executeUrl` reads + * `params.newTab` — so rewriting those into an api request body would be lossy, + * not lossless. Nothing in the reachable corpus authors that shape; it stays + * refused by the array-only field, and this entry does not touch it. + * + * **Not extended to registered actions.** `ActionSchema` is parsed at + * `defineAction`, so its array-only `params` has always refused the object form + * at the authoring door — the defect existed only on the inline path, where + * `PageComponent.properties` is an open bag and nothing parsed the props until + * #5068. Registered actions already reach the payload through `bodyExtra`. + * + * Precedence is {@link renameKey}'s house rule and nothing new: an + * already-present `bodyExtra` WINS and a differing object-form `params` is left + * exactly where it sits, for the author to reconcile (#4923). + * + * The reach is every position a button can be authored in, as for + * {@link pageHeaderSubtitleAlias} and the retired `page-card-body-to-children` + * (#6775, retired with the protocol-16.x kits in #25767): + * `regions[].components[]`, `slots.`, and the containers a component + * nests under its `properties` — which is where a submit button most often + * sits, inside the card that holds the form. The array-only field still covers + * anything the rewrite declines to touch: it refuses the object form with a + * message naming `bodyExtra`, at any position. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23421). The authoring funnel no longer rewrites the object form, + * so an author meets that array-only field's refusal naming `bodyExtra` at + * every door that judges page props — `os validate` / `os build` / `os lint` + * and the metadata save door alike, where before the first two converted + * silently and the save door refused. Stored rows keep healing: stored-row + * rehydration replays the full chain, this entry included + * (`applyConversionsToStoredItem`), and so do the artifact-ingestion door + * (inside its window) and `os migrate meta`. */ -const pageStructureInertKeysRemoved: MetadataConversion = { - id: 'page-structure-inert-keys-removed', +const inlineActionApiParamsToBodyExtra: MetadataConversion = { + id: 'inline-action-api-params-to-body-extra', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.page:header.icon / page.component.page:card.actions', + retiredAfter: '17.7.0', + surface: 'page.component.element:button.action.params', summary: - "page:header prop 'icon' and page:card prop 'actions' removed (neither has a renderer " - + 'read point in objectui; the header resolves icons per action and the card renders ' - + 'title/children/footer only)', + "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (a static payload and a parameter definition are two things, so the payload gets its own key; `params` stays the ActionParam[] definition array)", apply(stack, emit) { return mapPageComponents(stack, (component, path) => { + if (component.type !== 'element:button') return component; const properties = component.properties; if (!isDict(properties)) return component; - // Scoped by component type: `actions` is LIVE on `page:header` and `icon` - // is live on half a dozen other components, so a key-name-only strip - // would delete working metadata. - const key = - component.type === 'page:header' ? 'icon' - : component.type === 'page:card' ? 'actions' - : null; - if (!key) return component; - const stripped = stripKeys(properties, [key], emit, `${path}.properties`); - if (stripped === properties) return component; - return { ...component, properties: stripped }; + const action = properties.action; + if (!isDict(action)) return component; + // Only the payload meaning converts. `Array.isArray` is the whole + // discriminator — a definition array stays put — and the `api` guard + // keeps the url-action interpolation scope out of it. + if (action.type !== 'api') return component; + if (!isDict(action.params)) return component; + const renamed = renameKey(action, 'params', 'bodyExtra'); + if (!renamed) return component; + emit({ from: 'params', to: 'bodyExtra', path: `${path}.properties.action.bodyExtra` }); + return { ...component, properties: { ...properties, action: renamed } }; }); }, fixture: { before: { pages: [ { - name: 'connect_agent', + name: 'showcase_contact_form', regions: [ { - name: 'header', + name: 'main', components: [ - // The published platform-page shape: title + subtitle + a - // decorative icon nothing drew. + // The measured site: a pure-SDUI form submit posting page vars. { - type: 'page:header', - properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', icon: 'bot' }, + type: 'element:button', + properties: { + label: 'Submit inquiry', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + params: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, + }, + }, }, - // `page:header.actions` SURVIVES — the live half of the same key - // name (see the ⚠️ above). - { type: 'page:header', properties: { title: 'Lead', actions: ['convert_lead'], icon: 'user' } }, - // `icon` on a component that is not a page header — not this - // entry's key, and live on that one. - { type: 'element:button', properties: { label: 'Open', icon: 'external-link' } }, + // A real ActionParam[] definition array — the other meaning of + // the same key, untouched. { - type: 'page:card', - properties: { title: 'Shortcuts', actions: ['new_task'], footer: [{ type: 'element:text' }] }, + type: 'element:button', + properties: { + label: 'Close order', + action: { + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }, + }, + }, + // Both spellings, DIFFERENT payloads: kept, so the author + // reconciles the two bodies rather than the loader picking. + { + type: 'element:button', + properties: { + label: 'Both', + action: { + type: 'api', + target: '/api/v1/x', + params: { a: 1 }, + bodyExtra: { b: 2 }, + }, + }, + }, + // `type:'url'` — object-form `params` is the `${param.X}` + // interpolation scope there, a third meaning. Not this entry's. + { + type: 'element:button', + properties: { + label: 'Open', + action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, + }, }, - // A card nested inside a card's `children` (#6775 reach): the - // descent visits it, so the inner `actions` goes too. + // The shape a pure-SDUI form is actually built in (#6775): the + // submit button sits in the card's `footer`, one container down. { type: 'page:card', properties: { - title: 'Outer', - children: [ - { type: 'page:card', properties: { title: 'Inner', actions: ['edit'] } }, + title: 'Contact us', + footer: [ + { + type: 'element:button', + properties: { + label: 'Send', + action: { type: 'api', target: '/api/v1/forms/send', params: { note: '{{page.note}}' } }, + }, + }, ], }, }, @@ -6362,14 +2410,21 @@ const pageStructureInertKeysRemoved: MetadataConversion = { }, ], }, - // The named-slot shape (#6776): a header authored into a slotted - // record page's `details` slot. + // The named-slot shape: an action button in a slotted page's `actions`. { - name: 'connect_agent_detail', + name: 'showcase_contact_detail', kind: 'slotted', regions: [], slots: { - details: { type: 'page:header', properties: { title: 'Agent', icon: 'bot' } }, + actions: [ + { + type: 'element:button', + properties: { + label: 'Resend', + action: { type: 'api', target: '/api/v1/forms/resend', params: { id: '{{record.id}}' } }, + }, + }, + ], }, }, ], @@ -6377,178 +2432,85 @@ const pageStructureInertKeysRemoved: MetadataConversion = { after: { pages: [ { - name: 'connect_agent', + name: 'showcase_contact_form', regions: [ { - name: 'header', + name: 'main', components: [ { - type: 'page:header', - properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.' }, + type: 'element:button', + properties: { + label: 'Submit inquiry', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + bodyExtra: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, + }, + }, }, - { type: 'page:header', properties: { title: 'Lead', actions: ['convert_lead'] } }, - { type: 'element:button', properties: { label: 'Open', icon: 'external-link' } }, { - type: 'page:card', - properties: { title: 'Shortcuts', footer: [{ type: 'element:text' }] }, + type: 'element:button', + properties: { + label: 'Close order', + action: { + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }, + }, }, { - type: 'page:card', + type: 'element:button', properties: { - title: 'Outer', - children: [ - { type: 'page:card', properties: { title: 'Inner' } }, - ], + label: 'Both', + action: { + type: 'api', + target: '/api/v1/x', + params: { a: 1 }, + bodyExtra: { b: 2 }, + }, }, }, - ], - }, - ], - }, - { - name: 'connect_agent_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'page:header', properties: { title: 'Agent' } }, - }, - }, - ], - }, - expectedNotices: 5, - }, -}; - -/** - * `record:details.layout` — a mode selector whose two declared modes were never - * implemented (protocol 17, #6946, ADR-0049). - * - * Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」) on - * objectui#3818: the removal direction. - * - * This one is NOT a zero-read-point key, and the distinction is the whole - * finding. `RecordDetailsRenderer` does read it — and reads it against values - * this schema never permitted: - * - * const layout = schema.layout === 'inline' || schema.layout === 'compact' - * ? 'horizontal' : 'vertical'; - * - * The declared enum is `auto | custom`. Neither matches, so both legal values - * take the same `vertical` branch: the key was accepted, read, and could not - * change anything. Its `.describe()` meanwhile promised "auto uses object - * highlightFields, custom uses explicit sections" — a behaviour the renderer - * implements, but keyed off whether `sections` is authored at all, never off - * this flag. - * - * **Why every gate stayed green over it.** `check:react-declaration-parity` - * compares two DECLARATIONS, not a declaration against an implementation - * (AGENTS.md) — and objectui's registry declared `layout` with the SAME - * `auto | custom` enum, so the gate saw perfect agreement over a key nothing - * honoured. A third spelling, `stacked | inline | compact`, sat in - * `@object-ui/types`' `RecordDetailsComponentProps` mirror. Three declarations - * of one key, none of them the branch the renderer takes. - * - * **A PURE STRIP, deliberately.** There is no value to carry: `auto` and - * `custom` were behaviourally identical to each other and to omission, so a - * rewrite would have nowhere lossless to go. What already decides the body is - * what the author wrote — `sections` for explicit groups, its absence for the - * object's `highlightFields`. - * - * ⚠️ `record:highlights.layout` is a DIFFERENT, live key (`horizontal | - * vertical`, honoured) and is untouched: this entry is scoped by component - * `type`. - * - * objectui#3818 (delete the `layout` input and the dead `inline|compact` - * branch) is Blocked-by #6946 and proceeds on the next pin bump. - */ -const recordDetailsLayoutRemoved: MetadataConversion = { - id: 'record-details-layout-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.record:details.layout', - summary: - "record:details component prop 'layout' removed (the declared auto|custom modes were " - + 'never implemented; the renderer branches only on inline|compact, values the schema never ' - + 'permitted, so both legal values selected nothing)', - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== 'record:details') return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const stripped = stripKeys(properties, ['layout'], emit, `${path}.properties`); - if (stripped === properties) return component; - return { ...component, properties: stripped }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'task_detail_layout', - regions: [ - { - name: 'main', - components: [ - // The `custom` spelling, alongside the `sections` that actually - // decides the body. { - type: 'record:details', + type: 'element:button', properties: { - layout: 'custom', - columns: '2', - sections: [{ name: 'basics', fields: ['subject'] }], + label: 'Open', + action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, }, }, - // The `auto` spelling — the declared default, written out. - { type: 'record:details', properties: { layout: 'auto' } }, - // `layout` on a component that is not `record:details`: - // `record:highlights.layout` is LIVE and must survive. - { type: 'record:highlights', properties: { layout: 'horizontal', fields: ['status'] } }, - ], - }, - ], - }, - { - name: 'task_detail_layout_slotted', - kind: 'slotted', - regions: [], - slots: { - details: [ - { type: 'record:details', properties: { layout: 'custom', fields: ['name'] } }, - ], - }, - }, - ], - }, - after: { - pages: [ - { - name: 'task_detail_layout', - regions: [ - { - name: 'main', - components: [ { - type: 'record:details', + type: 'page:card', properties: { - columns: '2', - sections: [{ name: 'basics', fields: ['subject'] }], + title: 'Contact us', + footer: [ + { + type: 'element:button', + properties: { + label: 'Send', + action: { type: 'api', target: '/api/v1/forms/send', bodyExtra: { note: '{{page.note}}' } }, + }, + }, + ], }, }, - { type: 'record:details', properties: {} }, - { type: 'record:highlights', properties: { layout: 'horizontal', fields: ['status'] } }, ], }, ], }, { - name: 'task_detail_layout_slotted', + name: 'showcase_contact_detail', kind: 'slotted', regions: [], slots: { - details: [ - { type: 'record:details', properties: { fields: ['name'] } }, + actions: [ + { + type: 'element:button', + properties: { + label: 'Resend', + action: { type: 'api', target: '/api/v1/forms/resend', bodyExtra: { id: '{{record.id}}' } }, + }, + }, ], }, }, @@ -6558,229 +2520,6 @@ const recordDetailsLayoutRemoved: MetadataConversion = { }, }; -/** - * `app.hidden: true` → `app._unpublished: true` on **stored** rows (protocol 17, - * #4829, ADR-0045 amended 2026-08-09). - * - * ADR-0045 §3 originally hung its publish gate on `app.hidden`, citing an - * "ADR-0019 launcher contract" that does not exist — ADR-0019 contains no - * `hidden`. `hidden` already had a contract of its own, written in - * `ui/app.zod.ts` the day the key was born: navigation presentation, *"hidden - * apps stay fully routable and permission-checked"*, for personal-settings apps - * reached from the avatar menu. One boolean, two contracts, contradicting each - * other on the only question that matters — and the platform's own `account` - * app, authored `hidden: true` on purpose, was therefore withheld from every - * user without builder access. The gate now reads the machine-managed - * `_unpublished`; this entry carries the existing population across. - * - * **Why the rewrite is unambiguous.** Under the old regime a `hidden: true` row - * in `sys_metadata` could only have come from the materialization path, because - * that value *was* the gate: an app stored that way was invisible to every - * non-builder, so nobody stored it to mean "keep me out of the switcher". The - * one app that really does mean that is code-declared (`platform-objects`' - * ACCOUNT_APP), and code-declared artifacts never enter `sys_metadata`. The - * Studio app form has no `hidden` control either (`ui/app.form.ts`), so no - * authoring path could have produced a second meaning. - * - * **`retiredFromLoadPath: true` — load-bearing here, not bookkeeping.** - * Retirement is what confines this rewrite to *stored* rows. `hidden` is NOT - * retired as an authorable key — it keeps its birth contract, narrowed to - * navigation — so a conversion running on the load path would rewrite - * `defineApp({ hidden: true })`, and ACCOUNT_APP itself, into unpublished apps - * and reproduce #4829 through the conversion layer. Excluded from the load path, - * it replays only where the old meaning is the only meaning: the stored-row - * rehydration seams (`applyConversionsToStoredItem`, which pins - * `includeRetired`) and `os migrate meta`. - * - * That split is also the answer for anyone who later wants a *stored* app to be - * nav-hidden: declare it on the app artifact, which this entry never touches. If - * a stored-row spelling is ever wanted it needs its own decision — a Studio - * control, and a rule for how the two populations coexist — not this entry - * quietly ceasing to fire. - * - * A row that already carries `_unpublished` is left ALONE, both keys intact - * ({@link renameKey}'s house rule, #4923): the machine has already spoken about - * that row, and a disagreeing pair is for a human to reconcile rather than for - * the loader to pick a winner. It is also what makes a second pass a no-op. - */ -const appHiddenToUnpublished: MetadataConversion = { - id: 'app-hidden-to-unpublished', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'app.hidden', - summary: - "stored app publish gate 'hidden' → '_unpublished' (ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", - apply(stack, emit) { - return mapCollection(stack, 'apps', (app, path) => { - if (app.hidden !== true) return app; - if (app._unpublished != null) return app; - const next = { ...app }; - delete next.hidden; - next._unpublished = true; - emit({ from: 'hidden', to: '_unpublished', path: `${path}._unpublished` }); - return next; - }); - }, - fixture: { - // DISJOINT from every other app fixture: none of these apps carries a key - // another entry strips, so each replays through the whole table hitting - // only its own. - before: { - apps: [ - // The materialized build mid-flight — the population this exists for. - { name: 'production_management', label: '生产管理', hidden: true, navigation: [] }, - // Published and listed: `hidden: false` meant exactly that under both - // regimes, so there is nothing to rewrite. - { name: 'crm', label: 'CRM', hidden: false, navigation: [] }, - // Already carries the canonical gate. Left verbatim — the loader does - // not reconcile a disagreeing pair on the author's behalf (#4923), and - // this is what makes a second pass a no-op. - { name: 'team_settings', hidden: true, _unpublished: false, navigation: [] }, - ], - }, - after: { - apps: [ - { name: 'production_management', label: '生产管理', _unpublished: true, navigation: [] }, - { name: 'crm', label: 'CRM', hidden: false, navigation: [] }, - { name: 'team_settings', hidden: true, _unpublished: false, navigation: [] }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * `global_nav` leaves `ACTION_LOCATIONS` (protocol 17, #6888 — ADR-0049 - * enforce-or-remove, maintainer ruling 2026-08-09). - * - * The location was declared from the day the vocabulary was written and no - * product surface ever served it. The console's ⌘K palette - * (`app-shell/src/chrome/CommandPalette.tsx`) composes its seven groups from - * nav items, objects, dashboards, pages, reports, recent items, record search - * and theme — its `actions` group is hard-coded chrome — and the file - * references neither `global_nav` nor any action-metadata source at all. Of the - * five references to the value in the whole UI repo at the vendored SHA, four - * were the Studio designer and the fifth a doc comment. - * - * What makes it worse than an ordinary inert declaration is the direction of - * the lie: `metadata-admin/previews/ActionPreview.tsx` drew the author a mock - * `⌘K · Command palette` frame, so the authoring tool PROMISED a rendering the - * product cannot do. An author declares the location, watches it "work" in the - * designer, ships it, and it renders nowhere — the ADR-0078 - * declares/renders/does-nothing shape, arriving through the location - * vocabulary rather than through a missing key. Retired rather than - * implemented: no user has asked for command-palette actions and the only two - * declarers were our own showcase corpus, so wiring the palette would have been - * capability expansion with no pull. If real appetite appears it re-enters - * through the front door, implementation first. - * - * Like `hook-body-crypto-hash-removed` and - * `dataset-measure-array-string-agg-removed` above, this is an enum-VALUE - * retirement: there is no `retiredKey()` tombstone to hang the prescription on, - * so the enum's own error map carries it (`GLOBAL_NAV_RETIRED`, - * `ui/action.zod.ts`), keyed on `issue.input` so only the spelling that used to - * be legal is told it "was removed". For the same reason nothing lands in - * `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are expected to be - * byte-identical — no def and no authorable KEY changed. - * - * ## The empty-array edge, decided deliberately - * - * The value is stripped from `locations` and **the key is kept even when the - * array empties** — `locations: []`, never `delete locations`. On this surface - * the two are different declarations, not two spellings of one: - * `content/docs/ui/actions.mdx` ("Headless actions: declare it, then hide it") - * documents `[]` as a first-class shape — the action stays callable over - * REST/MCP/AI and keeps its capability gate, param contract and audit trail — - * while an ABSENT key means the author never placed the action at all, which is - * exactly what `packages/lint`'s `action-no-placement` rule warns about. The - * rule states the distinction in those words: "an author who said 'nowhere, - * deliberately' (`[]`) and one who never said anything at all (key absent)". - * Dropping the key would therefore convert a deliberate placement into a lint - * finding and lose the author's own statement of intent. Stripping to `[]` is - * also the truthful reading of the retirement: the action's UI home is gone, - * and headless is what it now is. - * - * This differs from `dataset-measure-array-string-agg-removed`, which DROPS the - * measure, for a reason that is about validity rather than taste: a measure - * with neither `aggregate` nor `derived` fails the dataset's own `superRefine`, - * so stripping alone would emit an item that cannot parse. An action with - * `locations: []` parses, and is documented to. It follows - * `hook-body-crypto-hash-removed`'s "the `capabilities` key itself stays (an - * empty grant set is legal)" instead. - * - * `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` — 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` lists the mechanical edits for author sources. - */ -const actionGlobalNavLocationRemoved: MetadataConversion = { - id: 'action-global-nav-location-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'action.locations[]', - summary: - "action location 'global_nav' removed (no running-app surface rendered it; the ⌘K " - + 'palette reads no action metadata, while the Studio designer previewed a command-palette ' - + 'frame for it. The value is stripped and the key kept, so an action left with no location ' - + 'becomes the documented headless shape `locations: []`)', - apply(stack, emit) { - return mapCollection(stack, 'actions', (action, path) => { - const locations = action.locations; - if (!Array.isArray(locations) || !locations.includes('global_nav')) return action; - emit({ from: 'global_nav', to: '(removed)', path: `${path}.locations` }); - return { ...action, locations: locations.filter((l) => l !== 'global_nav') }; - }); - }, - fixture: { - before: { - actions: [ - // The empty-array edge: `global_nav` was this action's ONLY location, - // so the strip empties the array. The key survives — see the docblock. - { - name: 'portfolio_snapshot', - label: 'Portfolio Snapshot', - type: 'script', - locations: ['global_nav'], - }, - // Surgical: the surviving locations stay, in order, beside the strip. - { - name: 'new_task', - label: 'New Task', - type: 'modal', - target: 'gallery', - locations: ['record_header', 'global_nav', 'list_toolbar'], - }, - // An action without the retired value passes through untouched. - { - name: 'mark_done', - label: 'Mark Done', - type: 'script', - locations: ['list_item'], - }, - ], - }, - after: { - actions: [ - { name: 'portfolio_snapshot', label: 'Portfolio Snapshot', type: 'script', locations: [] }, - { - name: 'new_task', - label: 'New Task', - type: 'modal', - target: 'gallery', - locations: ['record_header', 'list_toolbar'], - }, - { name: 'mark_done', label: 'Mark Done', type: 'script', locations: ['list_item'] }, - ], - }, - // One per rewritten action — `mark_done` is untouched. - expectedNotices: 2, - }, -}; - /** * Field `scale` / `precision`: malformed declarations removed (protocol 18, * #8321). @@ -10632,7 +6371,8 @@ const objectGridDefaultFiltersRemoved: MetadataConversion = { * nothing at runtime, so stopping the reads without this producer-side * retirement would turn a declared contract into a silent no-op. * - * NOT a pure lossless delete, unlike {@link pageStructureInertKeysRemoved}: + * NOT a pure lossless delete, unlike the retired `page-structure-inert-keys-removed` + * (#25767): * when `sort` is absent the fallback WAS the grid's sort, so the conversion * carries the pair over — renamed to `sort` and wrapped in the array shape the * renderer itself wraps it into at `:2847`. When `sort` is present the @@ -11209,8 +6949,8 @@ const objectKanbanQuickAddRemoved: MetadataConversion = { * (`previews/block-config.ts`), so stored `sys_metadata` pages can carry * either value. No example app, skill or doc in this repo authors it. * - * ⚠️ Scoped by component `type`, never by key name, as - * {@link pageStructureInertKeysRemoved} scoped `icon`: `breadcrumb` is an + * ⚠️ Scoped by component `type`, never by key name, as the retired + * `page-structure-inert-keys-removed` scoped `icon`: `breadcrumb` is an * ordinary word for an open-namespace component's own prop, and the kebab * `page-header` spelling is objectui's legacy registration, not a key this * spec declares. `nav:breadcrumb` — a component TYPE, not this key — is @@ -12488,8 +8228,8 @@ const formViewOptionDefaultRemoved: MetadataConversion = { * new: an already-canonical `reference` WINS — a redundant twin is dropped, a * DISAGREEING pair is left for the author to reconcile rather than the loader * picking a target. Covers object fields and object-extension fields: the same - * `FieldSchema`, so the same dialect ({@link fieldConditionalRequiredToRequiredWhen} - * is the precedent, one protocol back). + * `FieldSchema`, so the same dialect (`field-conditionalRequired-to-requiredWhen`, + * retired with the protocol-16.x kits in #25767, was the precedent one protocol back). */ const fieldReferenceToAlias: MetadataConversion = { id: 'field-reference-to-alias', @@ -14272,8 +10012,8 @@ const connectorTriggersRemoved: MetadataConversion = { * ONE value with TWO spellings across sibling arms of one union, and the driver * reading both. That is the consumer-side dialect Prime Directive #12 forbids. * - * Driver-awareness is load-bearing here for the reason - * {@link datasourceConfigDriverKeyAliases} states: `persistence` is a memory + * Driver-awareness is load-bearing here for the reason the retired + * `datasource-config-driver-key-aliases` stated: `persistence` is a memory * driver key, and a `persistence` block under some other driver is not this * shape. `resolveDriverId` keeps the rewrite where it belongs. * @@ -14594,8 +10334,8 @@ const tursoConfigEncryptionKeyRemoved: MetadataConversion = { * `datasources[].config.timeout` → `timeoutMs` for the turso driver (protocol * 18, #15680 for #14478). * - * Driver-awareness is load-bearing exactly as it is for - * {@link datasourceConfigDriverKeyAliases}: a bare `config.timeout` under some + * Driver-awareness is load-bearing exactly as it was for the retired + * `datasource-config-driver-key-aliases`: a bare `config.timeout` under some * OTHER driver is that driver's own key and must not be touched, so the rewrite * is gated on `resolveDriverId(ds.driver) === 'turso'` (which also catches a * stored `driver: 'libsql'` through the alias table). @@ -18441,14 +14181,9 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ * the built `./shared` entry. */ export const CONVERSIONS_BY_MAJOR: Readonly> = { - 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], - 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], - 14: [bookAudienceProfileToPermissionSet], + 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias], 15: [viewVisibleOnToVisibleWhen, pageComponentVisibilityToVisibleWhen], 17: [ - actionExecuteToTarget, - fieldConditionalRequiredToRequiredWhen, - agentToolsToSkills, sharingRuleAccessLevelFullToEdit, flowNodeCrudObjectAlias, flowNodeNotifyConfigAliases, @@ -18457,59 +14192,15 @@ export const CONVERSIONS_BY_MAJOR: Readonly { - it('replays a RETIRED conversion over a stored object row (conditionalRequired → requiredWhen)', () => { + it('replays a RETIRED conversion over a stored object row (externalSharingModel removed)', () => { const row = { - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + name: 'portal_case', + label: 'Case', + sharingModel: 'public_read', + externalSharingModel: 'private', }; const notices: ConversionNotice[] = []; - const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }); - expect(out.fields.due_date).toEqual({ type: 'date', requiredWhen: 'record.stage == "closed"' }); - expect(notices.map((n) => n.conversionId)).toContain('field-conditionalRequired-to-requiredWhen'); + const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as Record; + expect(out).toEqual({ name: 'portal_case', label: 'Case', sharingModel: 'public_read' }); + expect(notices.map((n) => n.conversionId)).toContain('object-external-sharing-model-removed'); }); - it('replays a retired conversion over a standalone stored action row (execute → target)', () => { - const row = { name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }; + it('replays a retired conversion over a standalone stored action row (aria removed)', () => { + const row = { name: 'escalate', label: 'Escalate', type: 'script', aria: { ariaLabel: 'Escalate', role: 'button' } }; const out = applyConversionsToStoredItem('action', row) as Record; - expect(out.target).toBe('convertHandler'); - expect('execute' in out).toBe(false); + expect(out).toEqual({ name: 'escalate', label: 'Escalate', type: 'script' }); + expect('aria' in out).toBe(false); }); it('reaches actions EMBEDDED in a stored object row', () => { const row = { name: 'crm_lead', label: 'Lead', - actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }], + actions: [{ name: 'reopen', type: 'script', aria: { ariaDescribedBy: 'reopen_help' } }], }; const out = applyConversionsToStoredItem('object', row) as { actions: Array> }; - expect(out.actions[0]!.target).toBe('convertHandler'); - expect('execute' in out.actions[0]!).toBe(false); + expect(out.actions[0]).toEqual({ name: 'reopen', type: 'script' }); + expect('aria' in out.actions[0]!).toBe(false); + }); + + // [#25767] The bound stated above, pinned: a ≤16.x kit is gone from the + // registered table, so its key is left exactly as stored — the row is not + // half-migrated by some other entry, and nothing is reported. This is what + // makes "rehydrate on ≤18.1 first" the prescription rather than a courtesy. + it('leaves an aged-out key (≤16.x kit) as stored — the stored pass converts only what is registered', () => { + const row = { + name: 'crm_task', + label: 'Task', + enable: { trash: false, searchable: true }, + fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + }; + const notices: ConversionNotice[] = []; + expect(applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) })).toBe(row); + expect(notices).toEqual([]); }); // #8321 — the authoring schema now refuses non-integer/negative @@ -68,27 +89,9 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { }); it('accepts the legacy PLURAL row-type spelling', () => { - const row = { name: 'crm_deal', label: 'Deal', sharingModel: 'read' }; + const row = { name: 'crm_deal', label: 'Deal', sharingModel: 'private', externalSharingModel: 'private' }; const out = applyConversionsToStoredItem('objects', row) as Record; - expect(out.sharingModel).toBe('public_read'); - }); - - // #3207 — a 16.x row still carrying the retired `enable.trash`/`enable.mru` - // flags replays clean instead of tripping the registry's spec-conformance - // diagnostic forever (`.strict()` capabilities would flag it - // `metadata_spec_invalid` on every rehydration). - it('strips the retired enable.trash/mru flags from a stored object row', () => { - const row = { - name: 'sys_audit_log', - label: 'Audit Log', - enable: { trash: false, mru: false, searchable: true }, - }; - const notices: ConversionNotice[] = []; - const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as { - enable: Record; - }; - expect(out.enable).toEqual({ searchable: true }); - expect(notices.filter((n) => n.conversionId === 'object-enable-trash-mru-removed')).toHaveLength(2); + expect(out).toEqual({ name: 'crm_deal', label: 'Deal', sharingModel: 'private' }); }); it('is idempotent — a canonical row passes through by reference', () => { @@ -103,7 +106,8 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { it('never mutates the stored input', () => { const row = { name: 'crm_task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.x == 1' } }, + sharingModel: 'private', + externalSharingModel: 'private', }; const snapshot = structuredClone(row); applyConversionsToStoredItem('object', row); @@ -119,52 +123,33 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { expect(applyConversionsToStoredItem('object', arr)).toBe(arr); }); - // #4456 — the driver-factory `??` fallback graduation. A runtime datasource - // persisted before the #4410 config gate may carry the legacy spellings; the - // stored pass MUST serve it canonical, because the factory now reads exactly - // one spelling per key (deleting the fallbacks without this replay would - // silently move a sqlite `file:` row's data to `:memory:`). - describe('stored datasource rows (datasource-config-driver-key-aliases)', () => { - // The fourth column is the driver id the stored pass SERVES, which differs - // from the stored one for exactly one row: commit e2798fab7 renamed the canonical mongo - // id to `mongodb`, and `datasource-driver-mongo-to-mongodb` converges the - // stored spelling in the same replay. Both conversions run over one row here, - // which is the case worth pinning — the config-key rename is keyed by - // canonical driver id, so it has to keep landing for a row whose id is itself - // being renamed. + // Commit e2798fab7 renamed the canonical mongo id to `mongodb`; + // `datasource-driver-mongo-to-mongodb` (retired from the load path at 18, + // #23543 F1) converges the stored spelling here. The driver-factory `??` + // fallback graduation that used to run over the same row + // (`datasource-config-driver-key-aliases`, #4456) aged out with the 16.x kits + // (#25767): a runtime datasource row still spelling `file:`/`uri:`/`user:` is + // served as stored, and is rehydrated on a release ≤18.1 to be canonicalized. + describe('stored datasource rows (datasource-driver-mongo-to-mongodb)', () => { it.each([ - ['sqlite', { file: './data/app.db' }, { filename: './data/app.db' }, 'sqlite'], - ['sqlite', { database: './data/app.db' }, { filename: './data/app.db' }, 'sqlite'], - ['postgres', { connectionString: 'postgresql://db/x', user: 'svc' }, { url: 'postgresql://db/x', username: 'svc' }, 'postgres'], - ['mysql', { host: 'db', database: 'orders', user: 'svc' }, { host: 'db', database: 'orders', username: 'svc' }, 'mysql'], - ['mongo', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }, 'mongodb'], - ['mongodb', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }, 'mongodb'], - ])('serves a stored %s row with legacy config keys canonical', (driver, config, expected, servedDriver) => { + ['mongo', 'mongodb'], + ['mongodb', 'mongodb'], + ])('serves a stored %s row as %s, config untouched', (driver, servedDriver) => { + const config = { url: 'mongodb://db/x', username: 'svc' }; const row = { name: 'legacy_ds', driver, config, origin: 'runtime' }; const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; - expect(out.config).toEqual(expected); + expect(out.config).toEqual(config); expect(out).toMatchObject({ name: 'legacy_ds', driver: servedDriver, origin: 'runtime' }); }); - it('leaves `database` alone for the drivers where it is canonical', () => { + it('leaves a canonical row of another driver alone, by reference', () => { const row = { name: 'wh', driver: 'postgres', config: { host: 'db', database: 'analytics' } }; expect(applyConversionsToStoredItem('datasource', row)).toBe(row); }); - it('keeps BOTH spellings on a stored row when they disagree', () => { - const row = { name: 'ds', driver: 'sqlite', config: { filename: './real.db', file: './stale.db' } }; - const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; - // `renameKey` convention since #4923: two DIFFERENT files under two names - // is the author's ambiguity to resolve, so the replay picks neither. - expect(out.config).toEqual({ filename: './real.db', file: './stale.db' }); - }); - - it('drops a redundant alias on a stored row when it repeats the canonical value', () => { - const row = { name: 'ds', driver: 'sqlite', config: { filename: './real.db', file: './real.db' } }; - const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; - // Data at rest gets the same hygiene as a fresh load (#3903 replays the - // full chain), so a rehydrated row is canonical rather than half-migrated. - expect(out.config).toEqual({ filename: './real.db' }); + it('leaves a pre-#4410 legacy config spelling as stored — the alias kit aged out (#25767)', () => { + const row = { name: 'ds', driver: 'sqlite', config: { file: './data/app.db' } }; + expect(applyConversionsToStoredItem('datasource', row)).toBe(row); }); }); diff --git a/packages/spec/src/conversions/view-spelling-walk.test.ts b/packages/spec/src/conversions/view-spelling-walk.test.ts index 92a1d4bf19a..11e93976e9a 100644 --- a/packages/spec/src/conversions/view-spelling-walk.test.ts +++ b/packages/spec/src/conversions/view-spelling-walk.test.ts @@ -25,6 +25,13 @@ * identical payload under `config`, and to the identical payload flattened at * the top level. Every conversion is exercised in all three, because the gap * was family-wide rather than specific to any entry. + * + * The entries driven here are the 17.x view-family retirements still in the + * table (`filterableFields`, `tabs`, `visibleOn`, the per-option `default`, + * the overlay's `owner`/`hidden`). The 16.x ones that used to be driven + * (`pdf`, the inert and pass-through list keys, the form's `defaultSort`/`aria`) + * aged out of the table at 18 (ADR-0087 amendment, #25767), so a row still + * carrying them is served as stored and meets the strict refusal at the parse. */ import { describe, expect, it } from 'vitest'; @@ -113,87 +120,55 @@ describe('view conversions reach the ViewItem-record spelling ({ viewKind, confi ]); }); - it('strips the retired `pdf` export format from a record `config`', () => { + it('strips the retired `filterableFields` whitelist from a record `config`, and only that', () => { const { out, notices } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }).record, - ); - expect((out.config as Dict).exportOptions).toEqual(['xlsx']); - expect(pathsFor(notices, 'view-export-options-pdf-removed')).toEqual([ - 'views[0].config.exportOptions', - ]); - }); - - it('strips `pdf` from the OBJECT export spelling under a record `config`', () => { - const { out } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - exportOptions: { formats: ['csv', 'pdf'], maxRecords: 100 }, + searchableFields: ['name'], // a different key that stays + filterableFields: ['status', 'priority'], }).record, ); - // The surviving sibling keys stay — the strip is the value, not the block. - expect((out.config as Dict).exportOptions).toEqual({ formats: ['csv'], maxRecords: 100 }); + const config = out.config as Dict; + expect('filterableFields' in config).toBe(false); + expect(config.searchableFields).toEqual(['name']); + expect(pathsFor(notices, 'view-list-filterable-fields-removed')).toEqual([ + 'views[0].config.filterableFields', + ]); }); - it('strips the inert LIST keys from a `viewKind: list` record, and only those', () => { + it('strips the retired list `tabs` from a record `config`', () => { const { out, notices } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - responsive: { sm: {} }, - performance: { lazyLoad: true }, - aria: { label: 'Leads' }, // list `aria` is LIVE — must survive - }).record, - ); - const config = out.config as Dict; - expect('responsive' in config).toBe(false); - expect('performance' in config).toBe(false); - expect(config.aria).toEqual({ label: 'Leads' }); - expect(pathsFor(notices, 'view-inert-keys-removed').sort()).toEqual([ - 'views[0].config.performance', - 'views[0].config.responsive', - ]); - }); - - it('strips the inert FORM keys from a `viewKind: form` record, and only those', () => { - const { out } = convertViewRow( - formSpellings({ - type: 'simple', - data: { provider: 'object', object: 'crm_lead' }, // form `data` is LIVE - defaultSort: [{ field: 'created_at', order: 'desc' }], - aria: { label: 'Lead form' }, + tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], }).record, ); const config = out.config as Dict; - expect('defaultSort' in config).toBe(false); - expect('aria' in config).toBe(false); - expect(config.data).toEqual({ provider: 'object', object: 'crm_lead' }); + expect('tabs' in config).toBe(false); + expect(Object.keys(config).sort()).toEqual(['columns', 'type']); + expect(pathsFor(notices, 'view-list-tabs-removed')).toEqual(['views[0].config.tabs']); }); - it('is SHAPE-scoped across the record arms: a form key is not stripped from a list record', () => { - // `aria` is inert on a FORM and live on a LIST. Reading `viewKind` wrong - // (or ignoring it and stripping both key sets) would delete a live key. - const { out } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], aria: { label: 'Leads' } }).record, + it('is SHAPE-scoped across the record arms: a list key is not stripped from a form record', () => { + // `tabs` is a retired LIST key; a form record carrying the same spelling is + // not this entry's business. Reading `viewKind` wrong (or ignoring it and + // stripping by key name alone) would delete a key the entry never named. + const { out, notices } = convertViewRow( + formSpellings({ type: 'simple', tabs: [{ name: 'mine', label: 'Mine' }] }).record, ); - expect((out.config as Dict).aria).toEqual({ label: 'Leads' }); + expect((out.config as Dict).tabs).toEqual([{ name: 'mine', label: 'Mine' }]); + expect(pathsFor(notices, 'view-list-tabs-removed')).toEqual([]); }); - it('strips the pass-through list keys from a record `config`', () => { - const { out, notices } = convertViewRow( - listSpellings({ - type: 'grid', - columns: ['name'], - resizable: true, // live — survives - striped: true, - bordered: true, - virtualScroll: true, - }).record, - ); - const config = out.config as Dict; - expect(config.resizable).toBe(true); - expect(Object.keys(config).sort()).toEqual(['columns', 'resizable', 'type']); - expect(pathsFor(notices, 'view-list-passthrough-keys-removed')).toHaveLength(3); + it('leaves an aged-out key (16.x kit) in a record `config` as stored — only registered entries run here', () => { + // `striped` / `pdf` / `responsive` left the table with their kits (#25767): + // the row is not half-migrated by some other entry, and nothing is reported. + const row = listSpellings({ type: 'grid', columns: ['name'], striped: true, exportOptions: ['xlsx', 'pdf'] }).record; + const { out, notices } = convertViewRow(row); + expect(out).toEqual(row); + expect(notices).toHaveLength(0); }); it('strips the per-option `default` inside a record `config`, nested rows included', () => { @@ -230,7 +205,7 @@ describe('view conversions reach the ViewItem-record spelling ({ viewKind, confi it('leaves the record identity fields alone while converting its `config`', () => { const { out } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], striped: true }).record, + listSpellings({ type: 'grid', columns: ['name'], filterableFields: ['status'] }).record, ); expect(out.name).toBe('crm_lead.all'); expect(out.object).toBe('crm_lead'); @@ -254,67 +229,40 @@ describe('view conversions reach the flattened-overlay spelling (payload at top ]); }); - it('strips the retired `pdf` export format from a flattened LIST overlay', () => { - const { out, notices } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }).flattened, - ); - expect(out.exportOptions).toEqual(['xlsx']); - expect(pathsFor(notices, 'view-export-options-pdf-removed')).toEqual([ - 'views[0].exportOptions', - ]); - }); - - it('strips the inert LIST keys from a flattened LIST overlay', () => { + it('strips the retired `filterableFields` whitelist from a flattened LIST overlay', () => { const { out, notices } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - responsive: { sm: {} }, - performance: { lazyLoad: true }, - aria: { label: 'Leads' }, + searchableFields: ['name'], + filterableFields: ['status', 'priority'], }).flattened, ); - expect('responsive' in out).toBe(false); - expect('performance' in out).toBe(false); - expect(out.aria).toEqual({ label: 'Leads' }); - expect(pathsFor(notices, 'view-inert-keys-removed').sort()).toEqual([ - 'views[0].performance', - 'views[0].responsive', + expect('filterableFields' in out).toBe(false); + expect(out.searchableFields).toEqual(['name']); + expect(pathsFor(notices, 'view-list-filterable-fields-removed')).toEqual([ + 'views[0].filterableFields', ]); }); - it('strips the inert FORM keys from a flattened FORM overlay', () => { - const { out } = convertViewRow( - formSpellings({ - type: 'simple', - data: { provider: 'object', object: 'crm_lead' }, - defaultSort: [{ field: 'created_at', order: 'desc' }], - aria: { label: 'Lead form' }, - }).flattened, - ); - expect('defaultSort' in out).toBe(false); - expect('aria' in out).toBe(false); - expect(out.data).toEqual({ provider: 'object', object: 'crm_lead' }); - }); - - it('strips the pass-through list keys from a flattened LIST overlay', () => { + it('strips the retired list `tabs` from a flattened LIST overlay', () => { const { out, notices } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - striped: true, - bordered: true, - virtualScroll: true, + tabs: [{ name: 'urgent', label: 'Urgent' }], }).flattened, ); - expect('striped' in out).toBe(false); - expect('bordered' in out).toBe(false); - expect('virtualScroll' in out).toBe(false); - expect(pathsFor(notices, 'view-list-passthrough-keys-removed').sort()).toEqual([ - 'views[0].bordered', - 'views[0].striped', - 'views[0].virtualScroll', - ]); + expect('tabs' in out).toBe(false); + expect(out.columns).toEqual(['name']); + expect(pathsFor(notices, 'view-list-tabs-removed')).toEqual(['views[0].tabs']); + }); + + it('leaves an aged-out key (16.x kit) on a flattened overlay as stored', () => { + const row = listSpellings({ type: 'grid', columns: ['name'], striped: true, virtualScroll: true }).flattened; + const { out, notices } = convertViewRow(row); + expect(out).toEqual(row); + expect(notices).toHaveLength(0); }); it('strips the per-option `default` inside a flattened FORM overlay', () => { @@ -361,7 +309,7 @@ describe('view conversions reach the flattened-overlay spelling (payload at top columnState: { order: ['name'], widths: { name: 120 } }, type: 'grid', columns: ['name'], - striped: true, + filterableFields: ['status'], }); expect(out.name).toBe('crm_lead.all'); expect(out.object).toBe('crm_lead'); @@ -372,7 +320,7 @@ describe('view conversions reach the flattened-overlay spelling (payload at top expect(out.scope).toBe('user'); expect(out.columnState).toEqual({ order: ['name'], widths: { name: 120 } }); // The two retired keys on this row — each stripped by its own entry. - expect('striped' in out).toBe(false); + expect('filterableFields' in out).toBe(false); expect('owner' in out).toBe(false); expect(pathsFor(notices, 'view-overlay-owner-hidden-removed')).toEqual(['views[0].owner']); }); @@ -380,7 +328,7 @@ describe('view conversions reach the flattened-overlay spelling (payload at top describe('the container spelling is unchanged, and the three agree', () => { it('converts a container `list`, a named `listViews` entry, a record and an overlay alike', () => { - const payload = { type: 'grid', columns: ['name'], striped: true, exportOptions: ['xlsx', 'pdf'] }; + const payload = { type: 'grid', columns: ['name'], filterableFields: ['status'], tabs: [{ name: 'mine', label: 'Mine' }] }; const s = listSpellings(payload); const fromContainer = convertViewRow(s.container).out.list as Dict; @@ -388,7 +336,7 @@ describe('the container spelling is unchanged, and the three agree', () => { const fromRecord = convertViewRow(s.record).out.config as Dict; const flattened = convertViewRow(s.flattened).out; - const expected = { type: 'grid', columns: ['name'], exportOptions: ['xlsx'] }; + const expected = { type: 'grid', columns: ['name'] }; expect(fromContainer).toEqual(expected); expect(fromNamed).toEqual(expected); expect(fromRecord).toEqual(expected); @@ -400,11 +348,21 @@ describe('the container spelling is unchanged, and the three agree', () => { it('converts a container `form` and a `viewKind: form` record alike', () => { const payload = { type: 'simple', - sections: [{ label: 'Details', visibleOn: 'record.open', fields: [] }], - aria: { label: 'Lead form' }, + sections: [{ + label: 'Details', + visibleOn: 'record.open', + fields: [{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open', default: true }] }], + }], }; const s = formSpellings(payload); - const expected = { type: 'simple', sections: [{ label: 'Details', visibleWhen: 'record.open', fields: [] }] }; + const expected = { + type: 'simple', + sections: [{ + label: 'Details', + visibleWhen: 'record.open', + fields: [{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }] }], + }], + }; expect(convertViewRow(s.container).out.form).toEqual(expected); expect((convertViewRow(s.containerNamed).out.formViews as Dict).quick).toEqual(expected); @@ -416,7 +374,7 @@ describe('the walker recognizes shapes rather than guessing at them', () => { it('leaves a body that is none of the three spellings untouched', () => { // No `viewKind`, no container slot — nothing positively identifies a view // payload here, so the chain must not invent one and start stripping. - const row = { name: 'mystery', object: 'crm_lead', striped: true, aria: { label: 'x' } }; + const row = { name: 'mystery', object: 'crm_lead', filterableFields: ['status'], aria: { label: 'x' } }; const { out, notices } = convertViewRow(row); expect(out).toEqual(row); expect(notices).toHaveLength(0); @@ -427,7 +385,7 @@ describe('the walker recognizes shapes rather than guessing at them', () => { // requires a config object; both overlay arms guard `config: undefined`). // A malformed body gets no rewrite — it is not silently re-read as a // flattened overlay whose top level would then be stripped. - const row = { name: 'broken', object: 'crm_lead', viewKind: 'list', config: null, striped: true }; + const row = { name: 'broken', object: 'crm_lead', viewKind: 'list', config: null, filterableFields: ['status'] }; const { out, notices } = convertViewRow(row); expect(out).toEqual(row); expect(notices).toHaveLength(0); @@ -435,7 +393,7 @@ describe('the walker recognizes shapes rather than guessing at them', () => { it('is idempotent: replaying the chain over an already-converted row is a no-op', () => { const once = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], striped: true, exportOptions: ['xlsx', 'pdf'] }).record, + listSpellings({ type: 'grid', columns: ['name'], filterableFields: ['status'], tabs: [] }).record, ); const twice = convertViewRow(once.out); expect(twice.out).toEqual(once.out); diff --git a/packages/spec/src/conversions/walk.ts b/packages/spec/src/conversions/walk.ts index effd2695fbe..8eb1d2ede23 100644 --- a/packages/spec/src/conversions/walk.ts +++ b/packages/spec/src/conversions/walk.ts @@ -237,10 +237,12 @@ const MAX_COMPONENT_DEPTH = 32; * and the three walks disagreed about `page:card.footer`). * * This walker reads EVERY entry, the retired ones included: `page:card.body` - * is a tombstoned spelling the authoring walks skip, but the renderers still - * read it for stored documents, and normalizing stored documents is what this - * walker is for — a conversion that runs before `page-card-body-to-children` - * in the chain meets the sub-tree still under `body`. + * was a tombstoned spelling the authoring walks skip (the tombstone and its + * `page-card-body-to-children` conversion were a ≤16.x kit and aged out on + * #25767; the slot is still listed as retired in COMPONENT_SLOTS), but the + * renderers still read it for stored documents, and normalizing stored + * documents is what this walker is for — a conversion that ran before the + * rename in the chain met the sub-tree still under `body`. * * Recognised by SHAPE — an array (a panel list: an array whose object entries * carry the panel key's array) — and deliberately NOT keyed by component @@ -280,10 +282,10 @@ function mapComponentList( * The mapper runs on the container FIRST and the descent then reads the * *mapped* component's `properties`, the same ordering {@link mapNodeTree} uses * for flow regions. That is what keeps the walk single-visit under a conversion - * that MOVES a container key: `page-card-body-to-children` renames + * that MOVES a container key: the retired `page-card-body-to-children` renamed * `properties.body` → `properties.children`, and because the descent happens * after the rename, the sub-tree is walked once under the canonical key instead - * of once per spelling. + * of once per spelling — the ordering stays for the next such conversion. */ function mapComponentTree( component: Dict, diff --git a/packages/spec/src/data/analytics-strictness-batchd.test.ts b/packages/spec/src/data/analytics-strictness-batchd.test.ts index a2ca0a70835..77158b525b4 100644 --- a/packages/spec/src/data/analytics-strictness-batchd.test.ts +++ b/packages/spec/src/data/analytics-strictness-batchd.test.ts @@ -294,9 +294,17 @@ describe('batch D — the strict base does not break the request wrapper', () => }); }); - it('the retired-envelope tombstones still fire — `query` and `format` carry their migration text', () => { - expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, query: { measures: ['count'] } })).toContain('was removed from AnalyticsQueryRequest'); - expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, format: 'csv' })).toContain('was removed from AnalyticsQueryRequest'); + it('the retired envelope keys `query` and `format` are refused as unknown keys naming the upgrade guide', () => { + // Their tombstones were 16.x retirement kits and aged out at 18 (#25767, + // ADR-0087 D2 = A): the strict base refuses each as an unknown key, and the + // refusal points at the protocol upgrade guide, where the envelope + // prescription now lives. Still never a silent strip. + for (const [key, body] of [['query', { measures: ['count'] }], ['format', 'csv']] as const) { + const message = reject(AnalyticsQueryRequestSchema, { ...QUERY, [key]: body }); + expect(message).toContain('unrecognized_keys'); + expect(message).toContain(`\`${key}\``); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } }); }); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 3ae04c6f21b..1fff233d61a 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -54,7 +54,7 @@ const RETIRED_CAPABILITIES: Record = { + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', readOnly: CAPABILITIES_REMOVED_PREFIX + '`readOnly` in particular NEVER made a datasource read-only: no write path consulted it, ' @@ -87,7 +87,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', healthCheck: '`datasource.healthCheck` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'health-check loop ever read it, so `enabled: true` scheduled nothing and the two timeouts ' @@ -95,12 +95,12 @@ 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', externalLabel: '`external.label` was removed in @objectstack/spec 17.0.0 (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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', externalRequirePermission: '`external.requirePermission` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'authorization check ever consulted it, so a permission named here gated nothing. Access to ' @@ -108,7 +108,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', }; /** @@ -166,7 +166,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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * The refusal of one retired driver spelling as a regular expression: diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 4ab081012b3..b1f9d4a2e03 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -280,8 +280,9 @@ export const DriverCapabilitiesSchema = lazySchema(() => z.object({ // which is the very defect the removal closes. `retiredKey()` keeps each // key authored-unwritable: tsc rejects it at the authoring site (the class // literal of every driver that `implements IDataDriver`), and a parse - // rejects it with the prescription below. Tombstones age out ~two majors - // after 17 (see shared/retired-key.ts). + // rejects it with the prescription below. Tombstones age out one major + // after the removal (see shared/retired-key.ts; #25767) — these are 17.x + // retirements, so they leave when 19 is cut. create: retiredKey(capRemoved('create', 'CRUD is not optional for a driver: `create`/`find`/`findOne`/`update`/`delete` are ' diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index fff70c7ab30..b013d3c705d 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1906,40 +1906,41 @@ describe('FieldSchema - group property', () => { describe('FieldSchema — the `conditionalRequired` alias is REMOVED', () => { // #3754 lowered the alias into `requiredWhen` and dropped it from the output. - // Protocol 17 removes it from the spec entirely. - // - // TOMBSTONED rather than deleted, because `FieldSchema` is deliberately not - // `.strict()` — this object already carries a comment about the last time a - // key outlived its schema and got silently stripped (`dataQuality` / `cached`, - // #3726 / #3733). A silent strip here would mean the field is simply never - // required, with nothing to read anywhere. - it('rejects an authored `conditionalRequired` instead of silently stripping it', () => { + // Protocol 17 removed it from the spec entirely, tombstoned rather than + // deleted because `FieldSchema` was not `.strict()` then (#3726 / #3733). + // The field shape is strict now, and the tombstone was a 16.x retirement + // kit, so it aged out on #25767 (ADR-0087 D2 = A): the key is refused as a + // strict unknown key, and the refusal's trailing sentence names the + // protocol upgrade guide, where the `requiredWhen` prescription lives. What + // these pins keep is the half that matters — never a silent strip. + it('refuses an authored `conditionalRequired` as a strict unknown key instead of silently stripping it', () => { expect(() => FieldSchema.parse({ type: 'text', conditionalRequired: "status = 'closed_won'", - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/Unrecognized key\(s\) on this field: `conditionalRequired`/); }); - it('carries the upgrade prescription in the rejection itself', () => { + it('points the refusal at the upgrade guide, where the prescription now lives', () => { let message = ''; try { FieldSchema.parse({ type: 'text', conditionalRequired: 'amount > 1000' }); } catch (err) { message = String(err); } - expect(message).toContain('use `requiredWhen`'); - expect(message).toContain('os migrate meta'); + expect(message).toContain('Keys retired before the previous protocol major are no longer prescribed here'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toContain('os migrate meta'); }); - it('rejects it even when the canonical `requiredWhen` is also present', () => { + it('refuses it even when the canonical `requiredWhen` is also present', () => { expect(() => FieldSchema.parse({ type: 'text', requiredWhen: "record.status == 'sent'", conditionalRequired: 'record.amount > 1000', - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/Unrecognized key\(s\) on this field: `conditionalRequired`/); }); - it('rejects it on the real authoring path — a field nested in an object', () => { + it('refuses it on the real authoring path — a field nested in an object', () => { // The removal has to bite where authors actually write fields, not only in // a bare `FieldSchema.parse()`. expect(() => ObjectSchema.parse({ @@ -1948,7 +1949,7 @@ describe('FieldSchema — the `conditionalRequired` alias is REMOVED', () => { fields: { amount: { type: 'currency', conditionalRequired: "record.stage == 'closing'" }, }, - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/Unrecognized key\(s\) on this field: `conditionalRequired`/); }); it('accepts the canonical `requiredWhen`, alone or beside `required`', () => { @@ -1993,12 +1994,13 @@ describe('FieldSchema - conditional field rules (visibleWhen / readonlyWhen / re // The original assertion pinned the two keys as coexisting in the output; // #3754 inverted it to "canonical wins, alias dropped"; #3855 removes the // alias outright, so the pair is now unrepresentable at the input too — the - // strongest form of the same guarantee. + // strongest form of the same guarantee. The alias's tombstone aged out at + // 18 (#25767), so the refusal is the strict unknown-key one, guide link and all. expect(() => FieldSchema.parse({ type: 'text', requiredWhen: "record.status == 'sent'", conditionalRequired: "record.status == 'closed_won'", - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/`conditionalRequired`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); const result = FieldSchema.parse({ type: 'text', requiredWhen: "record.status == 'sent'" }); expect(result.requiredWhen).toEqual({ dialect: 'cel', source: "record.status == 'sent'" }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 445bb270381..8afcf80b4ea 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; // Package-internal, like `strict-object` itself — the `shared/index.ts` barrel @@ -2020,21 +2019,6 @@ export const FieldSchema = lazySchema(() => { readonlyWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference."), requiredWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17."), - /** - * [REMOVED in protocol 17 — #3855] The deprecated alias of `requiredWhen`. - * Tombstoned rather than deleted: `FieldSchema` is `strictObject` (#4001), so - * a plain deletion would reject the key — but with a generic unknown-key - * error that does not name `requiredWhen`. The prescription is the payload: - * it carries the rename, so an author who wrote `conditionalRequired` is told - * where the CEL predicate goes. It also types the key `never`, so the mistake - * fails `tsc` at the authoring site before any parse runs. - */ - conditionalRequired: retiredKey( - '`conditionalRequired` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Deadline semantic — `date` / `datetime` only (DEADLINE_FIELD_TYPES). * objectui#11815, maintainer ruling D: overdue is DECLARED, never guessed. diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index 55bf0f90fde..49692ce92e2 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * 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 ea5ecf870b2..6e2ff1171da 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -44,14 +44,14 @@ const RETIRED_EXTRACT_QUERY = + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const RETIRED_ERROR_POLICY = '`mapping.errorPolicy` was removed in @objectstack/spec 17.0.0 (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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const RETIRED_BATCH_SIZE = '`mapping.batchSize` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' @@ -61,7 +61,7 @@ const RETIRED_BATCH_SIZE = + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const MAPPING_RETIRED_KEY_GUIDANCE: Readonly> = { extractQuery: RETIRED_EXTRACT_QUERY, diff --git a/packages/spec/src/data/object-external-sharing-model-retirement.test.ts b/packages/spec/src/data/object-external-sharing-model-retirement.test.ts index 12b88daf263..a403dea72d2 100644 --- a/packages/spec/src/data/object-external-sharing-model-retirement.test.ts +++ b/packages/spec/src/data/object-external-sharing-model-retirement.test.ts @@ -357,6 +357,12 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'packages/lint/src/runtime-gate.datasource-writes.test.ts', 'packages/lint/src/runtime-gate.inert-type-writes.test.ts', 'packages/cli/test/i18n-flow-liveness-gate.test.ts', + // The CLI write door's pin and the chain's own replay pin (#25767): each + // authors the pre-retirement object on purpose, to assert the D2 + // conversion strips the key at `--from 17` — this kit's fixtures at the + // two doors that replay it. + 'packages/cli/test/migrate-meta-write.test.ts', + 'packages/spec/src/migrations/migrations.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement object on purpose. diff --git a/packages/spec/src/data/object-strictness-batch20.test.ts b/packages/spec/src/data/object-strictness-batch20.test.ts index 21ca0ff5ef8..a387452499a 100644 --- a/packages/spec/src/data/object-strictness-batch20.test.ts +++ b/packages/spec/src/data/object-strictness-batch20.test.ts @@ -565,22 +565,22 @@ describe('批 20 — `IndexSchema` is closed (the held 14th site, once the conso expect(reject(IndexSchema, { fields: ['name'], notAnIndexKey: 1 })).not.toContain('database layer'); }); - it('the `type`/`partial` tombstones SURVIVE the strict close — their prescription, not `unrecognized_keys`', () => { - // The tombstones are declared keys, so the strict unknown-key path never - // sees them; each still answers its own migration text. Losing that prose - // to a generic strict rejection would be a regression the close must not - // ship — pinned per key, on the message itself. - const retiredPartial = IndexSchema.safeParse({ fields: ['name'], partial: "status = 'open'" }); - expect(retiredPartial.success, '`partial` is retired, not merely unknown').toBe(false); - expect(retiredPartial.error!.issues[0]!.message).toMatch(/`indexes\[\]\.partial` was removed.*Delete the key/s); - - const retiredType = IndexSchema.safeParse({ fields: ['name'], type: 'brin' }); - expect(retiredType.success, '`type` is retired, not merely unknown').toBe(false); - expect(retiredType.error!.issues[0]!.message).toMatch(/`indexes\[\]\.type` was removed.*Delete the key/s); - - // …and neither is offered as a rename target for a near-miss: a tombstone - // is excluded from the candidate list (`acceptsNothing`), so `partail` - // must not be answered with the one key the schema cannot accept. + it('the aged-out `type`/`partial` are refused by the strict close as unknown keys naming the upgrade guide', () => { + // Both tombstones were 16.x retirement kits and left the shape at 18 + // (#25767, ADR-0087 D2 = A). The close is what keeps the removal loud: each + // key is an `unrecognized_keys` refusal naming it, with the protocol + // upgrade guide — where the two prescriptions now live — in the message. + for (const body of [{ fields: ['name'], partial: "status = 'open'" }, { fields: ['name'], type: 'brin' }]) { + const retired = IndexSchema.safeParse(body); + expect(retired.success).toBe(false); + const issue = retired.error!.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + + // …and neither is offered as a rename target for a near-miss: a key the + // shape no longer declares is no candidate at all, so `partail` must not + // be answered with the spelling the schema cannot accept. expect(reject(IndexSchema, { fields: ['name'], partail: 'x' })).not.toContain('Did you mean'); }); diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 81972629144..cc34dd89cc7 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // (#5286). import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, isPublicSharingEnabled, resolveCrudAffordances, type ServiceObject } from './object.zod'; import { resolveInjectedSystemColumns } from './injected-system-columns'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; import { projectPublishedJsonSchema } from '../../scripts/lib/refinement-projection'; import { collectDroppedRefinements } from '../../scripts/lib/dropped-refinements'; import { Field } from './field.zod'; @@ -76,8 +77,11 @@ describe('ObjectCapabilities', () => { const message = result.success ? '' : result.error.issues.map((i) => i.message).join('\n'); expect(message).toContain(`\`${key}\``); expect(message, 'the migration command is the durable reference').not.toMatch(/#\d{3,5}/); - // The prescription names the source rewrite (the #3207 conversion). - expect(message).toContain('os migrate meta --from 16'); + // The prescription names the source rewrite the #3207 conversion used to + // perform; the conversion aged out with the 16.x kits (#25767), so the house + // sentence says so and links the guide instead of naming `--from 16`. + expect(message).toContain('This edit left `os migrate meta` with the protocol-16.x conversions'); + expect(message).not.toContain('os migrate meta --from 16'); } // `trash` additionally points at the parked soft-delete issue — the // parking spot the 2026-08-02 #3207 ruling designates (#1893, the old @@ -127,7 +131,7 @@ describe('ObjectCapabilities', () => { it('emission order: which key is wrong → the fix → the history, last', () => { const message = capabilityRejection({ trash: false, searchible: true }); const preamble = 'Unrecognized key(s) on `enable`:'; - const fix = 'os migrate meta --from 16'; + const fix = '`enable.trash` was removed'; const history = 'every flag carries an enforcement contract'; expect(message.startsWith(preamble)).toBe(true); @@ -452,19 +456,24 @@ describe('IndexSchema', () => { * the PRESCRIPTION, not merely the rejection. */ describe('IndexSchema retired keys', () => { - it('REJECTS `type`, with the fix and the reason in the message', () => { + // #25767: both tombstones were 16.x retirement kits and aged out (ADR-0087 + // D2 = A). The index shape is strict, so each key is refused as an unknown + // key and the refusal's trailing sentence names the protocol upgrade guide, + // which carries the two prescriptions from here. Still never a silent strip. + it('REFUSES `type` as a strict unknown key, pointing at the upgrade guide', () => { expect(() => IndexSchema.parse({ fields: ['tags'], type: 'gin' })) - .toThrow(/`indexes\[\]\.type` was removed.*no driver ever read it.*Delete the key/s); + .toThrow(/Unrecognized key\(s\) on this index: `type`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); - it('REJECTS `partial`, naming the database-layer replacement', () => { + it('REFUSES `partial` the same way', () => { expect(() => IndexSchema.parse({ fields: ['name'], partial: "state = 'active'" })) - .toThrow(/`indexes\[\]\.partial` was removed.*Delete the key.*CREATE \[UNIQUE\] INDEX/s); + .toThrow(/Unrecognized key\(s\) on this index: `partial`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); - it('points at the CLI conversion rather than naming the conversion id', () => { + it('no longer names a migrate step — the conversion that carried these aged out with its kit', () => { for (const bad of [{ fields: ['a'], type: 'btree' }, { fields: ['a'], partial: 'x' }]) { - expect(() => IndexSchema.parse(bad)).toThrow(/os migrate meta --from 16/); + expect(() => IndexSchema.parse(bad)).not.toThrow(/os migrate meta --from/); + expect(() => IndexSchema.parse(bad)).toThrow(); } }); @@ -494,7 +503,7 @@ describe('IndexSchema retired keys', () => { label: 'Invoice', fields: { name: { type: 'text', label: 'Name' } }, indexes: [{ fields: ['name'], partial: "state = 'active'" }], - })).toThrow(/`indexes\[\]\.partial` was removed/s); + })).toThrow(/Unrecognized key\(s\) on this index: `partial`/); }); }); @@ -2199,6 +2208,7 @@ describe('tenancy unknown-key message order — bullets before the explainer', ( expect(messageFor({ tenantfield: 'org_id' })).toBe( 'Unrecognized key(s) on `tenancy`: `tenantfield`. ' + 'Did you mean `tenantfield` → `tenantField`? ' + + `${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ` + EXPLAINER, ); }); @@ -2207,7 +2217,7 @@ describe('tenancy unknown-key message order — bullets before the explainer', ( // No tombstone, no near key: no bullet at all — the front matter and the // explainer carry everything the old catch-all bullet said. expect(messageFor({ zzNotAKey: 1 })).toBe( - `Unrecognized key(s) on \`tenancy\`: \`zzNotAKey\`. ${EXPLAINER}`, + `Unrecognized key(s) on \`tenancy\`: \`zzNotAKey\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${EXPLAINER}`, ); }); }); @@ -2702,7 +2712,10 @@ describe('managedBy: retiring the overloaded `system` bucket', () => { expect(msg).toMatch(/managedBy/s); expect(msg).toMatch(/removed in @objectstack\/spec 17/s); expect(msg).toMatch(/system-data/s); - expect(msg).toMatch(/os migrate meta --from 16/s); + // The automated fix rode a 16.x conversion, which aged out with its kit + // (#25767): the house sentence says so and links the guide. + expect(msg).toMatch(/protocol-16\.x conversions/s); + expect(msg).not.toMatch(/os migrate meta --from 16/s); }); it('accepts the replacement value', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 18360b61afb..bdca6584855 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -163,13 +163,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, ' + 'and if built returns as a live enforced flag (ADR-0049 prune-or-build). ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', mru: '`enable.mru` was removed from @objectstack/spec in the 16.x line (' + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', }; /** @@ -569,35 +569,6 @@ export const IndexSchema = lazySchema(() => strictObject({ // is NOT NULL-safe (#5030). unique: DeclaredIndexUniqueScopeSchema.optional().default(false).describe("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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization'").meta({ title: 'Unique' }), - // ── Tombstones (ADR-0049 / ADR-0087) ───────────────────────────────── - // Kept LAST in the shape on purpose — see the #5606 note in the block - // comment above. `IndexSchema` is not `.strict()`, so a plain delete would - // make Zod strip an authored value silently, which is the same no-op these - // keys already were (#3726 / #3733, the ADR-0104 class). The tombstone - // makes the removal audible in the two channels an upgrading author - // actually reads: `tsc` (input type `never`) and the parse itself. - // `object-index-type-partial-removed` strips both from stored/authored - // sources on the protocol-17 migration. - type: retiredKey( - '`indexes[].type` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - partial: retiredKey( - '`indexes[].partial` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), })); /** @@ -1522,7 +1493,7 @@ const MANAGED_BY_SYSTEM_RETIRED = + 'and can be deleted; keep it only to NARROW. CSV `import` is deliberately NOT in that ' + 'default: 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Known-confusable schema keys → precise authoring guidance. @@ -1574,8 +1545,9 @@ const UNKNOWN_KEY_GUIDANCE: Record = { // the one upgrade channel every consumer is guaranteed to hit — an agent // bumping @objectstack/spec sees THIS message, not our docs site. Each entry // names what replaced the key and the version/decision that removed it. - // Tombstones age out too: drop an entry ~two majors after the removal - // (by then it's archaeology, not an upgrade; see CHANGELOG.md for history). + // Tombstones age out too: drop an entry one major after the removal (#25767; + // by then it's archaeology, not an upgrade — the protocol upgrade guide and + // CHANGELOG.md carry the history, and the strict refusal links the guide). namespace: '`namespace` was retired (ADR-0129 D3) — the object `name` IS the canonical id ' + 'everywhere (API, ObjectQL, REST, SDK, DB table), so there is no separate namespace ' + diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 62db53f803b..d3f37aa2d2c 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -91,7 +91,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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; 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 84b04fa30ed..43663c9e629 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -967,7 +967,7 @@ const ConnectorBaseSchema = 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts index f9b9020cc71..f575f592b42 100644 --- a/packages/spec/src/kernel/manifest-unknown-keys.test.ts +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -340,7 +340,6 @@ describe('the accept side does not move, and `main` is declared', () => { it('a retired key still answers with its tombstone prescription — closing the surface did not replace it with a bare refusal', () => { for (const [key, value] of [ - ['loading', { strategy: 'lazy' }], ['capabilities', { implements: [] }], ['configuration', { title: 'Cfg' }], ['extensions', { a: 1 }], @@ -357,6 +356,17 @@ describe('the accept side does not move, and `main` is declared', () => { } }); + it('an aged-out retired key (`loading`, a 16.x kit) is refused as an unknown key naming the upgrade guide', () => { + // #25767 (ADR-0087 D2 = A): the tombstone left the shape one major after + // it retired, so the closed surface is what keeps the removal loud — the + // strict refusal names the key and the guide the prescription moved to. + const result = ManifestSchema.safeParse({ ...legal(), loading: { strategy: 'lazy' } }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.keys).toEqual(['loading']); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); + it('a near miss of a retired key is refused WITHOUT being pointed at the tombstone', () => { // `capabilitis` is one keystroke from the retired `capabilities`. The rename // channel must not send the author there — the second rejection would tell diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index ebb1f2158b4..81d2d8e9ed3 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -986,45 +986,6 @@ export const ManifestSchema = strictObject({ navigationContributions: z.array(NavigationContributionSchema).optional() .describe('Navigation items this package contributes into apps owned by other packages'), - /** - * REMOVED in v17 (#4914, ADR-0049 enforce-or-remove). - * - * `loading` carried the whole `PluginLoadingConfig` block — `strategy`, - * `preload`, `codeSplitting`, `dynamicImport`, `initialization`, - * `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring` - * — and NOTHING read any of it. It parsed, it entered the manifest, and it - * changed nothing. Tombstoned rather than deleted: when this landed - * `ManifestSchema` was not `.strict()`, so a plain deletion would have - * silently stripped the key, replacing an inert declaration with an invisible - * one; the surface has since been closed, and the tombstone stays because it - * types the key `never` and carries this prescription, which a bare refusal - * would not. - * - * See `plugin-loading.zod.ts` for the full record, including why `sandboxing` - * made this a security concern and not merely tidying. - */ - loading: retiredKey( - '`manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 ' + - 'enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, ' + - '`dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, ' + - '`sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it ' + - 'configured nothing. Delete the key. Plugins are composed at boot — `defineStack` ' + - 'registers them and the kernel runs `init` then `start` in an order topologically ' + - "resolved from each composed plugin's own `dependencies` / `optionalDependencies` " + - '(`resolvePluginOrder`); the set is fixed until the process restarts. ' + - '⚠️ `loading.sandboxing` in particular never isolated anything: it did not run ' + - 'plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no ' + - 'call. If you were relying on it for isolation, you had none — and the plugin trust ' + - 'tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud ' + - 'marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is ' + - 'rejected with HTTP 422 and forced to manual review), while load-side enforcement is ' + - 'NOT implemented, so a locally installed plugin is not isolated by the tier it ' + - 'declares. ⛔ Nor do the permission declarations give it back: the install-time ' + - 'granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by ' + - 'nothing, so it refuses no operation. Neither surface confines a plugin today — do ' + - 'not author either one expecting isolation.', - ), - /** * Platform Compatibility Requirements. * Specifies the minimum ObjectStack platform version required to run this package. diff --git a/packages/spec/src/kernel/plugin-loading-retirement.test.ts b/packages/spec/src/kernel/plugin-loading-retirement.test.ts index 081de1c5875..d2d9e697a59 100644 --- a/packages/spec/src/kernel/plugin-loading-retirement.test.ts +++ b/packages/spec/src/kernel/plugin-loading-retirement.test.ts @@ -18,11 +18,14 @@ import { ManifestSchema } from './manifest.zod'; // vocabulary as proof the platform isolates plugins, wrote the config, and got a // clean parse and zero isolation. // -// Route: `retiredKey()` tombstone, NOT plain deletion. `ManifestSchema` is not -// `.strict()`, so deleting the key would make zod strip it in silence — -// replacing an inert declaration with an invisible one (the #3726 / #3733 shape, -// ADR-0104). The tombstone is audible in two channels: `tsc` (the key's input -// type is `never`) and the parse below. +// Route at the time: `retiredKey()` tombstone, NOT plain deletion — then +// `ManifestSchema` was not `.strict()`, so deleting the key would have made zod +// strip it in silence (the #3726 / #3733 shape, ADR-0104). The surface closed +// since, and the tombstone — a 16.x retirement kit — aged out of the shape at +// 18 (#25767, ADR-0087 amendment, D2 = A). What keeps the removal audible now +// is the strict refusal itself: `tsc` (TS2353, the key does not exist on the +// type) and the `unrecognized_keys` issue below, which names the key and the +// protocol upgrade guide the prescription moved to. // // ⚠️ On the assertion set. The dispatch asked for the ADR-0112 rejection // envelope (`code` + `status`) as the floor for a rejection-class case. That @@ -47,62 +50,48 @@ describe('manifest.loading retirement', () => { name: 'Example Plugin', } as const; - it('REJECTS an authored `loading` block, naming the key and carrying the fix', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { strategy: 'lazy' }, - }); - + /** The one `unrecognized_keys` issue of a refused manifest, naming `loading`. */ + const unknownKeyRefusal = (loading: Record) => { + const result = ManifestSchema.safeParse({ ...baseManifest, loading }); expect(result.success).toBe(false); - if (result.success) return; // narrowing; the assertion above already failed + if (result.success) return undefined; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys') as + | { code: string; keys: string[]; path: PropertyKey[]; message: string } + | undefined; + expect(issue, 'the refusal must be the strict unknown-key one').toBeDefined(); + return issue!; + }; - const issue = result.error.issues.find((i) => i.path[0] === 'loading'); - expect(issue, 'the refusal must name `loading`').toBeDefined(); + it('REFUSES an authored `loading` block as an unknown key, naming the key and the upgrade guide', () => { + const issue = unknownKeyRefusal({ strategy: 'lazy' })!; // The machine-readable half of the envelope this surface actually has. - expect(issue!.code).toBe('invalid_type'); - expect(issue!.path).toEqual(['loading']); - // The prescription itself — this string IS the migration doc for whoever - // hits it, so it is contract, not commentary. - expect(issue!.message).toMatch(/`manifest\.loading`.*removed.*17\.0\.0/s); - expect(issue!.message).toMatch(/Delete the key/s); + expect(issue.keys).toEqual(['loading']); + expect(issue.path).toEqual([]); + // No tombstone issue beside it: the key is unknown now, not declared `never`. + expect(ManifestSchema.safeParse({ ...baseManifest, loading: { strategy: 'lazy' } }).error!.issues + .filter((i) => i.path[0] === 'loading')).toEqual([]); + // Where the prescription (sandboxing never isolated anything; the trust + // tier is `manifest.runtime`) now lives — the message says so. + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); - it('REJECTS the sandboxing block specifically, since that is the dangerous one', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { sandboxing: { enabled: true, isolationLevel: 'process' } }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - - const issue = result.error.issues.find((i) => i.path[0] === 'loading'); - expect(issue!.code).toBe('invalid_type'); - // The prescription must say plainly that no isolation was ever applied — - // an author who believed otherwise has a security decision to revisit, and - // silence here is what made the inert key dangerous in the first place. - expect(issue!.message).toMatch(/never isolated anything/s); - expect(issue!.message).toMatch(/manifest\.runtime/s); + it('REFUSES the sandboxing block the same way — the dangerous one is not stripped in silence', () => { + // An author who believed the block isolated anything has a security + // decision to revisit; a clean parse here is exactly what made the inert + // key dangerous, and the strict refusal is what forbids it. + const issue = unknownKeyRefusal({ sandboxing: { enabled: true, isolationLevel: 'process' } })!; + expect(issue.keys).toEqual(['loading']); }); - it('REJECTS hotReload too, and points at the surviving vocabulary', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { hotReload: { enabled: true, strategy: 'state-preserve' } }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error.issues.find((i) => i.path[0] === 'loading')!.code).toBe('invalid_type'); + it('REFUSES hotReload too', () => { + expect(unknownKeyRefusal({ hotReload: { enabled: true, strategy: 'state-preserve' } })!.keys).toEqual(['loading']); }); it('parses cleanly once the key is deleted, and grows no `loading` property', () => { const parsed = ManifestSchema.parse({ ...baseManifest }); expect(parsed.id).toBe('com.example.plugin'); - // Absence must stay absence. `ManifestSchema` is closed now, so a plain - // deletion would no longer strip an authored `loading` in silence — it would - // refuse it with a bare unknown-key message, without the prescription; this - // pin plus the rejection above are what keep the tombstone honest. + // Absence must stay absence. `ManifestSchema` is closed, which is the whole + // reason the tombstone could age out without the key going silent again. expect(parsed).not.toHaveProperty('loading'); }); diff --git a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts index 4bb8ad88867..4af919cd523 100644 --- a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts +++ b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { ManifestSchema, PluginRuntimeSchema } from './manifest.zod'; -import { entry as loadingRetiredEntry } from '../migrations/entries/semantic/17.plugin-manifest-loading-retired'; // ─── [commit a9ee98992] `manifest.runtime` text states publish-gate-only enforcement ── // @@ -31,25 +30,23 @@ import { entry as loadingRetiredEntry } from '../migrations/entries/semantic/17. // without that negative pin, a future edit could re-add "which are enforced" // beside the new text and every positive assertion would stay green. // -// ⚠️ On the assertion set: a describe string and a tombstone prescription are -// not a rejection envelope, so there is no ADR-0112 `code` + `status` to pin -// here (the tombstone's own envelope is pinned in -// `plugin-loading-retirement.test.ts`). Where the WORDING is the contract, the -// wording is what gets pinned (#5240). The final case guards the other -// direction — that this text-only change moved no accept/reject behaviour. +// ⚠️ On the assertion set: a describe string is not a rejection envelope, so +// there is no ADR-0112 `code` + `status` to pin here. Where the WORDING is the +// contract, the wording is what gets pinned (#5240). The final case guards the +// other direction — that this text-only change moved no accept/reject +// behaviour. +// +// [#25767] The `manifest.loading` tombstone and its ADR-0087 D3 entry — the +// two other carriers of this sentence, whose halves this file used to pin in +// the same way — were a 16.x retirement kit and aged out at 18 (ADR-0087 +// amendment, D2 = A). `loading` is a strict unknown key now, pinned in +// `plugin-loading-retirement.test.ts`; the describes below are the carriers +// that remain, and the ones the reference docs render. /** Both halves of the truthful split, as separate probes. */ const SAYS_PUBLISH_GATE_ENFORCED = /publish gate/i; const SAYS_422 = /422/; const SAYS_LOAD_SIDE_NOT_ENFORCED = /load-side enforcement is NOT implemented/i; -/** - * [commit aaacf1d5c] The permissions half of the same sentence, under the same rule: the - * granted set is REGISTERED at load and queried by nothing. Probes the two - * load-bearing words rather than a whole clause, so a rewording that keeps the - * fact keeps the pin. - */ -const SAYS_GRANT_REGISTERED_NOT_ENFORCED = /REGISTERED on the PluginPermissionEnforcer at load and queried by\s+nothing/i; - describe('manifest.runtime trust-tier text is truthful', () => { const baseManifest = { id: 'com.example.plugin', @@ -80,91 +77,17 @@ describe('manifest.runtime trust-tier text is truthful', () => { expect(text).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); }); - it('corrects the `loading` tombstone that redirected authors here', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { sandboxing: { enabled: true, isolationLevel: 'process' } }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - - const message = result.error.issues.find((i) => i.path[0] === 'loading')!.message; - - // Still names the tier — the redirection itself is not the defect, the - // unqualified "which are enforced" was. - expect(message).toMatch(/manifest\.runtime/); - expect(message).toMatch(SAYS_PUBLISH_GATE_ENFORCED); - expect(message).toMatch(SAYS_422); - expect(message).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); - - // ⛔ The retracted claim must not come back. This is the pin that makes the - // three above mean something: they all stay green if someone re-adds the - // old sentence alongside the new one. - expect( - message, - 'the tombstone must not claim the trust tier is enforced without qualification', - ).not.toMatch(/trust tier \(`manifest\.runtime`\) and the permission declarations, which are enforced/); - }); - - it('states the PERMISSIONS half truthfully too — the handoff landed', () => { - // The coordination pin this replaces read - // `expect(message).toContain('the permission declarations, which are enforced')` - // and existed to go RED the day commit aaacf1d5c corrected the other half of the - // sentence. It did its job: maintainer ruling 2026-09-12 took option B on - // the permissions half as well — say it truthfully now — so the retracted - // claim is gone and the truthful split is asserted in its place. - // - // Both halves of the tombstone now name a surface that does NOT confine a - // plugin, for two DIFFERENT reasons, and the sentence has to keep both: - // • the trust tier — enforced at the marketplace publish gate, not at load; - // • the permission declarations — REGISTERED on the enforcer at load - // (#13457) and queried by nothing, so they refuse no operation. - // The measurement behind the second lives in `@objectstack/core`: - // `granted-permissions-not-enforced.pin.test.ts` goes red when a production - // `SecurePluginContext` construction site appears (measured in commit aaacf1d5c, the ADR-0025 - // materialize seam), which is the signal that this text is stale again. + it('`loading` no longer carries the sentence at all — it is a strict unknown key (#25767)', () => { + // The tombstone that used to redirect authors here aged out; the refusal + // an author meets is the strict one, naming the key and the upgrade guide. + // Pinned so a re-added tombstone would have to bring the truthful split + // back with it, under the pins above. const result = ManifestSchema.safeParse({ ...baseManifest, loading: { strategy: 'lazy' } }); expect(result.success).toBe(false); if (result.success) return; - - const message = result.error.issues.find((i) => i.path[0] === 'loading')!.message; - expect(message).toMatch(SAYS_GRANT_REGISTERED_NOT_ENFORCED); - // The negative pin, for the same reason the tier half carries one: without - // it, a future edit could re-add the retracted sentence beside the truthful - // one and the positive assertion above would stay green. - expect( - message, - 'the tombstone must not claim the permission declarations are enforced', - ).not.toMatch(/permission declarations, which are enforced/); - }); - - it('corrects the permissions half in the shipped ADR-0087 D3 entry too', () => { - // Same two-carrier problem the tier half had: the tombstone ships once in - // the schema and once through this entry into `docs/protocol-upgrade-guide.md`. - const text = loadingRetiredEntry.replacement; - - expect(text).toMatch(SAYS_GRANT_REGISTERED_NOT_ENFORCED); - expect( - text, - 'the D3 entry must not present the permission declarations as an enforced surface', - ).not.toMatch(/permission declarations, which\s+are the surfaces the platform actually enforces/); - }); - - it('corrects the same claim in the shipped ADR-0087 D3 entry', () => { - // The tombstone ships TWICE — once in the schema, once through this entry - // into `docs/protocol-upgrade-guide.md`. Fixing only the schema would leave - // the upgrade guide still telling authors the tier is a surface "the - // platform actually enforces". - const text = loadingRetiredEntry.replacement; - - expect(text).toMatch(SAYS_PUBLISH_GATE_ENFORCED); - expect(text).toMatch(SAYS_422); - expect(text).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); - expect( - text, - 'the D3 entry must not present the trust tier as an enforced surface', - ).not.toMatch(/trust tier\s*\(`manifest\.runtime`, ADR-0025 §3\.6\) and the manifest permission declarations/); + expect(result.error.issues.filter((i) => i.path[0] === 'loading')).toEqual([]); + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('changes NO accept/reject behaviour — every tier still parses as before', () => { diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 5b79a2413ef..d9316c94ffa 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -574,15 +574,30 @@ describe('migration chain (ADR-0087 D3)', () => { // floor to 16 and retired `step11`–`step16` with it, so the oldest hop the // chain still guarantees is FLOOR → FLOOR + 1 and the shapes are that // hop's. Written against the constant rather than the literal 16: the next - // floor move should re-point this replay, not delete it. + // floor move should re-point this replay, not delete it. #25767 re-pointed + // it once already: the two conversions it used to replay + // (`action-execute-to-target`, `field-conditionalRequired-to-requiredWhen`) + // aged out with their 16.x kits, so it replays two that step 17 still + // graduates. const OLDEST_HOP = MIGRATION_SUPPORT_FLOOR + 1; const oldShape = () => ({ - actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], - objects: [ + sharingRules: [ { - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + name: 'share_open_deals', + type: 'criteria', + object: 'crm_deal', + accessLevel: 'full', + condition: 'record.status == "open"', + sharedWith: { type: 'business_unit', value: 'bu_sales' }, + }, + ], + flows: [ + { + name: 'lead_lookup', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'get_record', config: { object: 'lead', recordId: '{leadId}' } }, + ], }, ], }); @@ -590,18 +605,17 @@ describe('migration chain (ADR-0087 D3)', () => { it('migrates the oldest supported major\'s shapes to canonical', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); - const action = (result.stack.actions as any[])[0]; - expect(action).not.toHaveProperty('execute'); - expect(action.target).toBe('convertHandler'); - const field = (result.stack.objects as any[])[0].fields.due_date; - expect(field).not.toHaveProperty('conditionalRequired'); - expect(field.requiredWhen).toBe('record.stage == "closed"'); + const rule = (result.stack.sharingRules as any[])[0]; + expect(rule.accessLevel).toBe('edit'); + const config = (result.stack.flows as any[])[0].nodes[1].config; + expect(config).not.toHaveProperty('object'); + expect(config.objectName).toBe('lead'); // Two mechanical rewrites, named — a count alone would survive one of // them being replaced by an unrelated conversion firing on this shape. expect([...new Set(result.applied.map((a) => a.conversionId))].sort()).toEqual([ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', + 'flow-node-crud-object-alias', + 'sharing-rule-access-level-full-to-edit', ]); // Semantic TODOs are advisory per-major and always surfaced for the hop, // whatever the stack contains. @@ -626,7 +640,7 @@ describe('migration chain (ADR-0087 D3)', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); expect(result.hops).toHaveLength(1); expect(result.hops[0]!.toMajor).toBe(OLDEST_HOP); - expect((result.hops[0]!.stack.actions as any[])[0].target).toBe('convertHandler'); + expect((result.hops[0]!.stack.sharingRules as any[])[0].accessLevel).toBe('edit'); }); }); diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 033ef951d94..5a9781ada93 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1090,9 +1090,11 @@ const step17: MigrationStep = { '`formats` array rather than deleting the declaration.', ].join(''), conversionIds: [ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', - 'agent-tools-to-skills', + // #25767 (ADR-0087 2026-10-26 amendment, scoped by ruling 6434038284): the 45 + // conversions this step graduated with `retiredAfter: 16.1.0` aged out with + // their kits and left the registry, so their ids left this list. The step + // stays (the floor is 16); its semantic entries are unchanged, and the + // edits those conversions applied are prescribed by hand from the guide. 'sharing-rule-access-level-full-to-edit', 'flow-node-crud-object-alias', 'flow-node-notify-config-aliases', @@ -1101,52 +1103,10 @@ const step17: MigrationStep = { 'flow-node-map-flow-alias', 'flow-node-subflow-flow-alias', 'flow-node-script-config-aliases', - 'permission-rls-priority-removed', - 'tool-inert-authoring-keys-removed', - 'app-dead-authoring-keys-removed', - 'app-area-fail-open-gates-removed', - 'action-inert-keys-removed', - 'flow-inert-keys-removed', - 'view-inert-keys-removed', - 'view-list-passthrough-keys-removed', - 'view-export-options-pdf-removed', - 'dashboard-inert-keys-removed', - 'agent-knowledge-removed', - 'skill-trigger-phrases-removed', - 'stack-api-require-auth-removed', - 'datasource-capabilities-removed', - 'datasource-inert-blocks-removed', - 'mapping-inert-keys-removed', - 'book-translations-removed', - 'job-id-removed', - 'translation-validation-messages-removed', - 'flow-node-wait-timeout-keys-removed', - 'datasource-read-replicas-removed', - 'datasource-config-driver-key-aliases', 'datasource-driver-mongo-to-mongodb', - 'flow-node-script-branch-keys-removed', - 'object-managed-by-system-to-system-data', 'retry-policy-converged', - 'object-enable-trash-mru-removed', - 'hook-body-crypto-hash-removed', - 'connector-rate-limit-config-removed', - 'dashboard-widget-responsive-removed', - 'dashboard-widget-action-aria-removed', - 'dashboard-widget-compareto-converged', - 'theme-inert-token-scales-removed', 'page-header-subtitle-alias', - 'object-index-type-partial-removed', - 'field-mapping-transform-removed', - 'record-picker-display-field-to-label-field', - 'record-picker-inert-keys-removed', - 'page-card-body-to-children', - 'dataset-measure-array-string-agg-removed', 'inline-action-api-params-to-body-extra', - 'page-tabs-type-to-tab-style', - 'page-structure-inert-keys-removed', - 'record-details-layout-removed', - 'app-hidden-to-unpublished', - 'action-global-nav-location-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index ec3358eef77..08875a6c653 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -121,12 +121,13 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.enabled).toBe(true); }); - it('priority is RETIRED: absent parses clean, authored rejects with the prescription', () => { + it('priority is RETIRED: absent parses clean, authored is refused as an unknown key naming the upgrade guide', () => { // Removed by the 2026-07-30 #3896 security audit: no outcome depends on // an order (reads OR-combine; a write's check is chosen per operation, // then OR-combined), so the promised "conflict resolution" cannot - // exist and nothing ever read the key. The tombstone keeps the removal - // audible instead of silently stripping an authored value. + // exist and nothing ever read the key. Its tombstone was a 16.x kit and + // aged out at 18 (#25767); the strict shape keeps the removal audible + // instead of silently stripping an authored value. const policy = { name: 'test_policy', object: 'account', @@ -138,7 +139,10 @@ describe('Row-Level Security (RLS) Protocol', () => { const authored = { ...policy, priority: 10 } as never; const r = RowLevelSecurityPolicySchema.safeParse(authored); expect(r.success).toBe(false); - expect(JSON.stringify(!r.success ? r.error.issues : [])).toContain('removed in @objectstack/spec 17.0.0'); + const issues = JSON.stringify(!r.success ? r.error.issues : []); + expect(issues).toContain('unrecognized_keys'); + expect(issues).toContain('`priority`'); + expect(issues).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should handle policies for all operations', () => { @@ -551,12 +555,10 @@ describe('unknown keys are rejected, not stripped', () => { } }); - it('keeps the retired `priority` tombstone prescription (not a bare strict error)', () => { - const result = RowLevelSecurityPolicySchema.safeParse({ ...policy, priority: 10 }); - expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('`rowLevelSecurity[].priority` was removed'); - expect(messages).toContain('Delete the key'); + it('refuses the aged-out `priority` as a strict unknown key, with the upgrade guide (#25767)', () => { + const issue = unknownKeyIssue({ ...policy, priority: 10 })!; + expect(issue.keys).toEqual(['priority']); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index d5293268e07..085118906db 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -636,31 +636,6 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( .default(true) .describe('Whether this policy is active'), - /** - * REMOVED — `priority` promised "conflict resolution" that cannot exist. - * - * No outcome depends on an order. On a read, the applicable policies' `using` - * clauses OR-combine (any match allows access). On an insert or an update, - * which predicates judge the written rows is decided once per operation - * across the applicable policies (the declared `check`s when any exists, - * otherwise each `using`; see `check`), and the chosen ones OR-combine. So - * there is never a conflict to order and evaluation order cannot change an - * outcome. Nothing ever read the key: the 2026-07-30 - * security-subset liveness re-verification (#3896 follow-up) closed the call - * graph across the collection site, the projection round-trip and the - * compiler, and found no consumer. A semantically-void knob on a SECURITY - * policy is worse than dead — an author (very often an AI, ADR-0033) reads it - * as a precedence lever and reasons about policy interactions that do not - * exist. Removed per the #3715 / #3950 precedent; tombstoned so the removal - * is audible (tsc `never` + the parse-time prescription) instead of a silent - * strip. - */ - priority: retiredKey( - '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. ' + - 'It never had an effect. Delete the key — policy outcomes are unchanged. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * REMOVED — `tags` promised "categorization and reporting" for governance and * compliance, and nothing ever read it (#20321, ADR-0049 enforce-or-remove). @@ -734,7 +709,7 @@ const DEFAULT_ROW_SCOPE_OBJECT_GUIDANCE = * this tree declares it. */ export const DefaultRowScopeSchema = lazySchema(() => { - const { object: _implied, priority: _priority, tags: _tags, ...members } = RowLevelSecurityPolicySchema.shape; + const { object: _implied, tags: _tags, ...members } = RowLevelSecurityPolicySchema.shape; return strictObject( { surface: "this object's default row scope", diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts index e2d66ff65e0..4a527888d6b 100644 --- a/packages/spec/src/shared/index.ts +++ b/packages/spec/src/shared/index.ts @@ -16,6 +16,7 @@ export * from './metadata-types.zod'; // live at the surfaces themselves: inline regexes on object/field/flow names, // bare `SnakeCaseIdentifierSchema` on app and position names. export * from './suggestions.zod'; +export * from './protocol-upgrade-guide'; // `suggestFieldType` lives apart from `suggestions.zod` so that module imports no // schema: strict-object → suggestions → data/field.zod was the eager import // cycle that crashed the `/api` and `/data` entries under OS_EAGER_SCHEMAS=1. diff --git a/packages/spec/src/shared/mapping.test.ts b/packages/spec/src/shared/mapping.test.ts index e2fdf2d605a..e80d2655a60 100644 --- a/packages/spec/src/shared/mapping.test.ts +++ b/packages/spec/src/shared/mapping.test.ts @@ -89,8 +89,12 @@ describe('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 an ADR-0087 conversion covers this key. - expect(result.error!.issues[0]!.message).toMatch(/os migrate meta --from 16/s); + // The ADR-0087 conversion that covered this key aged out with the 16.x + // kits (#25767); the tombstone itself stays because this base is a plain + // `z.object` (a delete would strip in silence), and its house sentence now + // says the conversion is gone and links the guide. + expect(result.error!.issues[0]!.message).toMatch(/protocol-16\.x conversions/s); + expect(result.error!.issues[0]!.message).not.toMatch(/os migrate meta --from 16/s); }); // `the surviving extender inherits the tombstone` stood here, asserting diff --git a/packages/spec/src/shared/mapping.zod.ts b/packages/spec/src/shared/mapping.zod.ts index 67e7d0b81e2..46b928a4ddf 100644 --- a/packages/spec/src/shared/mapping.zod.ts +++ b/packages/spec/src/shared/mapping.zod.ts @@ -101,7 +101,7 @@ export const FieldMappingSchema = lazySchema(() => z.object({ + '`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; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/shared/protocol-upgrade-guide.ts b/packages/spec/src/shared/protocol-upgrade-guide.ts new file mode 100644 index 00000000000..4286e9719c8 --- /dev/null +++ b/packages/spec/src/shared/protocol-upgrade-guide.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a retired key's prescription lives once its tombstone has aged out + * (ADR-0087, amendment 2026-10-26 — #25767, maintainer ruling D2 = A). + * + * A retirement kit — the `retiredKey()` tombstone, its `RETIRED_KEYS_BY_MAJOR` + * / `RETIRED_DEFS_BY_MAJOR` rows, its D3 semantic entry and its D2 conversion + * — lives ONE protocol major past the one it retired in. After that the key is + * an ordinary unknown key: the `strictObject` refusal names it, offers the + * nearest declared spelling, and points HERE, because the prescription that + * used to ride on the tombstone is now only in the generated upgrade guide + * (`scripts/build-upgrade-guide.ts`, one page per migrated major). The kit + * ages out; the migration chain's STEP stays: `os migrate meta --from N` still + * replays down to the ruled support floor (`MIGRATION_SUPPORT_FLOOR`, 16), and + * moving that floor is its own release-policy ruling (#25767 ruling + * 6434038284, ADR-0087 D3). + * + * One spelling, shared by the strict refusal (`suggestions.zod.ts`) and the + * console's own aged-out refusals (`@object-ui/types`), so every door an author + * can hit sends them to the same page. + */ +export const PROTOCOL_UPGRADE_GUIDE_URL = 'https://objectstack.ai/docs/protocol-upgrade'; + +/** + * The sentence every strict unknown-key refusal carries, after its fix channels + * and ahead of the history sentence (#5955 order). Static on purpose: + * it names no major, so the pins on the refusal text do not move once per + * release, and the page it links indexes every migrated major itself. + */ +export const RETIRED_KEY_UPGRADE_GUIDE_SENTENCE = + 'Keys retired before the previous protocol major are no longer prescribed here — ' + + `see the protocol upgrade guide: ${PROTOCOL_UPGRADE_GUIDE_URL}.`; 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 afb7e349b3a..e67237741a9 100644 --- a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts +++ b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts @@ -156,7 +156,20 @@ const CORPORA: Corpus[] = [ }, ]; -const MARKER = /(?:Run )?`os migrate meta --from \d+`/g; +const MARKER = /(?:Run )?`os migrate meta --from \d+`|This edit left `os migrate meta` with the protocol-\d+\.x conversions/g; + +/** + * [#25767] The AGED-OUT house form. A tombstone whose conversion aged out + * with its 16.x kit used to name `os migrate meta --from 16`; that command + * still replays the chain (the support floor stays 16, ruling 6434038284) but + * no longer carries this edit, so the sentence says what is still true: the + * edit is applied by hand, or by the tool on a release that still carries the + * conversion, and the protocol upgrade guide is where the prescription lives + * from here. Same anchoring as the `--from` house form: last sentence of its + * literal, nothing after the closing period. + */ +const POST_FLOOR_AT_MARKER = + /^This edit left `os migrate meta` with the protocol-\d+\.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.['"]/; /** * House form, anchored at the marker: the sentence must be the LAST sentence @@ -247,7 +260,7 @@ function judgeReconstructed({ merged, segments }: Reconstructed, file: string): 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); + const ok = HOUSE_AT_MARKER.test(rest) || MIXED_AT_MARKER.test(rest) || POST_FLOOR_AT_MARKER.test(rest); judged.push({ file, line: lineAt(segments, at), excerpt: rest.slice(0, 120).replace(/\n/g, ' '), ok, }); diff --git a/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts index c8af6fa59d9..bed186232b1 100644 --- a/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts +++ b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts @@ -29,8 +29,16 @@ * * The cases span both object modes and both value kinds, because `tsc` words * them differently: an array value reads as a missing property (TS2741), a - * string as a plain non-assignable type (TS2322). `PageSchema` and - * `FieldSchema` are `strictObject`s; `IndexSchema` is a plain `z.object`. + * string or boolean as a plain non-assignable type (TS2322). `PageSchema` and + * `ObjectSchema` are `strictObject`s; the stack's `api` block is a plain + * `z.object` (its `requireAuth` tombstone stays for exactly that reason). + * + * The two tombstones this used to probe on `FieldSchema` and `IndexSchema` + * (`conditionalRequired`, `indexes[].type`) were 16.x retirement kits and aged + * out at 18 (#25767): an authored one is TS2353 now — "does not exist in + * type" — the ordinary unknown-key diagnostic, with no retirement sentence + * because the key is no longer declared at all. The strict parse is where + * that author meets the upgrade-guide link. * * ⚠️ Anti-vacuity: a harness that resolves nothing reports import errors or * nothing at all, and both look like "no retirement text". The control probe @@ -48,7 +56,7 @@ const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const PROBE_DIR = resolve(SRC_DIR, 'shared/__retired_key_tsc_probes__'); /** The stack every probe authors; `retired` splices retired keys into it. */ -function stackProbe(retired: { page?: string; field?: string; index?: string }): string { +function stackProbe(retired: { page?: string; object?: string; api?: string }): string { return [ "import { defineStack } from '@objectstack/spec';", '', @@ -57,15 +65,16 @@ function stackProbe(retired: { page?: string; field?: string; index?: string }): ' objects: [', ' {', " name: 'deal',", - " label: 'Deal',", + ` label: 'Deal'${retired.object ?? ''},`, ' fields: {', " amount: { type: 'number', label: 'Amount' },", - ` stage: { type: 'text', label: 'Stage'${retired.field ?? ''} },`, + " stage: { type: 'text', label: 'Stage' },", ' },', - ` indexes: [{ fields: ['amount']${retired.index ?? ''} }],`, + " indexes: [{ fields: ['amount'] }],", ' },', ' ],', ` pages: [{ name: 'home_page', label: 'Home', type: 'home'${retired.page ?? ''} }],`, + ...(retired.api === undefined ? [] : [` api: {${retired.api} },`]), '});', '', ].join('\n'); @@ -75,10 +84,10 @@ const PROBES = { control: stackProbe({}), // HotCRM's measured case: `page.assignedProfiles` (strictObject, array value). page: stackProbe({ page: ", assignedProfiles: ['sales_rep']" }), - // A rename retirement on a strictObject, string value. - field: stackProbe({ field: ", conditionalRequired: 'amount > 0'" }), - // A non-strict `z.object` tombstone, string value. - index: stackProbe({ index: ", type: 'btree'" }), + // A removal retirement on a strictObject, string value. + object: stackProbe({ object: ", externalSharingModel: 'private'" }), + // A non-strict `z.object` tombstone, boolean value. + api: stackProbe({ api: ' requireAuth: true' }), } as const; type ProbeName = keyof typeof PROBES; @@ -149,8 +158,8 @@ describe('a retired key names its retirement in the tsc diagnostic', () => { const cases: ReadonlyArray<[ProbeName, string, number]> = [ ['page', 'assignedProfiles', 2741], - ['field', 'conditionalRequired', 2322], - ['index', "type: 'btree'", 2322], + ['object', 'externalSharingModel', 2322], + ['api', 'requireAuth', 2322], ]; it.each(cases)('%s: writing %s fails with TS%i, and the message says [REMOVED] and names `os validate`', (name, key, code) => { diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index 20a4fde53f4..5a9f5b8d8ca 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -89,8 +89,16 @@ * code review. * * Tombstones age out, exactly like the `UNKNOWN_KEY_GUIDANCE` entries in - * `data/object.zod.ts`: drop one ~two majors after the removal, by which point - * it is archaeology rather than an upgrade (the history lives in CHANGELOG.md). + * `data/object.zod.ts`: a tombstone lives through the protocol major AFTER the + * one its key was removed in and leaves when the next major is cut (one major, + * ADR-0087 amendment 2026-10-26, #25767; ~two majors before that). After it + * leaves, the key is refused as a plain strict unknown key and the refusal's + * guide sentence (after its fix channels, ahead of its history) points at the + * protocol upgrade guide + * (`shared/protocol-upgrade-guide.ts`), where the history lives alongside + * CHANGELOG.md. Its D2 conversion leaves with it; the migration chain's step + * does not — `os migrate meta --from ` still replays down to the support + * floor (`MIGRATION_SUPPORT_FLOOR`), which only a release-policy ruling moves. */ import { z } from 'zod'; diff --git a/packages/spec/src/shared/retry-policy.test.ts b/packages/spec/src/shared/retry-policy.test.ts index 619ceb18919..39610294208 100644 --- a/packages/spec/src/shared/retry-policy.test.ts +++ b/packages/spec/src/shared/retry-policy.test.ts @@ -70,52 +70,33 @@ describe('RetryPolicy is a single declaration across entries', () => { expect(parsed.jitter).toBe(false); }); - it('keeps the `retryDelayMs` tombstone loud from both entries', () => { + it('refuses the aged-out `retryDelayMs` from both entries as an unknown key naming the upgrade guide', () => { + // Its tombstone was a 16.x retirement kit and left the shape at 18 (#25767, + // ADR-0087 D2 = A); the shape is strict, so the spelling is still refused + // loudly — as an unknown key, with the guide the rename now lives in. for (const entry of [Automation.RetryPolicySchema, System.RetryPolicySchema]) { const parse = () => entry.parse({ retryDelayMs: 500 }); - expect(parse).toThrow(/backoffMs/); + expect(parse).toThrow(/Unrecognized key\(s\) on this retry policy: `retryDelayMs`/); + expect(parse).toThrow(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); } }); - // The tombstone STRING is not documentation. `shared/retired-key.ts` says so - // in its own words — "an agent bumping `@objectstack/spec` sees THIS string, - // not our docs site" — which makes its enumeration of carrying surfaces - // contract, not prose. It went stale exactly once and in the worst direction: - // it kept listing "an ETL pipeline's `retry`" after #6414 deleted the whole L2 - // layer, so the one message guaranteed to reach an upgrading author named a - // fourth place to write `backoffMs` on which `tsc` now reports TS2724/TS2305 - // (#6630). Nothing could see that: every pre-existing assertion on this string - // matched `/backoffMs/` or `/retryDelayMs/`, i.e. the prescription, never the - // enumeration. Pin the enumeration in BOTH directions. - // - // Note on the assertion shape: there is no ADR-0112 `code`/`status` envelope - // to assert here. `retiredKey()` is a Zod `never` whose issue carries the - // guidance as its `message`, so for this rejection class the wording IS the - // whole contract (#5240) and the message is the only thing worth asserting. - it('the tombstone enumerates exactly the surfaces that still carry the policy', () => { + // The tombstone that used to enumerate the carrying surfaces here (pinned in + // both directions after it went stale once, #6630) aged out at 18 with its + // kit (#25767). What the one message guaranteed to reach an upgrading author + // says now is the strict refusal — the key, this surface's own name, and the + // guide the rename lives in — and it must not name a retired layer either. + it('the refusal names this surface and the upgrade guide, and nothing retired', () => { const result = RetryPolicySchema.safeParse({ retryDelayMs: 500 }); expect(result.success).toBe(false); const message = result.error!.issues.map((issue) => issue.message).join('\n'); - - // Every surface that builds from `retryPolicyShape()` today, spelled the way - // an author writes it. A prescription naming a scope NARROWER than the truth - // is the #4964 defect (a real surface left out reads as "not converged"). - for (const live of ['`job.retryPolicy`', "`try_catch` node's `retry`", '`flow.errorHandling`']) { - expect(message, `the prescription must still name ${live}`).toContain(live); - } - - // ...and nothing retired. Wider than the truth is the #6630 defect: the - // export absence is pinned in `automation/sync-retirement.test.ts`, so a - // surface named here that cannot be imported there is a signpost to a - // compile error. - expect( - message, - 'the prescription must not point at the retired ETL layer', - ).not.toMatch(/\bETL\b/i); - - // None of the above may be bought by weakening the prescription itself. - expect(message).toContain('Rename the key to `backoffMs`'); - expect(message).toContain('os migrate meta --from 16'); + expect(message).toContain('Unrecognized key(s) on this retry policy: `retryDelayMs`'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Wider than the truth is the #6630 defect: the export absence is pinned in + // `automation/sync-retirement.test.ts`, so a surface named here that cannot + // be imported there is a signpost to a compile error. + expect(message, 'the refusal must not point at the retired ETL layer').not.toMatch(/\bETL\b/i); + expect(message).not.toContain('os migrate meta --from 16'); }); }); @@ -196,10 +177,10 @@ describe('RetryPolicySchema refuses a key it does not declare', () => { expect(System.JobSchema.safeParse({ ...job, retryPolicy: declared }).success).toBe(true); }); - it('the retryDelayMs tombstone keeps its own refusal — the rename — rather than becoming an unknown key', () => { + it('the aged-out retryDelayMs is an unknown key of the policy now — one refusal, at the policy (#25767)', () => { const result = RetryPolicySchema.safeParse({ retryDelayMs: 500 }); expect(result.success ? [] : result.error.issues.map((issue) => ({ code: issue.code, path: issue.path }))) - .toEqual([{ code: 'invalid_type', path: ['retryDelayMs'] }]); + .toEqual([{ code: 'unrecognized_keys', path: [] }]); }); }); @@ -294,7 +275,8 @@ describe('every retry surface carries ONE contract', () => { ...minimalFlow, errorHandling: { strategy: 'retry', maxRetries: 2, retryDelayMs: 500 }, }); expect(flowRetired.success).toBe(false); - expect(JSON.stringify(flowRetired.error!.issues)).toMatch(/backoffMs/); + expect(JSON.stringify(flowRetired.error!.issues)).toMatch(/unrecognized_keys[^}]*`retryDelayMs`/); + expect(JSON.stringify(flowRetired.error!.issues)).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('accepts a policy authored once and pasted onto any surface', () => { diff --git a/packages/spec/src/shared/retry-policy.zod.ts b/packages/spec/src/shared/retry-policy.zod.ts index 68d57c1f8ea..e425cdb6cac 100644 --- a/packages/spec/src/shared/retry-policy.zod.ts +++ b/packages/spec/src/shared/retry-policy.zod.ts @@ -75,7 +75,6 @@ import { z } from 'zod'; import { lazySchema } from './lazy-schema'; -import { retiredKey } from './retired-key'; import { strictObject } from './strict-object'; /** @@ -113,28 +112,6 @@ export function retryPolicyShape() { jitter: z.boolean().default(false) .describe('Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries'), - // ── Tombstone (ADR-0087) ──────────────────────────────────────────── - // `retryDelayMs` was the automation-side spelling of `backoffMs`, on BOTH - // `try_catch`'s `retry` (#4661) and `Flow.errorHandling` (#4964). It was - // tombstoned rather than deleted because two of the three owning shapes were - // not `.strict()` then: a plain deletion would have had Zod silently strip - // the authored value and drop the delay back to the 1000ms default, which is - // precisely the quiet-failure class ADR-0049 exists to remove. Every owning - // shape is strict since #22343, and the tombstone is still the better - // channel — it carries the rename, where a bare unknown-key rejection would - // only carry the key. `retry-policy-converged` rewrites it wherever stored - // documents are replayed (it left the load path at 18, #23543 F2, so an - // authored copy meets this tombstone directly); a copy that conversion - // leaves (a `backoffMs` beside it with a different value, or a `null`) - // meets it at parse too. - retryDelayMs: retiredKey( - '`retryDelayMs` was removed in @objectstack/spec 17.0.0 — 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }; } diff --git a/packages/spec/src/shared/strict-object.test.ts b/packages/spec/src/shared/strict-object.test.ts index 9a7be738608..ba9d797d0f6 100644 --- a/packages/spec/src/shared/strict-object.test.ts +++ b/packages/spec/src/shared/strict-object.test.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { describe, expect, it } from 'vitest'; import { lazySchema } from './lazy-schema'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from './protocol-upgrade-guide'; import { strictObject } from './strict-object'; import { keySetMatches } from './suggestions.zod'; @@ -239,12 +240,13 @@ describe('message order — the fix comes before the history', () => { expect(m.split(HISTORY)).toHaveLength(2); }); - it('is unchanged when there is no fix to offer', () => { - // No rename, no prescription — the sentence follows the key statement - // directly, exactly as it always did. Full-message pin, so any stray - // separator or duplicated clause fails here. + it('is unchanged in SHAPE when there is no fix to offer', () => { + // No rename, no prescription — the key statement, then the one sentence + // every refusal carries since #25767 (the pointer to the upgrade guide, + // where an aged-out retirement's prescription lives), then the history. + // Full-message pin, so any stray separator or duplicated clause fails here. expect(messageFor({ nonsense: 1 })) - .toBe(`Unrecognized key(s) on this widget: \`nonsense\`. ${HISTORY}`); + .toBe(`Unrecognized key(s) on this widget: \`nonsense\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`); }); }); diff --git a/packages/spec/src/shared/suggestions.zod.ts b/packages/spec/src/shared/suggestions.zod.ts index 4ed9b1d8c6f..5838ebdea13 100644 --- a/packages/spec/src/shared/suggestions.zod.ts +++ b/packages/spec/src/shared/suggestions.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from './protocol-upgrade-guide'; import { aliasProbe } from './alias-probe'; import { @@ -427,6 +428,15 @@ export function strictUnknownKeyError(options: StrictUnknownKeyErrorOptions): z. let message = `Unrecognized key(s) on ${surface}: ${keys.map((k) => `\`${k}\``).join(', ')}.`; if (renames.length) message += ` Did you mean ${renames.join(', ')}?`; if (prescriptions.length) message += `\n${prescriptions.map((p) => ` • ${p}`).join('\n')}`; - return `${message} ${history}`; + // The last fix channel, still ahead of `history`: where an aged-out + // retirement's prescription now lives (ADR-0087 amendment, #25767). A key + // whose tombstone left the shape one major after it retired arrives here as + // a plain unknown key — nothing in this message can tell it from a typo — + // and the generated upgrade guide is the one place its prescription is + // still written down. It is a pointer, not a prescription, so it is one + // static sentence on every refusal rather than a per-key bullet, and it + // sits with the fixes rather than behind the history sentence the #5955 + // ruling moved last. + return `${message} ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${history}`; }; } diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index d25c16f2635..8ac7e029c6f 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -881,8 +881,7 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { + '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 list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** 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 41cd8fc2888..16f47667ea0 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -76,7 +76,7 @@ const BOOK_TRANSLATIONS_RETIRED = + '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; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; export const BookGroupSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts index 9d1b901835e..62e478119c4 100644 --- a/packages/spec/src/system/job.test.ts +++ b/packages/spec/src/system/job.test.ts @@ -293,11 +293,11 @@ describe('RetryPolicySchema', () => { expect(policy.jitter).toBe(true); }); - it('rejects the retired `retryDelayMs` spelling with the rename prescription', () => { + it('refuses the aged-out `retryDelayMs` spelling as an unknown key naming the upgrade guide (#25767)', () => { const parse = () => RetryPolicySchema.parse({ retryDelayMs: 500 }); - expect(parse).toThrow(/backoffMs/); - expect(parse).toThrow(/retryDelayMs/); + expect(parse).toThrow(/Unrecognized key\(s\) on this retry policy: `retryDelayMs`/); + expect(parse).toThrow(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); // Bounds the job side did not have before the merge. Both fail loudly rather diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 28e227b31f4..c2833afb80e 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -171,7 +171,7 @@ const JOB_ID_RETIRED = + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * `job.timeout` → `job.timeoutMs` (#14478). The unit lived only in the diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 91d9027d304..8d23c62903d 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -1106,7 +1106,7 @@ describe('metadata form ↔ Zod reconciliation — a hand-written form held to i // // Pinned against a SYNTHETIC schema rather than against whichever live schema // happens to carry a tombstone today: tombstones age out (`retired-key.ts` says -// ~two majors), and a self-test anchored to one would either rot or, worse, go +// one major, #25767), and a self-test anchored to one would either rot or, worse, go // quietly vacuous the release its anchor is deleted — leaving a gate that // filters nothing and still reports green. // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index c14a149ddfd..71262767c64 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -676,8 +676,7 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message', which the write " - + 'path resolves. Delete this key. Run ' - + '`os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'path resolves. Delete this key. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", 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-params.test.ts b/packages/spec/src/ui/action-params.test.ts index 54a6acc3b0d..d3cdacf7c16 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -11,7 +11,6 @@ import { } from './action-params.zod'; import type { FilterCondition } from '../data/filter.zod'; import type { EngineQueryOptions } from '../data/data-engine.zod'; -import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const codes = (issues: ReturnType) => issues.map((i) => i.code).sort(); @@ -375,25 +374,6 @@ describe('ActionSession `positions` canonical + `roles` deprecated alias', () => expect(doc).not.toMatch(/deliberately NO `positions` key/); }); - it('registers the rename as an ADR-0087 semantic migration (the reader-facing channel)', () => { - // `spec-changes.json`, the generated upgrade guide and the `spec_changes` - // MCP tool are all projections of this registry. Without the entry the - // rename reaches a consumer only when their read starts returning - // `undefined` — which is the failure mode a deprecation window exists to - // replace with an announcement. - const entry = MIGRATIONS_BY_MAJOR[17]?.semantic - .find((s) => s.id === 'action-session-roles-to-positions'); - - expect(entry).toBeDefined(); - expect(entry!.surface).toBe('ui.actionSession.roles'); - expect(entry!.replacement).toMatch(/positions/); - // It is a RENAME under a window, not the retirement its hook-side - // neighbour (`hook-context-session-roles-retired`, #5050) was — the reason - // must not read as a removal notice. - expect(entry!.reason).toMatch(/deprecation window/); - expect(entry!.reason).toMatch(/ruled contract-first/); - expect(entry!.acceptanceCriteria).toMatch(/ctx\.session\.positions/); - }); }); // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 66c4124b38b..52550d68737 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1268,11 +1268,13 @@ describe('ActionSchema — the `execute` alias is REMOVED', () => { // was lowered into `target` at parse time and dropped from the output; as of // protocol 17 it is gone from the spec entirely. // - // It is TOMBSTONED rather than deleted, because `ActionSchema` is not + // It was TOMBSTONED rather than deleted while `ActionSchema` was not // `.strict()`: a plain deletion would have silently stripped the key, leaving // the action with no handler bound and no diagnostic — the #2169 "Mark Done - // does nothing" shape. The rejection is what makes the removal audible, and - // it carries the fix so an upgrading agent does not have to find our docs. + // does nothing" shape. The surface closed since, and the tombstone — a 16.x + // retirement kit — aged out at 18 (#25767, ADR-0087 D2 = A). The strict + // refusal is what keeps the removal audible now: it names the key and the + // protocol upgrade guide, where the `target` prescription lives. const parseWithExecute = () => ActionSchema.parse({ name: 'legacy_action', @@ -1285,18 +1287,18 @@ describe('ActionSchema — the `execute` alias is REMOVED', () => { expect(parseWithExecute).toThrow(); }); - it('carries the upgrade prescription in the rejection itself', () => { + it('names the key and the upgrade guide in the refusal itself', () => { // This string is the one upgrade channel every consumer is guaranteed to - // hit — it must name the replacement AND the automated fix. + // hit — it must name the key and where its prescription now lives. let message = ''; try { parseWithExecute(); } catch (err) { message = String(err); } - expect(message).toContain('`execute` was removed'); - expect(message).toContain('use `target`'); - expect(message).toContain('os migrate meta'); + expect(message).toContain('unrecognized_keys'); + expect(message).toContain('`execute`'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('rejects `execute` even when the canonical `target` is also present', () => { @@ -1308,7 +1310,7 @@ describe('ActionSchema — the `execute` alias is REMOVED', () => { type: 'script', target: 'preferredHandler', execute: 'legacyHandler', - })).toThrow(/`execute` was removed/); + })).toThrow(/`execute`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('accepts the canonical `target` on its own', () => { @@ -1476,13 +1478,13 @@ describe('ActionSchema - target required for non-script types', () => { // Before protocol 17 this parsed: `execute` was lowered into `target`, so a // flow action bound through the alias worked. The alias is gone, so the // action now fails BOTH on the removed key and on its missing `target` — - // and the first message tells the author which key to rename. + // and the refusal names the key (and, since #25767, the upgrade guide). expect(() => ActionSchema.parse({ name: 'flow_legacy', label: 'Flow Legacy', type: 'flow', execute: 'my_flow', - })).toThrow(/`execute` was removed/); + })).toThrow(/`execute`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); expect(ActionSchema.parse({ name: 'flow_canonical', @@ -1544,13 +1546,16 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { it('refuses the value WITH the prescription — bare enum and whole action alike', () => { // The prescription: names the value, says it was removed, points at the // live alternatives (a served location, or the headless `locations: []`), - // and closes with the house `os migrate meta` sentence (#6856 route D). + // and closes with the house `os migrate meta` sentence (#6856 route D) — + // in its aged-out form since #25767: the 16.x conversion that carried + // the rewrite aged out with its kit, so the sentence says so and links the guide. const prescribes = (fn: () => unknown) => { expect(fn).toThrow(/`global_nav` was removed from `ACTION_LOCATIONS`/s); expect(fn).toThrow(/ADR-0049 enforce-or-remove/s); expect(fn).not.toThrow(/#\d{3,5}\b/); expect(fn).toThrow(/locations: \[\]/s); - expect(fn).toThrow(/os migrate meta --from 16/s); + expect(fn).toThrow(/protocol-16\.x conversions/s); + expect(fn).not.toThrow(/os migrate meta --from 16/s); }; prescribes(() => ActionLocationSchema.parse('global_nav')); @@ -1631,21 +1636,20 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { }); describe('audit close-out — retired shortcut/bulkEnabled, capability claims nothing enforced', () => { - it('REJECTS the retired `shortcut` and names the real keyboard stack', () => { + // Both tombstones were 16.x retirement kits and aged out at 18 (#25767): + // each key is a strict unknown key now, refused with the upgrade guide the + // keyboard-stack / `bulkActions` prescriptions moved to. + it.each([ + ['shortcut', 'Ctrl+S'], + ['bulkEnabled', true], + ])('REFUSES the aged-out `%s` as an unknown key naming the upgrade guide', (key, value) => { let message = ''; try { - ActionSchema.parse({ name: 'save_now', label: 'Save', type: 'script', target: 'h', shortcut: 'Ctrl+S' }); + ActionSchema.parse({ name: 'save_now', label: 'Save', type: 'script', target: 'h', [key]: value }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/keyboard stack/); - expect(message).toMatch(/audit close-out/); - }); - it('REJECTS the retired `bulkEnabled` and points at the view bulkActions', () => { - let message = ''; - try { - ActionSchema.parse({ name: 'close_all', label: 'Close', type: 'script', target: 'h', bulkEnabled: true }); - } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/bulkActions/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toContain(`\`${key}\``); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index ff6d7981d0c..245acf8f37c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -614,7 +614,8 @@ 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` lists the edit for you to apply. + * silently stripped; `os migrate meta --from 16` listed the edit until the + * protocol-16.x conversion aged out with its kit (#25767). * * @example Good action names * - 'on_close_deal' @@ -648,7 +649,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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Action Location — where an action is allowed to surface in the UI. @@ -1266,21 +1267,6 @@ const actionObject = () => strictObject({ */ execution: BulkActionExecutionSchema.optional().describe("The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`)."), - /** - * [REMOVED in protocol 17 — #3855] The deprecated alias of `target`. - * Tombstoned rather than deleted: `ActionSchema` is `strictObject`, so a - * plain deletion would reject the key — but with a generic unknown-key error - * that does not name `target`. The prescription is the payload: it carries - * the rename, so an author who wrote `execute` is told where the handler ref - * goes instead of only that the key is unknown. It also types the key - * `never`, so the mistake fails `tsc` before any parse runs. - */ - execute: retiredKey( - '`execute` was removed in @objectstack/spec 17 — 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * User Input Requirements — the **parameter DEFINITION array** rendered as a * dialog before the action runs. `ActionParam[]`, never a values map. @@ -1577,27 +1563,6 @@ const actionObject = () => strictObject({ requiredPermissions: z.array(z.string()).optional().describe('[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.'), /** Keyboard Shortcut */ - // `shortcut` and `bulkEnabled` REMOVED by the 2026-07 #3896 audit close-out — - // both were authorable capability claims nothing enforced (ledger: dead, - // #3686 re-verification): ActionEngine registered them but no keydown path - // ever dispatched a shortcut, and the multi-select toolbar reads the LIST - // VIEW's bulkActions, never this flag. Tombstoned with the prescription; - // `action-inert-keys-removed` strips them from authored sources. - shortcut: retiredKey( - '`action.shortcut` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - bulkEnabled: retiredKey( - '`action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * AI exposure block (ADR-0011). Opt-in, default off: an action is exposed diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 9ee7823ac63..004a3d9e6a6 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -631,10 +631,11 @@ describe('AppSchema', () => { expect(() => AppSchema.parse(hrApp)).not.toThrow(); }); - it('rejects the retired app-level sharing/embed blocks with the per-form-view pointer', () => { + it('refuses the aged-out app-level sharing/embed blocks as unknown keys naming the upgrade guide', () => { // Pre-#4001 these parsed and were never read — a declared-but-unenforced - // security surface (2026-06 liveness audit, ADR-0049). The tombstone - // carries the prescription: public access is per FORM VIEW. + // security surface (2026-06 liveness audit, ADR-0049). Their tombstones + // were 16.x retirement kits and left the shape at 18 (#25767); the strict + // shell refuses both, and the guide carries the per-FORM-VIEW prescription. const result = AppSchema.safeParse({ name: 'shared_portal', label: 'Shared Portal', @@ -643,8 +644,9 @@ describe('AppSchema', () => { embed: { enabled: true }, }); expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('FormView.sharing'); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys') as { keys: string[]; message: string }; + expect(issue.keys.sort()).toEqual(['embed', 'sharing']); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should accept CRM app with deeply nested navigation tree', () => { @@ -719,8 +721,9 @@ describe('App Mobile Navigation (retired)', () => { }); expect(result.success).toBe(false); const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('mobileNavigation'); - expect(messages).toContain('unimplemented'); + expect(messages).toContain('`mobileNavigation`'); + // The tombstone aged out at 18 (#25767): the strict refusal, with the guide. + expect(messages).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); @@ -755,24 +758,16 @@ describe('defineApp', () => { }); describe('AppSchema retired sharing/embed keys', () => { - it('rejects app-level sharing with the FormView prescription', () => { + it.each(['sharing', 'embed'])('refuses app-level `%s` as an unknown key naming the upgrade guide (#25767)', (key) => { const result = AppSchema.safeParse({ name: 'public_app', label: 'Public App', - sharing: { enabled: true }, + [key]: { enabled: true }, }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('FormView.sharing'); - }); - - it('rejects app-level embed with the per-form-view prescription', () => { - const result = AppSchema.safeParse({ - name: 'embeddable_app', - label: 'Embeddable App', - embed: { enabled: true }, - }); - expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('iframe'); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain(`Unrecognized key(s) on this app: \`${key}\``); + expect(messages).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should accept app without sharing/embed (backward compatibility)', () => { @@ -784,8 +779,8 @@ describe('AppSchema retired sharing/embed keys', () => { ], }); - expect(app.sharing).toBeUndefined(); - expect(app.embed).toBeUndefined(); + expect(app).not.toHaveProperty('sharing'); + expect(app).not.toHaveProperty('embed'); expect(app.navigation).toHaveLength(1); }); }); @@ -1111,25 +1106,24 @@ describe('AppSchema with areas', () => { }); // #4001 (app step, PR A) — the seven keys the 2026-06 AppSchema liveness audit -// verdicted DEAD are retiredKey() tombstones: tsc-level `never` for typed -// authors, and a parse-time prescription for everyone else. Not a silent -// strip: AppSchema is not yet .strict(), so a bare deletion would have -// reintroduced the exact failure mode this campaign eliminates. -describe('retired dead keys carry prescriptions', () => { +// verdicted DEAD were retiredKey() tombstones: tsc-level `never` for typed +// authors, and a parse-time prescription for everyone else, while AppSchema +// was not yet .strict(). The shell closed since, and the tombstones — 16.x +// retirement kits — aged out at 18 (#25767, ADR-0087 D2 = A). Not a silent +// strip even so: each key is a strict unknown key, refused with the protocol +// upgrade guide the prescriptions moved to. +describe('retired dead keys are refused as unknown keys naming the upgrade guide', () => { it.each([ - ['version', '1.0.0', 'manifest.version'], - // ⚠️ The expected fragment used to be `component/widget`. The widget half - // of that prescription named `dashboard.widgets[].aria`, retired by #5010 - // in this same 17.0.0 — so the pin held `App.aria`'s prescription pointing - // at another tombstone (#6756). Re-aimed at the surviving carrier. - ['aria', { label: 'x' }, 'page.components[].aria'], - ['objects', [], 'defineStack'], - ['apis', [], 'defineStack'], - ] as const)('rejects `%s` with its upgrade prescription', (key, value, fragment) => { + ['version', '1.0.0'], + ['aria', { label: 'x' }], + ['objects', []], + ['apis', []], + ] as const)('refuses `%s`', (key, value) => { const result = AppSchema.safeParse({ name: 'app_x', label: 'X', [key]: value }); expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain(fragment); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys') as { keys: string[]; message: string }; + expect(issue.keys).toEqual([key]); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('an app without any retired key parses unchanged', () => { @@ -1138,46 +1132,6 @@ describe('retired dead keys carry prescriptions', () => { navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], })).not.toThrow(); }); - - // #5238 (#5040 E9) — the `apis` tombstone redirects to `defineStack({ apis })`, - // so it also has to be TRUE about what that surface does. It used to add "which - // is ALSO not executable in v17 (#4936) … until the endpoint executor ships", - // and the executor shipped: an author following the prescription today would be - // told to move a declaration to a place the same sentence calls dead, and would - // reasonably keep serving the route from handler code instead. - // - // The assertions below are deliberately two-sided. Asserting only the presence - // of the new words would keep passing if the retired claim were re-added - // underneath them, and asserting only the absence would pass on an empty - // string — the vacuity the surrounding pins exist to avoid. - describe('the `apis` prescription tracks the executor', () => { - const prescription = (): string => { - const result = AppSchema.safeParse({ name: 'app_x', label: 'X', apis: [] }); - expect(result.success).toBe(false); - return result.error!.issues.map((i) => i.message).join('\n'); - }; - - it('states the stack-level surface EXECUTES, and where the endpoint may live', () => { - const messages = prescription(); - expect(messages).toContain('defineStack({ apis })'); - expect(messages).toContain('EXECUTES from protocol 17'); - // The one edit an author moving a declaration up must make (ADR-0121 D1/D2). - expect(messages).toContain('/api/v1/apps//'); - // The key whose wrong answer is unrecoverable, plus its D6 obligation. - expect(messages).toContain('`authRequired` defaults to `true`'); - expect(messages).toContain('enabled: true'); - // Where the full security review lives — one source of truth, not a copy. - expect(messages).toContain('declarative-apis-endpoints-live'); - }); - - it('no longer tells the author that stack-level `apis:` is rejected', () => { - const messages = prescription(); - expect(messages).not.toContain('not executable in v17'); - expect(messages).not.toContain('until the endpoint executor ships'); - // The historical fact stays: #4936 happened, and the prescription says so. - expect(messages).toContain('refused wholesale'); - }); - }); }); // #4001 (app step, PR B) — the app shell is `.strict()`, and the nav-item @@ -1217,10 +1171,10 @@ describe('unknown keys are rejected, not stripped', () => { .toContain('defineStack'); }); - it('keeps the PR A tombstone prescriptions rather than a bare strict error', () => { + it('the PR A tombstones aged out (#25767): the strict error carries the upgrade guide instead', () => { const result = AppSchema.safeParse({ name: 'app_a', label: 'A', sharing: { enabled: true } }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('FormView.sharing'); + expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); // #4829 — the ADR-0045 publish gate's acceptance face, both directions. @@ -1427,40 +1381,19 @@ describe('unknown keys are rejected, not stripped', () => { }); // ── homePageId / areas[].order retired in 17.0.0 (#4667, ADR-0049) ──────── describe('retired app keys', () => { - it('rejects `homePageId` and names what actually decides the landing page', () => { - // Tombstoned (retiredKey), matching the seven #4142 retirements on this - // schema — so it is a tsc error as well as a parse error. Post-v17 the - // landing page really is the first navigation item; what the message must - // NOT do is explain *why* by claiming nobody read the key (see below). - const parse = () => AppSchema.parse({ - name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', - navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], - }); - expect(parse).toThrow(/homePageId.*removed.*17\.0\.0/s); - expect(parse).toThrow(/first navigation item/s); - expect(parse).toThrow(/isDefault/s); - }); - - it('the tombstone states the REAL reason, not the false "no shell read it" premise', () => { - // #4667 shipped this tombstone claiming "no shell ever read it". It was - // false — objectui's console read the key in `resolveLandingRoute()` - // (packages/app-shell/src/console/AppContent.tsx @785b8a5d) and it was the - // only thing deciding where an app opened — and it contradicted this - // repo's own 2026-06 AppSchema liveness audit, which listed the key LIVE. - // The removal stands (#4709 ruling B): the key encoded the landing page as - // an ID cross-reference that silently fell back when it dangled. Pinned - // because a tombstone is what the next reader reasons FROM: this sentence - // already sent one session to the renderer to discover it was wrong, and a - // regression would re-arm exactly that trap. + it('refuses `homePageId` as an unknown key naming the upgrade guide (#25767)', () => { + // Its tombstone (the one #4709 corrected to state the REAL reason — the + // console did read the key in `resolveLandingRoute()`) was a 16.x + // retirement kit and aged out at 18. The strict shell keeps the removal + // loud: the refusal names the key and the guide the prescription lives in. + // The three retired aliases below still answer with the prescription — + // they are unknown-key guidance on the strict shell, not ledger rows. const parse = () => AppSchema.parse({ name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], }); + expect(parse).toThrow(/`homePageId`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); expect(parse).not.toThrow(/no shell ever read/i); - expect(parse).toThrow(/did read it/i); - expect(parse).toThrow(/resolveLandingRoute/); - // The prescription is unchanged by the correction — reorder, or isDefault. - expect(parse).toThrow(/reorder `navigation`/s); }); it('routes the three retired homePageId aliases to the same prescription', () => { diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 3a128a414a0..c4b408799b2 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -5,7 +5,6 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { I18nLabelSchema } from './i18n.zod'; import { ElementApiDataSourceSchema } from './page.zod'; -import { retiredKey } from '../shared/retired-key'; import { strictObject, type StrictObjectOptions } from '../shared/strict-object'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; @@ -1239,8 +1238,7 @@ const AREA_ORDER_RETIRED = + 'ever sorted areas; both the sidebar and the schema renderer iterate the array as ' + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + '`order` is genuinely sorted — this removal does not touch it. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * `app.areas[].visible` and `app.areas[].requiredPermissions`, retired in @@ -1290,8 +1288,7 @@ const AREA_VISIBLE_RETIRED = + 'tree — the app\'s top-level `navigation` AND every `areas[].navigation`, both stripped ' + 'server-side. 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + '`requiredPermissions` stops that entry from being served at all. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const AREA_REQUIRED_PERMISSIONS_RETIRED = '`areas[].requiredPermissions` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' @@ -1306,7 +1303,7 @@ const AREA_REQUIRED_PERMISSIONS_RETIRED = + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Navigation Area Schema @@ -1446,7 +1443,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', showall: '`contextSelectors[].includeAll` (which `showall` aliased) was removed in ' + '@objectstack/spec 17.0.0 — selectors are mandatory-scope and never render an ' @@ -1454,8 +1451,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = placement: '`contextSelectors[].placement` was removed in @objectstack/spec 17.0.0 (' + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + "block, and `'topbar'` placed nothing in the topbar. Delete the key. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.", location: '`contextSelectors[].placement` (which `location` aliased) was removed in ' + '@objectstack/spec 17.0.0 — selectors always render in the sidebar header. ' @@ -1638,7 +1634,7 @@ const HOME_PAGE_ID_RETIRED = + '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; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * The prescription for every author-shaped spelling of the ADR-0045 publish @@ -1721,18 +1717,6 @@ export const AppSchema = lazySchema(() => strictObject( /** Display label */ label: I18nLabelSchema.describe('App display label'), - /** - * REMOVED — never read by any consumer (2026-06 AppSchema liveness audit). - * An app's version is its owning package's `manifest.version`; a second - * per-app number had no reader and could silently disagree with it. - */ - version: retiredKey( - '`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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** Description */ description: I18nLabelSchema.optional().describe('App description'), @@ -1845,14 +1829,6 @@ export const AppSchema = lazySchema(() => strictObject( contextSelectors: z.array(AppContextSelectorSchema).optional() .describe('App-level scope dropdowns whose value is injected into nav items as {} template vars'), - /** - * REMOVED in 17.0.0 (#4667) — see {@link HOME_PAGE_ID_RETIRED}. Tombstoned - * rather than deleted, matching the seven #4142 retirements on this schema: - * `retiredKey` types it `never`, so an app still authoring it fails to - * compile as well as to parse. - */ - homePageId: retiredKey(HOME_PAGE_ID_RETIRED), - /** * Access Control * List of permissions required to access this app. @@ -1861,77 +1837,6 @@ export const AppSchema = lazySchema(() => strictObject( */ requiredPermissions: z.array(z.string()).optional().describe('Permissions required to access this app'), - /** - * REMOVED — the self-described "config file convenience" slots were never - * read (2026-06 liveness audit): objects register via `defineStack`, and the - * ambient chatbot derives an app's object list from its NAV ITEMS - * (`collectNavObjects`), never from `App.objects`. - */ - objects: retiredKey( - '`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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - apis: retiredKey( - '`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. Before ' + - 'the executor landed 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. An endpoint whose work needs CODE still lands here: ' + - 'compute in a flow and register the code as a stack function ' + - '(`defineStack({ functions })`) or a connector — an app does not mount routes on ' + - '`http.server`, and the manifest `contributes.routes` key was removed in ' + - '@objectstack/spec 17 (nothing ever read it; authoring it is now rejected with its ' + - 'own prescription). ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - /** - * REMOVED — a declared-but-unenforced security surface (ADR-0049 class, - * 2026-06 liveness audit): the only live sharing/embed path is - * `FormView.sharing` (public data collection); no public-app or iframe route - * ever read the app-level blocks, so authoring them created a false - * security/feature impression. - */ - sharing: retiredKey( - '`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; `--write` applies the ones it can prove, and you apply the rest 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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - /** - * REMOVED — fully unimplemented (2026-06 liveness audit: even - * `packages/mobile` ignored it). Re-admit only together with a real mobile - * navigation implementation, per the ADR-0049 trichotomy — a mode picker - * that changes nothing is an authoring trap. - */ - mobileNavigation: retiredKey( - '`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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Default agent for this app's ambient chat surface. * @@ -1966,21 +1871,6 @@ export const AppSchema = lazySchema(() => strictObject( defaultAgent: SnakeCaseIdentifierSchema.optional() .describe("Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1"), - /** - * REMOVED — never read at the APP level (2026-06 liveness audit). ARIA - * attributes are live on the page / page-component / list-view surfaces that - * render DOM. NOT on a dashboard widget: `dashboard.widgets[].aria` was - * retired in this same 17.0.0 (#5010), so "component/widget" pointed half of - * its readers at another tombstone (#6756). - */ - aria: retiredKey( - '`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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here, in a code package's source; diff --git a/packages/spec/src/ui/aria-carrier-tombstones.test.ts b/packages/spec/src/ui/aria-carrier-tombstones.test.ts index 60823c620dd..a66d02c0796 100644 --- a/packages/spec/src/ui/aria-carrier-tombstones.test.ts +++ b/packages/spec/src/ui/aria-carrier-tombstones.test.ts @@ -37,6 +37,15 @@ import { ObjectSchema } from '../data/object.zod'; * class. `retiredKey()` is a Zod `never` whose issue carries the guidance as * its `message`, so here the wording IS the whole contract (#5240). * + * [#25767] The two tombstones this file was written for — `dashboard.widgets[].aria` + * and `App.aria`, both 17.0.0 — were 16.x retirement kits and aged out of + * their shapes at 18 (ADR-0087 amendment, D2 = A). Each key is a strict + * unknown key now, refused with the protocol upgrade guide the prescriptions + * moved to, so there is no enumeration left on either to go stale at the + * other; the first case below pins that shape. The ground-truth half and the + * third member of the family (`ChartConfig.aria`, 17.5.0, still in its + * window) carry on unchanged. + * * ⚠️ Two halves, two directions — stated up front because only one of them can * go red on the defect this file was written for: * @@ -69,68 +78,21 @@ describe('the `aria` tombstones name only live `AriaProps` carriers', () => { return result.error!.issues.map((i) => i.message).join('\n'); }; - it('the widget tombstone still fires, and points at surfaces that survived 17.0.0', () => { - const message = messageOf(DashboardWidgetSchema.safeParse(widgetWithAria)); - - // Anti-vacuity. Deleting the tombstone outright does not silence this file: - // `DashboardWidgetSchema` is a `strictObject`, so the key would still be - // refused — but as a generic unrecognized-key guidance that cannot contain - // the prescription's own opening clause. Wording drift fails the assertions - // below; DISAPPEARANCE fails this one. - expect( - message, - 'the widget `aria` prescription must still be reachable through the parse', - ).toContain('`dashboard.widgets[].aria` was removed'); - - // Every surface that still declares `aria: AriaPropsSchema` and is graded - // `live` in the liveness ledger, spelled the way an author writes it. A - // prescription NARROWER than the truth is the #4964 defect. - for (const live of ['`page.aria`', '`page.components[].aria`', 'list view `aria`']) { - expect(message, `the prescription must still name ${live}`).toContain(live); + it('the two aged-out tombstones are strict unknown keys now, refused with the upgrade guide (#25767)', () => { + for (const [name, result] of [ + ['`dashboard.widgets[].aria`', DashboardWidgetSchema.safeParse(widgetWithAria)], + ['`App.aria`', AppSchema.safeParse(appWithAria)], + ] as const) { + const message = messageOf(result); + // The refusal names the key and where its prescription lives — and + // carries no enumeration of carriers, so the #6756 loop cannot re-form. + expect(message, name).toContain('`aria`'); + expect(message, name).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message, name).not.toContain('was removed'); } - - // ...and nothing retired. WIDER than the truth is the #6756 defect itself: - // `app.aria` is `dead` in `liveness/app.json` and stripped by the - // protocol-17 `app-dead-authoring-keys-removed` conversion, so naming it - // sent the author to a door that does not exist. Matched by idiom (any - // mention at all), not by the exact sentence that used to carry it. - expect( - message, - 'the prescription must not point at `app.aria`, retired in this same major', - ).not.toMatch(/app\.aria/i); - - // None of the above may be bought by weakening the prescription itself. - expect(message).toContain('author a `title`'); - // The conversion STRIPS this key (`stripKeys(..., [... 'aria'])`). The - // key's fate is the body's job ("Delete the key."), asserted above; the - // `os migrate meta` sentence states what the TOOL does and is the house - // sentence (#6856 route D — superseding the #6854 `to remove it` pin, - // an accepted cost of that ruling). Class-wide shape enforcement lives in - // `shared/retired-key-migrate-sentence.test.ts`; this pin holds the one - // site that already flipped once. - expect(message).toContain('Delete the key.'); - expect(message).toContain('os migrate meta --from 16'); - expect(message).toMatch(/to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\.$/); - }); - - it('the App.aria tombstone points at a page component, not at the retired widget surface', () => { - const message = messageOf(AppSchema.safeParse(appWithAria)); - - // Anti-vacuity, same shape as above. - expect( - message, - 'the `App.aria` prescription must still be reachable through the parse', - ).toContain('`App.aria` was removed'); - - expect(message, 'the prescription must name the surviving carrier').toContain('`page.components[].aria`'); - - // The reciprocal half of the loop. "component/widget" read as an - // instruction sends half its readers at `dashboard.widgets[].aria`, which - // #5010 retired in this same 17.0.0. - expect( - message, - 'the prescription must not send the author to a dashboard widget', - ).not.toMatch(/widget/i); + // The widget refusal must not point the author at `app.aria` either — the + // suggester offers no rename onto a key the shell no longer declares. + expect(messageOf(DashboardWidgetSchema.safeParse(widgetWithAria))).not.toMatch(/app\.aria/i); }); // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a6ec1d1c966..c64d92809f9 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -121,9 +121,13 @@ describe('PageHeaderProps recordChrome / showCopyId — declared because the hea // the header's own bag is never asked for one, and the registration publishes // no `icon` input. Four in-repo pages authored it and none ever drew it. describe('PageHeaderProps icon is retired — no renderer reads it', () => { - it('rejects the retired `icon` with its prescription', () => { + // #25767: the tombstone was a 16.x retirement kit and aged out (ADR-0087 + // D2 = A), so the key is refused as a strict unknown key and the refusal's + // trailing sentence names the protocol upgrade guide instead of carrying + // the `recordChrome` prescription itself. + it('refuses the retired `icon` as a strict unknown key, pointing at the upgrade guide', () => { expect(() => PageHeaderProps.parse({ title: 'Connect an Agent', icon: 'bot' })) - .toThrow(/`icon`.*removed.*`recordChrome`/s); + .toThrow(/Unrecognized key\(s\) on this `page:header`: `icon`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `icon` on a clean parse', () => { @@ -240,12 +244,12 @@ describe('PageTabsProps tabStyle — renamed from `type`, which collides with th } }); - it('rejects the retired `type` with the rename prescription', () => { - // Not `.toThrow()` alone: an undeclared key on this non-strict schema would - // be stripped silently, and a bare throw assertion cannot tell the two - // apart. The message IS the migration doc, so it is what gets asserted. + it('refuses the retired `type` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { + // Not `.toThrow()` alone: a stripping schema would drop the key silently, + // and a bare throw assertion cannot tell the two apart. The `tabStyle` + // rename prescription left with its 16.x tombstone; the guide carries it. expect(() => PageTabsProps.parse({ type: 'card', items: [] })) - .toThrow(/`type`.*removed.*`tabStyle`/s); + .toThrow(/Unrecognized key\(s\) on this `page:tabs`: `type`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `type` on a clean parse', () => { @@ -417,7 +421,8 @@ describe('PageCardProps', () => { const result = PageCardProps.parse({}); expect(result.bordered).toBe(true); expect(result.title).toBeUndefined(); - expect(result.actions).toBeUndefined(); + // [#25767] `actions` left the shape with its ≤16.x tombstone. + expect(result).not.toHaveProperty('actions'); expect(result.children).toBeUndefined(); expect(result.footer).toBeUndefined(); }); @@ -447,9 +452,9 @@ describe('PageCardProps', () => { expect(result.children).toHaveLength(1); }); - it('rejects the retired `body` with the rename prescription', () => { + it('refuses the retired `body` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => PageCardProps.parse({ body: ['component1'] })) - .toThrow(/`body`.*removed.*`children`/s); + .toThrow(/Unrecognized key\(s\) on this `page:card`: `body`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `body` on a clean parse', () => { @@ -461,9 +466,9 @@ describe('PageCardProps', () => { // title/bordered/children/footer and has no actions area; the objectui // registration publishes no `actions` input either. The prescription points // at composition, which is what actually renders. - it('rejects the retired `actions` with the composition prescription', () => { + it('refuses the retired `actions` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => PageCardProps.parse({ title: 'Shortcuts', actions: ['new_task'] })) - .toThrow(/`actions`.*removed.*`children`.*`footer`/s); + .toThrow(/Unrecognized key\(s\) on this `page:card`: `actions`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `actions` on a clean parse', () => { @@ -479,7 +484,6 @@ describe('pageComponentSlotPositions — the one slot list, derived from the row it('names exactly the positions the rows declare, in the walks\' visit order', () => { expect(pageComponentSlotPositions()).toEqual([ { key: 'children', retired: false }, - { key: 'body', retired: true }, { key: 'footer', retired: false }, { key: 'items', panelKey: 'children', retired: false }, ]); @@ -489,7 +493,9 @@ describe('pageComponentSlotPositions — the one slot list, derived from the row const cardShape = PageCardProps.shape as Record; const tabsItem = (PageTabsProps.shape.items as any).def.element.shape as Record; const accordionItem = (PageAccordionProps.shape.items as any).def.element.shape as Record; - for (const key of ['children', 'body', 'footer']) expect(cardShape).toHaveProperty(key); + for (const key of ['children', 'footer']) expect(cardShape).toHaveProperty(key); + // `body` (≤16.x kit, aged out on #25767) is a strict unknown key, not a row. + expect(cardShape).not.toHaveProperty('body'); expect(PageContainerProps.shape).toHaveProperty('children'); expect(tabsItem).toHaveProperty('children'); expect(accordionItem).toHaveProperty('children'); @@ -506,7 +512,8 @@ describe('pageComponentSlotPositions — the one slot list, derived from the row const unmarked = z.array(z.unknown()).optional().describe('Card footer components (slot)'); expect(z.toJSONSchema(footer)).toEqual(z.toJSONSchema(unmarked)); expect(footer.parse(['bare-id', { type: 'element:button' }])).toEqual(['bare-id', { type: 'element:button' }]); - expect(() => PageCardProps.parse({ body: [] })).toThrow(/`body`.*removed.*`children`/s); + expect(() => PageCardProps.parse({ body: [] })) + .toThrow(/Unrecognized key\(s\) on this `page:card`: `body`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('is derived once and handed back frozen', () => { @@ -838,13 +845,15 @@ describe('RecordDetailsProps', () => { // two keys in that ruling this one WAS read — against `inline`/`compact`, // values this enum never permitted — so both legal values took the same // branch and the key selected nothing. - it('rejects the retired `layout` with its prescription', () => { + // The ≤16.x kit aged out on #25767: `layout` is a strict unknown key whose + // refusal names the upgrade guide, not the old prescription. + it('refuses the retired `layout` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => RecordDetailsProps.parse({ layout: 'custom' })) - .toThrow(/`layout`.*removed.*`sections`.*`highlightFields`/s); + .toThrow(/Unrecognized key\(s\) on this `record:details`: `layout`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); // The declared default is refused too — `auto` was never distinguishable // from `custom` or from omitting the key. expect(() => RecordDetailsProps.parse({ layout: 'auto' })) - .toThrow(/`layout`.*removed/s); + .toThrow(/Unrecognized key\(s\) on this `record:details`: `layout`/); }); it('does not materialize the retired `layout` on a clean parse', () => { @@ -2148,30 +2157,21 @@ describe('Interactive Elements — element:record_picker', () => { }).success).toBe(true); }); - // #5775 tombstones — the prescription IS the payload. `displayField` was a - // REQUIRED declaration no renderer read; `searchFields` / `multiple` were - // capability claims the single-select control never kept (ADR-0049). - it('rejects the retired `displayField` with the rename prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ displayField: 'title' })) - .toThrow(/displayField.*removed.*use `labelField`|displayField.*removed.*`labelField`/s); - }); - - it('rejects the retired `searchFields` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ searchFields: ['name'] })) - .toThrow(/`searchFields`.*removed.*Delete the key/s); - }); - - it('the `searchFields` prescription names the binding\'s filter, never the retired flat one', () => { - const r = ElementRecordPickerPropsSchema.safeParse({ searchFields: ['name'] }); - const message = r.success ? '' : r.error.issues[0]!.message; - expect(message).toContain('use the component-level `dataSource.filter`'); - expect(message).not.toContain('use `filter`'); - }); - - it('rejects the retired `multiple` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ multiple: true })) - .toThrow(/`multiple`.*removed.*Delete the key/s); - }); + // #5775 tombstones (≤16.x kit) aged out on #25767: `displayField`, + // `searchFields` and `multiple` are strict unknown keys whose refusal names + // the upgrade guide; the rename/delete prescriptions live in the release + // that carried them. + it.each(['displayField', 'searchFields', 'multiple'])( + 'refuses the retired `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', + (key) => { + const r = ElementRecordPickerPropsSchema.safeParse({ [key]: key === 'multiple' ? true : key === 'searchFields' ? ['name'] : 'title' }); + expect(r.success).toBe(false); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this \`element:record_picker\`: \`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/was removed/); + }, + ); it('does not materialize the retired keys on a clean parse', () => { const props = ElementRecordPickerPropsSchema.parse({}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 145dfb5ed5c..7ddfd934525 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -527,12 +527,11 @@ const retiredComponentProps = (type: string) => { * JSON Schema and the authorable surface are byte-identical to the unmarked * key; it only records the instance in a module-private registry. * - * {@link retiredComponentSlot} marks a TOMBSTONED spelling of a slot: - * `page:card.body`, retired by #5775 (maintainer ruling 2026-08-06, direction - * A — one composition key, `children`). It is not an authorable spelling, and - * the authoring walks do not descend it; the renderers still read it as a - * back-compat fallback for STORED documents, which is exactly the population - * the conversion walker normalizes, so that walker keeps descending it. + * The registry's `retired` flag marked a TOMBSTONED spelling of a slot + * (`page:card.body`, retired by #5775) for the conversion walker alone. That + * tombstone aged out with the protocol-16.x retirements (ADR-0087 amendment + * 2026-10-26, #25767), so no row sets it today; the flag stays because the walk + * contract exposes it, and the next retired slot spelling sets it again. */ const COMPONENT_SLOT_DECLARATIONS = new WeakMap(); @@ -546,12 +545,6 @@ function componentSlot(schema: T): T { return schema; } -/** {@link componentSlot} for a tombstoned spelling of a slot — see the block above. */ -function retiredComponentSlot(schema: T): T { - COMPONENT_SLOT_DECLARATIONS.set(schema, { retired: true }); - return schema; -} - /** * The composition slot every thin container renders: `page:section`, * `page:footer`, `page:sidebar`. @@ -655,34 +648,6 @@ export const PageHeaderProps = strictObject({ '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: I18nLabelSchema.optional().describe('Page subtitle'), - /** - * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829, - * route (c) — retire upstream). - * - * A header icon nothing has ever drawn. `PageHeaderRenderer` resolves `icon` - * only per header ACTION (`action.icon`, - * inside the action pipeline) and never off the header's own props bag; - * `@object-ui/layout`'s `` accepts an `icon` REACT prop from a - * host but — unlike `actions`, whose `schema?.actions ?? - * schema?.properties?.actions` fallback sits four lines away in the same - * function — gives it no schema fallback, so an authored node cannot reach - * it. objectui's registration publishes no `icon` input either, which is - * what put this key in that repo's `UNPUBLISHED_EXEMPTIONS` map as a B-class - * "spec declares it, NO renderer read point" entry. - * - * The live mechanism is the record chrome (`recordChrome`, on by default) - * for the header's own identity, and each action's own `icon` for the - * buttons beside it. - */ - icon: retiredKey( - '`page:header` property `icon` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * REMOVED (#20758, ADR-0049 enforce-or-remove through the ADR-0087 D2 route, * the way `icon` above left this row; the spec half of objectui#11166). @@ -850,18 +815,6 @@ export const PageTabsProps = strictObject({ */ tabStyle: z.enum(['line', 'card', 'pill']).default('line') .describe("Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills"), - /** - * REMOVED (#6776). The declared spelling of `tabStyle`, unauthorable in any - * flat or JSX carrier because a page component's own dispatch key is also - * called `type`. The live mechanism is `tabStyle`. - */ - type: retiredKey( - '`page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), position: z.enum(['top', 'left']).default('top'), /** * Keep the tab strip visible when there is only one tab (#4001 batch A). @@ -1020,32 +973,6 @@ export const PageCardProps = strictObject({ }, { title: I18nLabelSchema.optional(), bordered: z.boolean().default(true), - /** - * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829, - * route (c) — retire upstream). - * - * A card action list nothing has ever rendered. `PageCardRenderer` - * (`containers.tsx`) reads exactly four keys — `title`, `bordered`, - * `body ?? children`, `footer` — and returns a `` built from them; - * there is no actions area in the markup and no `actions` input in the - * registration, which is what put this key in objectui's - * `UNPUBLISHED_EXEMPTIONS` map as a B-class "spec declares it, NO renderer - * read point" entry. The card's sibling `page:header` DOES read `actions` - * off its bag, so the divergence was invisible to anyone reading the two - * declarations side by side. - * - * The live mechanism is composition: author the buttons as components in - * `children` or `footer` (`element:button`, `record:quick_actions`). - */ - actions: retiredKey( - '`page:card` property `actions` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * Card content, in order — the canonical composition slot, matching every * other container (`grid`, `flex`, `page:section`, `page:tabs` items). @@ -1058,22 +985,6 @@ export const PageCardProps = strictObject({ * (Prime Directive #12). `footer` is a genuinely distinct slot and stays. */ children: componentSlot(z.array(z.unknown()).optional().describe('Card content components, in order (the card body slot)')), - /** - * REMOVED (#5775). `body` was the declared spelling of the slot every other - * container calls `children`; the two are the same slot, and the renderer - * already reads both. The live mechanism is `children`. - * - * Marked a RETIRED slot spelling (#20940): the authoring walks do not - * descend it, the ADR-0087 conversion walker does — see - * {@link retiredComponentSlot}. - */ - body: retiredComponentSlot(retiredKey( - '`page:card` property `body` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - )), /** * Slot for footer content — a declared, rendered slot distinct from * `children` (objectui's `PageCardRenderer` draws it under the body), so @@ -1319,35 +1230,6 @@ export const RecordDetailsProps = strictObject({ guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'), - /** - * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3818 — - * the removal direction). - * - * The declared `auto` | `custom` semantics were never implemented. objectui's - * `RecordDetailsRenderer` does read `layout`, but only to test it against - * `inline` | `compact` — two values this enum never permitted — so BOTH legal - * values fell to the same `vertical` branch and the key selected nothing. - * That is why it survived `check:react-declaration-parity`: objectui's - * registry declared `layout` with the same `auto` | `custom` enum this schema - * did, and the gate compares two DECLARATIONS, never a declaration against a - * renderer (AGENTS.md). A third spelling, `stacked` | `inline` | `compact`, - * sat in `@object-ui/types`' mirror — three declarations of one key, none of - * them the branch the renderer takes. - * - * The live mechanism is what you author: `sections` renders the explicit - * groups (the old `custom`), and omitting it falls back to the object's - * `highlightFields` (the old `auto`). objectui#3818 deletes the input and the - * dead branch on the next pin bump. - */ - layout: retiredKey( - '`record:details` property `layout` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * Field groups rendered as the detail body, IN ORDER. * @@ -3377,40 +3259,6 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ placeholder: I18nLabelSchema.optional().describe('Placeholder text'), /** Shown in place of the row list when the query returns nothing. */ emptyText: I18nLabelSchema.optional().describe('Text shown when the query returns no records (default "No records")'), - /** - * REMOVED (#5775). A synonym of `labelField` — the same concept in two - * spellings, of which only `labelField` was ever read. - */ - displayField: retiredKey( - '`element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 ' - + '(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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** - * REMOVED (#5775). ADR-0049 enforce-or-remove: the control has no search - * box, so this narrowed nothing. - */ - searchFields: retiredKey( - '`element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 ' - + '(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 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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** - * REMOVED (#5775). ADR-0049 enforce-or-remove: the control is a single-select - * `Select`, and a page variable binds one record id. - */ - multiple: retiredKey( - '`element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 ' - + '(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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); @@ -8753,7 +8601,7 @@ export interface PageComponentSlotPosition { * `page:card.footer`. * * A key is a slot exactly when its row wraps the key's schema in - * `componentSlot` / `retiredComponentSlot` (module-private, top of this file); + * `componentSlot` (module-private, top of this file); * a key of a panel list's element object (`items: z.array(z.object({ children * }))`) so marked yields a panel position. The walks match a position by SHAPE * on every component, not by the type whose row declared it: `properties` is an diff --git a/packages/spec/src/ui/dashboard-compareto.test.ts b/packages/spec/src/ui/dashboard-compareto.test.ts index dfe67328ab2..1ff5d08999f 100644 --- a/packages/spec/src/ui/dashboard-compareto.test.ts +++ b/packages/spec/src/ui/dashboard-compareto.test.ts @@ -122,11 +122,14 @@ describe('compareTo — every retired spelling is rejected WITH its upgrade', () expect(period).not.toMatch(/#\d{3,5}\b/); expect(period).toContain("kind: "); expect(period).toContain('previousPeriod'); - expect(period).toContain('os migrate meta --from 16'); + // The protocol-16.x conversion aged out with its kit on #25767: the sentence + // says so instead of naming a `--from 16` run that no longer lists the edit. + expect(period).toContain('protocol-16.x conversions'); + expect(period).not.toContain('--from 16'); const year = reject('previousYear'); expect(year).toContain('previousYear'); - expect(year).toContain('os migrate meta --from 16'); + expect(year).toContain('protocol-16.x conversions'); }); /** diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 86f3ef32b5a..d4cc30c5385 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; import { DashboardSchema, DashboardWidgetSchema, @@ -218,10 +219,11 @@ describe('widget unknown-key message order — fix before history', () => { }); it('is unchanged in SHAPE when no fix matches — full-message pin', () => { - // A key with no family, no guidance and no near-declared-key: the history - // follows the key statement directly. Any stray separator fails here. + // A key with no family, no guidance and no near-declared-key: the upgrade + // guide sentence (#25767, the last fix channel) and then the history follow + // the key statement directly. Any stray separator fails here. expect(messageFor({ zzWrongKey: 'blue' })) - .toBe(`Unrecognized key(s) on this dashboard widget: \`zzWrongKey\`. ${HISTORY}`); + .toBe(`Unrecognized key(s) on this dashboard widget: \`zzWrongKey\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`); }); it('a near-miss of a DECLARED key now gets the rename the bespoke map never offered', () => { @@ -233,7 +235,7 @@ describe('widget unknown-key message order — fix before history', () => { const m = messageFor({ colourVariant: 'blue' }); expect(m).toBe( 'Unrecognized key(s) on this dashboard widget: `colourVariant`. ' - + `Did you mean \`colourVariant\` → \`colorVariant\`? ${HISTORY}`, + + `Did you mean \`colourVariant\` → \`colorVariant\`? ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`, ); }); @@ -540,7 +542,7 @@ describe('date-range preset vocabulary — one source, in the spec', () => { describe('DashboardWidgetSchema — retired `responsive`', () => { const widget = { id: 'orders_kpi', type: 'metric', dataset: 'orders', values: ['total'] }; - it('REJECTS an authored `responsive` with the prescription (not "unrecognized key")', () => { + it('REFUSES an authored `responsive` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { let message = ''; try { DashboardWidgetSchema.parse({ @@ -549,24 +551,13 @@ describe('DashboardWidgetSchema — retired `responsive`', () => { }); } catch (e) { message = String((e as Error).message); } - // The prescription itself, in the four parts an upgrading author needs: - // the fully-qualified key, the version, the issue, and the fix. - expect(message).toMatch(/dashboard\.widgets\[\]\.responsive/); - expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/ADR-0049 D2/); - expect(message).toMatch(/Delete the key/); - // The redirect correction (#11027): this message used to prescribe - // `page.components[].responsive` as the live home of the shared shape; - // that key measured equally unread and is retired too, so the message - // must (a) say so rather than silently dropping the old promise — an - // author who followed it needs the correction — and (b) point at the - // channel that IS applied. - expect(message).toMatch(/page\.components\[\]\.responsive[\s\S]*/); - expect(message).toMatch(/responsiveStyles/); - // `.strict()` on this schema would answer a DELETED key with a generic - // unrecognized-key error. The tombstone is what makes it a prescription — - // if this ever regresses to the strict path, this assertion is the tripwire. - expect(message).not.toMatch(/Unrecognized key/); + // The tombstone was a 16.x retirement kit and aged out (ADR-0087 D2 = A): + // the strict widget shape refuses the key, and the refusal's trailing + // sentence names the guide that carries the prescription (the #11027 + // `responsiveStyles` redirect included) from here. Never a silent strip. + expect(message).toMatch(/Unrecognized key\(s\)[^\n]*`responsive`/); + expect(message).toMatch(/Keys retired before the previous protocol major are no longer prescribed here/); + expect(message).toMatch(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('still accepts a widget with no `responsive` (the retirement strips nothing else)', () => { @@ -622,47 +613,26 @@ describe('DashboardWidgetSchema — retired action trio + `aria`', () => { ['actionUrl', 'export_dashboard_pdf'], ['actionType', 'script'], ['actionIcon', 'download'], - ] as const)('REJECTS an authored `%s` with the prescription', (key, value) => { + ] as const)('REFUSES an authored `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', (key, value) => { const message = parseWith({ [key]: value }); - // The prescription, in the parts an upgrading author needs: the - // fully-qualified key, the version, the ADR, and the fix. NOT the tracker - // id — it resolves to nothing for the author reading this refusal. - expect(message).toMatch(new RegExp(`dashboard\\.widgets\\[\\]\\.${key}`)); - expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/ADR-0049 enforce-or-remove/); - expect(message).not.toMatch(/#\d{3,5}\b/); - // The three went together — an author who deletes only the one key they - // were told about would hit this same error twice more. - expect(message).toMatch(/delete all three/i); - // It must name the surviving home, or this reads as "dashboards cannot have - // buttons" rather than "the button belongs on the header". - expect(message).toMatch(/header\.actions\[\]/); - // The tombstone is what makes it a prescription; a plain `.strict()` - // rejection of a DELETED key would be a generic unrecognized-key error. - expect(message).not.toMatch(/Unrecognized key/); - }); - - it('REJECTS an authored `aria` with the prescription, naming its surviving homes', () => { + // The trio's tombstones were 16.x retirement kits and aged out (ADR-0087 + // D2 = A). The strict widget shape refuses each key; the "delete all + // three, the button lives on `header.actions[]`" prescription is the + // guide's now, named by the refusal's trailing sentence. Never a strip. + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(message).toMatch(/Keys retired before the previous protocol major are no longer prescribed here/); + expect(message).toMatch(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); + }); + + it('REFUSES an authored `aria` the same way (#25767)', () => { const message = parseWith({ aria: { ariaLabel: 'Total orders' } }); - expect(message).toMatch(/dashboard\.widgets\[\]\.aria/); - expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/ADR-0049 D2/); - expect(message).toMatch(/Delete the key/); - // The shared shape survives elsewhere. Without this, the message reads as - // "AriaProps is gone", which would send an author deleting live metadata. - // - // ⚠️ This assertion used to require `/app\.aria/` — and `App.aria` is a - // `retiredKey()` tombstone removed in this same 17.0.0, so the pin was - // holding the prescription ON a dead surface (#6756). Re-aimed at the - // surfaces that really do still declare `aria: AriaPropsSchema` and are - // graded `live` in the liveness ledger. The full both-directions - // enumeration pin lives in `aria-carrier-tombstones.test.ts`. - expect(message).toMatch(/page\.aria/); - expect(message).toMatch(/page\.components\[\]\.aria/); - expect(message).not.toMatch(/app\.aria/i); - expect(message).not.toMatch(/Unrecognized key/); + // The surviving `aria` carriers (`page.aria`, `page.components[].aria`) + // are enumerated in `aria-carrier-tombstones.test.ts`; this refusal no + // longer names them itself — the guide does. + expect(message).toMatch(/Unrecognized key\(s\)[^\n]*`aria`/); + expect(message).toMatch(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('still accepts a widget carrying none of the four (nothing else was stripped)', () => { @@ -779,11 +749,14 @@ describe('DashboardHeaderAction fields carry an item-level `title`', () => { const RETIRED_DESCRIPTION_PREFIX = '[REMOVED] '; /** - * Every `retiredKey()` tombstone on the `widgets[]` row, BY NAME, derived - * from `z.toJSONSchema(DashboardSchema)` on this tree — the same five under - * `io: 'input'` and `io: 'output'`, out of 22 row properties. + * Every `retiredKey()` tombstone on the dashboard ROOT, BY NAME, derived + * from `z.toJSONSchema(DashboardSchema)` on this tree — the same set under + * `io: 'input'` and `io: 'output'`. The five on the `widgets[]` row + * (`actionIcon`/`actionType`/`actionUrl`/`aria`/`responsive`) were ≤16.x + * kits and aged out on #25767: the row carries no tombstone now, so the + * root's 17.x tombstone carries the rule and the row is the control. */ - const WIDGET_ROW_TOMBSTONES = ['actionIcon', 'actionType', 'actionUrl', 'aria', 'responsive'] as const; + const ROOT_TOMBSTONES = ['refreshInterval'] as const; // The console derives the panel schema with `io: 'input'`; `GET /meta` // derives it in output mode. The name must survive both. @@ -807,21 +780,23 @@ describe('DashboardHeaderAction fields carry an item-level `title`', () => { // could each be titled with this file still green — so the set is // DERIVED from the served schema and every member is asserted. // (`widgets[].id` held this role until its carrier was titled.) - const widgetProps = js.properties.widgets.items.properties; - const tombstones = Object.keys(widgetProps).filter( - (k) => - typeof widgetProps[k]?.description === 'string' && - widgetProps[k].description.startsWith(RETIRED_DESCRIPTION_PREFIX), - ); + const isTombstone = (props: any, k: string) => + typeof props[k]?.description === 'string' && props[k].description.startsWith(RETIRED_DESCRIPTION_PREFIX); + const rootProps = js.properties; + const tombstones = Object.keys(rootProps).filter((k) => isTombstone(rootProps, k)); // Floor, BY NAME — the loop below is vacuously green over an EMPTY set, // and the `'Widget ID'` leg guards only that the node resolved, not that // the predicate still selects anything. A floor rather than an equality: - // a sixth tombstone is covered by the loop on the day it lands, which is + // a second tombstone is covered by the loop on the day it lands, which is // the reason for deriving the set instead of listing it. - expect(tombstones.slice().sort()).toEqual(expect.arrayContaining([...WIDGET_ROW_TOMBSTONES])); + expect(tombstones.slice().sort()).toEqual(expect.arrayContaining([...ROOT_TOMBSTONES])); for (const key of tombstones) { - expect(widgetProps[key].title, `widgets.items.properties.${key}.title`).toBeUndefined(); + expect(rootProps[key].title, `properties.${key}.title`).toBeUndefined(); } + // CONTROL (#25767): the `widgets[]` row's five ≤16.x tombstones aged out + // — they are strict unknown keys now, not rows, so the row derives EMPTY. + const widgetProps = js.properties.widgets.items.properties; + expect(Object.keys(widgetProps).filter((k) => isTombstone(widgetProps, k))).toEqual([]); // Lit — the authorable sibling really does carry one, so the lines above // measure the tombstone rule, not an emitter that never writes titles. expect(widgetProps.id.title).toBe('Widget ID'); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 17ac776359a..c0105c45bd9 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -392,8 +392,8 @@ 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 list the mechanical edits for the `1y` case; `--write` applies the ones it can prove, ' - + 'and the other durations are reported for you to re-state.'; + + 'The `1y` case left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool ' + + 'on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade; the other durations you re-state yourself.'; // The two string arms. They parsed, and on a dataset widget they then did // NOTHING — DatasetWidget dropped them deliberately, because forwarding one made @@ -407,30 +407,7 @@ const COMPARE_TO_STRING_RETIRED = (kind: 'previousPeriod' | 'previousYear') => + '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 list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; - -// ── Per-widget action button prescriptions (#5010) ─────────────────────────── -// -// `//` rather than `/** */` deliberately, per the `COMPARE_TO_*` block above: -// build-docs lifts JSDoc onto the reference page, and an upgrade note is not a -// doc for a shape that still exists. -// -// The three keys read as one affordance ("give this widget its own button"), so -// they share one prescription and name each other — an author who removes only -// `actionUrl` should learn in the same breath that its two companions are gone -// too, rather than hitting three parse errors in three edit rounds. -const WIDGET_ACTION_RETIRED = (key: 'actionUrl' | 'actionType' | 'actionIcon') => - `\`dashboard.widgets[].${key}\` was removed in @objectstack/spec 17.0.0 ` - + '(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; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * The one widget `type` that reads `options.stageOrder`. @@ -1177,19 +1154,6 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ */ requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered').meta({ title: 'Requires Service' }), - // `actionUrl` / `actionType` / `actionIcon` REMOVED (#5010, ADR-0049 D2): - // the three keys described a per-widget header action BUTTON that no renderer - // has ever drawn. Re-measured 2026-08-04 against objectui@91757a7: all 14 - // `actionUrl` reads in `DashboardRenderer.tsx` are scoped to - // `schema.header.actions[]` (a `DashboardHeaderAction`, a different schema); - // `actionIcon` has zero references in either repo outside this declaration. - // The one consumer of the pair was `packages/lint`'s reference-integrity rule, - // which failed builds when a NEVER-RENDERED button pointed at a missing - // action — its widget branch goes with the keys, in this same change. - actionUrl: retiredKey(WIDGET_ACTION_RETIRED('actionUrl')), - actionType: retiredKey(WIDGET_ACTION_RETIRED('actionType')), - actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')), - /** * Presentation-scope filter (MongoDB-style), ANDed into the dataset query as * `runtimeFilter`. [#20116] It is charted through the analytics `where` door @@ -1424,61 +1388,6 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ */ suppressWarnings: z.array(z.string()).optional().describe('Build diagnostic rule ids suppressed on this widget').meta({ title: 'Suppress Warnings' }), - // `responsive` REMOVED (#4876): authorable and inert, exactly like the - // same-named `view.responsive` retired four days earlier (#3896 close-out). - // No objectui code read `widget.responsive` — DashboardRenderer / - // DashboardEditor / plugin-designer mention it only in comments. It escaped - // the #3896 sweep through a liveness-ledger drill gap, not on evidence: - // `dashboard.json` declared no `children` on `widgets`, so no widget-level - // key had ever been classified (#4956). - // - // CORRECTED (#11027): this tombstone used to redirect authors to - // `page.components[].responsive` as the live home of the shared - // `ResponsiveConfig` shape, on the belief that objectui `useResponsiveConfig` - // read it. Measured 2026-08 across both repos, that hook (and the second - // implementation, `ResponsiveProtocol`) had ZERO callers — the prescribed - // destination applied no breakpoint behaviour either. The page key and the - // shape are retired too (`page.zod.ts` tombstone, ADR-0049 D2); the live - // per-breakpoint channel is `responsiveStyles` (ADR-0065). - responsive: retiredKey( - '`dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (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. ' + - 'This message used to point at `page.components[].responsive` as the live home of the shared ' + - '`ResponsiveConfig` shape; that key was measured equally unread and removed with the shape ' + - 'with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page ' + - 'component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - // `aria` REMOVED (#5010, ADR-0049 D2): the same "false compliance" the - // dashboard-level `aria` was retired for in the #3896 close-out (see the - // tombstone on `DashboardSchema` below) — declared ARIA attributes never - // reached the DOM, so a dashboard could claim accessibility work that had - // measurably not happened. Call graph closed by hand across both repos - // 2026-08-04: no consumer of `widget.aria` anywhere. The `aria-*` attributes - // in `DashboardRenderer` / `DatasetWidget` are the renderer's OWN DOM props, - // and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a - // `view`'s. The shared `AriaPropsSchema` is untouched — it stays live on - // `page.aria` / `page.components[].aria` and on the list view's `aria`, - // which really are applied (`action.aria` carries the shape too, but the - // ledger grades that one PARTIAL). It is NOT live on `app.aria`: that key - // is a `retiredKey()` tombstone removed in the same 17.0.0 by the 2026-06 - // app liveness audit, and `os migrate meta --from 16` strips it — so the - // "lift it up to the app" repair this note used to imply was a dead end - // that also lost the block (#6756). - aria: retiredKey( - '`dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (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. 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; `--write` applies the ones it can prove, and you apply the rest 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. // @@ -1851,21 +1760,6 @@ export const DashboardSchema = lazySchema(() => strictObject({ */ requiredPermissions: z.array(z.string()).optional().describe(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION), - // `aria` / `performance` REMOVED (#3896 audit close-out): authorable and - // inert — no DashboardRenderer path applied either (ledger: dead; the - // report twins were removed in the report-liveness close-out). - aria: retiredKey( - '`dashboard.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - performance: retiredKey( - '`dashboard.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here, in a code package's source; diff --git a/packages/spec/src/ui/list-view-export-options.ts b/packages/spec/src/ui/list-view-export-options.ts index e9cfb0e2a64..b8cd66cef62 100644 --- a/packages/spec/src/ui/list-view-export-options.ts +++ b/packages/spec/src/ui/list-view-export-options.ts @@ -49,7 +49,7 @@ export const LIST_VIEW_EXPORT_PDF_RETIRED = + "not produce documents: to let users print a list as shown, set the view's `allowPrinting`; " + 'a printable document (an invoice, a delivery order, a letter) is a page that declares ' + '`print` — its paper, margins and running header and footer — with its blocks in `regions`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Export formats the platform actually delivers (#8010): `csv`/`json` on both diff --git a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts index 6e06345acdf..0224d62f3ae 100644 --- a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts +++ b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts @@ -308,6 +308,10 @@ describe('tree-scoped absence: no ViewItem record or flattened overlay inside th 'packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts', // This pin names the keys to assert their absence. THIS_FILE, + // [#25767] The view union's prescription pin authors `owner` on a view + // record on purpose, to assert the branch-typed refusal names this kit's + // prescription. + 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement record on purpose. diff --git a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts index 1b509afdfba..1d58170d5b6 100644 --- a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts +++ b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts @@ -361,6 +361,13 @@ describe('tree-scoped absence: no list-view payload inside the declared radius s // The `os lint` twin of that pin: `os lint` never parses, so the lint // rule reports the key by name, and this file authors it to assert that. 'packages/lint/src/list-view-filterable-fields-retired.test.ts', + // [#25767] The view union's prescription pin authors the key at the + // container and overlay doors to assert the branch-typed refusal names this + // kit's prescription, and the liveness advisory's witnesses author it on an + // UNPARSED intake to prove the ledger's `dead` verdict still warns there — + // both took this key over from `striped` when its 16.x tombstone aged out. + 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', + 'packages/lint/src/lint-liveness-properties.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement payload on purpose. diff --git a/packages/spec/src/ui/view-union-retirement-prescription.test.ts b/packages/spec/src/ui/view-union-retirement-prescription.test.ts index 71125883fa6..f117e15982c 100644 --- a/packages/spec/src/ui/view-union-retirement-prescription.test.ts +++ b/packages/spec/src/ui/view-union-retirement-prescription.test.ts @@ -20,7 +20,7 @@ * ``` * * ⚠️ Shipped behaviour, not a regression: `virtualScroll`, `striped` and - * `bordered` have read this way since 17.0.0. Guidance that exists and cannot + * `bordered` read this way from 17.0.0 until their kit aged out (#25767). Guidance that exists and cannot * be seen is, from the author's side, indistinguishable from guidance that does * not exist. * @@ -86,47 +86,60 @@ const topMessage = (body: unknown): string => { // from the WRONG branch would be measuring nothing about reach. // ─────────────────────────────────────────────────────────────────────────── -const BRANCH_PINS: ReadonlyArray, string]> = [ +/** + * [#25767] The carriers moved: `virtualScroll` / `striped` / `form.aria` were + * ≤16.x kits that aged out and read as strict unknown keys now (the catchall + * channel, §3), so each branch carries a 17.x tombstone instead. The last + * column says whether the prescription carries the `os migrate meta` + * sentence: the form branch's only surviving top-level-reachable tombstone is + * the ADR-0089 D2 `visibleOn` rename, which the parse folded and no + * conversion ever rode, so it never carried one. + */ +const BRANCH_PINS: ReadonlyArray, string, boolean]> = [ [ 'viewItem', - 'config.virtualScroll', + 'owner (the record\'s own key)', { - name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', - config: { type: 'grid', columns: ['name'], virtualScroll: true }, + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', owner: 'admin', + config: { type: 'grid', columns: ['name'] }, }, - '`view.virtualScroll` was removed', + '`view.owner` was removed', + true, ], [ 'container', - 'list.striped', + 'list.filterableFields', { name: 'crm_lead', object: 'crm_lead', - list: { type: 'grid', columns: ['name'], striped: true }, + list: { type: 'grid', columns: ['name'], filterableFields: ['name'] }, }, - '`view.striped` was removed', + '`view.list.filterableFields` was removed', + true, ], [ 'listOverlay', - 'virtualScroll (the card\'s own repro)', + 'filterableFields (the card\'s own repro, re-targeted)', { name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', - type: 'grid', columns: ['name'], virtualScroll: true, + type: 'grid', columns: ['name'], filterableFields: ['name'], }, - '`view.virtualScroll` was removed', + '`view.list.filterableFields` was removed', + true, ], [ 'formOverlay', - 'aria', + 'sections[].visibleOn', { name: 'crm_lead.edit', object: 'crm_lead', viewKind: 'form', - type: 'simple', aria: { label: 'x' }, + type: 'simple', sections: [{ fields: ['name'], visibleOn: 'record.status == "open"' }], }, - '`form.aria` was removed', + '`view.form.sections[].visibleOn` was removed', + false, ], ]; describe('§1 reach — every branch of the view union surfaces its own prescription', () => { - it.each(BRANCH_PINS)('%s branch (%s)', (branch, _key, body, opening) => { + it.each(BRANCH_PINS)('%s branch (%s)', (branch, _key, body, opening, migrateSentence) => { expect(selectViewMetadataBranch(body), 'the pin must exercise the branch it names').toBe(branch); const message = topMessage(body); @@ -136,7 +149,9 @@ describe('§1 reach — every branch of the view union surfaces its own prescrip // its `os migrate meta` sentence is pinned class-wide by // `../shared/retired-key-migrate-sentence.test.ts`. A message this code // composed would be a second spelling of a pinned string. - expect(message).toContain('to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'); + const sentence = 'to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + if (migrateSentence) expect(message).toContain(sentence); + else expect(message).not.toContain(sentence); }); it('the lifted string is byte-identical to the nested issue it came from', () => { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 1e10e9d9b29..08662c86393 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -69,6 +69,7 @@ import { checkViewCompleteness } from '../kernel/functional-completeness'; // [#19088] The object-field `scale` site #18972 bounded — read only, to pin // that this row's refusal text and that one's are the same platform fact. import { FieldSchema } from '../data/field.zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; describe('HttpMethodSubsetSchema', () => { it('should accept valid HTTP methods', () => { const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; @@ -3203,7 +3204,10 @@ describe('Airtable-style ListView enhancements', () => { // ============================================================================ describe('FormViewSchema — retired defaultSort (audit close-out: nothing read it)', () => { - it('REJECTS the retired `defaultSort` and points at the related list view', () => { + // The ≤16.x kit aged out on #25767: `defaultSort` is a strict unknown key + // whose refusal names the upgrade guide; the audit close-out prescription + // lives in the release that carried it. + it('refuses the retired `defaultSort` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { let message = ''; try { FormViewSchema.parse({ @@ -3211,8 +3215,9 @@ describe('FormViewSchema — retired defaultSort (audit close-out: nothing read defaultSort: [{ field: 'created_at', order: 'desc' }], }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/list view/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/Unrecognized key\(s\) on this form view: `defaultSort`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/audit close-out/); }); it('a form without it still parses', () => { expect(() => FormViewSchema.parse({ type: 'simple', sections: [{ fields: ['name'] }] })).not.toThrow(); @@ -3916,17 +3921,19 @@ describe('ListViewSchema filter field', () => { // Issue #7: ListView responsive and performance config // ============================================================================ describe('ListViewSchema — retired responsive/performance (audit close-out: no renderer read them)', () => { - it('REJECTS the retired `responsive` with the prescription', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], responsive: { hiddenOn: ['xs'] }, - })).toThrow(/responsive.*removed|removed.*responsive/s); - }); - it('REJECTS the retired `performance` with the prescription', () => { + // The ≤16.x kits aged out on #25767: both keys are strict unknown keys whose + // refusal names the upgrade guide, not the audit close-out prescription. + it.each([ + ['responsive', { hiddenOn: ['xs'] }], + ['performance', { lazyLoad: true }], + ] as const)('refuses the retired `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', (key, value) => { let message = ''; try { - ListViewSchema.parse({ type: 'grid', columns: ['name'], performance: { lazyLoad: true } }); + ListViewSchema.parse({ type: 'grid', columns: ['name'], [key]: value }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this list view: \`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/audit close-out|was removed/); }); }); @@ -3937,31 +3944,22 @@ describe('ListViewSchema — retired responsive/performance (audit close-out: no // end, never spells any of the three. // ============================================================================ describe('ListViewSchema — retired striped/bordered/virtualScroll (every reader only passed them through)', () => { - it('REJECTS the retired `striped` with the prescription', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], striped: true, - })).toThrow(/`view\.striped`.*removed.*no renderer ever applied it.*delete the key/s); - }); - it('REJECTS the retired `bordered` with the prescription', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], bordered: true, - })).toThrow(/`view\.bordered`.*removed.*Delete the key/s); - }); - it('REJECTS the retired `virtualScroll` with the prescription, naming the live mechanism', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], virtualScroll: true, - })).toThrow(/`view\.virtualScroll`.*removed.*large datasets page via `pagination`/s); - }); - it('the prescriptions carry the pinned migrate sentence (the retirement rides the protocol-17 conversion)', () => { - for (const key of ['striped', 'bordered', 'virtualScroll'] as const) { + // The #7176 kit rode the protocol-17 conversion and aged out on #25767: the + // three keys are strict unknown keys whose refusal names the upgrade guide. + // The prescriptions (and the `--from 16` sentence) live in the release that + // carried them. + it.each(['striped', 'bordered', 'virtualScroll'] as const)( + 'refuses the retired `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', + (key) => { let message = ''; try { ListViewSchema.parse({ type: 'grid', columns: ['name'], [key]: true }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(new RegExp(`view\\.${key}\` was removed`)); - expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./); - } - }); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this list view: \`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/was removed|os migrate meta/); + }, + ); it('accepts the live grid siblings byte-identically (rowHeight/selection/pagination/resizable)', () => { const siblings = { resizable: true, @@ -3981,13 +3979,13 @@ describe('ListViewSchema — retired striped/bordered/virtualScroll (every reade expect(parsed).not.toHaveProperty('bordered'); expect(parsed).not.toHaveProperty('virtualScroll'); }); - it('ObjectListViewSchema (the .extend copy) rejects the keys with the same prescription', () => { + it('ObjectListViewSchema (the .extend copy) refuses the keys the same way', () => { expect(() => ObjectListViewSchema.parse({ type: 'grid', columns: ['name'], striped: true, - })).toThrow(/`view\.striped`.*removed/s); + })).toThrow(/Unrecognized key\(s\) on [^:]+: `striped`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); expect(() => ObjectListViewSchema.parse({ type: 'grid', columns: ['name'], virtualScroll: true, - })).toThrow(/`view\.virtualScroll`.*removed/s); + })).toThrow(/Unrecognized key\(s\) on [^:]+: `virtualScroll`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); }); @@ -4037,7 +4035,10 @@ describe('ListViewSchema.exportOptions — object form + array lift + pdf retire expect(message).toMatch(/a printable document \(an invoice, a delivery order, a letter\) is a page that declares\s+`print`/); expect(message).not.toMatch(/#\d{3,5}\b/); expect(message).toMatch(/'csv', 'xlsx' and 'json'/); - expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./); + // [#25767] The protocol-16.x conversion aged out with its kit: the sentence + // says so instead of naming a `--from 16` run that no longer lists the edit. + expect(message).toMatch(/left `os migrate meta` with the protocol-16\.x conversions/); + expect(message).not.toMatch(/--from 16/); }); it("REJECTS 'pdf' in the object form's `formats` with the same prescription", () => { @@ -4387,7 +4388,7 @@ describe('visibility unknown-key message order — fix before history', () => { const res = FormSectionSchema.safeParse({ label: 'S', fields: [], bogusKey: true }); expect(res.success).toBe(false); const m = res.error!.issues.find((i) => i.code === 'unrecognized_keys')!.message; - expect(m).toBe(`Unrecognized key(s) on this form section: \`bogusKey\`. ${HISTORY}`); + expect(m).toBe(`Unrecognized key(s) on this form section: \`bogusKey\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`); }); it('emits the history exactly once, whatever the key count', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 7982f33f422..49aa268b69a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3014,8 +3014,6 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Grid Features */ resizable: z.boolean().optional().describe('Enable column resizing'), - // `striped` / `bordered` — tombstoned at the bottom of this shape with - // `virtualScroll` (#7176, pass-through-only; see the retired block below). compactToolbar: z.boolean().optional().describe('Collapse Group/Color/Density/Hide-fields into a single View settings popover'), /** Selection */ @@ -3241,46 +3239,6 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the list view'), - // `responsive` / `performance` REMOVED (#3896 audit close-out): authorable - // and inert — no renderer in either repo read them (re-grepped 2026-07-16, - // objectui@fb35e48; ledger: dead). - responsive: retiredKey( - '`view.responsive` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - performance: retiredKey( - '`view.performance` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - // `striped` / `bordered` / `virtualScroll` REMOVED (#7176, ADR-0049 - // enforce-or-remove; maintainer ruling 2026-08-10). Every measured reader was - // a forwarding copy — react spec-bridge → plugin-list → plugin-view/app-shell - // — and the chain ends at ObjectGrid, which never spells any of the three: - // copy-without-apply is dead in effect. Per the ruling, if objectui wants one - // of these as real behavior, that is an implementation card filed first, and - // the key stays retired pending it. - striped: retiredKey( - '`view.striped` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - bordered: retiredKey( - '`view.bordered` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - virtualScroll: retiredKey( - '`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), })); /** @@ -4687,15 +4645,6 @@ export const FormViewSchema = lazySchema(() => strictObject({ maxRows: z.number().optional(), })).optional().describe('Inline master-detail child collections'), - // `defaultSort` REMOVED (#3896 audit close-out): no reader in either repo — - // related lists sort by their OWN list view's sort, never by this. - defaultSort: retiredKey( - '`form.defaultSort` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** Public form sharing configuration */ sharing: SharingConfigSchema.optional().describe('Public sharing configuration for this form'), @@ -4824,17 +4773,6 @@ export const FormViewSchema = lazySchema(() => strictObject({ defaults: z.record(z.string(), z.unknown()).optional() .describe('Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;).'), - // `aria` REMOVED from the FORM view (#3896 audit close-out): no form - // renderer applied it (plugin-form/SchemaForm never read schema.aria) — an - // ACCESSIBILITY claim that is merely accepted is false compliance, the - // requiresConfirmation shape. - aria: retiredKey( - '`form.aria` was removed in @objectstack/spec 17.0.0 (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; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }).superRefine((view, ctx) => { // `section.pane` is split-only vocabulary. On any other form type it would // be a silent no-op — the worst failure mode for authored (and especially diff --git a/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts b/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts index 81a32795efb..6cc170e7513 100644 --- a/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts +++ b/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts @@ -250,6 +250,9 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'packages/spec/src/ui/component.test.ts', // The view write door's generic moved-key projection, modelled on the old fold. 'packages/metadata-protocol/src/protocol.project-storable-view-body.test.ts', + // [#25767] The view union's prescription pin authors `visibleOn` on a form + // overlay to assert the branch-typed refusal names the alias prescription. + 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', // The SDUI parser tier's base-prop list (`SDUI_BASE_PROPS`) still accepts the // deprecated `visibleOn` on every node; this pin gives each member a // well-typed value (moved in from the renderer's copy by #25712). diff --git a/scripts/check-spec-docblock-symbol-anchors.mjs b/scripts/check-spec-docblock-symbol-anchors.mjs index 4369001d570..7ece33b5188 100644 --- a/scripts/check-spec-docblock-symbol-anchors.mjs +++ b/scripts/check-spec-docblock-symbol-anchors.mjs @@ -239,6 +239,18 @@ export const CENSUS_17065 = { trackedTargetFiles: 5, hardFindings: 7, hardFindingFiles: 6, + /* CENSUS_RESIDUAL rows deleted since the census, each because the cited + * docblock LEFT THE TREE (never because a finding was re-graded). The + * self-test pins the residual at `hardFindings - repairedRows.length`, so the + * ratchet still only moves down, and every move is dated and named here. */ + repairedRows: [ + { + doc: 'packages/spec/src/conversions/registry.ts', + raw: 'packages/rest/src/import-mapping.ts:115-167', + dated: '2026-10-26', + why: '#25767 deleted the docblock with its conversion (a retirement kit at or below protocol 16.x)', + }, + ], declinedCitations: 183, declinedByShape: { 'bare-filename': 69, 'directory-qualified': 62, continuation: 52 }, softCrossRepoRows: 3, @@ -590,14 +602,6 @@ export const CENSUS_RESIDUAL = Object.freeze([ repair: 'the symbol is real — write the path from the repo root, ' + '`packages/spec/src/contracts/realtime-service.ts#RealtimeSubscriptionOptions`', }, - { - doc: 'packages/spec/src/conversions/registry.ts', - raw: 'packages/rest/src/import-mapping.ts:115-167', - kind: 'line-anchor', - count: 1, - dated: '2026-09-09', - repair: 'cite the symbol in `packages/core/src/utils/import-mapping.ts` (`applyMappingToRows`), or drop to a file-level anchor', - }, { doc: 'packages/spec/src/data/filter-array-declaration.test.ts', raw: 'examples/app-showcase/src/ui/pages/my-work.page.ts:52', @@ -1262,9 +1266,18 @@ export function selfTest() { check(row.repair && row.repair.length > 20, `the residual row for \`${row.raw}\` must name its repair`); check(row.doc.startsWith(`${SPEC_SRC_DIR}/`), `a residual row may only name a file in this corpus, got \`${row.doc}\``); } - // ...and it is the census's own number, so a row quietly added later reds. - check(liveTriage.pinned.length === CENSUS_17065.hardFindings, - `the pinned residual must equal the census's ${CENSUS_17065.hardFindings} hard findings, got ${liveTriage.pinned.length}`); + // ...and it is the census's own number less the rows it names as repaired, + // so a row quietly added later reds, and so does a row quietly deleted. + const expectedPinned = CENSUS_17065.hardFindings - CENSUS_17065.repairedRows.length; + check(liveTriage.pinned.length === expectedPinned, + `the pinned residual must equal the census's ${CENSUS_17065.hardFindings} hard findings less its ` + + `${CENSUS_17065.repairedRows.length} repaired row(s), got ${liveTriage.pinned.length}`); + for (const row of CENSUS_17065.repairedRows) { + check(/^\d{4}-\d{2}-\d{2}$/.test(row.dated), `the repaired row for \`${row.raw}\` must carry an ISO date`); + check(row.why && row.why.length > 20, `the repaired row for \`${row.raw}\` must say why the citation left`); + check(!CENSUS_RESIDUAL.some((r) => r.doc === row.doc && r.raw === row.raw), + `the repaired row for \`${row.raw}\` is still in CENSUS_RESIDUAL — a row is repaired or pinned, never both`); + } check(liveTriage.hard.length === 0, `the live corpus must carry NO finding outside the day-one residual: ${liveTriage.hard.map((f) => `${f.doc} ${f.raw}`).join(', ')}`); diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 9a7eaf48ae4..ac7700e1983 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -5531,14 +5531,13 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // them fails HERE, naming the key, instead of resurfacing as an unexplained // red in a derived loop. const HARVESTED: Array<[string, string]> = [ - ['element:record_picker', 'displayField'], - ['element:record_picker', 'multiple'], - ['element:record_picker', 'searchFields'], - ['page:card', 'actions'], - ['page:card', 'body'], - ['page:header', 'icon'], - ['page:tabs', 'type'], - ['record:details', 'layout'], + // The first eight (`element:record_picker` `displayField` / `multiple` / + // `searchFields`, `page:card` `actions` / `body`, `page:header` `icon`, + // `page:tabs` `type`, `record:details` `layout`) were 17.0.0 tombstones + // and aged out of their rows at spec 18.2 (objectstack#25767, ADR-0087 + // one major on): no longer LISTED, so the "still listed" leg below has + // nothing to read for them, and the reverse direction never demands an + // unknown key. They are pinned as absent in the describe after this one. // The ninth, harvested at the `@objectstack/spec` 17.5.0 bump // (objectui#11073): objectui#8285's ruled retirement, which emptied // `LAZY_BLOCK_RULED_CARVE_OUTS` in the same change. @@ -5613,6 +5612,31 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(exemptedTombstones).toEqual([]); }); + it('the eight 17.0.0 tombstones that aged out at spec 18.2 are gone from the rows — not listed, not published, not exempted', () => { + // objectstack#25767 (ADR-0087, one major on): the spec no longer declares + // these keys at all and refuses each as an unknown key naming the protocol + // upgrade guide. The three legs a tombstone row used to get still hold for + // an absent key — nothing may publish or exempt it — and the first leg + // inverts: the release no longer lists it. Pinned by name so a key that + // comes back (re-declared upstream, or re-published here) is a red that + // names it rather than an unexplained row in a derived loop. + const AGED_OUT: Array<[string, string]> = [ + ['element:record_picker', 'displayField'], + ['element:record_picker', 'multiple'], + ['element:record_picker', 'searchFields'], + ['page:card', 'actions'], + ['page:card', 'body'], + ['page:header', 'icon'], + ['page:tabs', 'type'], + ['record:details', 'layout'], + ]; + for (const [type, key] of AGED_OUT) { + expect(specListedKeys(type), `${type} lists ${key} again`).not.toContain(key); + expect(Object.keys(UNPUBLISHED_EXEMPTIONS), `${type}.${key} is exempted — an absent key needs no cover`).not.toContain(`${type}.${key}`); + expect(declaredInputs(type) ?? [], `${type} publishes ${key}, a key the contract refuses as unknown`).not.toContain(key); + } + }); + // ── the ARM direction (objectui#4971) ────────────────────────────────────── it.each(covered)('%s declares no arm the spec refuses outright', (type) => { diff --git a/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx b/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx index 1f47229136f..7a35d837beb 100644 --- a/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx +++ b/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx @@ -187,9 +187,13 @@ describe('the demo satisfies the `page:header` contract (objectui#3906)', () => expect(issuesOf(safeValidateSchema(demo()))).toEqual([]); }); - it('CONTROL — the row refuses the retired `icon` by name', () => { - const issues = issuesOf(row.safeParse({ ...demo().properties, icon: 'users' })); - expect(issues).toEqual(['icon: invalid_type']); + it('CONTROL — the row refuses the retired `icon` as an unknown key of the bag', () => { + // `icon`'s tombstone aged out of the row at spec 18.2 (objectstack#25767): + // the strict row reports it at the bag with `unrecognized_keys`, the + // message naming the key and the protocol upgrade guide. + const result = row.safeParse({ ...demo().properties, icon: 'users' }); + expect(issuesOf(result)).toEqual([': unrecognized_keys']); + expect(result.success ? '' : result.error.issues[0]?.message).toMatch(/`icon`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('CONTROL — the mirror refuses a misspelled key inside `properties`', () => { diff --git a/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts b/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts index f34b1bc8990..9efb9e4c484 100644 --- a/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts +++ b/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts @@ -138,15 +138,18 @@ describe('slotted-pages "customize only the header" example (objectui#11165)', ( }); it('CONTROL: the node the page used to teach is refused on `eyebrow` and `icon`', () => { + // Both as unknown keys of the bag now: `icon`'s 17.0.0 tombstone aged out + // of the row at spec 18.2 (objectstack#25767), so it is refused the way + // `eyebrow` always was — by the strict row, naming the key. const old = { ...header(), properties: { ...header().properties, eyebrow: 'ACCOUNT', icon: 'building-2' }, }; expect(issuesOf(safeValidateSchema(old)).sort()).toEqual( - ['properties.icon: invalid_type', 'properties: unrecognized_keys eyebrow'].sort(), + ['properties: unrecognized_keys eyebrow,icon'].sort(), ); expect(issuesOf(row.safeParse(old.properties)).sort()).toEqual( - ['icon: invalid_type', ': unrecognized_keys eyebrow'].sort(), + [': unrecognized_keys eyebrow,icon'].sort(), ); }); }); diff --git a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 3cc5b70a54d..99a604b9a4f 100644 --- a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -466,13 +466,18 @@ const ABSENCES: Record = { // A rung for one of these would relay a value into a void, and the void is // the finding: each is pinned here so that the day a reader appears, this // census reddens instead of the value silently not arriving. - striped: { kind: 'unread', reason: 'Retired by objectstack#7176 (maintainer-ruled 2026-08-10) after every objectui reader was measured as pass-through. Present only as the spec import that carries its tombstone.' }, - bordered: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`.' }, + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance`: + // the spec import carried their tombstones through 18.1; at spec 18.2 the + // tombstones aged out (objectstack#25767), and the twin now declares each as + // its own refusal (`agedOutSpecKeyRefusal`), so an authored value never + // parses and there is nothing for a rung to relay. + striped: { kind: 'unread', reason: 'Retired by objectstack#7176 (maintainer-ruled 2026-08-10) after every objectui reader was measured as pass-through. Its spec tombstone aged out at 18.2 (objectstack#25767); `ListViewSchema` declares it only as the console\'s own refusal (`agedOutSpecKeyRefusal`).' }, + bordered: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, + virtualScroll: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, + responsive: { kind: 'unread', reason: 'Retired at spec 17.0.0 with no reader in either repo; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, + performance: { kind: 'unread', reason: 'Retired at spec 17.0.0 with no reader in either repo; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, requiredPermissions: { kind: 'unread', reason: 'The ADR-0066 audience gate `@objectstack/spec` 18 adds to a list view (objectui#12030). The SERVER enforces it before the view is served: a user who lacks the capabilities is never listed the view and is refused it by name, so nothing on the ListView path has a value to consume.' }, - virtualScroll: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`.' }, filterableFields: { kind: 'unread', reason: 'Retired by objectstack#24640 (maintainer ruling C, pulled into 18.0): no per-view Filter-panel whitelist replaces it. objectui#13118 removed its one reader, the whitelist branch of `ListView`\'s Filter-panel field list, and this relay\'s rung with it. Present only as the spec import that carries its tombstone from the pin bump that takes the retirement.' }, - responsive: { kind: 'unread', reason: 'Imported from the spec by reference; no objectui renderer reads it.' }, - performance: { kind: 'unread', reason: 'Imported from the spec by reference; no objectui renderer reads it.' }, pageName: { kind: 'unread', reason: 'Page-context key with no reader on the list path.' }, tabs: { kind: 'unread', reason: "Spec view-tab list. ListView has no reader — the object page's tab bar is `ViewTabBar`, driven by `buildViewTabs`, and the `tabs` readers in the tree belong to `plugin-detail`'s DetailView." }, dataSource: { kind: 'unread', reason: "The spec's per-element binding (declared on the list-view arm by objectui#11070). `ListView` has no read of `schema.dataSource`: the binding is resolved one layer up, by the registered `list-view` renderer `ListViewBlock` through `ElementDataSourceGate`, and `renderListView` bypasses that layer. It renders `ListView` directly with `schema={fullSchema}` and takes the ADAPTER as its separate `dataSource` argument (`ds`), not off the node." }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts b/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts index d20acf79863..71f2154f240 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts @@ -60,18 +60,23 @@ describe('validateMetadataDraft — object field conditional rules (real engine) }); // `conditionalRequired` is no longer one of OUR lint keys — @objectstack/spec - // 17 (#3855) retired it, so the draft is rejected by the spec parse itself, - // and the rejection carries the rename prescription. We assert the author - // still gets told, and told by the spec (AGENTS.md #0.1 — one contract). - it('rejects the retired conditionalRequired alias via the spec tombstone', async () => { + // 17 (#3855) retired it, so the draft is rejected by the spec parse itself. + // Through spec 18.1 the rejection was the tombstone's, at the key's own path, + // carrying the rename prescription; at 18.2 the tombstone aged out one major + // on (objectstack#25767) and the strict field refuses the key as an unknown + // one at the field's path, naming it and the protocol upgrade guide. We + // assert the author still gets told, and told by the spec (AGENTS.md #0.1 — + // one contract). + it('rejects the retired conditionalRequired alias via the spec\'s strict unknown-key refusal', async () => { const res = await validateMetadataDraft( 'object', draftWith({ status: { type: 'text', conditionalRequired: 'record.x == 1' } }), ); expect(res.ok).toBe(false); - const issue = res.issues.find((i) => i.path === 'fields.status.conditionalRequired'); - expect(issue).toBeTruthy(); - expect(issue!.message).toMatch(/requiredWhen/); + const issue = res.issues.find((i) => i.path === 'fields.status' && /`conditionalRequired`/.test(i.message)); + expect(issue, JSON.stringify(res.issues)).toBeTruthy(); + expect(issue!.message).toMatch(/Unrecognized key\(s\)/); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('uses index paths for the ARRAY fields shape', async () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx index 65a76353785..724aa122bcb 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx @@ -315,9 +315,12 @@ describe('the contract walk can fail, at every depth it judges (objectui#11968)' expect(violationsOf('wait', [field(['waitEventConfig', 'bogusKey'])])).toEqual([ "wait writes waitEventConfig.bogusKey: 'bogusKey' is not declared in FlowNodeSchema", ]); - // `script.template` survives in the shape only as a `[REMOVED]` tombstone. + // `script.template` survived in the shape as a `[REMOVED]` tombstone through + // spec 18.1; at 18.2 the tombstone aged out one major on (objectstack#25767), + // so the walk reads the key as undeclared — the same verdict as a stray key, + // which is the contract's verdict now too (a strict unknown-key refusal). expect(violationsOf('script', [field(['config', 'template'])])).toEqual([ - "script (node type 'script') writes config.template: 'template' is a retired tombstone in its config contract", + "script (node type 'script') writes config.template: 'template' is not declared in its config contract", ]); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index 088df3baa66..604268f9e99 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -312,13 +312,16 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216 // The descent is derived, so its non-vacuity is measured on both sides: a // public-block arm resolves to its bag — the designer's `title` control is // a declared member there, and the bag still carries the spec's tombstone - // for `icon` — while a top-level arm is untouched by it. + // for `breadcrumb` (17.x) — while a top-level arm is untouched by it. The + // `icon` tombstone (17.0.0) aged out of the bag at spec 18.2 + // (objectstack#25767), so the bag reads it as MISSING now. const header = NODE_ORACLES['page:header']; expect(header, 'no node arm resolved for page:header').toBeTruthy(); expect(listedShapeKeys(header)).toContain('title'); expect(listedShapeKeys(header), 'judged at the node, not at its bag').not.toContain('properties'); expect(judge(header, 'title')).toBeUndefined(); - expect(judge(header, 'icon')?.kind).toBe('RETIRED'); + expect(judge(header, 'breadcrumb')?.kind).toBe('RETIRED'); + expect(judge(header, 'icon')?.kind).toBe('MISSING'); expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('groupBy'); expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('type'); }); @@ -329,11 +332,15 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216 }); it('the RETIRED probe separates a tombstone from an ordinary member', () => { - // `PageHeaderProps.icon` is the canonical tombstone (objectui#3829): still a - // listed key, refused by name. Without this pair the retired direction could - // be reporting nothing at all and read identically. - expect(listedShapeKeys(SPEC_ORACLES['page:header'])).toContain('icon'); - expect(judge(SPEC_ORACLES['page:header'], 'icon')?.kind).toBe('RETIRED'); + // `PageHeaderProps.breadcrumb` is the tombstone the pin still carries + // (objectstack#20758, spec 17.x): a listed key, refused by name. Without + // this pair the retired direction could be reporting nothing at all and + // read identically. (`icon`, the canonical tombstone of objectui#3829, aged + // out at spec 18.2 — objectstack#25767 — and reads MISSING now.) + expect(listedShapeKeys(SPEC_ORACLES['page:header'])).toContain('breadcrumb'); + expect(judge(SPEC_ORACLES['page:header'], 'breadcrumb')?.kind).toBe('RETIRED'); + expect(listedShapeKeys(SPEC_ORACLES['page:header'])).not.toContain('icon'); + expect(judge(SPEC_ORACLES['page:header'], 'icon')?.kind).toBe('MISSING'); expect(shapeMemberTypeName(SPEC_ORACLES['page:header'], 'title')).toBeTruthy(); // …and on the node face, where `retirementTombstone()` lives. expect(judge(NODE_ORACLES['object-kanban'], 'groupField')?.kind).toBe('RETIRED'); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 75ceddb0e8e..19db07e6f87 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -431,12 +431,23 @@ describe('page:header `icon` — the designer field retired with the spec key (# // criterion is `@object-ui/test-support`'s shared judge (objectui#3809, // converged here by objectui#4947), which OR-s the structural channel this // block used to spell out by hand with the `[REMOVED]` description channel. - it('the spec tombstones `icon` rather than deleting it', () => { - expect(listedShapeKeys(PageHeaderProps)).toContain('icon'); - expect(isShapeKeyTombstoned(PageHeaderProps, 'icon')).toBe(true); - // Non-vacuity for the probe itself: a Zod-internals change that made every - // member read `'never'` would make the line above meaningless, and a live - // key is the only thing that can tell the difference. + it('the spec aged the `icon` tombstone out — the key is no longer listed, and the strict row refuses it as unknown', () => { + // Through spec 18.1 the ADR-0087 D2 tombstone kept `icon` IN the shape as + // `z.never()`; at 18.2 the ≤16.x tombstones aged out one major on + // (objectstack#25767), so `icon` is not a key of the shape at all and the + // strict row refuses it as an unknown key naming the protocol upgrade guide. + expect(listedShapeKeys(PageHeaderProps)).not.toContain('icon'); + const refused = PageHeaderProps.safeParse({ title: 'T', icon: 'users' }); + expect(refused.success).toBe(false); + const root = refused.success ? '' : refused.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(/Unrecognized key\(s\)[^\n]*`icon`/); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // The tombstone probe's own non-vacuity moves to a tombstone the pin still + // carries: `breadcrumb` (spec 17.x, objectstack#20758) is listed AND reads + // as tombstoned, and a live key is the only thing that can tell the + // difference from a Zod-internals change that made every member `'never'`. + expect(listedShapeKeys(PageHeaderProps)).toContain('breadcrumb'); + expect(isShapeKeyTombstoned(PageHeaderProps, 'breadcrumb')).toBe(true); expect(isShapeKeyTombstoned(PageHeaderProps, 'title')).toBe(false); expect(shapeMemberTypeName(PageHeaderProps, 'title')).toBeTruthy(); }); diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index daad781ef2a..916ef09c474 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -502,22 +502,14 @@ export interface ActionDef { * active language before dispatch, because the runner has no language. */ outcomeMessages?: SpecActionInput['outcomeMessages']; - /** - * @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone — - * authoring it is a hard parse rejection, so this resolves to `undefined` and - * assigning a value is a compile error. A HOST may still pass a shortcut - * explicitly via `ActionEngine.registerAction(action, { shortcut })`; it is - * only no longer sourced from authored metadata. No replacement key. - */ - shortcut?: SpecActionInput['shortcut']; - /** - * @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone — - * authoring it is a hard parse rejection, so this resolves to `undefined` and - * assigning a value is a compile error. Use the list view's `bulkActions` / - * `bulkActionDefs` instead; `ActionEngine.registerAction(action, { bulkEnabled })` - * remains available to a HOST passing it explicitly. - */ - bulkEnabled?: SpecActionInput['bulkEnabled']; + // No `shortcut` and no `bulkEnabled`: spec 17 retired both as `retiredKey()` + // tombstones and this interface mirrored each as `SpecActionInput['…']` + // (always `undefined`) until objectstack#25767 aged the ≤16.x kits out of + // the schema one major on, which left nothing to mirror. A HOST still passes + // either one explicitly through `ActionEngine.registerAction(action, + // { shortcut, bulkEnabled })`; neither is sourced from authored metadata, + // and a bulk action is declared in the list view's `bulkActions` / + // `bulkActionDefs`. } /** diff --git a/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts b/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts index e56e2e2f60b..f2937701ec9 100644 --- a/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts +++ b/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts @@ -67,7 +67,7 @@ const _typoTarget: ActionDef = { type: 'script', targt: 'saveRecord' }; // A retired SPEC key that is not a tombstone, to show the closure is general // rather than a list of two special cases. -// @ts-expect-error - `bulkEnabled` is a retiredKey() tombstone in spec 17 +// @ts-expect-error - `bulkEnabled` is not an `ActionDef` key: its spec-17 tombstone aged out at objectstack#25767 and the mirror left with it const _retiredBulk: ActionDef = { type: 'api', bulkEnabled: true }; // ── The unified `visible` / `disabled` shape: three arms each ──────────────── diff --git a/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts b/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts index 1e906fddedb..7c560b9c743 100644 --- a/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts +++ b/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts @@ -140,7 +140,7 @@ describe('action key inventory (objectstack#4075 step 1)', () => { expect({ missing, stale }).toEqual({ missing: [], stale: [] }); }); - it('`execute` is still a live spec tombstone, so it must not count as known', () => { + it('`execute` is still refused by the spec (the 17 tombstone aged out at objectstack#25767; the strict unknown-key refusal took over), so it must not count as known', () => { const parsed = SpecActionSchema.safeParse({ name: 'mark_done', label: 'Mark Done', diff --git a/ui/packages/core/src/actions/actionKeys.ts b/ui/packages/core/src/actions/actionKeys.ts index 9b0fab5323d..9184acb74c2 100644 --- a/ui/packages/core/src/actions/actionKeys.ts +++ b/ui/packages/core/src/actions/actionKeys.ts @@ -199,8 +199,8 @@ export const ACTION_DEF_KEYS = [ // Spec 17.6.0's success copy per handler outcome (objectui#11344), declared // when the runner learned to read it: the success toast's first rung. 'outcomeMessages', - 'shortcut', - 'bulkEnabled', + // `shortcut` and `bulkEnabled` left with their spec tombstones + // (objectstack#25767): `ActionDef` no longer declares either. ] as const; /** @@ -234,7 +234,6 @@ export const SPEC_ACTION_KEYS = [ 'body', 'bodyExtra', 'bodyShape', - 'bulkEnabled', 'component', 'confirmText', // Added to `ActionSchema` in @objectstack/spec 17.0.0-rc.6. Listed here purely @@ -248,7 +247,12 @@ export const SPEC_ACTION_KEYS = [ 'description', 'disabled', 'errorMessage', - 'execute', + // No `execute`: its spec-17 tombstone (`retiredKey()`, objectstack#3855) aged + // out of `ActionSchema` at objectstack#25767 — the spec now refuses it as an + // unknown key naming the protocol upgrade guide — so the spec no longer + // declares it and this inventory, which restates the spec's declared keys, + // drops the row. `RETIRED_ACTION_KEYS` below still carries the console's own + // rename prescription: a stored row can reach the runner unparsed. // Added to `ActionSchema` in @objectstack/spec 17.5.0 (objectui#11073): the // bulk dispatch contract the action's body is written for, in // `bulkActionDefs`' own `'perRecord' | 'aggregate'` vocabulary. Listed for the @@ -310,7 +314,6 @@ export const SPEC_ACTION_KEYS = [ // question. 'requiresMembershipReach', 'resultDialog', - 'shortcut', 'successMessage', 'target', 'type', diff --git a/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts b/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts index 63a610675d4..c09fd5db014 100644 --- a/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts +++ b/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts @@ -119,22 +119,12 @@ const POSITIONS: ReadonlyArray<{ }), probe: (p) => p.regions[0].components[0].properties.items[0].children[0].properties, }, - { - name: 'page:card → properties.body[]', - page: recordPage({ - regions: [ - { name: 'main', components: [{ type: 'page:card', properties: { body: [legacyHeader('card body line')] } }] }, - ], - }), - // `page-card-body-to-children` MOVES the container key on the way past, so - // the converted node carries the sub-tree under `children`. Read whichever - // spelling survives — this row is about the header inside, not about which - // key the card ends up using. - probe: (p) => { - const props = p.regions[0].components[0].properties; - return (props.children ?? props.body)[0].properties; - }, - }, + // `page:card → properties.body[]` was a row here until spec 18.2: the + // `page-card-body-to-children` conversion that moved the container key on + // the way past aged out with the 16.x kits (objectstack#25767, ADR-0087), so + // a `body` sub-tree is no longer walked — the strict spec refuses `body` on + // the card as an unknown key naming the upgrade guide, and nothing descends + // into it. The surviving rows cover every container key the walk reads. { name: 'page:card → properties.footer[]', page: recordPage({ diff --git a/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx index 7fdcec146b8..b0992cf1fd1 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx @@ -11,9 +11,12 @@ * action key (objectstack#7129). * * `actionUrl` / `actionType` / `actionIcon` were retired at the widget level in - * @objectstack/spec 17.0.0-rc.3 (objectstack#5010, ADR-0049 D2). They are - * `retiredKey` tombstones: `DashboardWidgetSchema` types them `never` and - * REFUSES any value — including `''`. Until this pin, `WidgetConfigPanel` + * @objectstack/spec 17.0.0-rc.3 (objectstack#5010, ADR-0049 D2). Through 18.1 + * they were `retiredKey` tombstones: `DashboardWidgetSchema` typed them `never` + * and refused any value — including `''` — at the key's own path. At 18.2 the + * tombstones aged out one major on (objectstack#25767): the strict container + * refuses each as an unknown key, at the widget's path, naming the key and the + * protocol upgrade guide. Either way the spec refuses. Until this pin, `WidgetConfigPanel` * offered a Behavior-group "Click-through URL" field bound to `actionUrl`, and * `DashboardWithConfig` seeded `actionUrl: widget.actionUrl ?? ''` into every * widget config it handed the panel — so every save from Studio persisted a @@ -65,7 +68,17 @@ describe('the spec really does refuse these keys (premise check)', () => { const result = DashboardWidgetSchema.safeParse({ ...SPEC_VALID_WIDGET, [key]: value }); expect(result.success, `'${key}': ${JSON.stringify(value)} must be refused`).toBe(false); if (!result.success) { - expect(result.error.issues.some((i) => i.path[0] === key)).toBe(true); + // The strict unknown-key refusal (objectstack#25767): one issue at the + // widget's own path (`[]`), naming the key and the upgrade guide. The + // tombstone it replaced reported at `[key]`; a pin on that path would + // read the age-out as "no longer refused", which is the opposite of + // what happened. + const refusal = result.error.issues.find( + (i) => i.path.length === 0 && i.message.includes(`\`${key}\``), + ); + expect(refusal, `'${key}': refused by name at the widget's path`).toBeTruthy(); + expect(refusal!.message).toMatch(/Unrecognized key\(s\)/); + expect(refusal!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); } } } diff --git a/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx index 85d63cff8c8..3c1c4c54775 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx @@ -270,14 +270,16 @@ describe('the two ruled-out keys stay unpublished — and checkably so (objectui expect(unrecognizedKeys(result)).toContain('title'); }); - it('the spec refuses every `aria` value — the tombstone is still standing', () => { + it('the spec refuses every `aria` value — as an unknown key now that its tombstone aged out (objectstack#25767)', () => { const result = specVerdict({ aria: { ariaLabel: 'Ops' } }); expect(result.success, 'the spec re-admitted dashboard.aria — re-open objectui#5742').toBe(false); + // Through spec 18.1 the tombstone reported at `aria`; the strict + // dashboard now reports at its own path, naming the key and the guide. const ariaIssue = (result as { error: { issues: Array<{ path: unknown[]; message: string }> } }) - .error.issues.find((i) => i.path.join('.') === 'aria'); - expect(ariaIssue, 'no issue at path `aria`').toBeTruthy(); - // The tombstone names the removal; a mere shape error would not. - expect(ariaIssue!.message).toMatch(/removed/); + .error.issues.find((i) => i.path.length === 0 && i.message.includes('`aria`')); + expect(ariaIssue, 'no root issue naming `aria`').toBeTruthy(); + expect(ariaIssue!.message).toMatch(/Unrecognized key\(s\)/); + expect(ariaIssue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it.each(NON_AUTHOR)('$key draws unknown-prop from the real validator — the ruled outcome', ({ key, sample }) => { diff --git a/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index 7de2286bd33..74bf57f08e9 100644 --- a/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -540,23 +540,26 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { * legible if that helper is ever loosened. */ describe('record:details — `layout` is retired, not merely undocumented (#3818)', () => { - it('the spec REJECTS `layout`, with the ADR-0087 migration message', () => { - // The premise, checked first: if upstream ever un-retires the key, this - // fails before the negative pins below start guarding a dead rule. - expect(specTopLevelKeys()).toContain('layout'); // tombstone is still declared - expect(isTombstoned('layout')).toBe(true); + it('the spec REJECTS `layout` — as an unknown key naming the upgrade guide, its tombstone aged out', () => { + // The premise, checked first: if upstream ever re-declares the key, this + // fails before the negative pins below start guarding a dead rule. Through + // spec 18.1 the tombstone was still declared (`z.never()`); at 18.2 it aged + // out of the row one major on (objectstack#25767), so the key is not + // declared at all and the strict row refuses it as an unknown key. + expect(specTopLevelKeys()).not.toContain('layout'); expect(specAcceptedTopLevelKeys()).not.toContain('layout'); // A rejection case, so the assertion set is the envelope and not a bare - // "it failed": path + code + the named migration text. Both formerly - // published values AND both values the dead renderer branch tested — all - // four are unauthorable now, which is the whole point. + // "it failed": path + code + the text that sends the author on. Both + // formerly published values AND both values the dead renderer branch + // tested — all four are unauthorable now, which is the whole point. for (const value of ['auto', 'custom', 'inline', 'compact']) { const parsed = RecordDetailsProps.safeParse({ layout: value }); expect(parsed.success).toBe(false); - expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('layout'); - expect(parsed.error?.issues.map((i) => i.code)).toContain('invalid_type'); - expect(parsed.error?.issues[0]?.message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); + const root = parsed.error?.issues.find((i) => i.path.length === 0 && i.message.includes('`layout`')); + expect(root?.code).toBe('unrecognized_keys'); + expect(root?.message).toMatch(/Unrecognized key\(s\)/); + expect(root?.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); } }); diff --git a/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts b/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts index 326ec21021e..8a1bef58501 100644 --- a/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts +++ b/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts @@ -37,10 +37,20 @@ describe('useActionEngine', () => { type: 'url', target: '/details', locations: ['list_item'], + // @ts-expect-error `shortcut` is retired on `ActionDef` too, and for the + // same reason as `bulkEnabled` above: the inverse test needs metadata + // that still declares one. On its OWN entry rather than beside + // `global_nav` below: an object literal with a mistyped member is not + // also checked for excess members, so a directive on the second of two + // stale keys in one literal reports unused (TS2578) once `shortcut` + // stops being a declared `never` and becomes an unknown key — which it + // did when its spec tombstone aged out and `ActionDef` stopped + // mirroring it (objectstack#25767). + shortcut: 'ctrl+k', }, { // Deliberately STALE, in the same posture as the `bulkEnabled` and - // `shortcut` keys this fixture also keeps: `global_nav` was retired from + // `shortcut` keys this fixture keeps on other entries: `global_nav` was retired from // the spec's `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 // (objectstack#6888), so metadata like this can no longer be authored. // Kept rather than deleted because the coverage that matters now is the @@ -55,10 +65,6 @@ describe('useActionEngine', () => { // pins it: were the location ever re-admitted to the spec, this line // would stop erroring and TS2578 would report the now-unused directive. locations: ['global_nav'], - // @ts-expect-error `shortcut` is retired on `ActionDef` too, and for the - // same reason as `bulkEnabled` above: the inverse test needs metadata - // that still declares one. - shortcut: 'ctrl+k', }, ]; diff --git a/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index 46813110867..fbac8f268bb 100644 --- a/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -29,16 +29,27 @@ * objectui#9736 closed that half. The interface now extends the spec's own * `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its * mirror reads), so it inherits the spec's tombstone as a DECLARED member typed - * as the spec's retired key (the branded `[REMOVED]` mark at the pinned 18.1.0 - * and on objectstack `main`, bare `undefined` through 17.5.0) — `aria` is back in the declared key - * set, but only as a refusal, - * and an authored value is a compile error: the same verdict the Zod twin gives - * at parse. The pins below assert exactly that, with the former neighbours as - * the control. Real enforcement because `packages/types/tsconfig.test.json` is - * chained from this package's `type-check` script (#3009). + * as the spec's retired key (the branded `[REMOVED]` mark at the pinned 18.1.0, + * bare `undefined` through 17.5.0) — `aria` was back in the declared key set, + * but only as a refusal, and an authored value a compile error: the same + * verdict the Zod twin gave at parse. + * + * objectstack#25767 (spec 18.2, ADR-0087) aged the ≤16.x tombstones out of the + * spec one major on. `DashboardSchema` no longer declares `aria` at all: the + * strict container refuses it as an unknown key, at the dashboard's own path, + * naming the key and the protocol upgrade guide. The projection carries nothing + * under the name, so both twins now carry the refusal as their OWN: `aria?: + * never` on the interface, `agedOutSpecKeyRefusal` on the Zod twin (whose + * `.passthrough()` base would otherwise turn a two-major refusal into a silent + * KEEP). The pins below assert exactly that — declared, unwritable, refused at + * its own path with the console's message — with the former neighbours as the + * control and the spec's own refusal as the contract leg. Real enforcement + * because `packages/types/tsconfig.test.json` is chained from this package's + * `type-check` script (#3009). */ import { describe, it, expect } from 'vitest'; +import { DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui'; import type { DashboardComponentSchema } from '../complex'; import type { IsRetiredKeyType } from './retired-key-type'; import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/index.zod'; @@ -48,13 +59,15 @@ import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/ type DeclaredKeys = { [K in keyof T as string extends K ? never : K]: T[K] }; type Declared = keyof DeclaredKeys; -describe('the TS interface declares `aria` only as the spec tombstone (objectui#5830, objectui#9736)', () => { - it('`aria` is declared, typed as the spec\'s retired key; the neighbours it stood beside still are', () => { - // Type-level pins, erased at runtime. `aria` is a DECLARED key again — the - // spec projection carries the tombstone — and its type admits no value: - // the branded `[REMOVED]` mark at the pinned 18.1.0 and on objectstack - // `main`, bare `undefined` through 17.5.0 (both spellings are - // `retired-key-type.ts`'s, objectui#11330). +describe('the TS interface declares `aria` only as a refusal — its own, since the spec tombstone aged out (objectui#5830, objectui#9736, objectstack#25767)', () => { + it('`aria` is declared and admits no value; the neighbours it stood beside still are live', () => { + // Type-level pins, erased at runtime. Through spec 18.1 `aria` was a + // DECLARED key typed as the spec's retired key (the branded `[REMOVED]` + // mark, bare `undefined` through 17.5.0 — both `retired-key-type.ts`'s + // spellings, objectui#11330). At 18.2 the tombstone aged out of the spec + // (objectstack#25767), so the projection carries nothing under the name + // and the member is this face's own `aria?: never` — which the same + // helper reads as a retired-key type (`undefined`, the pinned spelling). const ariaDeclared: 'aria' extends Declared ? true : false = true; const ariaAdmitsNoValue: IsRetiredKeyType extends true ? true : false = true; // The control for the line above, through the same helper: a live @@ -67,15 +80,15 @@ describe('the TS interface declares `aria` only as the spec tombstone (objectui# expect(ariaDeclared && ariaAdmitsNoValue && widgetsIsLive && widgetsDeclared && dateRangeDeclared).toBe(true); }); - it('an authored `aria` value is a compile error — no longer absorbed by the index signature', () => { + it('an authored `aria` value is a compile error — the member is `never`, and no index signature absorbs it', () => { const legal: DashboardComponentSchema = { type: 'dashboard', widgets: [] }; - // @ts-expect-error — `aria` is the spec's retirement tombstone (objectui#9736). + // @ts-expect-error — `aria` is this face's own `?: never` refusal: the spec tombstone it projected aged out (objectstack#25767), and no index signature absorbs it (objectui#9736). const authored: DashboardComponentSchema = { type: 'dashboard', widgets: [], aria: { ariaLabel: 'Ops' } }; expect([legal.type, authored.type]).toEqual(['dashboard', 'dashboard']); }); }); -describe('the Zod twin refuses the key by name — the spec tombstone, inherited by reference', () => { +describe('the Zod twin refuses `aria` by name — its own arm now; the spec contract refuses it as an unknown key', () => { const legal = { type: 'dashboard' as const, widgets: [] }; it('a legal dashboard parses green — the control for the refusal below', () => { @@ -90,12 +103,41 @@ describe('the Zod twin refuses the key by name — the spec tombstone, inherited expect(r.success).toBe(true); }); - it('`aria` is refused at its own path, with the removal message', () => { + it('`aria` is refused at its own path, with the console\'s own message naming the key, the guide and the cut', () => { + // Through 18.1 the refusal was the spec's tombstone, inherited by + // reference (`/removed/` in the message). The twin is `.passthrough()`, + // so once the projection stopped carrying the name only an arm of its own + // keeps the verdict: `agedOutSpecKeyRefusal` (`zod/tombstone.zod.ts`), + // `invalid_type` at `aria`, opening with the sentence the spec answers the + // key with. Pinned on the message's three anchors so a twin that quietly + // dropped the arm (a silent keep) or restated the spec's gone tombstone + // goes red by name. const r = DashboardComponentZodSchema.safeParse({ ...legal, aria: { ariaLabel: 'Ops' } }); expect(r.success).toBe(false); if (r.success) return; const issue = r.error.issues.find((i) => i.path.join('.') === 'aria'); expect(issue, 'no issue at path `aria`').toBeTruthy(); - expect(issue!.message).toMatch(/removed/); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain('Unrecognized key(s) on this dashboard: `aria`'); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(issue!.message).toContain('objectstack#25767'); + expect(issue!.message).not.toMatch(/\[REMOVED\]/); + }); + + it('the spec contract refuses `aria` as an unknown key at the dashboard\'s path, naming it and the upgrade guide (objectstack#25767)', () => { + // The contract leg: where the refusal lives now. `DashboardSchema` is + // `.strict()`, so the aged-out key meets the unknown-key refusal — one + // issue at the root, `unrecognized_keys`, naming the key and the protocol + // upgrade guide an author of a ≤16.x document is sent to. + const r = SpecDashboardSchema.safeParse({ name: 'ops', label: 'Ops', widgets: [], aria: { ariaLabel: 'Ops' } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.length === 0 && i.message.includes('`aria`')); + expect(issue, 'no root issue naming `aria`').toBeTruthy(); + expect(issue!.code).toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/Unrecognized key\(s\)/); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Control: the same document without the key is the contract's own green. + expect(SpecDashboardSchema.safeParse({ name: 'ops', label: 'Ops', widgets: [] }).success).toBe(true); }); }); diff --git a/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts b/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts index fa8f470e5f6..68ba80949ba 100644 --- a/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts +++ b/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts @@ -339,7 +339,12 @@ describe('objectui#10872 batch 10 — what the refusal leaves alone', () => { }); it('some row members are the spec\'s own retirements (non-vacuity)', () => { - expect(RETIRED.map(([label]) => label)).toContain('page:header.icon'); + // `page:header.breadcrumb`, retired at spec 17.x, is the row member that + // still carries a tombstone; `page:header.icon` (retired at 17.0.0) aged out + // of the shape one major on (objectstack#25767) and is an unknown key there + // now, so it is not a RETIRED row member any more. + expect(RETIRED.map(([label]) => label)).toContain('page:header.breadcrumb'); + expect(RETIRED.map(([label]) => label)).not.toContain('page:header.icon'); }); it.each(RETIRED.filter(([label]) => label !== 'page:card.body'))( diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index fda212249c2..c114c38563b 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -163,6 +163,7 @@ import { import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, + RETIRED_KEY_UPGRADE_GUIDE_SENTENCE, } from '@objectstack/spec/shared'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -679,6 +680,13 @@ describe('the import boundary strips every imported default (objectui#8317)', () * There is no Zod graph to walk and no default to remove: the whole effect * of a refinement is `ctx.addIssue`, so it cannot write a value into a * parsed document, which is the only thing this boundary is about. + * - a spec TEXT (objectstack#25767) — a sentence such as + * `RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, which a mirror's own refusal + * restates so the two doors answer an aged-out key with the same words. A + * string has no Zod graph and no default; it cannot write a value into a + * parsed document. It is imported rather than copied because ui's + * `check:spec-symbols` refuses a hand-written declaration under a spec + * export's name, and the assertion below holds each entry to be a string. */ describe('every `@objectstack/spec` value read in the mirrors goes through the boundary', () => { /** `:` for each read that is allowed to stay raw. */ @@ -755,6 +763,18 @@ describe('the import boundary strips every imported default (objectui#8317)', () ['SpecViewMetadataMembers', SpecViewMetadataMembers], ]); + /** + * Spec TEXT a mirror restates in its own refusal, keyed by binding name + * (objectstack#25767): the guide sentence `agedOutSpecKeyRefusal` + * (`tombstone.zod.ts`) opens with, so a console-side refusal of a key whose + * spec tombstone aged out carries the spec's own words. Held as name → + * binding so the assertion below can check each entry really is a string: + * a schema must not reach this list merely by being listed. + */ + const TEXT_EXCEPTIONS = new Map([ + ['RETIRED_KEY_UPGRADE_GUIDE_SENTENCE', RETIRED_KEY_UPGRADE_GUIDE_SENTENCE], + ]); + const isSpecModule = (m: string): boolean => m === '@objectstack/spec' || m.startsWith('@objectstack/spec/'); @@ -835,6 +855,7 @@ describe('the import boundary strips every imported default (objectui#8317)', () .filter((r) => r.kind === 'value' && !r.wrapped) .filter((r) => !VOCABULARY_EXCEPTIONS.has(`${r.file}:${r.owner}`)) .filter((r) => !REFINEMENT_EXCEPTIONS.has(r.name)) + .filter((r) => !TEXT_EXCEPTIONS.has(r.name)) .filter((r) => !(SCHEMA_RECORDS.has(r.name) && r.wrappedViaMember)); expect( offenders.map((r) => `${r.file}:${r.line} ${r.name} (in \`${r.owner ?? ''}\`)`), @@ -859,6 +880,18 @@ describe('the import boundary strips every imported default (objectui#8317)', () } }); + it('every declared text exception is a STRING the mirrors read, not a schema in disguise', () => { + expect(TEXT_EXCEPTIONS.size, 'the list is empty — delete it rather than leave a hole').toBeGreaterThan(0); + for (const [name, binding] of TEXT_EXCEPTIONS) { + expect(typeof binding, `${name} is not a string`).toBe('string'); + expect((binding as string).length, `${name} is empty`).toBeGreaterThan(0); + expect('_zod' in Object(binding), `${name} is a schema; a schema keeps the strip`).toBe(false); + const matching = reads.filter((r) => r.name === name && r.kind === 'value'); + expect(matching.length, `declared exception ${name} matches no read — delete it`).toBeGreaterThan(0); + for (const r of matching) expect(r.wrapped, `${r.file}:${r.line} wraps ${name}; a string is not stripped`).toBe(false); + } + }); + it('every declared refinement exception is a LIVE FUNCTION, not a schema in disguise', () => { expect(REFINEMENT_EXCEPTIONS.size, 'the list is empty — delete it rather than leave a hole').toBeGreaterThan(0); for (const [name, binding] of REFINEMENT_EXCEPTIONS) { @@ -890,7 +923,7 @@ describe('the import boundary strips every imported default (objectui#8317)', () it('every symbol the mirrors import is covered by the differential above', () => { const differential = new Set(IMPORTED.map(([n]) => n)); const missing = [...new Set(reads.map((r) => r.name.replace(/^Spec/, '')))] - .filter((n) => !differential.has(n) && !REFINEMENT_EXCEPTIONS.has(n)); + .filter((n) => !differential.has(n) && !REFINEMENT_EXCEPTIONS.has(n) && !TEXT_EXCEPTIONS.has(n)); expect( missing, 'a schema imported by a mirror is not in this file\'s `IMPORTED` list, so nothing measures ' + diff --git a/ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts b/ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts new file mode 100644 index 00000000000..f18e2857cbb --- /dev/null +++ b/ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts @@ -0,0 +1,124 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The list view twin refuses the five spec tombstones that aged out at 18.2 + * (objectstack#25767, ADR-0087 one major on). + * + * `zod/objectql.zod.ts`'s `ListViewSchema` is `BaseSchema` (`.passthrough()`) + * extended with the spec list view's members by reference. Through spec 18.1 + * that projection carried `striped`, `bordered`, `virtualScroll` (retired by + * objectstack#7176) and `responsive`, `performance` (the #3896 audit + * close-out) as `retiredKey()` tombstones, so the twin refused each at the + * key's own path. At 18.2 the tombstones aged out of the spec's shape: the + * spec refuses each as an UNKNOWN key at the view's root, naming the protocol + * upgrade guide, and the projection carries nothing under the name — so + * without an arm of its own the twin would KEEP an authored `striped: true` + * in silence. The arms are the console's own refusals + * (`agedOutSpecKeyRefusal`, `zod/tombstone.zod.ts`). + * + * Each key is pinned on three faces, with the spec's own verdict as control: + * + * - the flat twin (`ListViewSchema`): `invalid_type` at the key's path, the + * message naming the key, the spec's guide sentence and the age-out; + * - the page `list-view` node (`ListViewBlockSchema`), whose `properties` + * bag reads the twin's shape: refused in the bag and, through the row's + * own retirement, written flat — `responsive` excepted, which is a node- + * level key of the spec's page component and keeps that meaning flat; + * - the TypeScript face: `ListViewSchema` is derived from the twin + * (`ListViewInferred = z.input<…>`), so each key is `?: undefined` there and + * an authored value is a compile error. There is no hand-kept `?: never` + * member to keep in step, as with the twin's `body` / `children`. + * + * The control: the spec's `ListViewSchema` refuses each key as an unknown one. + * If the spec ever re-admits a key, the twin's refusal would be hiding a live + * member, and the control goes red first. + */ + +import { describe, it, expect } from 'vitest'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ListViewSchema as OuiListViewSchema, ListViewBlockSchema } from '../zod/objectql.zod.js'; +import type { ListViewSchema } from '../objectql'; + +const AGED_OUT_LIST_VIEW_KEYS = ['striped', 'bordered', 'virtualScroll', 'responsive', 'performance'] as const; + +const TWIN_LEGAL = { type: 'list-view', objectName: 'contact' }; +const BLOCK_LEGAL = { type: 'list-view', properties: { data: { provider: 'object', object: 'contact' } } }; +const SPEC_LEGAL = { columns: ['name'] }; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +const issueAt = (issues: readonly Issue[], path: string) => issues.find((i) => i.path.join('.') === path); + +const expectAgedOutIssue = (issue: Issue | undefined, key: string) => { + expect(issue, `no issue at '${key}' — the value is kept in silence`).toBeTruthy(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain(`Unrecognized key(s) on this list view: \`${key}\``); + expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + expect(issue!.message).toContain('objectstack#25767'); +}; + +describe('list view: the five spec tombstones that aged out at 18.2 are the twin\'s own refusals (objectstack#25767)', () => { + it('accept legs: a list view without them parses on every face', () => { + expect(OuiListViewSchema.safeParse(TWIN_LEGAL).success).toBe(true); + expect(ListViewBlockSchema.safeParse(BLOCK_LEGAL).success).toBe(true); + expect(SpecListViewSchema.safeParse(SPEC_LEGAL).success).toBe(true); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS)('`%s`: the spec refuses it as an unknown key (the control)', (key) => { + const r = SpecListViewSchema.safeParse({ ...SPEC_LEGAL, [key]: true }); + expect(r.success, `the spec accepts '${key}' again — the twin's refusal would hide a live member`).toBe(false); + const issues = r.success ? [] : (r.error.issues as readonly Issue[]); + expect(issueAt(issues, key), `the spec refuses '${key}' at its own path — a tombstone came back`).toBeUndefined(); + const root = issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS)('`%s`: the flat twin refuses it by name', (key) => { + expect(Object.keys(OuiListViewSchema.shape), `'${key}' is no longer declared`).toContain(key); + const r = OuiListViewSchema.safeParse({ ...TWIN_LEGAL, [key]: true }); + expect(r.success, `the twin keeps '${key}'`).toBe(false); + expectAgedOutIssue(r.success ? undefined : issueAt(r.error.issues as readonly Issue[], key), key); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS)('`%s`: a page `list-view` node refuses it in its `properties` bag', (key) => { + const r = ListViewBlockSchema.safeParse({ ...BLOCK_LEGAL, properties: { ...BLOCK_LEGAL.properties, [key]: true } }); + expect(r.success, `the bag keeps '${key}'`).toBe(false); + expectAgedOutIssue(r.success ? undefined : issueAt(r.error.issues as readonly Issue[], `properties.${key}`), key); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS.filter((k) => k !== 'responsive'))( + '`%s`: a page `list-view` node refuses it written flat, with the row\'s own retirement', + (key) => { + const r = ListViewBlockSchema.safeParse({ ...BLOCK_LEGAL, [key]: true }); + expect(r.success, `the node keeps a flat '${key}'`).toBe(false); + expectAgedOutIssue(r.success ? undefined : issueAt(r.error.issues as readonly Issue[], key), key); + }, + ); + + it('the TypeScript face: each key is `?: undefined`, so an authored value is a compile error', () => { + const ok: ListViewSchema = { type: 'list-view', objectName: 'contact' }; + // @ts-expect-error — `striped` aged out of the spec (objectstack#25767); the twin's arm types it `undefined`. + const striped: ListViewSchema = { type: 'list-view', objectName: 'contact', striped: true }; + // @ts-expect-error — `bordered`, the same. + const bordered: ListViewSchema = { type: 'list-view', objectName: 'contact', bordered: true }; + // @ts-expect-error — `virtualScroll`, the same. + const virtualScroll: ListViewSchema = { type: 'list-view', objectName: 'contact', virtualScroll: true }; + // @ts-expect-error — `responsive`, the same. + const responsive: ListViewSchema = { type: 'list-view', objectName: 'contact', responsive: { breakpoint: 'md' } }; + // @ts-expect-error — `performance`, the same. + const performance: ListViewSchema = { type: 'list-view', objectName: 'contact', performance: { lazyLoad: true } }; + // The compile-time verdict and the parse verdict agree. + for (const authored of [striped, bordered, virtualScroll, responsive, performance]) { + expect(OuiListViewSchema.safeParse(authored).success).toBe(false); + } + expect(OuiListViewSchema.safeParse(ok).success).toBe(true); + }); +}); diff --git a/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts b/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts index 13f8a8b9152..caf5866b60c 100644 --- a/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -147,6 +147,19 @@ const HANDLER_KEY_REFUSALS = new Set([ */ const PAGE_COMPONENT_ENVELOPE = new Set(['dataSource']); +/** + * A FIFTH category (objectstack#25767): spec keys whose `retiredKey()` + * tombstones aged out of the spec's `ListViewSchema` at 18.2, one major on + * (ADR-0087). Through 18.1 each flowed in by reference and refused at its own + * path; the spec now refuses each as an unknown key, and this arm declares the + * refusal itself (`agedOutSpecKeyRefusal`) so `BaseSchema.passthrough()` does + * not keep an authored value. Like {@link HANDLER_KEY_REFUSALS} the arm accepts + * nothing under these names, and the test below checks that — plus that the + * spec really declares none of them — so this set cannot park a live field. + * Full pins: `./list-view-aged-out-spec-keys-25767.test.ts`. + */ +const AGED_OUT_SPEC_REFUSALS = new Set(['striped', 'bordered', 'virtualScroll', 'responsive', 'performance']); + describe('ListView spec parity (#2231 drift guard)', () => { it('covers every @objectstack/spec ListView field (spec cannot grow a field objectui ignores)', () => { // Fails when the spec adds a field that objectui neither imports nor envelope-owns — @@ -170,11 +183,25 @@ describe('ListView spec parity (#2231 drift guard)', () => { !ENVELOPE.has(k) && !SANCTIONED_LOCAL.has(k) && !HANDLER_KEY_REFUSALS.has(k) && - !PAGE_COMPONENT_ENVELOPE.has(k), + !PAGE_COMPONENT_ENVELOPE.has(k) && + !AGED_OUT_SPEC_REFUSALS.has(k), ); expect(rogue).toEqual([]); }); + it('every AGED_OUT_SPEC_REFUSALS member really refuses and is no spec member — the set cannot hide a field (objectstack#25767)', () => { + const node = (extra: Record) => ({ type: 'list-view', objectName: 'accounts', ...extra }); + // ⭐ CONTROL first: the bare node parses, or the loop below proves nothing. + expect(OuiListViewSchema.safeParse(node({})).success).toBe(true); + for (const key of AGED_OUT_SPEC_REFUSALS) { + expect(ouiKeys.has(key), `${key} is listed but not declared on the arm`).toBe(true); + expect(specShape[key], `the spec declares ${key} again — triage it as a spec field instead`).toBeUndefined(); + for (const value of [true, false, {}, 'x']) { + expect(OuiListViewSchema.safeParse(node({ [key]: value })).success, `${key} accepted ${JSON.stringify(value)}`).toBe(false); + } + } + }); + it('every PAGE_COMPONENT_ENVELOPE member is the spec binding — the set cannot hide a local field (objectui#11070)', () => { const node = (extra: Record) => ({ type: 'list-view', objectName: 'accounts', ...extra }); for (const key of PAGE_COMPONENT_ENVELOPE) { diff --git a/ui/packages/types/src/__tests__/node-slots-11170.test.ts b/ui/packages/types/src/__tests__/node-slots-11170.test.ts index 2a114aaa9ec..c364ed23adb 100644 --- a/ui/packages/types/src/__tests__/node-slots-11170.test.ts +++ b/ui/packages/types/src/__tests__/node-slots-11170.test.ts @@ -182,6 +182,16 @@ describe('the `page:*` rows are the spec’s positions, by reference (objectui#1 for (const type of row.types) expect(type.startsWith(PAGE_FAMILY_PREFIX), type).toBe(true); for (const s of row.slots) { const spec = bySpecPath.get(s.path); + if (s.retired === true && !spec) { + // A RETIRED row position the spec no longer declares at all: its + // tombstone aged out of the spec one major on (objectstack#25767 — + // `page:card`'s `body`), so the spec's list has no row for it, while + // the renderer still reads the stored spelling (see the module + // header). Only a `retired` position may be console-own this way; a + // live one the spec does not declare is still a red below. + expect(s.path, `\`${row.types[0]}.${s.path}\`: a retired position the spec dropped — only \`body\` is read this way`).toBe('body'); + continue; + } expect(spec, `\`${row.types[0]}\` declares \`${s.path}\`, which the installed spec does not`).toBeDefined(); expect(s.retired === true, `\`${row.types[0]}.${s.path}\`: retirement disagrees with the spec`).toBe(spec!.retired); } @@ -192,7 +202,9 @@ describe('the `page:*` rows are the spec’s positions, by reference (objectui#1 const declared = new Set(pageRows.flatMap((row) => row.slots.map((s) => s.path))); const unplaced = specPositions.map(asPath).filter((p) => p !== 'children' && !declared.has(p)); expect(unplaced, 'the spec declares a page slot no `page:*` row places — measure which renderer reads it').toEqual([]); - // The reading has a population: the spec's list carries the known four. - expect(specPositions.map(asPath)).toEqual(expect.arrayContaining(['children', 'body', 'footer', 'items[].children'])); + // The reading has a population: the spec's list carries the known three + // (`body` left it when its tombstone aged out, objectstack#25767). + expect(specPositions.map(asPath)).toEqual(expect.arrayContaining(['children', 'footer', 'items[].children'])); + expect(specPositions.map(asPath)).not.toContain('body'); }); }); diff --git a/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts b/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts index 066f5072d0c..49cff8bf305 100644 --- a/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -364,16 +364,34 @@ describe('the ADR-0087 tombstone filter this guard is built on (#2890)', () => { }); it('reports retired ListView keys as DECLARED but not LIVE', () => { - // objectstack#7176 retired all five (maintainer-ruled 2026-08-10). They flow - // into objectui's ListViewSchema by reference on purpose, tombstones and - // all, so an author writing one gets a named rejection instead of silence. - for (const retired of ['striped', 'bordered', 'virtualScroll', 'responsive', 'performance']) { + // The tombstones the current pin still carries: `filterableFields` + // (objectstack#24640), `pageName` and `tabs` (17.5.0). They flow into + // objectui's ListViewSchema by reference on purpose, tombstones and all, so + // an author writing one gets a named rejection instead of silence. + for (const retired of ['filterableFields', 'pageName', 'tabs']) { expect(declared, `${retired} should still be declared`).toContain(retired); expect(isTombstoned(SpecListViewSchema, retired), `${retired} should read as a tombstone`).toBe(true); expect(live, `${retired} must not read as live`).not.toContain(retired); } }); + it('the five objectstack#7176 retirements aged out of the shape — not declared, refused as unknown keys', () => { + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance` + // were tombstones through spec 18.1; at 18.2 the ≤16.x kits left the schema + // one major on (objectstack#25767), so the filter no longer sees them — + // neither declared nor live — and the strict container refuses each as an + // unknown key naming the protocol upgrade guide. + for (const aged of ['striped', 'bordered', 'virtualScroll', 'responsive', 'performance']) { + expect(declared, `${aged} is declared again`).not.toContain(aged); + expect(live, `${aged} reads as live`).not.toContain(aged); + const r = SpecListViewSchema.safeParse({ name: 'all', label: 'All', [aged]: true }); + expect(r.success, `${aged} is accepted`).toBe(false); + const msg = r.success ? '' : r.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(msg).toMatch(new RegExp('Unrecognized key\\(s\\)[^\\n]*`' + aged + '`')); + expect(msg).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + }); + it('does not mistake a live key for a tombstone', () => { // The control. Without it "everything is a tombstone" would pass the above. for (const alive of ['columns', 'filter', 'userActions', 'navigation', 'searchableFields']) { diff --git a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 8dc5eff3f7e..92cf5cd976f 100644 --- a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -381,26 +381,38 @@ const NAMED_VIEW_ABSENT_CONTROL = 'stickyHeader'; * spec bump that moves a key moves this. */ const PROTOCOL_LIST_VIEW_KEYS = [ - 'addRecord', 'allowPrinting', 'appearance', 'aria', 'bordered', 'bulkActionDefs', 'bulkActions', + 'addRecord', 'allowPrinting', 'appearance', 'aria', 'bulkActionDefs', 'bulkActions', 'calendar', 'chart', 'columns', 'compactToolbar', 'conditionalFormatting', 'data', 'description', 'emptyState', 'exportOptions', 'fieldOrder', 'filter', 'filterableFields', 'gallery', 'gantt', 'grouping', 'hiddenFields', 'inlineEdit', 'kanban', 'label', 'map', 'name', 'navigation', - 'pageName', 'pagination', 'performance', 'requiredPermissions', 'resizable', 'responsive', 'rowActions', 'rowColor', - 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showRecordCount', 'sort', 'striped', - 'tabs', 'timeline', 'tree', 'type', 'userActions', 'userFilters', 'virtualScroll', + 'pageName', 'pagination', 'requiredPermissions', 'resizable', 'rowActions', 'rowColor', + 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showRecordCount', 'sort', + 'tabs', 'timeline', 'tree', 'type', 'userActions', 'userFilters', ] as const; /** - * Eight of those 51 are RETIREMENT TOMBSTONES, refused by name with the - * protocol's own `[REMOVED]` text (seven until the pin move to - * `@objectstack/spec` 18.0.0 took the retirement of `filterableFields`, - * objectui#13261; five until `@objectstack/spec` 17.5.0 retired `pageName` and - * `tabs`, objectui#11073). ⚠️ They are why a raw key-count diff reads - * objectui as "narrower" and is wrong to: not declaring a key the protocol - * refuses is agreement, not a gap. Derived by the tombstone's own marker, not - * listed by hand. + * Three of those 46 are RETIREMENT TOMBSTONES, refused by name with the + * protocol's own `[REMOVED]` text (`filterableFields` since the pin move to + * `@objectstack/spec` 18.0.0 took its retirement, objectui#13261; `pageName` + * and `tabs` since 17.5.0, objectui#11073). Eight until spec 18.2: the five + * objectstack#7176 retirements (`bordered`, `performance`, `responsive`, + * `striped`, `virtualScroll`) were tombstones through 18.1 and AGED OUT of the + * shape one major on (objectstack#25767, ADR-0087) — the protocol declares + * nothing under those names now and refuses each as an unknown key naming the + * protocol upgrade guide ({@link PROTOCOL_AGED_OUT_KEYS}). ⚠️ The tombstones + * are why a raw key-count diff reads objectui as "narrower" and is wrong to: + * not declaring a key the protocol refuses is agreement, not a gap. Derived by + * the tombstone's own marker, not listed by hand. */ -const PROTOCOL_RETIRED_KEYS = ['bordered', 'filterableFields', 'pageName', 'performance', 'responsive', 'striped', 'tabs', 'virtualScroll'] as const; +const PROTOCOL_RETIRED_KEYS = ['filterableFields', 'pageName', 'tabs'] as const; + +/** + * The five objectstack#7176 retirements after their tombstones aged out + * (objectstack#25767): not members of the protocol's shape any more, refused + * as unknown keys. Listed so the age-out is a reading here, not a silent + * shrink of {@link PROTOCOL_LIST_VIEW_KEYS}. + */ +const PROTOCOL_AGED_OUT_KEYS = ['bordered', 'performance', 'responsive', 'striped', 'virtualScroll'] as const; /** * The FIFTH bucket — LIVE protocol members DECLARED here and read off no named view BY @@ -1789,20 +1801,37 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w // Membership, not equality or a count (#26238): a key the protocol adds is // judged by the narrower-than-protocol zero below, derived on both sides. for (const key of PROTOCOL_LIST_VIEW_KEYS) expect(keys).toContain(key); + // …and the five objectstack#7176 tombstones spec 18.2 aged out + // (objectstack#25767) are declared under no name any more. + for (const aged of PROTOCOL_AGED_OUT_KEYS) expect(keys, `${aged} is declared again`).not.toContain(aged); // Firing control + its negative twin, on the same query that produces every // "the protocol declares this nowhere" zero below. expect(keys).toContain(NAMED_VIEW_READ_CONTROL); expect(keys).not.toContain(NAMED_VIEW_ABSENT_CONTROL); }); - it('the eight pinned RETIREMENT TOMBSTONES are refused BY NAME — so objectui not declaring them LIVE is agreement, not narrowness', () => { + it('the three pinned RETIREMENT TOMBSTONES are refused BY NAME — so objectui not declaring them LIVE is agreement, not narrowness', () => { // Membership (#26238): a retirement the protocol adds later is judged by // the derived narrower-than-protocol zero below. for (const key of PROTOCOL_RETIRED_KEYS) expect(protocolRetiredKeys()).toContain(key); const base = { label: 'Directory', columns: ['name', 'email'] }; // The accept leg first: without it every refusal below is a schema that - // refuses everything rather than a reading of these eight keys. + // refuses everything rather than a reading of these keys. expect(SpecObjectListViewSchema.safeParse(base).success).toBe(true); + // The five aged-out keys (objectstack#25767): refused too, but as UNKNOWN + // keys at the view's own path, the one issue naming the key and the + // protocol upgrade guide — a deleted member's refusal, by design now. + for (const key of PROTOCOL_AGED_OUT_KEYS) { + const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: true }); + expect(r.success, `the protocol accepts the aged-out \`${key}\``).toBe(false); + if (!r.success) { + const issues = r.error.issues as readonly Issue[]; + expect(refusedAt(issues, key)).toBe(false); + const root = issues.filter((i) => (i.path ?? []).length === 0).map((i) => i.message ?? '').join('\n'); + expect(root).toMatch(new RegExp('Unrecognized key\\(s\\)[^\\n]*`' + key + '`')); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + } for (const key of PROTOCOL_RETIRED_KEYS) { const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: true }); expect(r.success, `the protocol no longer refuses the retired \`${key}\``).toBe(false); @@ -1815,10 +1844,10 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w || (i.message ?? '').includes('was removed in @objectstack/spec'))).toBe(true); } } - // …and none of the eight is declared LIVE on the objectui face. Five were - // never declared; the two objectui#8980 declared are `?: never` tombstones - // since objectui#11073, and `filterableFields` since objectui#13118, each - // refused by name on this face as on the protocol's. + // …and none of the three is declared LIVE on the objectui face: the two + // objectui#8980 declared are `?: never` tombstones since objectui#11073, + // and `filterableFields` since objectui#13118, each refused by name on this + // face as on the protocol's. (The five aged-out keys were never declared.) const { names, tombstones } = namedListViewMembers(); const declaredLive = names.filter((n) => !tombstones.includes(n)); expect(PROTOCOL_RETIRED_KEYS.filter((k) => declaredLive.includes(k))).toEqual([]); diff --git a/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index f2c10756ce1..7dc74ddb4dd 100644 --- a/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -40,6 +40,8 @@ */ import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; import { AppSchema as SpecAppSchema, DashboardSchema as SpecDashboardSchema, @@ -64,6 +66,11 @@ interface Case { local: string[]; } +/** The App keys whose spec tombstones aged out at 18.2 (objectstack#25767); the twin's own refusals. */ +const AGED_OUT_APP_KEYS = ['version', 'homePageId', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', 'aria'] as const; +/** The Dashboard keys of the same cut. */ +const AGED_OUT_DASHBOARD_KEYS = ['aria', 'performance'] as const; + const CASES: Record = { Page: { spec: SpecPageSchema, @@ -116,17 +123,54 @@ const CASES: Record = { // (objectui#11363) is a refusal of the same kind: the spec refuses it, and // the mode is read only from `AppSchemaRenderer`'s prop or an // `app-schema-renderer` node, never from this document. - local: ['title', 'logo', 'favicon', 'mobileNavMode', 'layout', 'menu', 'actions'], + // The eight `AGED_OUT_APP_KEYS` are refusals of the same kind, since spec + // 18.2 (objectstack#25767): each was a spec tombstone the twin inherited + // by reference through 18.1, aged out of `AppSchema` one major on, and is + // declared here only so an authored value is refused by name instead of + // kept in silence. Pinned below. + local: ['title', 'logo', 'favicon', 'mobileNavMode', 'layout', 'menu', 'actions', ...AGED_OUT_APP_KEYS], }, Dashboard: { spec: SpecDashboardSchema, oui: OuiDashboardSchema, // `dateRange` left this list with objectui#10334: it now flows in by reference. omitted: ['widgets', 'globalFilters'], - local: [], + // Refusals, not capabilities — the aged-out spec tombstones of + // objectstack#25767, declared so an authored value is refused by name + // (`AGED_OUT_DASHBOARD_KEYS`, pinned below). + local: [...AGED_OUT_DASHBOARD_KEYS], }, }; +/** + * Pins one aged-out key on a twin: declared, unwritable (`invalid_type` at its + * own path, the console's refusal naming the key and the guide), and refused + * by the spec as an unknown key at the container's root — the control that + * the twin's refusal is not standing in for a spec member. + */ +const expectAgedOutRefusal = ( + oui: z.ZodType, spec: z.ZodType, container: string, key: string, legal: Record, specLegal: Record, +) => { + expect(Object.keys(shapeOf(oui)), `'${key}' is no longer declared — an authored value would be KEPT in silence`).toContain(key); + const twin = oui.safeParse({ ...legal, [key]: 'x' }); + expect(twin.success, `'${key}' is accepted again`).toBe(false); + const issue = twin.success ? undefined : twin.error.issues.find((i) => i.path.join('.') === key); + expect(issue, `no issue at path '${key}'`).toBeTruthy(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain(`Unrecognized key(s) on this ${container}: \`${key}\``); + // The guide sentence is the spec's own: `zod/tombstone.zod.ts` imports it from + // `@objectstack/spec/shared` (a declared TEXT exception of the mirrors' import + // boundary, imported-defaults-8317), so the twin's refusal carries the spec's words. + expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + expect(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(issue!.message).toContain('objectstack#25767'); + const specResult = spec.safeParse({ ...specLegal, [key]: 'x' }); + expect(specResult.success, `the spec accepts '${key}' again — the twin's refusal would then hide a live member`).toBe(false); + const root = specResult.success ? '' : specResult.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); +}; + describe.each(Object.keys(CASES))('%s spec parity (objectstack#4115 drift guard)', (name) => { const { spec, oui, omitted, local } = CASES[name]; const specShape = shapeOf(spec); @@ -176,27 +220,39 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 it('App declares the package-lock and access keys that used to ride through', () => { const keys = Object.keys(shapeOf(OuiAppSchema)); - // `homePageId` was in this list until spec 17.0.0 retired it; it is now a - // tombstone rather than an authorable key, so it is pinned separately - // below — listing it here would read as "still authorable". - for (const key of ['branding', 'sharing', 'embed', 'objects', 'apis', 'requiredPermissions', '_packageId']) { + // `homePageId` was in this list until spec 17.0.0 retired it, and + // `sharing`, `embed`, `objects` and `apis` until spec 18.2 aged their + // tombstones out: all four are refusals now, pinned in the leg below, and + // listing one here would read as "still authorable". + for (const key of ['branding', 'requiredPermissions', '_packageId']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); - it('App REJECTS the retired `homePageId` rather than stripping it', () => { - // objectstack#4667 / #4709 retired the key: it was an ID cross-reference - // with no referential integrity, so a dangling id silently fell back to the - // first nav item. The landing page is now the first reachable nav item (by - // `order`), and the root landing follows `isDefault`. + it.each(AGED_OUT_APP_KEYS)('App `%s`: a spec tombstone that aged out at 18.2 is the twin\'s OWN refusal now, not a silent keep (objectstack#25767)', (key) => { + // objectstack#4667 / #4709 retired `homePageId` at spec 17.0.0 (an ID + // cross-reference with no referential integrity: a dangling id fell back + // to the first nav item in silence; the landing page is the first + // reachable nav item by `order`, the root landing follows `isDefault`), + // and the 2026-06 liveness audit retired the other seven with it. All + // eight were `retiredKey()` tombstones through 18.1, flowing into this + // twin by reference, so the twin REFUSED each at its own path. // - // Rejection, not stripping, is the whole point — objectui's derivation is - // strip-mode, so if the tombstone ever stopped flowing in by reference an - // author would get their landing-page intent silently deleted instead of an - // error naming the migration. That is the failure this pins. - const rejected = OuiAppSchema.safeParse({ type: 'app', name: 'app_x', homePageId: 'home' }); - expect(rejected.success, '`homePageId` is being accepted again').toBe(false); + // At 18.2 the tombstones aged out (objectstack#25767, ADR-0087): the + // spec's strict `AppSchema` refuses the key as an unknown one at the app's + // own path, naming it and the protocol upgrade guide — that is where an + // author is told at every door that parses with the spec (the client + // validator, the server's 422). The twin inherits nothing under the name, + // and is `.passthrough()`, so without an arm of its own it would KEEP the + // value in silence: a refusal the console gave for two majors would + // vanish. So each key is declared here as the console's own refusal + // (`agedOutSpecKeyRefusal`, `zod/tombstone.zod.ts`), the TS twin is + // `?: never` (`../app.ts`), and this pin holds both the twin's verdict and + // the spec's — a twin whose refusal outlived a key the spec re-admitted + // goes red on the control. + expectAgedOutRefusal(OuiAppSchema, SpecAppSchema, 'app', key, { type: 'app', name: 'app_x' }, { name: 'app_x', label: 'App X' }); expect(OuiAppSchema.safeParse({ type: 'app', name: 'app_x' }).success).toBe(true); + expect(SpecAppSchema.safeParse({ name: 'app_x', label: 'App X' }).success).toBe(true); }); it('App validates branding instead of accepting any shape', () => { @@ -205,16 +261,33 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 expect(OuiAppSchema.safeParse({ type: 'app', branding: 'blue' }).success).toBe(false); }); - it('Dashboard declares header/refreshIntervalSeconds/performance', () => { + it('Dashboard declares header/refreshIntervalSeconds/protection', () => { const keys = Object.keys(shapeOf(OuiDashboardSchema)); // `refreshIntervalSeconds` is the post-rename spelling (objectui#7783); the // spec still carries `refreshInterval` as a `retiredKey` tombstone, so // asserting the OLD name here would pass on the tombstone and prove nothing. - for (const key of ['header', 'refreshIntervalSeconds', 'performance', 'protection']) { + // `performance` left this list at spec 18.2: it was a tombstone through + // 18.1, aged out of `DashboardSchema` one major on (objectstack#25767), + // and is the twin's own refusal now — pinned in the leg below. + for (const key of ['header', 'refreshIntervalSeconds', 'protection']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); + it.each(AGED_OUT_DASHBOARD_KEYS)('Dashboard `%s`: a spec tombstone that aged out at 18.2 is the twin\'s OWN refusal now, not a silent keep (objectstack#25767)', (key) => { + // The #3896 audit close-out retired both at spec 17.0.0 (no dashboard + // renderer applied `aria`; nothing read `performance`); the twin refused + // each by reference through 18.1. The rule and the posture are the App + // leg's above; `dashboard-aria-retired-contract-twins.test.ts` pins the + // `aria` face pair in detail. + expectAgedOutRefusal( + OuiDashboardSchema, SpecDashboardSchema, 'dashboard', key, + { type: 'dashboard', widgets: [] }, { name: 'ops', label: 'Ops', widgets: [] }, + ); + expect(OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [] }).success).toBe(true); + expect(SpecDashboardSchema.safeParse({ name: 'ops', label: 'Ops', widgets: [] }).success).toBe(true); + }); + it('Dashboard validates header instead of accepting any shape', () => { expect( OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [], header: { showTitle: true } }).success, diff --git a/ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts b/ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts new file mode 100644 index 00000000000..77ed02ce490 --- /dev/null +++ b/ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts @@ -0,0 +1,92 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Three public-block arms refuse the flat spelling of a row member whose spec + * tombstone aged out at 18.2 (objectstack#25767, ADR-0087 one major on). + * + * Each public-block arm (`zod/public-blocks.zod.ts`) is `BaseSchema` + * (`.passthrough()`) plus `flatPropRefusals` over its spec row, which refuses + * a row member written flat on the node by name — and, for a member the row + * RETIRED (a `z.never` tombstone), with the row's own retirement. Through spec + * 18.1 `page:header`'s `icon`, `page:card`'s `actions` and `record:details`'s + * `layout` were such tombstones. At 18.2 they aged out of the rows: the + * strict row refuses `properties.KEY` as an unknown key naming the upgrade + * guide, and the spec's page component refuses a flat one the same way, but + * `flatPropRefusals` reads the row's key set and no longer sees the name — so + * `BaseSchema` would KEEP a flat value in silence. Each arm now declares the + * console's own refusal (`agedOutSpecKeyRefusal`, `zod/tombstone.zod.ts`). + * + * Pinned per key: the flat refusal (`invalid_type` at the key, naming the key, + * the spec's guide sentence and the age-out); the bag refusal (the row's own + * unknown-key verdict); and the control — the spec's `PageComponentSchema` + * refuses the flat key as an unknown one at the node's root. No TypeScript + * face restates these nodes (`zod-mirror-parity.test.ts` says so per arm). + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; +import { PageComponentSchema as SpecPageComponentSchema } from '@objectstack/spec/ui'; +import { PageCardBlockSchema, PageHeaderBlockSchema, RecordDetailsBlockSchema } from '../zod/public-blocks.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +interface Case { + type: string; + key: string; + value: unknown; + oui: z.ZodType; + properties: Record; +} + +const CASES: Case[] = [ + { type: 'page:header', key: 'icon', value: 'star', oui: PageHeaderBlockSchema, properties: { title: 'Accounts' } }, + { type: 'page:card', key: 'actions', value: [], oui: PageCardBlockSchema, properties: {} }, + { type: 'record:details', key: 'layout', value: 'auto', oui: RecordDetailsBlockSchema, properties: {} }, +]; + +describe.each(CASES)('$type `$key`: a row tombstone that aged out at 18.2 is refused flat by the arm itself (objectstack#25767)', ({ type, key, value, oui, properties }) => { + const node = { type, properties }; + + it('accept leg: the node without it parses on both faces', () => { + expect(oui.safeParse(node).success).toBe(true); + expect(SpecPageComponentSchema.safeParse(node).success).toBe(true); + }); + + it('the spec refuses it written flat as an unknown key at the node (the control)', () => { + const r = SpecPageComponentSchema.safeParse({ ...node, [key]: value }); + expect(r.success, `the spec accepts a flat '${key}' again`).toBe(false); + const root = r.success ? '' : (r.error.issues as readonly Issue[]).filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\) on this page component[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); + + it('the arm refuses it written flat, by name, instead of keeping it', () => { + expect(Object.keys((oui as unknown as { shape: Record }).shape)).toContain(key); + const r = oui.safeParse({ ...node, [key]: value }); + expect(r.success, `the arm keeps a flat '${key}'`).toBe(false); + const issue = r.success ? undefined : (r.error.issues as readonly Issue[]).find((i) => i.path.join('.') === key); + expect(issue, `no issue at '${key}'`).toBeTruthy(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain(`Unrecognized key(s) on this page component: \`${key}\``); + expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + expect(issue!.message).toContain('objectstack#25767'); + expect(issue!.message).toContain(`\`properties.${key}\` is refused as an unknown key too`); + }); + + it('the arm refuses it in the `properties` bag with the row\'s own unknown-key verdict', () => { + const r = oui.safeParse({ ...node, properties: { ...properties, [key]: value } }); + expect(r.success, `the bag keeps '${key}'`).toBe(false); + const bag = r.success ? [] : (r.error.issues as readonly Issue[]).filter((i) => i.path.join('.') === 'properties'); + const message = bag.map((i) => i.message).join('\n'); + expect(bag.map((i) => i.code)).toContain('unrecognized_keys'); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this \`${type}\`[^\\n]*\`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); +}); diff --git a/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts b/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts index 196ef1a566b..1f6984ab121 100644 --- a/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts +++ b/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -233,12 +233,17 @@ describe('the bag is the spec row, read by reference (objectui#10872)', () => { .toBe(true); }); - it('a key the spec retired travels with the row — refused at the member', () => { + it('a key the spec retired travels with the row — refused at the bag, by name', () => { + // `page:card`'s `actions` tombstone (17.0.0) aged out of the row one major + // on (objectstack#25767): the strict row refuses the key as an unknown one + // at the bag's own path, naming it and the protocol upgrade guide, rather + // than at the member. Still the row's own refusal, travelling by reference. const document = { type: 'page:card', properties: { title: 'Summary', actions: ['edit'] } }; const result = safeValidateSchema(document); expect(result.success).toBe(false); - expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))) - .toContain('properties.actions'); + const atBag = result.success ? [] : result.error.issues.filter((issue) => issue.path.join('.') === 'properties'); + expect(atBag.map((issue) => issue.message).join('\n')).toMatch(/Unrecognized key\(s\)[^\n]*`actions`/); + expect(atBag.map((issue) => issue.message).join('\n')).toContain('https://objectstack.ai/docs/protocol-upgrade'); // Control: the spec's own row refuses it too. expect(rowOf('page:card').safeParse({ title: 'Summary', actions: ['edit'] }).success).toBe(false); }); diff --git a/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts b/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts index 8c1de6ab00f..8fa76650cc7 100644 --- a/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts +++ b/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts @@ -172,13 +172,15 @@ type _KeyofStillResolvesOnThisInterface = Expect< >; /** - * The contract's face, untouched by this removal: the tombstone accepts nothing. - * Its type is the spec's retired-key type — the branded `[REMOVED]` mark at the - * pinned 18.1.0 and on objectstack `main`, - * bare `undefined` through 17.5.0 — and the helper accepts - * exactly those two spellings (objectui#11330). + * The contract's face. Through spec 18.1 the tombstone accepted nothing — its + * type was the spec's retired-key type, the branded `[REMOVED]` mark (bare + * `undefined` through 17.5.0), the two spellings `IsRetiredKeyType` accepts + * (objectui#11330). At 18.2 the ≤16.x tombstones aged out of the spec one + * major on (objectstack#25767, ADR-0087), so the contract no longer declares + * `layout` at all: a `keyof` absence, in the form the interface pin above + * uses, because indexing a key that no longer exists is itself a compile error. */ -type _LayoutOnTheContractFace = Expect>; +type _LayoutOnTheContractFace = Expect>; /** THE CONTROL for the line above, through the same helper: a live contract key is not retired. */ type _ColumnsOnTheContractFaceIsLive = Expect, false>>; @@ -191,7 +193,7 @@ type _ColumnsOnTheContractFaceIsLive = Expect { - // The distinction this leg exists for: a tombstone is still a declared - // member typed `never`, so it fails with `invalid_type` at its own path - // and carries the removal prescription. An undeclared key fails with - // `unrecognized_keys` at the root. Reading them as the same failure is how - // a retired key gets mistaken for a typo. + it('PREMISE (spec-only): `layout` is refused as an unknown key naming it and the upgrade guide — its tombstone aged out', () => { + // Through spec 18.1 this leg read the tombstone: a declared member typed + // `never`, failing with `invalid_type` at its own path and carrying the + // removal prescription, which is what told a retired key from a typo. At + // 18.2 the ≤16.x tombstones aged out of the spec one major on + // (objectstack#25767): the strict container refuses `layout` with + // `unrecognized_keys` at the root, the one issue naming the key and the + // protocol upgrade guide. That is the contract's reading now, and a pin on + // the old path would call the age-out "accepted". for (const value of ['stacked', 'inline', 'compact', 'auto', 'custom']) { const refused = RecordDetailsProps.safeParse({ layout: value }); expect(refused.success, `the contract accepted layout: ${value}`).toBe(false); - const issue = refused.error?.issues.find((i) => i.path.join('.') === 'layout'); - expect(issue?.code).toBe('invalid_type'); - expect(issue?.message).toContain('was removed in @objectstack/spec 17.0.0'); + const issue = refused.error?.issues.find((i) => i.path.length === 0 && i.message.includes('`layout`')); + expect(issue?.code, `layout: ${value} was refused, but not as a named unknown key at the root`).toBe('unrecognized_keys'); + expect(issue?.message).toMatch(/Unrecognized key\(s\)/); + expect(issue?.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); } - // CONTROL A — a near-miss typo of the same key gets the OTHER code, which - // is what makes "by name" a reading rather than a claim. + // CONTROL A — a near-miss typo of the same key gets the SAME code: the spec + // no longer tells the two apart, by design (the guide link is what a + // retired key's author follows). The reading above is therefore "refused + // by name at the root", not "distinguished from a typo". const typo = RecordDetailsProps.safeParse({ layoutt: 'compact' }); expect(typo.success).toBe(false); expect(typo.error?.issues[0]?.code).toBe('unrecognized_keys'); diff --git a/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index 2bc0ab3624e..e81c19f796e 100644 --- a/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -474,21 +474,33 @@ describe('DashboardWidgetSchema derives from the spec', () => { it('REFUSES the four keys objectstack#5010 retired, by name', () => { // Not "strips" — refuses. That distinction is the whole point of // ADR-0049 enforce-or-remove: a stale dashboard carrying `actionUrl` gets - // told where the affordance moved (`header.actions[]`) instead of silently - // losing it, which is what the pre-derivation hand copy did. + // refused instead of silently losing it, which is what the pre-derivation + // hand copy did. Through spec 18.1 each was a tombstone reporting at its + // own path with the move prescription (`header.actions[]`); at 18.2 the + // tombstones aged out one major on (objectstack#25767, ADR-0087), so the + // key meets this schema's `.strict()` door instead — the objectui#6002 + // ruled outcome for an undeclared widget key: zod's `unrecognized_keys` + // issue at the widget's path, naming the key. Loud, which is what the + // ruling asked; the prescription moved to the spec's own strict widget, + // whose refusal names the key and the protocol upgrade guide (the contract + // leg below). Unlike the `app` / `dashboard` twins, this schema is not + // `.passthrough()`, so no arm of its own is needed to keep the refusal. + const widget = { id: 'w1', type: 'bar', dataset: 'pipeline', dimensions: ['stage'], values: ['amount'] }; for (const key of ['actionUrl', 'actionType', 'actionIcon', 'aria'] as const) { - const result = DashboardWidgetSchema.safeParse({ - id: 'w1', - type: 'bar', - dataset: 'pipeline', - dimensions: ['stage'], - values: ['amount'], - [key]: key === 'aria' ? { ariaLabel: 'Pipeline' } : 'x', - }); + const value = key === 'aria' ? { ariaLabel: 'Pipeline' } : 'x'; + const result = DashboardWidgetSchema.safeParse({ ...widget, [key]: value }); expect(result.success, `'${key}' must be refused, not accepted`).toBe(false); if (!result.success) { - expect(result.error.issues.some((i) => i.path[0] === key)).toBe(true); + const refusal = result.error.issues.find((i) => i.path.length === 0 && i.code === 'unrecognized_keys'); + expect(refusal, `'${key}' must be refused at the widget's path`).toBeDefined(); + expect((refusal as { keys?: string[] }).keys, `'${key}' must be named by the refusal`).toContain(key); } + // The contract leg: the spec's strict widget names the key AND the guide. + const spec = SpecDashboardWidgetSchema.safeParse({ ...widget, [key]: value }); + expect(spec.success, `the spec accepts '${key}' again`).toBe(false); + const root = spec.success ? '' : spec.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); } }); @@ -512,19 +524,23 @@ describe('DashboardWidgetSchema derives from the spec', () => { }); expect(result.success, '`responsive` must be refused, not accepted').toBe(false); if (!result.success) { - const issue = result.error.issues.find((i) => i.path[0] === 'responsive'); - expect(issue, '`responsive` must be reported by name').toBeDefined(); - // The tombstone names its own retirement, not #5010's — if this ever reads - // `#5010` the key was folded into the wrong retirement upstream. - // Was `'#4876, ADR-0049 D2'`. 17.3.0 stripped the `#NNNN` half of that - // citation and kept the ADR reference and the prescription, so the - // assertion keeps the durable half plus the repair the author acts on. - expect(issue?.message).toContain('ADR-0049 D2'); - expect(issue?.message).toContain('Delete the key.'); - // And it must point at the surviving home for breakpoint behaviour rather - // than just saying "removed". - expect(issue?.message).toContain('page.components[].responsive'); + // Its tombstone aged out with the others at spec 18.2 (objectstack#25767): + // the refusal is this schema's `.strict()` unknown-key issue at the + // widget's path, naming the key; the per-retirement citation + // (`ADR-0049 D2`) and the pointer at `page.components[].responsive` + // (itself retired, #11027) left with the tombstone text, and the guide + // sentence lives on the spec's own refusal (the contract leg). + const issue = result.error.issues.find((i) => i.path.length === 0 && i.code === 'unrecognized_keys'); + expect(issue, '`responsive` must be reported at the widget\'s path').toBeDefined(); + expect((issue as { keys?: string[] }).keys, '`responsive` must be named by the refusal').toContain('responsive'); } + const spec = SpecDashboardWidgetSchema.safeParse({ + id: 'w1', type: 'bar', dataset: 'pipeline', dimensions: ['stage'], values: ['amount'], responsive: { sm: { columns: 1 } }, + }); + expect(spec.success, 'the spec accepts `responsive` again').toBe(false); + const root = spec.success ? '' : spec.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(/Unrecognized key\(s\)[^\n]*`responsive`/); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('refuses `responsive` on the TypeScript interface too', () => { diff --git a/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index 65e55dcba70..356a4e4576e 100644 --- a/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -44,6 +44,7 @@ import { describe, it, expect } from 'vitest'; import type { App, Dashboard, Page } from '@objectstack/spec/ui'; +import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; import type { AppComponentSchema } from '../app'; import type { DashboardComponentSchema } from '../complex'; import type { PageNodeSchema } from '../layout'; @@ -104,15 +105,33 @@ describe('admitted spec keys land on the twin with the SPEC\'S type, not `any`', }); describe('spec tombstones surface on the twin as a refusal — the verdict the mirror gives', () => { - it('App `version` / Dashboard `refreshInterval`: authoring a value is a compile error AND a parse failure', () => { - // @ts-expect-error — `version` is the spec's `retiredKey()` tombstone. - const app: AppComponentSchema = { type: 'app', version: '1.0.0' }; + it('Dashboard `refreshInterval`: authoring a value is a compile error AND a parse failure', () => { // @ts-expect-error — `refreshInterval` is the #15680 rename's tombstone. const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshInterval: 30 }; - expect(AppMirror.safeParse(app).success).toBe(false); expect(DashboardMirror.safeParse(dashboard).success).toBe(false); }); + it('App `version`: its tombstone aged out of the spec (objectstack#25767) — the spec refuses it as an unknown key, and the twin refuses it on its own', () => { + // Through spec 18.1 `version` was a `retiredKey()` tombstone the twin + // inherited by reference and refused. At 18.2 the ≤16.x tombstones aged + // out one major on: the spec's strict `AppSchema` refuses the key as an + // unknown one at the app's path, naming it and the protocol upgrade guide; + // the twin, with no member to inherit over its `.passthrough()` base, + // carries the refusal as its OWN arm (`agedOutSpecKeyRefusal`) and its own + // `version?: never` — so the verdict the mirror gives is unchanged: a + // compile error AND a parse failure at the key's own path. + // @ts-expect-error — `version` is the twin's own `?: never`: the spec tombstone it projected aged out (objectstack#25767). + const app: AppComponentSchema = { type: 'app', version: '1.0.0' }; + const spec = SpecAppSchema.safeParse({ name: 'app_x', label: 'App X', version: '1.0.0' }); + expect(spec.success).toBe(false); + const root = spec.success ? '' : spec.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(/Unrecognized key\(s\)[^\n]*`version`/); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + const twin = AppMirror.safeParse(app); + expect(twin.success).toBe(false); + expect(twin.success ? [] : twin.error.issues.map((i) => [i.code, i.path.join('.')])).toContainEqual(['invalid_type', 'version']); + }); + it('the controls: the same documents without the tombstone key pass both faces', () => { const app: AppComponentSchema = { type: 'app' }; const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshIntervalSeconds: 30 }; diff --git a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts index 89fd88bc6a1..0fd3d590b24 100644 --- a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4929,11 +4929,11 @@ const EXCLUSIONS: Readonly> = { // pinned in `./content-channel-public-blocks-9256.test.ts`; each entry below // names them where they apply. 'public-blocks.zod.ts#PageHeaderBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`, + an objectstack#25767 `agedOutSpecKeyRefusal` for the flat `icon` (the row's tombstone aged out at spec 18.2, so `flatPropRefusals` no longer sees the name), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageTabsBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`, + the `onTabChange` runtime-slot refusal (objectui#6124) its renderer's read requires, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageCardBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`, + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`, + an objectstack#25767 `agedOutSpecKeyRefusal` for the flat `actions` (the row's tombstone aged out at spec 18.2, so `flatPropRefusals` no longer sees the name), + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageAccordionBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:accordion` literal + `properties`, which IS `ComponentPropsMap['page:accordion']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageSectionBlockSchema': @@ -4943,7 +4943,7 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#PageSidebarBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:sidebar` literal + `properties`, which IS `ComponentPropsMap['page:sidebar']`, + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordDetailsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + an objectstack#25767 `agedOutSpecKeyRefusal` for the flat `layout` (the row's tombstone aged out at spec 18.2, so `flatPropRefusals` no longer sees the name), + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordHighlightsBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordRelatedListBlockSchema': diff --git a/ui/packages/types/src/app.ts b/ui/packages/types/src/app.ts index 8a451885cdd..5998a721410 100644 --- a/ui/packages/types/src/app.ts +++ b/ui/packages/types/src/app.ts @@ -747,6 +747,82 @@ export interface AppComponentSchema extends BaseSchema, Omit` projection above; the projection now carries + * nothing under the name, so the refusal is this face's own — `?: never` is + * the twin of `zod/app.zod.ts`'s `agedOutSpecKeyRefusal` arm, and an authored + * value is a compile error here as it is a parse error there. The same holds + * for the seven members after it (`homePageId`, `objects`, `apis`, `sharing`, + * `embed`, `mobileNavigation`, `aria`). Pinned by + * `__tests__/page-app-dashboard-spec-parity.test.ts` and + * `__tests__/twins-spec-by-reference-9736.test.ts`. + * + * @deprecated Not a key of this contract. An app is versioned by its owning + * package: `manifest.version`. + */ + version?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. The landing page is the first + * reachable `navigation` item (by `order`); the root landing follows `isDefault`. + */ + homePageId?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Objects belong to the stack + * (`defineStack({ objects })`); an app reaches them through its navigation. + */ + objects?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Declare endpoints on the stack, + * `defineStack({ apis })` (ADR-0121) — read the protocol upgrade guide's + * `declarative-apis-endpoints-live` entry before moving one. + */ + apis?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Public access is granted per form + * view (`FormView.sharing`). + */ + sharing?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Embedding is a per-form-view + * surface (`FormView.sharing`), not an app-level switch. + */ + embed?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. The console's mobile navigation + * mode is the `mobileNavMode` prop of `AppSchemaRenderer` or the same key of + * an `app-schema-renderer` node (see {@link AppComponentSchema.mobileNavMode}). + */ + mobileNavigation?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Declare `aria` on the page + * component that renders the DOM node (`page.components[].aria`). + */ + aria?: never; + /** * Required permissions (ObjectStack Spec v2.0.1) * Permissions required to access this application diff --git a/ui/packages/types/src/complex.ts b/ui/packages/types/src/complex.ts index 4a844a2fdb7..a0065c5fda3 100644 --- a/ui/packages/types/src/complex.ts +++ b/ui/packages/types/src/complex.ts @@ -2609,7 +2609,9 @@ export interface DashboardComponentSchema extends BaseSchema, Omit` projection above. Pinned by // `__tests__/dashboard-daterange-spec-10334.test.ts`. - // `aria` was DECLARED here until objectui#5830, under a comment claiming - // alignment with @objectstack/spec AriaPropsSchema — by then the opposite of - // the contract: the spec removed `dashboard.aria` at the objectstack-ai/objectstack#3896 audit - // close-out (no dashboard renderer ever applied it), so - // `DashboardSchema.shape.aria` is a tombstone that refuses any value, the - // Zod twin (`zod/complex.zod.ts`) inherits that refusal through - // `SpecDashboardFields`, and `plugin-dashboard` has no `schema.aria` read - // site. Since objectui#9736 this interface inherits the same tombstone from - // the spec projection (`aria?: undefined`), so an authored `aria` is now a - // compile error rather than `any` through the index signature. Pinned by - // `__tests__/dashboard-aria-retired-contract-twins.test.ts`. + /** + * RETIRED upstream at @objectstack/spec 17.0.0 (the objectstack#3896 audit + * close-out: no dashboard renderer ever applied it); its tombstone aged out + * of `DashboardSchema` at spec 18.2 (objectstack#25767, ADR-0087 one major + * on). The history, so nobody re-declares it: `aria` was DECLARED here until + * objectui#5830 under a comment claiming alignment with the spec's + * `AriaPropsSchema` — by then the opposite of the contract — and from + * objectui#9736 this interface inherited the spec's tombstone through the + * `Omit< Dashboard, … >` projection above (`aria?: undefined`), so an + * authored value was a compile error rather than `any` through the index + * signature. The projection now carries nothing under the name, so the + * refusal is this face's own: `?: never` is the twin of + * `zod/complex.zod.ts`'s `agedOutSpecKeyRefusal` arm, and `plugin-dashboard` + * still has no `schema.aria` read site. Pinned by + * `__tests__/dashboard-aria-retired-contract-twins.test.ts`. + * + * @deprecated Not a key of this contract. ARIA attributes are live on + * `page.aria`, `page.components[].aria` and the list view's `aria`. + */ + aria?: never; + /** + * Aged-out spec tombstone of the same cut (objectstack#25767); see `aria` + * above. No renderer or runtime read it. Pinned by + * `__tests__/page-app-dashboard-spec-parity.test.ts`. + * + * @deprecated Not a key of this contract. + */ + performance?: never; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `dashboard` reads NEITHER * content channel: no renderer read consumes `body` or `children` for this diff --git a/ui/packages/types/src/zod/app.zod.ts b/ui/packages/types/src/zod/app.zod.ts index 2fcf7eab128..8a347afee01 100644 --- a/ui/packages/types/src/zod/app.zod.ts +++ b/ui/packages/types/src/zod/app.zod.ts @@ -39,7 +39,7 @@ import { EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, } from '@objectstack/spec/shared'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { agedOutSpecKeyRefusal, aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import type { AppMenuItem, NavigationItemType } from '../app.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -749,6 +749,60 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( areas: z.array(NavigationAreaSchema).optional().describe('Navigation areas (business-domain partitions)'), contextSelectors: z.array(AppContextSelectorSchema).optional().describe('App-level scope dropdowns injected into nav items as {} vars'), actions: retirementTombstone(APP_ACTIONS_REFUSAL), + // The eight `App` keys the spec retired at 17.0.0 (#4667 and the 2026-06 + // liveness audit) and carried as `retiredKey()` tombstones through 18.1 — + // flowing into this twin by reference, so an authored value was refused + // HERE at the key's own path. At spec 18.2 the tombstones aged out of + // `AppSchema` one major on (objectstack#25767, ADR-0087): the spec refuses + // each as an unknown key naming the upgrade guide, and `SpecAppFields` no + // longer carries the name — so without these arms `BaseSchema`'s + // `.passthrough()` would turn a two-major refusal into a silent KEEP. Each + // is the console's own refusal now (`agedOutSpecKeyRefusal`, the reasoning + // on its docblock); the TS twins are `?: never` in `../app.ts`, and the pins + // are `../__tests__/page-app-dashboard-spec-parity.test.ts` and + // `../__tests__/twins-spec-by-reference-9736.test.ts`. + version: agedOutSpecKeyRefusal( + 'app', 'App.version', 'version', '17.0.0', + 'An app is versioned by its owning package: use `manifest.version`. Delete the key.', + ), + homePageId: agedOutSpecKeyRefusal( + 'app', 'App.homePageId', 'homePageId', '17.0.0', + 'The landing page is the first reachable `navigation` item (by `order`), and the root landing follows ' + + '`isDefault`; an id cross-reference with no referential integrity fell back to the first item in silence. ' + + 'Delete the key.', + ), + objects: agedOutSpecKeyRefusal( + 'app', 'App.objects', 'objects', '17.0.0', + 'Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. ' + + 'Delete the key.', + ), + apis: agedOutSpecKeyRefusal( + 'app', 'App.apis', 'apis', '17.0.0', + 'Declare the endpoint on the stack instead, `defineStack({ apis })` — a surface that EXECUTES under its own ' + + 'publish gates (ADR-0121): read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide ' + + 'before moving it, since the move is a security review, not a rename. Delete the key here.', + ), + sharing: agedOutSpecKeyRefusal( + 'app', 'App.sharing', 'sharing', '17.0.0', + 'Public access is granted per form view (`FormView.sharing`, the public-data-collection surface); no ' + + 'public-app route ever read an app-level block. Delete the key.', + ), + embed: agedOutSpecKeyRefusal( + 'app', 'App.embed', 'embed', '17.0.0', + 'Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch; no iframe route ever read ' + + 'it. Delete the key.', + ), + mobileNavigation: agedOutSpecKeyRefusal( + 'app', 'App.mobileNavigation', 'mobileNavigation', '17.0.0', + 'No renderer, `packages/mobile` included, ever read it. The console\'s mobile navigation mode is the ' + + '`mobileNavMode` prop of `AppSchemaRenderer` or the same key of an `app-schema-renderer` node, never a key of ' + + 'this document. Delete the key.', + ), + aria: agedOutSpecKeyRefusal( + 'app', 'App.aria', 'aria', '17.0.0', + 'No renderer read app-level ARIA attributes. Declare `aria` on the page component that renders the DOM node ' + + '(`page.components[].aria`; `page.aria` and the list view\'s `aria` are live too). Delete the key.', + ), }); /** diff --git a/ui/packages/types/src/zod/complex.zod.ts b/ui/packages/types/src/zod/complex.zod.ts index 0b8afaa90d9..6a3e504e123 100644 --- a/ui/packages/types/src/zod/complex.zod.ts +++ b/ui/packages/types/src/zod/complex.zod.ts @@ -17,7 +17,7 @@ */ import { z } from 'zod'; -import { aliasKeyRefusal, durationKeyRenamedGuidance, handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; +import { agedOutSpecKeyRefusal, aliasKeyRefusal, durationKeyRenamedGuidance, handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { ChartTypeSchema as SpecChartTypeSchema, checkDashboardWidgetChartMeasureArity, @@ -1519,8 +1519,13 @@ const WidgetComponentSlotUnion = z.core.$constructor( * consumed keys, undeclared widget keys refuse loudly — zod's * `unrecognized_keys` issue names every offending key. The spec's own * tombstones (`actionUrl` / `actionType` / `actionIcon` / `aria` / - * `responsive`) stay DECLARED `z.never()` members, so they keep their specific - * removal messages rather than degrading to a generic unknown-key error. + * `responsive`) stayed DECLARED `z.never()` members through spec 18.1, keeping + * their specific removal messages; at 18.2 they aged out of the spec's widget + * shape one major on (objectstack#25767, ADR-0087) and meet this `.strict()` + * door like any other undeclared key — loud, named, and sent to the spec's + * own refusal (which names the protocol upgrade guide) for the prescription. + * No arm of this schema's own is needed to keep that refusal, unlike the + * `.passthrough()` `app` / `dashboard` twins (`agedOutSpecKeyRefusal`). * * ⚠️ This schema owns spec-family widgets only. A component node in the widget * slot (`type: 'metric-card'`) is routed to @@ -1726,6 +1731,27 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh + 'What it renders instead: `columns`, `dateRange`, `description`, `gap`, `globalFilters`, ' + '`header`, `label`, `name`, `refreshIntervalSeconds`, `type`, `widgets`.', ), + // `aria` and `performance`: spec 17.0.0 tombstones (the #3896 audit + // close-out) that this twin inherited by reference and refused at the key's + // own path through 18.1. At spec 18.2 both aged out of `DashboardSchema` one + // major on (objectstack#25767, ADR-0087): the spec refuses each as an unknown + // key naming the upgrade guide, `SpecDashboardFields` no longer carries the + // name, and without these arms `BaseSchema`'s `.passthrough()` would turn the + // refusal into a silent KEEP. Each is the console's own refusal now + // (`agedOutSpecKeyRefusal`, the reasoning on its docblock); the TS twins are + // `?: never` in `../complex.ts`, and the pins are + // `../__tests__/dashboard-aria-retired-contract-twins.test.ts` and + // `../__tests__/page-app-dashboard-spec-parity.test.ts`. + aria: agedOutSpecKeyRefusal( + 'dashboard', 'dashboard.aria', 'aria', '17.0.0', + 'No dashboard renderer ever applied it, so declared ARIA attributes never reached the DOM, and ' + + '`plugin-dashboard` has no `schema.aria` read site. Delete the key; ARIA attributes are live on `page.aria`, ' + + '`page.components[].aria` and the list view\'s `aria`.', + ), + performance: agedOutSpecKeyRefusal( + 'dashboard', 'dashboard.performance', 'performance', '17.0.0', + 'No renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key.', + ), }); /** diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 460a99353a9..4f40b548a6c 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -93,7 +93,7 @@ import { // reference. import { InlineGridColumnSchema as SpecInlineGridColumnSchema } from '@objectstack/spec/data'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; +import { agedOutSpecKeyRefusal, aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { DataTableSchema, DrillDownConfigSchema, PivotTableSchema } from './data-display.zod.js'; // The kanban CARD vocabulary has one authority (`./complex.zod.ts`); the // `object-kanban` lane below reads it rather than restating it (objectui#8913). @@ -2288,24 +2288,24 @@ export const ListViewSchema = BaseSchema // `scripts/check-spec-symbol-derivation.mjs` instead of hidden one hop away. // // Imported here: data, filter, sort, searchableFields, - // filterableFields, resizable, striped, bordered, compactToolbar, selection, navigation, + // filterableFields, resizable, compactToolbar, selection, navigation, // pagination, chart, tree, rowHeight, grouping, rowColor, hiddenFields, fieldOrder, - // rowActions, bulkActions, bulkActionDefs, virtualScroll, inlineEdit, userActions, - // appearance, tabs, addRecord, showRecordCount, allowPrinting, emptyState, responsive, - // performance. + // rowActions, bulkActions, bulkActionDefs, inlineEdit, userActions, + // appearance, tabs, addRecord, showRecordCount, allowPrinting, emptyState. // - // `striped`, `bordered` and `virtualScroll` are in that list because the spec - // pin still carries them, NOT because objectui offers them: objectstack#7176 - // retired all three (maintainer-ruled 2026-08-10) after measuring every - // objectui reader as pass-through — no renderer ever applied one. objectui's - // own declarations and the forwarding chain came out with objectui#4649; what - // is left here is the by-reference import, which is exactly what must stay. - // It carries the spec's `retiredKey()` tombstones in on the GA bump, so an - // author writing one gets a rejection from the protocol rather than silence. - // Do NOT add them to LIST_VIEW_LOCAL_OVERRIDES to "clean this up" — that - // excludes the tombstone and hands the key back to `BaseSchema`'s - // passthrough, turning a loud rejection into a silently-accepted dead key. - // Re-forwarding needs an implementation card filed first (the ruling's text). + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance` are + // NOT in that list any more: the spec carried them as `retiredKey()` + // tombstones through 18.1 (objectstack#7176 retired the first three, + // maintainer-ruled 2026-08-10, after measuring every objectui reader as + // pass-through; the #3896 audit close-out the last two), and this import + // brought each tombstone in by reference, so an author got a rejection from + // the protocol rather than silence. At spec 18.2 the tombstones aged out of + // the shape one major on (objectstack#25767, ADR-0087), so the projection + // carries nothing under those names and `BaseSchema`'s passthrough would + // turn the loud rejection into a silently-accepted dead key. The five are + // the console's OWN refusals now, declared in the `.extend({...})` below + // (`agedOutSpecKeyRefusal`). Re-forwarding any of them needs an + // implementation card filed first (the ruling's text). .extend(specFieldsExcept(stripImportedDefaults(SpecListViewSchema).shape, LIST_VIEW_LOCAL_OVERRIDES).shape) .extend({ // Component discriminator — load-bearing for the ObjectQLComponentSchema union. @@ -2483,6 +2483,45 @@ export const ListViewSchema = BaseSchema // ⚠️ This arm feeds its own TypeScript face (`ListViewInferred` below), so these two members are // what put `body?: undefined` / `children?: undefined` on `ListViewSchema`; there is no separate // `?: never` pair to keep in step. + // + // The five list view keys the spec retired at 17.0.0 and carried as + // `retiredKey()` tombstones through 18.1 — flowing in by reference through + // the projection above, so an authored value was refused at the key's own + // path. At spec 18.2 the tombstones aged out of `ListViewSchema` one major + // on (objectstack#25767, ADR-0087): the spec refuses each as an unknown key + // naming the upgrade guide, and the projection no longer carries the name, + // so without these arms `BaseSchema`'s `.passthrough()` would KEEP an + // authored `striped: true` in silence. Each is the console's own refusal + // now (`agedOutSpecKeyRefusal`, the reasoning on its docblock). As with + // `body` / `children` above, the TypeScript face is derived from this arm + // (`ListViewInferred`), so each key is `?: undefined` on `ListViewSchema` + // with no hand-kept twin; `ListViewPropsBag` reads this shape, so a page + // `list-view` node refuses the five in its `properties` bag and, through + // the row's own retirement, written flat. Pinned by + // `../__tests__/list-view-aged-out-spec-keys-25767.test.ts`. + striped: agedOutSpecKeyRefusal( + 'list view', 'ListView.striped', 'striped', '17.0.0', + 'Every measured reader only copied it forward and no renderer ever applied it (objectstack#7176), so ' + + 'there is no authorable striped-rows switch. Delete the key.', + ), + bordered: agedOutSpecKeyRefusal( + 'list view', 'ListView.bordered', 'bordered', '17.0.0', + 'Every measured reader only copied it forward and no renderer ever applied it (objectstack#7176); the ' + + 'grid frame is the renderer\'s own constant, not authorable. Delete the key.', + ), + virtualScroll: agedOutSpecKeyRefusal( + 'list view', 'ListView.virtualScroll', 'virtualScroll', '17.0.0', + 'Every measured reader only copied it forward and no grid ever virtualized off it (objectstack#7176). ' + + 'Large datasets page via `pagination`. Delete the key.', + ), + responsive: agedOutSpecKeyRefusal( + 'list view', 'ListView.responsive', 'responsive', '17.0.0', + 'No renderer ever read it; the grid is responsive by its own layout rules. Delete the key.', + ), + performance: agedOutSpecKeyRefusal( + 'list view', 'ListView.performance', 'performance', '17.0.0', + 'No renderer or runtime read it; list view performance tuning was never implemented. Delete the key.', + ), }) // ⭐ THE SPEC'S OBJECT-LEVEL CHECKS, re-attached (objectui#7715, ruling B1). // diff --git a/ui/packages/types/src/zod/public-blocks.zod.ts b/ui/packages/types/src/zod/public-blocks.zod.ts index fcc0b3b9868..e90471842f9 100644 --- a/ui/packages/types/src/zod/public-blocks.zod.ts +++ b/ui/packages/types/src/zod/public-blocks.zod.ts @@ -231,6 +231,7 @@ import { import { BaseSchema } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; import { + agedOutSpecKeyRefusal, aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, @@ -498,6 +499,20 @@ export const PageHeaderBlockSchema = BaseSchema.extend({ ...NODE_ENVELOPE, // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('page:header', stripImportedDefaults(SpecPageHeaderProps)), + // The row's `icon` was a `retiredKey()` tombstone through spec 18.1, so the + // spread above gave a flat `icon` the row's own retirement. At 18.2 it aged + // out of `PageHeaderProps` one major on (objectstack#25767, ADR-0087): the + // row refuses `properties.icon` as an unknown key, and the spec's page + // component refuses a flat one the same way, but the spread no longer sees + // the name, so `BaseSchema`'s `.passthrough()` would KEEP a flat value in + // silence. The console's own refusal (`agedOutSpecKeyRefusal`), pinned by + // `../__tests__/public-block-aged-out-spec-keys-25767.test.ts`. + icon: agedOutSpecKeyRefusal( + 'page component', 'PageHeaderProps.icon', 'icon', '17.0.0', + 'No renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the ' + + 'header\'s own props, and `properties.icon` is refused as an unknown key too. The header\'s identity is ' + + 'drawn by the record chrome (`recordChrome`), and each action carries its own `icon`. Delete the key.', + ), properties: propsBag('page:header', stripImportedDefaults(SpecPageHeaderProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -555,6 +570,15 @@ export const PageCardBlockSchema = BaseSchema.extend({ ...NODE_ENVELOPE, // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('page:card', stripImportedDefaults(SpecPageCardProps)), + // `actions` aged out of `PageCardProps` at spec 18.2 (objectstack#25767, + // ADR-0087), as `page:header`'s `icon` did: the console's own refusal of the + // flat spelling, which `BaseSchema`'s `.passthrough()` would otherwise keep. + actions: agedOutSpecKeyRefusal( + 'page component', 'PageCardProps.actions', 'actions', '17.0.0', + 'No renderer ever read it: the card renderer has no actions area, and `properties.actions` is refused as an ' + + 'unknown key too. Author the buttons as components in the card\'s `properties.children` or ' + + '`properties.footer` (`element:button`, `record:quick_actions`), which is what renders. Delete the key.', + ), properties: propsBag('page:card', stripImportedDefaults(SpecPageCardProps)), // objectui#10872 batch 6: the child list is the row's `children` member, in the bag. body: retirementTombstone(PAGE_CARD_CHILD_LIST), @@ -685,6 +709,15 @@ export const RecordDetailsBlockSchema = BaseSchema.extend({ ...NODE_ENVELOPE, // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('record:details', stripImportedDefaults(SpecRecordDetailsProps)), + // `layout` aged out of `RecordDetailsProps` at spec 18.2 (objectstack#25767, + // ADR-0087), as `page:header`'s `icon` did: the console's own refusal of the + // flat spelling, which `BaseSchema`'s `.passthrough()` would otherwise keep. + layout: agedOutSpecKeyRefusal( + 'page component', 'RecordDetailsProps.layout', 'layout', '17.0.0', + 'Its `auto` | `custom` semantics were never implemented, and `properties.layout` is refused as an unknown ' + + 'key too. The body is chosen by what you author: `properties.sections` renders explicit groups, and ' + + 'omitting it falls back to the object\'s `highlightFields`. Delete the key.', + ), properties: propsBag('record:details', stripImportedDefaults(SpecRecordDetailsProps)), // objectui#12294: the one-record binding this block's renderer reads (`RECORD_BLOCK_DATA_SOURCE`). dataSource: RECORD_BLOCK_DATA_SOURCE, diff --git a/ui/packages/types/src/zod/tombstone.zod.ts b/ui/packages/types/src/zod/tombstone.zod.ts index dce8bca7d97..6e483dd158c 100644 --- a/ui/packages/types/src/zod/tombstone.zod.ts +++ b/ui/packages/types/src/zod/tombstone.zod.ts @@ -15,6 +15,7 @@ */ import { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; /** * Declare an ADR-0049 RETIREMENT TOMBSTONE: a key that stays declared but is @@ -65,6 +66,51 @@ export function retirementTombstone(guidance: string) { return z.never({ error: guidance }).optional().describe(guidance); } +/** + * Declare the console's OWN refusal for a key whose `@objectstack/spec` + * tombstone AGED OUT (objectstack#25767, ADR-0087 one major on). A key the spec + * retired at major N is a `retiredKey()` tombstone through N+1.x and leaves the + * spec's shape at the next major's cut; from then on the spec's strict + * container refuses it as an UNKNOWN key — `unrecognized_keys` at the + * container's own path, naming the key and the protocol upgrade guide. + * + * A twin derived from that shape by reference (`specFieldsExcept(...)` over + * `BaseSchema`, which is `.passthrough()`) inherits nothing under the name once + * the tombstone is gone: a refusal the console gave at the key's own path for + * two majors would turn into a silent KEEP. So the refusal moves to this side + * as the console's own arm, in the posture `app.zod.ts`'s `mobileNavMode` and + * `actions` set: {@link retirementTombstone}'s primitive, `invalid_type` at the + * key's own path, one string feeding both channels. The guidance opens with the + * sentence the spec answers the key with and the guide sentence the spec's + * refusal carries — `RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, imported from + * `@objectstack/spec/shared` rather than restated (ui's `check:spec-symbols` + * refuses a hand-written declaration under a spec export's name); the mirrors' + * import boundary (`../__tests__/imported-defaults-8317.test.ts`) admits the + * read as a declared TEXT exception, a sentence being unable to write a value + * into a parsed document — then says what happened to the key and where its + * value went. The TS twin is `?: never`. + * + * ⛔ Asserts no spec behaviour that does not exist: the spec's tombstone text is + * gone and is not restated; the unknown-key refusal is the only spec verdict + * named. The other case keeps its rule — a key the twin NEVER refused gets no + * hand-written refusal (`complex.ts` `DashboardComponentSchema`, `title`). + * + * @param container the noun the spec's refusal uses (`'app'`, `'dashboard'`) + * @param def the spec def and key as its ledger spelled it (`'App.version'`) + * @param key the member name, spelled into the lead sentence + * @param retiredIn the spec version that retired the key (`'17.0.0'`) + * @param remedy where the value went — one or two sentences ending in `Delete the key.` + */ +export function agedOutSpecKeyRefusal(container: string, def: string, key: string, retiredIn: string, remedy: string) { + const agedOutGuidance = + `Unrecognized key(s) on this ${container}: \`${key}\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ` + + `\`${def}\` was retired in @objectstack/spec ${retiredIn}, and its tombstone aged out of the spec's shape one ` + + 'major on (objectstack#25767, ADR-0087): the spec refuses the key as an unknown one, and this node — derived ' + + 'from that shape by reference over a `.passthrough()` base — refuses it by name here rather than keeping it in ' + + `silence. ${remedy}`; + return retirementTombstone(agedOutGuidance); +} + /** How a handler key sits on the TypeScript face (objectui#6124, measured per key). */ export type HandlerKeyDisposition = /** A host-supplied function REACHES a renderer at runtime — the TS twin keeps its function type. */