From 8232d5069a7da25c395343578bf036a311d5dfc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:18:22 +0000 Subject: [PATCH 1/6] wip(spec): action:button / action:icon refuse endpoint with ActionSchema's target rename, read from one table Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- packages/spec/src/ui/action-target-aliases.ts | 43 +++++++++++++++++ packages/spec/src/ui/action.zod.ts | 6 ++- packages/spec/src/ui/component.zod.ts | 48 ++++++++++++++----- 3 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 packages/spec/src/ui/action-target-aliases.ts diff --git a/packages/spec/src/ui/action-target-aliases.ts b/packages/spec/src/ui/action-target-aliases.ts new file mode 100644 index 00000000000..7466cbdb549 --- /dev/null +++ b/packages/spec/src/ui/action-target-aliases.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The spellings an author reaches for when they mean an action's `target` — + * ONE table, read by every surface that declares `target` for the action + * runner (#21005). + * + * `target` is the one key the runner dispatches an executor on: the URL of a + * `url` action, the endpoint of an `api` action, the script or flow name. + * `url`, `endpoint`, `path` and `href` are the words borrowed from HTTP + * clients and anchors for the same thing, so each surface renames them onto + * `target` through its `strictObject` `aliases` and prints the same + * prescription — "Did you mean `endpoint` → `target`?". + * + * Three surfaces read it: + * + * - `ActionSchema` (`action.zod.ts`), and `InlineActionSchema`, which derives + * from the same object half; + * - the `action:button` and `action:icon` rows of `ComponentPropsMap` + * (`component.zod.ts`), the page blocks that hand their props to the same + * runner. + * + * Why a module of its own: the two files must read the SAME object, never two + * copies of it. Until #21005 the rows declared `endpoint` as a key of their + * own while `ActionSchema` refused it with this rename — one concept, two + * verdicts in one release — and objectui's console `api` handler reads + * `target` only, so an `endpoint` the rows accepted called nothing. The rows + * also answered `path` with the edit-distance guess `patch`, the declarative + * write's field values, which parses. `component.zod.ts` already imports + * `action.zod.ts`, so the table cannot live in the rows' file; and exported + * from `action.zod.ts` it would ride the `ui` barrel into the published API, + * a contract for what is authoring judgement. This module is reached by + * relative import only, like `filter-rule-array.ts`. + * + * A stored or authored `endpoint` on either block is rewritten to `target` by + * the ADR-0087 D2 conversion `action-block-endpoint-to-target`. + */ +export const ACTION_TARGET_ALIASES = { + url: 'target', + endpoint: 'target', + path: 'target', + href: 'target', +} as const satisfies Readonly>; diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 549467b41df..18d22454de5 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -32,6 +32,7 @@ import { strictUnknownKeyError } from '../shared/suggestions.zod'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { lazySchema } from '../shared/lazy-schema'; +import { ACTION_TARGET_ALIASES } from './action-target-aliases'; /** * Semantic near-misses — a different **word** for the same intent, usually @@ -857,7 +858,10 @@ const actionObject = () => strictObject({ title: 'label', displayName: 'label', text: 'label', object: 'objectName', entity: 'objectName', actionType: 'type', - url: 'target', endpoint: 'target', path: 'target', href: 'target', + // The executor-target family lives in ONE table the `action:button` / + // `action:icon` rows read too (`action-target-aliases.ts`), so the action + // and the blocks that run it print the same rename (#21005). + ...ACTION_TARGET_ALIASES, parameters: 'params', args: 'params', inputs: 'params', fields: 'params', confirm: 'confirmText', confirmation: 'confirmText', confirmMessage: 'confirmText', success: 'successMessage', successText: 'successMessage', toast: 'successMessage', diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 523cd2dc040..dc84ad21588 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -28,6 +28,7 @@ import { EmptyStateSchema, } from './view.zod'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; +import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; @@ -2929,8 +2930,22 @@ const BUTTON_PRIMITIVE_SIZES = ['default', 'sm', 'lg', 'icon'] as const; * - `visibleWhen` / `visibility` → `visible`: the `record:alert` pair. The * renderer evaluates `visible` itself, so an author bringing the node * spelling down into this bag is reaching for exactly that key. + * - `url` / `endpoint` / `path` / `href` → `target`: `ActionSchema`'s own + * rename for the executor target, read from the one table both files share + * (`ACTION_TARGET_ALIASES`, `action-target-aliases.ts`) so the rows and the + * action print the same prescription. `endpoint` was a declared key of both + * rows until #21005 — forwarded by the renderers, read by no console `api` + * handler (those read `target` only), so an `endpoint` the rows accepted + * called nothing. A stored one is rewritten to `target` by the D2 + * conversion `action-block-endpoint-to-target`. Without the table the rows + * answered `path` with the edit-distance guess `patch`, which parses. */ -const ACTION_NODE_ALIASES = { type: 'actionType', visibleWhen: 'visible', visibility: 'visible' } as const; +const ACTION_NODE_ALIASES = { + type: 'actionType', + visibleWhen: 'visible', + visibility: 'visible', + ...ACTION_TARGET_ALIASES, +} as const; /** * Read-but-not-authorable keys shared by `action:button` and `action:icon`, @@ -2972,18 +2987,26 @@ const ACTION_NODE_GUIDANCE = { * list and an object is the static values. (A node-level object `params` * outside `properties` is ignored with a development warning.) * - Forwarded to the runner (`:215-311`): `description`, `target`, `openIn`, - * `endpoint`, `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, + * `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, * `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, * `undoable`, `recordIdField`, `locations`, `toast`, `resultDialog`, * `onSuccess`, `objectName`. * - * The registration's `inputs` (`:414-551`) publish twenty-seven of these - * twenty-nine keys since objectui#11168 slice 1 (at `db11afd49` they published - * seven, `:395-416`); the two left unpublished are `endpoint` and `undoable`, - * each still forwarded (`:215-311`), on objectui's measurement that the - * console's own `api` handler reads `target` and never `endpoint`, and that - * the runner offers Undo only with a host row stash this block never writes. - * It also publishes `className`, which is read but is a node key. + * NOT declared, though forwarded: `endpoint`. Both console runtimes register + * their own `api` handler, which reads `action.target || action.name` and + * never `endpoint`, so an `endpoint` written here reached the runner and + * called nothing. Refused since #21005 with `ActionSchema`'s rename onto + * `target` (`ACTION_NODE_ALIASES` above) — one concept, one spelling, one + * verdict on the action and on the block that runs it. + * + * The registration's `inputs` (`:414-551`) publish twenty-seven of the + * twenty-nine keys the renderer forwards or reads since objectui#11168 slice 1 + * (at `db11afd49` they published seven, `:395-416`); the two left unpublished + * are `endpoint` (refused here, above) and `undoable`, each still forwarded + * (`:215-311`), on objectui's measurement that the console's own `api` handler + * reads `target` and never `endpoint`, and that the runner offers Undo only + * with a host row stash this block never writes. It also publishes + * `className`, which is read but is a node key. */ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ surface: 'this `action:button`', @@ -3016,7 +3039,6 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`'), openIn: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), - endpoint: z.string().optional().describe('API endpoint for an `api` action, forwarded to the runner'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), @@ -3066,9 +3088,12 @@ export type ActionButtonPropsParsed = z.infer; * renderer default `ghost`. * - `params` — `:143-146`, routed exactly as on `action:button`. * - Forwarded (`:160-208`): `actionType`, `name`, `target`, `openIn`, - * `endpoint`, `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, + * `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, * `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, * `locations`, `toast`, `resultDialog`, `onSuccess`, `objectName`. + * - `endpoint` is forwarded too and NOT declared, for `action:button`'s + * reason: no console `api` handler reads it. Refused with `ActionSchema`'s + * rename onto `target`, through the same `ACTION_NODE_ALIASES` (#21005). */ export const ActionIconPropsSchema = lazySchema(() => strictObject({ surface: 'this `action:icon`', @@ -3103,7 +3128,6 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`'), openIn: z.enum(['self', 'new-tab']).optional() .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), - endpoint: z.string().optional().describe('API endpoint for an `api` action, forwarded to the runner'), method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), From 6ccba6f3e87385dd46101bdd45196189c12f9cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:22:02 +0000 Subject: [PATCH 2/6] wip(spec): drop the two endpoint rows from the authorable-surface shard (proof 2: def unreachable from metadata roots) Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f3a0fe77801..fe2af4a331d 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -66,7 +66,6 @@ "ui/ActionButtonProps:confirmText", "ui/ActionButtonProps:description", "ui/ActionButtonProps:disabled", - "ui/ActionButtonProps:endpoint", "ui/ActionButtonProps:errorMessage", "ui/ActionButtonProps:icon", "ui/ActionButtonProps:label", @@ -103,7 +102,6 @@ "ui/ActionIconProps:confirmText", "ui/ActionIconProps:description", "ui/ActionIconProps:disabled", - "ui/ActionIconProps:endpoint", "ui/ActionIconProps:errorMessage", "ui/ActionIconProps:icon", "ui/ActionIconProps:label", From 6c2f4d8a5fb18ae5daeaeee0db10f8919715db90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:27:48 +0000 Subject: [PATCH 3/6] wip(spec): action-block-endpoint-to-target D2 conversion, D3 entry, step-18 rationale, pins Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .../action-block-endpoint-to-target.test.ts | 147 ++++++++++++ packages/spec/src/conversions/registry.ts | 213 ++++++++++++++++++ ....action-block-endpoint-spelling-retired.ts | 33 +++ packages/spec/src/migrations/registry.ts | 45 ++++ ...omponent-action-element-rows-20371.test.ts | 5 +- ...omponent-action-row-endpoint-21005.test.ts | 78 +++++++ 6 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/conversions/action-block-endpoint-to-target.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.action-block-endpoint-spelling-retired.ts create mode 100644 packages/spec/src/ui/component-action-row-endpoint-21005.test.ts diff --git a/packages/spec/src/conversions/action-block-endpoint-to-target.test.ts b/packages/spec/src/conversions/action-block-endpoint-to-target.test.ts new file mode 100644 index 00000000000..314d560b2e6 --- /dev/null +++ b/packages/spec/src/conversions/action-block-endpoint-to-target.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ActionButtonPropsSchema, ActionIconPropsSchema } from '../ui/component.zod.js'; +import { applyConversions } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice, ConversionTodoNotice } from './types.js'; + +/** + * [#21005] `action-block-endpoint-to-target` — the D2 half of refusing + * `endpoint` on the `action:button` / `action:icon` rows. + * + * The fixture pair in `conversions.test.ts` already proves before → after over + * the whole table. What this file pins: + * + * - the stored-row seam REWRITES a stored `endpoint` to `target` on both + * blocks (the entry is `retiredFromLoadPath`, so only data-at-rest seams + * and `os migrate meta` apply it), and the result parses against the row + * that now refuses `endpoint`; + * - the authoring funnel does NOT replay it — an author meets the rows' + * rename instead; + * - every site with no lossless rewrite is left byte-identical and reported + * as a TODO, never converted and never silent; + * - idempotence, and copy-on-write identity for a page with nothing to do. + */ +const ID = 'action-block-endpoint-to-target'; + +/** A stored `page` row carrying one block in a region. */ +const pageWith = (component: Record) => ({ + name: 'ops_console', + regions: [{ name: 'main', components: [component] }], +}); + +/** The one block of a converted {@link pageWith} row. */ +const blockOf = (page: unknown) => + (page as { regions: { components: Record[] }[] }).regions[0]!.components[0]!; + +/** Replay the stored chain over `page`, collecting this entry's notices and TODOs. */ +function replayStored(page: Record) { + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const out = applyConversionsToStoredItem('page', page, { + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }); + return { + out, + notices: notices.filter((n) => n.conversionId === ID), + todos: todos.filter((t) => t.conversionId === ID), + }; +} + +describe('[#21005] action-block-endpoint-to-target (ADR-0087 D2)', () => { + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + }); + + it.each([ + ['action:button', ActionButtonPropsSchema, { label: 'Sync now' }], + ['action:icon', ActionIconPropsSchema, { icon: 'refresh-cw' }], + ] as const)('the stored-row seam rewrites a stored `endpoint` to `target` on `%s`', (type, schema, rest) => { + const props = { ...rest, actionType: 'api', endpoint: '/api/v1/ops/sync', method: 'POST' }; + // Before: the row refuses the stored props — a pre-#21005 row would be + // badged by the props gate without the replay. + expect(schema.safeParse(props).success).toBe(false); + const { out, notices, todos } = replayStored(pageWith({ type, properties: props })); + const block = blockOf(out); + expect(block.properties).toEqual({ ...rest, actionType: 'api', target: '/api/v1/ops/sync', method: 'POST' }); + expect(schema.safeParse(block.properties).success).toBe(true); + expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([ + ['endpoint', 'target', 'pages[0].regions[0].components[0].properties.target'], + ]); + expect(todos).toEqual([]); + }); + + it('a redundant twin is dropped; a disagreeing pair is kept and reported as a TODO', () => { + const twin = replayStored(pageWith({ + type: 'action:icon', + properties: { icon: 'x', actionType: 'api', target: '/api/v1/a', endpoint: '/api/v1/a' }, + })); + expect(blockOf(twin.out).properties).toEqual({ icon: 'x', actionType: 'api', target: '/api/v1/a' }); + expect(twin.notices).toHaveLength(1); + + const pair = pageWith({ + type: 'action:button', + properties: { label: 'Both', actionType: 'api', target: '/api/v1/a', endpoint: '/api/v1/b' }, + }); + const disagreeing = replayStored(pair); + expect(disagreeing.out, 'left exactly as stored').toEqual(pair); + expect(disagreeing.notices).toEqual([]); + expect(disagreeing.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.endpoint']); + }); + + it.each([ + ['no `actionType`', { label: 'Legacy', endpoint: '/api/v1/legacy' }, /actionType: 'api'/], + ['another `actionType`', { label: 'Open', actionType: 'url', endpoint: '/x' }, /"url"/], + ['a non-string `endpoint`', { label: 'Cfg', actionType: 'api', endpoint: { url: '/x' } }, /not a string/], + ])('%s: no lossless rewrite — left as stored and reported as a TODO', (_label, properties, reason) => { + const page = pageWith({ type: 'action:button', properties }); + const { out, notices, todos } = replayStored(page); + expect(out).toEqual(page); + expect(notices).toEqual([]); + expect(todos).toHaveLength(1); + expect(todos[0]!.path).toBe('pages[0].regions[0].components[0].properties.endpoint'); + expect(todos[0]!.reason).toMatch(reason); + }); + + it('control: `endpoint` on another block type is neither converted nor reported', () => { + const page = pageWith({ type: 'element:text', properties: { endpoint: '/not/an/action' } }); + const { out, notices, todos } = replayStored(page); + expect(out).toEqual(page); + expect(notices).toEqual([]); + expect(todos).toEqual([]); + }); + + it('control: a canonical page comes back as the SAME reference, with no notice', () => { + const clean = { pages: [pageWith({ type: 'action:button', properties: { label: 'Run', actionType: 'api', target: '/api/v1/run' } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out, 'nothing to rename ⇒ copy-on-write returns the input').toBe(clean); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions( + { pages: [pageWith({ type: 'action:button', properties: { label: 'Go', actionType: 'api', endpoint: '/api/v1/go' } })] }, + { includeRetired: true }, + ); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('the authoring funnel does not replay it — an author meets the rows\' rename instead', () => { + const authored = { pages: [pageWith({ type: 'action:button', properties: { label: 'Go', actionType: 'api', endpoint: '/api/v1/go' } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(authored, { onNotice: (n) => notices.push(n) }); + expect(out).toBe(authored); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index ba4373a3dfc..29a347b3bb4 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10093,6 +10093,218 @@ const jobTimeoutToTimeoutMs: MetadataConversion = { }, }; +/** + * The two page blocks that hand their props to objectui's action runner as an + * action — the rows whose `endpoint` {@link actionBlockEndpointToTarget} + * rewrites. + */ +const ACTION_RUNNER_BLOCK_TYPES = new Set(['action:button', 'action:icon']); + +/** + * `action:button` / `action:icon` component prop `endpoint` → `target` + * (protocol 18, #21005). + * + * One concept — the endpoint an `api` action calls — with two verdicts in one + * release. `ActionSchema` has always refused `endpoint` with the rename + * "Did you mean `endpoint` → `target`?", while the two `ComponentPropsMap` + * rows declared it as a key of their own. The renderers forward it, and + * objectui's console registers its own `api` handler, which reads + * `action.target || action.name` and never `endpoint` — so an `api` button + * authored with `endpoint` passed the props gate and called nothing. The rows + * now refuse it with the same rename, read from the one table both files share + * (`ui/action-target-aliases.ts`); this entry carries the sources and stored + * rows that wrote it. + * + * **Scoped to `actionType: 'api'`, deliberately** — the one meaning the key + * declared ("API endpoint for an `api` action"). There the rename is lossless + * for every host: the console's handler reads `target`, and the runner's + * built-in `api` executor resolves `api || endpoint || target`. Anywhere else + * it is not, so the site is left as stored and reported as a TODO + * (`context.reportTodo`, ADR-0087 D3's model): + * + * - no `actionType` — the runner's legacy fallback dispatches an `endpoint` + * with no type as an API call, and a `target` with no type as nothing, so a + * bare rename would leave the button with no executor; + * - another `actionType` — that executor reads `target` as its URL, script or + * flow, and never read `endpoint`, so moving the value there would make a + * dead key decide what the action does; + * - a non-string `endpoint` — not the string the row declared, and `target` + * takes a string only. + * + * Precedence is {@link renameKey}'s house rule (#4923): an already-canonical + * `target` WINS — a redundant twin is dropped, a DISAGREEING pair is left for + * the author to reconcile (and reported as a TODO, since the strict row + * refuses `endpoint` there). The reach is every position `mapPageComponents` + * visits — regions, slots, nested containers — which is the props gate's own + * reach. A member of `action:group` / `action:menu` is an action object, not a + * page component, and is not this entry's surface. + * + * **Retired from the load path from day one** — the `field-reference-to-alias` + * shape: the authoring door keeps teaching with the rows' rename, and this + * entry serves the two paths that rewrite EXISTING data, stored-row + * rehydration and `os migrate meta`. Census at landing (objectstack + * `2821e9f15b`, objectui `5262f7dd`): zero producers author `endpoint` on + * either block. + */ +const actionBlockEndpointToTarget: MetadataConversion = { + id: 'action-block-endpoint-to-target', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.5.0', + surface: 'page.component.action:button.endpoint / page.component.action:icon.endpoint', + summary: + "action:button / action:icon component prop 'endpoint' → 'target' on an `api` action — the " + + "rename `ActionSchema` already prescribes; the console's `api` handler reads `target` only. " + + 'An `endpoint` on a block with no `actionType` or another one is left as stored and reported ' + + 'as a TODO', + apply(stack, emit, context) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string' || !ACTION_RUNNER_BLOCK_TYPES.has(type)) return component; + const properties = component.properties; + if (!isDict(properties) || properties.endpoint == null) return component; + const endpoint = properties.endpoint; + const at = `${path}.properties.endpoint`; + const todo = (reason: string): Dict => { + context?.reportTodo?.({ + path: at, + from: JSON.stringify(endpoint), + reason: `On ${describeBlock(component)}, ${reason}`, + }); + return component; + }; + if (typeof endpoint !== 'string') { + return todo('`endpoint` is not a string, so it is not the URL `target` takes. Rewrite it ' + + 'by hand as the `target` string the `api` action should call.'); + } + const actionType = properties.actionType; + if (actionType === undefined) { + return todo('there is no `actionType`, and the action runner calls an `endpoint` with no ' + + 'type through its legacy API fallback, while a `target` with no type runs nothing. ' + + "Set `actionType: 'api'` and rename `endpoint` to `target`."); + } + if (actionType !== 'api') { + return todo(`\`actionType\` is ${JSON.stringify(actionType)}, whose executor reads ` + + '`target` and never read `endpoint`. Delete `endpoint`, or move its value to `target` ' + + 'if that is what the action should run.'); + } + const renamed = renameKey(properties, 'endpoint', 'target'); + if (!renamed) { + return todo('`target` and `endpoint` name different endpoints; the console calls `target`. ' + + 'Keep the one the button should call, as `target`, and delete `endpoint`.'); + } + emit({ from: 'endpoint', to: 'target', path: `${path}.properties.target` }); + return { ...component, properties: renamed }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'ops_console', + regions: [ + { + name: 'main', + components: [ + // The measured defect: an `api` button written with `endpoint`. + { + type: 'action:button', + properties: { label: 'Sync now', actionType: 'api', endpoint: '/api/v1/ops/sync', method: 'POST' }, + }, + // Both spellings, SAME endpoint: the redundant twin goes (#4923). + { + type: 'action:icon', + properties: { icon: 'refresh-cw', actionType: 'api', target: '/api/v1/ops/refresh', endpoint: '/api/v1/ops/refresh' }, + }, + // Both spellings, DIFFERENT endpoints: kept for the author. + { + type: 'action:button', + properties: { label: 'Both', actionType: 'api', target: '/api/v1/a', endpoint: '/api/v1/b' }, + }, + // No `actionType`: no lossless rewrite (the runner's legacy fallback) — kept. + { type: 'action:button', properties: { label: 'Legacy', endpoint: '/api/v1/legacy' } }, + // Canonical already: untouched. + { type: 'action:icon', properties: { icon: 'play', actionType: 'api', target: '/api/v1/run' } }, + // `endpoint` on another block is not this entry's business. + { type: 'element:text', properties: { endpoint: '/not/an/action' } }, + // Nested one container down, where a toolbar button usually sits. + { + type: 'page:card', + properties: { + title: 'Integrations', + children: [ + { type: 'action:icon', properties: { icon: 'upload', actionType: 'api', endpoint: '/api/v1/ops/push' } }, + ], + }, + }, + ], + }, + ], + }, + // A slotted record page's named slot. + { + name: 'ops_job_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:button', properties: { label: 'Retry', actionType: 'api', endpoint: '/api/v1/ops/retry' } }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'ops_console', + regions: [ + { + name: 'main', + components: [ + { + type: 'action:button', + properties: { label: 'Sync now', actionType: 'api', target: '/api/v1/ops/sync', method: 'POST' }, + }, + { + type: 'action:icon', + properties: { icon: 'refresh-cw', actionType: 'api', target: '/api/v1/ops/refresh' }, + }, + { + type: 'action:button', + properties: { label: 'Both', actionType: 'api', target: '/api/v1/a', endpoint: '/api/v1/b' }, + }, + { type: 'action:button', properties: { label: 'Legacy', endpoint: '/api/v1/legacy' } }, + { type: 'action:icon', properties: { icon: 'play', actionType: 'api', target: '/api/v1/run' } }, + { type: 'element:text', properties: { endpoint: '/not/an/action' } }, + { + type: 'page:card', + properties: { + title: 'Integrations', + children: [ + { type: 'action:icon', properties: { icon: 'upload', actionType: 'api', target: '/api/v1/ops/push' } }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'ops_job_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:button', properties: { label: 'Retry', actionType: 'api', target: '/api/v1/ops/retry' } }, + }, + }, + ], + }, + // Sync now (rename), the redundant twin (dropped), the nested icon and the + // slotted header. The disagreeing pair and the type-less button are TODOs, + // which are not notices. + expectedNotices: 4, + }, +}; + /** * `apis[].cacheTtl` → `apis[].cacheTtlSeconds` (protocol 18, #15677 for #14478) * — the `api` half of the same rename `hookTimeoutToTimeoutMs` and @@ -13226,6 +13438,7 @@ function inApplicationOrder(entries: readonly OrderedConversion[]): readonly Met */ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: actionAriaRemoved, order: 43 }, + { conversion: actionBlockEndpointToTarget, order: 52 }, { conversion: apiEndpointCacheTtlToCacheTtlSeconds, order: 23 }, { conversion: chartConfigAriaRemoved, order: 32 }, { conversion: connectorConnectionTimeoutMsRemoved, order: 20 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.action-block-endpoint-spelling-retired.ts b/packages/spec/src/migrations/entries/semantic/18.action-block-endpoint-spelling-retired.ts new file mode 100644 index 00000000000..4e0713fdb16 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.action-block-endpoint-spelling-retired.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21005 — the D3 entry of the `action-block-endpoint-to-target` family (ruling +// B on #17152: one D3 entry per retirement family, even when D2 is lossless). +// The `action:button` / `action:icon` rows declared `endpoint` while +// `ActionSchema` refused it with the rename to `target`; the console's `api` +// handler reads `target` only. The rename is lossless on an `api` action and is +// declined, as a TODO, everywhere else — and it cannot reach code. +export const entry: SemanticMigration = { + id: 'action-block-endpoint-spelling-retired', + surface: 'page.component.action:button.endpoint / page.component.action:icon.endpoint — the ' + + 'endpoint an `api` action button calls, on the two page blocks that run an action', + replacement: '`target` — the one key the action runner dispatches an executor on, and the key ' + + '`ActionSchema` already renames `endpoint` to.', + reason: 'The D2 conversion `action-block-endpoint-to-target` renames `endpoint` to `target` in ' + + 'author sources and on every stored-row rehydration, for a block whose `actionType` is ' + + '`api` — the one meaning the key declared, and the rename is lossless there. Three things ' + + 'are left. A block that carries `endpoint` with no `actionType` was called through the ' + + 'action runner\'s legacy API fallback, which a `target` with no type does not reach, so the ' + + 'author has to add `actionType: \'api\'` as well. A block with another `actionType` never read ' + + '`endpoint`, so only the author can say whether its value should become the `target` or be ' + + 'deleted. And a block carrying both spellings with different values is left for the author ' + + 'to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom ' + + 'action handler that read `endpoint` off the action it was handed reads nothing once the ' + + 'block carries `target`.', + acceptanceCriteria: 'No `action:button` or `action:icon` block carries `endpoint` in source or ' + + 'at rest; each block that called an endpoint names it as `target` with `actionType: \'api\'`. ' + + 'Pressing such a button in the console sends one request to that endpoint, and `os validate` ' + + 'reports no `component-props-unknown-key` finding for `endpoint`. No custom action handler ' + + 'reads `endpoint` from an action dispatched by either block.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e186e8ef657..bdb50b3a7f1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5041,6 +5041,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'so authors are refused at parse; its D3 record is the semantic entry ' + '`action-aria-retired`.', }, + { + id: 'action-block-endpoint-spelling-retired', + order: 54, + text: + 'It also gives the executor target of an action one spelling on the page blocks that run ' + + 'one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the ' + + '`action:button` and `action:icon` component rows declared `endpoint` as a key of their own, ' + + 'and the console\'s `api` handler reads `target` only — so an `api` button authored with ' + + '`endpoint` was accepted by the props gate and called nothing. The rows now refuse it with ' + + 'the same rename, read from the one alias table both share. The D2 conversion ' + + '`action-block-endpoint-to-target` renames the key on an `api` action, where the rename is ' + + 'lossless, retired from the load path so authors are refused at the door while stored rows ' + + 'and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another ' + + 'one is left as stored and reported as a TODO. Its D3 record is the semantic entry ' + + '`action-block-endpoint-spelling-retired`.', + }, { id: 'admin-export-wildcard-removed', order: 4, @@ -6062,6 +6078,35 @@ const step18: MigrationStep = { + "component's or list view's `aria` block, or confirmed the existing label already says it. " + 'With a screen reader, focusing an icon-only action announces its label.', }, + // #21005 — the D3 entry of the `action-block-endpoint-to-target` family (ruling + // B on #17152: one D3 entry per retirement family, even when D2 is lossless). + // The `action:button` / `action:icon` rows declared `endpoint` while + // `ActionSchema` refused it with the rename to `target`; the console's `api` + // handler reads `target` only. The rename is lossless on an `api` action and is + // declined, as a TODO, everywhere else — and it cannot reach code. + { + id: 'action-block-endpoint-spelling-retired', + surface: 'page.component.action:button.endpoint / page.component.action:icon.endpoint — the ' + + 'endpoint an `api` action button calls, on the two page blocks that run an action', + replacement: '`target` — the one key the action runner dispatches an executor on, and the key ' + + '`ActionSchema` already renames `endpoint` to.', + reason: 'The D2 conversion `action-block-endpoint-to-target` renames `endpoint` to `target` in ' + + 'author sources and on every stored-row rehydration, for a block whose `actionType` is ' + + '`api` — the one meaning the key declared, and the rename is lossless there. Three things ' + + 'are left. A block that carries `endpoint` with no `actionType` was called through the ' + + 'action runner\'s legacy API fallback, which a `target` with no type does not reach, so the ' + + 'author has to add `actionType: \'api\'` as well. A block with another `actionType` never read ' + + '`endpoint`, so only the author can say whether its value should become the `target` or be ' + + 'deleted. And a block carrying both spellings with different values is left for the author ' + + 'to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom ' + + 'action handler that read `endpoint` off the action it was handed reads nothing once the ' + + 'block carries `target`.', + acceptanceCriteria: 'No `action:button` or `action:icon` block carries `endpoint` in source or ' + + 'at rest; each block that called an endpoint names it as `target` with `actionType: \'api\'`. ' + + 'Pressing such a button in the console sends one request to that endpoint, and `os validate` ' + + 'reports no `component-props-unknown-key` finding for `endpoint`. No custom action handler ' + + 'reads `endpoint` from an action dispatched by either block.', + }, { id: 'action-bulk-dispatch-contract-undeclared', surface: '`action.execution` — the bulk dispatch contract an action’s body is written for', diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts index be7eec60ad7..9004deffd45 100644 --- a/packages/spec/src/ui/component-action-element-rows-20371.test.ts +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -81,8 +81,11 @@ describe('key sets, asserted whole — measured from the renderers\' read points // (`execute({ ...forwarded })`). `undoable` / `recordIdField` are forwarded // by the button only. `objectName` joined the forward with the pin that // carries it; the four renderers forward it, the two containers per member. + // `endpoint` is forwarded too and deliberately NOT declared (#21005): no + // console `api` handler reads it, and the rows refuse it with + // `ActionSchema`'s rename onto `target` (`component-action-row-endpoint-21005.test.ts`). const FORWARDED = [ - 'params', 'description', 'target', 'openIn', 'endpoint', 'method', 'bodyExtra', 'bodyShape', + 'params', 'description', 'target', 'openIn', 'method', 'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', ]; diff --git a/packages/spec/src/ui/component-action-row-endpoint-21005.test.ts b/packages/spec/src/ui/component-action-row-endpoint-21005.test.ts new file mode 100644 index 00000000000..447a52334fd --- /dev/null +++ b/packages/spec/src/ui/component-action-row-endpoint-21005.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #21005 — `action:button` / `action:icon` refuse `endpoint` with the rename +// `ActionSchema` gives, read from ONE table (`action-target-aliases.ts`). +// +// Before: the two `ComponentPropsMap` rows declared `endpoint` while +// `ActionSchema` refused it with "Did you mean `endpoint` → `target`?" — one +// concept, two verdicts in one release — and the console's `api` handler reads +// `target` only, so an `api` button written with `endpoint` called nothing. +// What is pinned here is the refusal's ENVELOPE (`unrecognized_keys`, the key) +// and the prescription clause, compared between the row and the action rather +// than against a hand-copied string: the claim is that they are one message. + +import { describe, expect, it } from 'vitest'; +import { ActionSchema } from './action.zod'; +import { ACTION_TARGET_ALIASES } from './action-target-aliases'; +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; + +/** The one root-level `unrecognized_keys` issue of a failed parse. */ +const unknownKeyIssue = (result: { success: boolean; error?: { issues: unknown[] } }): Issue => { + expect(result.success).toBe(false); + const at = (result.error!.issues as Issue[]).filter((i) => i.code === 'unrecognized_keys' && i.path.length === 0); + expect(at).toHaveLength(1); + return at[0]!; +}; + +/** The rename clause of an unknown-key message — `Did you mean `a` → `b`?`. */ +const renameClause = (message: string): string | undefined => /Did you mean [^?]*\?/.exec(message)?.[0]; + +/** The prescription `ActionSchema` prints for `key` — the reference both rows must equal. */ +const actionClause = (key: string): string | undefined => + renameClause(unknownKeyIssue(ActionSchema.safeParse({ name: 'go', label: 'Go', type: 'api', [key]: '/api/v1/x' })).message); + +const ROWS = [ + ['action:button', ActionButtonPropsSchema, { label: 'Go' }], + ['action:icon', ActionIconPropsSchema, { icon: 'play' }], +] as const; + +describe('[#21005] the action rows refuse `endpoint` with ActionSchema\'s own rename', () => { + it.each(ROWS)('`%s` refuses `endpoint` — the same `endpoint` → `target` clause ActionSchema prints', (type, schema, rest) => { + const issue = unknownKeyIssue(schema.safeParse({ ...rest, actionType: 'api', endpoint: '/api/v1/x' })); + expect(issue.keys).toEqual(['endpoint']); + expect(issue.message).toContain(`\`${type}\``); + const clause = renameClause(issue.message); + expect(clause).toContain('`endpoint` → `target`'); + expect(clause).toBe(actionClause('endpoint')); + }); + + it.each(ROWS)('control: `%s` parses the same value written as `target`', (_type, schema, rest) => { + const authored = { ...rest, actionType: 'api', target: '/api/v1/x' }; + expect(schema.parse(authored)).toEqual(authored); + }); + + it('`endpoint` is no longer a declared key of either row (the props-map entries are the exported schemas)', () => { + for (const [type, schema] of ROWS) { + expect((ComponentPropsMap as Record)[type]).toBe(schema); + expect(Object.keys((schema as unknown as { shape: Record }).shape), type).not.toContain('endpoint'); + } + }); + + it('one table: every executor-target alias renames identically on the action and on both rows', () => { + // `path` is the one the rows used to get WRONG on their own: the + // edit-distance fallback answered `patch` — the declarative write's field + // values, `z.unknown()`, which parses. + expect(Object.keys(ACTION_TARGET_ALIASES).sort()).toEqual(['endpoint', 'href', 'path', 'url']); + for (const key of Object.keys(ACTION_TARGET_ALIASES)) { + const expected = actionClause(key); + expect(expected, key).toContain(`\`${key}\` → \`target\``); + for (const [type, schema, rest] of ROWS) { + const issue = unknownKeyIssue(schema.safeParse({ ...rest, [key]: '/api/v1/x' })); + expect(issue.keys, `${type}.${key}`).toEqual([key]); + expect(renameClause(issue.message), `${type}.${key}`).toBe(expected); + } + } + }); +}); From 240d38330548884066e31990e854c7f04715d0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:43:13 +0000 Subject: [PATCH 4/6] wip(docs): regenerate component reference for the action rows Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index c68178e7273..47e45b8bad6 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -62,7 +62,6 @@ const result = AIChatWindowProps.parse(data); | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | | **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | -| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner | | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | @@ -118,7 +117,6 @@ const result = AIChatWindowProps.parse(data); | **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values | | **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType` | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | -| **endpoint** | `string` | optional | API endpoint for an `api` action, forwarded to the runner | | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | From 04599f6fd58abd1613e8a2b945042bf9f0c86236 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 05:43:51 +0000 Subject: [PATCH 5/6] wip: changeset for the action-row endpoint refusal Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .../21005-action-row-endpoint-refused.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/21005-action-row-endpoint-refused.md diff --git a/.changeset/21005-action-row-endpoint-refused.md b/.changeset/21005-action-row-endpoint-refused.md new file mode 100644 index 00000000000..bbc5154a5db --- /dev/null +++ b/.changeset/21005-action-row-endpoint-refused.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `action:button` / `action:icon` refuse `endpoint` with the rename `ActionSchema` already prescribes — `endpoint` → `target` (#21005) + +**BREAKING** — `endpoint` on an `action:button` or `action:icon` component (`ActionButtonProps`, `ActionIconProps`) is no longer a declared key. `ActionSchema` has always refused `endpoint` with "Did you mean `endpoint` → `target`?", while these two rows accepted it. objectui's console registers its own `api` handler, which reads `target` and never `endpoint`, so an `api` button written with `endpoint` passed the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table the action and both rows share. Write the endpoint as `target`. + +Clause-②: yes (narrowing) + +## FROM → TO + +| you wrote (17.5 and earlier) | write instead | +| --- | --- | +| `{ type: 'action:button', properties: { actionType: 'api', endpoint: '/api/v1/x' } }` | `{ type: 'action:button', properties: { actionType: 'api', target: '/api/v1/x' } }` | +| `{ type: 'action:icon', properties: { actionType: 'api', endpoint: '/api/v1/x' } }` | `{ type: 'action:icon', properties: { actionType: 'api', target: '/api/v1/x' } }` | +| `endpoint` on a block with no `actionType` | add `actionType: 'api'` and rename `endpoint` to `target` | + +**The one-line fix:** rename `endpoint` to `target` in the block's `properties`; the value (the URL the `api` action calls) is unchanged. + +**What an author who still writes it sees.** A page is never refused for it: a page component's `properties` is an open bag, so `definePage()`, `defineStack({ pages })` and the page write door accept the page as before. `os validate` / `os build` / `os lint` report `component-props-unknown-key` as a warning at `properties.endpoint`, with the rename "Did you mean `endpoint` → `target`?" — the same clause `ActionSchema` prints. The two rows also stop answering `path` with the edit-distance guess `patch` (the declarative write's field values): `url`, `endpoint`, `path` and `href` all rename to `target`, on the action and on both blocks alike. A typed `ActionButtonProps` / `ActionIconProps` input fails `tsc` at `endpoint`. + +## The migration kit + +- **The D2 conversion `action-block-endpoint-to-target`** (protocol 18, retired from the load path) renames `endpoint` to `target` on an `action:button` / `action:icon` whose `actionType` is `api`, the one meaning the key declared, with one notice per block. It reaches blocks in regions, nested in a container's `children`, and in a slotted page's named slots, so a stored `page` row or a built artifact that carries the key loads with `target` through the rehydration seams, which replay it. An already-present `target` wins: a twin with the same value is dropped. A block with no `actionType`, another `actionType`, a non-string `endpoint`, or a `target` that names a different endpoint is left as stored and reported as a TODO. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. +- **The D3 entry `action-block-endpoint-spelling-retired`** names what the rename cannot decide: the TODO sites above, and code — a custom action handler that read `endpoint` off the action reads nothing once the block carries `target`. +- **No deprecation window**, per the project's startup-stage posture. + +Census at landing: no producer in this repository (examples, templates, platform pages, fixtures) or in objectui's examples authors `endpoint` on either block. ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + From dcaabdbbb6cb0348653b5f2273b8dedfaf87263e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:41:34 +0000 Subject: [PATCH 6/6] fix(spec): step-18 rationale order for the action-block endpoint fragment follows main's newest Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a5fbccfd005..7495ea54d8b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5043,7 +5043,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ }, { id: 'action-block-endpoint-spelling-retired', - order: 54, + order: 55, text: 'It also gives the executor target of an action one spelling on the page blocks that run ' + 'one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the '