From 0c6f39767c2c2140d58c3f2e0bf21117c17014e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:02:09 +0000 Subject: [PATCH 1/4] fix(spec)!: action:icon and action:group/menu members refuse confirmText beside a non-empty array params The fourth and fifth doors of the confirmText + params pair refusal, with action:button's predicate and message (declaresConfirmBesideParams, CONFIRM_BESIDE_PARAMS_REFUSAL). Both blocks forward confirmText and an array params (as actionParams) to the same runner, which opens the confirm and then the param dialog. Still accepted: no params, params: [], and an object params (an icon's static values; an api member's payload). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- ...n-members-confirm-beside-params-refused.md | 22 ++++ .../ui/action-confirm-params-guard.test.ts | 122 +++++++++++++++++- ...ort-items-action-members-typed.pin.test.ts | 9 ++ packages/spec/src/ui/component.zod.ts | 35 ++++- 4 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 .changeset/30017-action-icon-members-confirm-beside-params-refused.md diff --git a/.changeset/30017-action-icon-members-confirm-beside-params-refused.md b/.changeset/30017-action-icon-members-confirm-beside-params-refused.md new file mode 100644 index 00000000000..0711d618a8c --- /dev/null +++ b/.changeset/30017-action-icon-members-confirm-beside-params-refused.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: a page `action:icon` (`ActionIconPropsSchema`) and each `action:group` / `action:menu` member refuse `confirmText` beside a non-empty array `params`, as `action:button`, a registered action (`ActionSchema`) and an inline action (`InlineActionSchema`) already do, instead of accepting a pair that opens two dialogs for one decision + +The `action:icon` block forwards its row's `confirmText` and an array `params` (as `actionParams`) to the console's action runner, and both containers do the same for each member. The runner awaits the confirm dialog and only then opens the param dialog, so the user saw a confirm that already read as "the action ran" while nothing had been sent, then a second dialog. The other three action doors refused that pair; these two accepted it. They now refuse it with the same predicate and the same message, at the `confirmText` path: `confirmText` on the icon's properties, `actions.N.confirmText` on a container member. + +Clause-②: no (narrowing) — `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows refuse `confirmText` beside a non-empty array `params`, which they accepted before. Nothing is widened. + +Metadata-protocol: narrowed (a page `action:icon`, and an `action:group` or `action:menu` member, may no longer declare `confirmText` beside a non-empty array `params`) + + + +**BREAKING** accept-set narrowing at `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows (`@objectstack/spec`), served where a page's component `properties` are judged: the component-props gate (`component-props-invalid`, `@objectstack/lint`) at every CLI command (`os validate`, `os compile`, the publish gate) and at the runtime `page` save door. `defineStack` does not parse a component's `properties` bag, so it does not refuse the pair. + +- **Before.** `{ type: 'action:menu', properties: { actions: [{ name: 'reject', label: 'Reject', type: 'api', target: '/api/v1/approvals/reject', confirmText: 'Reject this request?', params: [{ name: 'reason', type: 'textarea' }] }] } }` parsed clean, and the console showed a confirm dialog and then the param dialog. The same held for an `action:icon` with both keys. +- **Now.** The same `properties` are refused at `actions.0.confirmText` (at `confirmText` on an `action:icon`) with the action doors' message: the pair shows TWO dialogs for one decision; carry the question in `description` and drop `confirmText`. +- **Unchanged.** `confirmText` with no `params`, beside `params: []`, or beside an object `params` (an icon's static parameter values; a `type: 'api'` member's request payload — neither opens a dialog) parses as before; so does an array `params` with `description`. +- **Who sees it.** An author whose page declares an `action:icon`, or an `action:group` / `action:menu` member, with both keys. A stored page keeps loading and serving; its next save is refused, with the remedy in the finding. + +**The remedy.** Move the confirm sentence to the action's `description`, which the param dialog draws under its title, and drop `confirmText`. On an `action:icon` with no `label`, `description` is also the tooltip text. diff --git a/packages/spec/src/ui/action-confirm-params-guard.test.ts b/packages/spec/src/ui/action-confirm-params-guard.test.ts index a82699adc1e..04a66dd4f3f 100644 --- a/packages/spec/src/ui/action-confirm-params-guard.test.ts +++ b/packages/spec/src/ui/action-confirm-params-guard.test.ts @@ -23,8 +23,10 @@ * two dialogs open, and its pick carries the `description` remedy. So does the * page `action:button` row, `ActionButtonPropsSchema` (#29844, ruling record * 6597621758 letter A), for an ARRAY `params` only: an object `params` there is - * the static values and opens no dialog. The three doors share one predicate - * and one message (`./action-confirm-beside-params.ts`), pinned equal below. + * the static values and opens no dialog. So do `action:icon` and the + * `action:group` / `action:menu` members (#30017, ruling record 6624208841 + * letter A), on the same terms. The doors share one predicate and one message + * (`./action-confirm-beside-params.ts`), pinned equal below. * * The rejection tests assert the issue PATH and the MESSAGE SUBSTANCE rather * than a bare `success === false`. One condition, one wording: a refusal whose @@ -35,7 +37,12 @@ import { describe, expect, it } from 'vitest'; import { ActionSchema, InlineActionSchema, defineAction } from './action.zod'; import { BulkActionDefSchema } from './bulk-action.zod'; -import { ActionButtonPropsSchema } from './component.zod'; +import { + ActionButtonPropsSchema, + ActionGroupPropsSchema, + ActionIconPropsSchema, + ActionMenuPropsSchema, +} from './component.zod'; /** * Minimum legal registered action. `type` defaults to `script`, whose own @@ -378,3 +385,112 @@ describe('`confirmText` + non-empty ARRAY `params` is refused on the `action:but expect(result.success, parseErrorOf(result)).toBe(true); }); }); + +describe('`confirmText` + non-empty ARRAY `params` is refused on `action:icon` and the `action:group` / `action:menu` members too', () => { + // #30017 (ruling record 6624208841, letter A). `action:icon` forwards its + // row's `confirmText` and an array `params` (as `actionParams`) to the same + // `ActionRunner`, and so do both containers for each member: the same two + // dialogs. Same predicate, same refusal: asserted against `action:button`'s + // own issue, so a wording drifting onto one door goes red. An object `params` + // (the icon's static values; a `type: 'api'` member's request payload) opens + // no dialog, so it keeps `confirmText`. + const pairParams = [{ name: 'reason', label: 'Reason', type: 'textarea' }]; + const iconReject = { + label: 'Reject', + icon: 'x', + actionType: 'api', + target: '/api/v1/approvals/reject', + params: pairParams, + } as const; + const memberReject = { + name: 'reject', + label: 'Reject', + type: 'api', + target: '/api/v1/approvals/reject', + params: pairParams, + } as const; + const asContainer = (member: Record) => ({ + actions: [{ name: 'approve', label: 'Approve', type: 'script', target: 'approve' }, member], + }); + const CONTAINERS = [ + ['action:group', ActionGroupPropsSchema], + ['action:menu', ActionMenuPropsSchema], + ] as const; + + const parseErrorOf = (result: { success: boolean; error?: unknown }) => JSON.stringify(result.error); + /** `action:button`'s issue for the pair: the message every other door must equal. */ + const buttonMessage = (): string | undefined => { + const r = ActionButtonPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' }); + return r.success ? undefined : r.error.issues.find((i) => i.path.join('.') === 'confirmText')?.message; + }; + + it('`action:icon` refuses the pair at the `confirmText` path, with `action:button`\'s message', () => { + const result = ActionIconPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' }); + + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('custom'); + expect(issues[0]?.path).toEqual(['confirmText']); + expect(buttonMessage()).toBeDefined(); + expect(issues[0]?.message).toBe(buttonMessage()); + // The prescription: the remedy key, and the param-less case that keeps `confirmText` live. + expect(issues[0]?.message).toContain('TWO dialogs'); + expect(issues[0]?.message).toContain('`description`'); + expect(issues[0]?.message).toContain('param-LESS'); + }); + + for (const [row, schema] of CONTAINERS) { + it(`an \`${row}\` member refuses the pair at \`actions.N.confirmText\`, with \`action:button\`'s message`, () => { + const result = schema.safeParse(asContainer({ ...memberReject, confirmText: 'Reject this request?' })); + + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('custom'); + // The member's own index, so the editor underlines the offending member, not the list. + expect(issues[0]?.path).toEqual(['actions', 1, 'confirmText']); + expect(issues[0]?.message).toBe(buttonMessage()); + expect(issues[0]?.message).toContain('TWO dialogs'); + expect(issues[0]?.message).toContain('`description`'); + }); + + it(`an \`${row}\` member refuses the localized-map form of \`confirmText\` too`, () => { + const result = schema.safeParse(asContainer({ + ...memberReject, + confirmText: { en: 'Reject this request?', 'zh-CN': '拒绝该请求?' }, + })); + + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((i) => i.path.join('.'))).toEqual(['actions.1.confirmText']); + }); + } + + it('`action:icon` accepts `confirmText` with no `params`, beside `params: []`, and beside an OBJECT `params`', () => { + const { params: _params, ...paramless } = iconReject; + for (const props of [ + { ...paramless, confirmText: 'Reject this request?' }, + { ...iconReject, params: [], confirmText: 'Reject this request?' }, + { ...iconReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' }, + { ...iconReject, description: 'Reject this request? Say why — the requester sees it.' }, + ]) { + const result = ActionIconPropsSchema.safeParse(props); + expect(result.success, parseErrorOf(result)).toBe(true); + } + }); + + for (const [row, schema] of CONTAINERS) { + it(`an \`${row}\` member accepts \`confirmText\` with no \`params\`, beside \`params: []\`, and beside a \`type: 'api'\` member's OBJECT \`params\``, () => { + const { params: _params, ...paramless } = memberReject; + for (const member of [ + { ...paramless, confirmText: 'Reject this request?' }, + { ...memberReject, params: [], confirmText: 'Reject this request?' }, + { ...memberReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' }, + { ...memberReject, description: 'Reject this request? Say why — the requester sees it.' }, + ]) { + const result = schema.safeParse(asContainer(member)); + expect(result.success, parseErrorOf(result)).toBe(true); + } + }); + } +}); diff --git a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts index f71df3665fc..34af888c4a7 100644 --- a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts +++ b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts @@ -221,6 +221,15 @@ describe('§1 each member accepts every shape a measured writer authors', () => successMessage: { dialect: 'template', source: 'Closed' }, errorMessage: { dialect: 'template', source: 'Could not close' }, refreshAfter: true, locations: ['record_header'], objectName: 'case', + }], + }], + // #30017: an array `params` beside `confirmText` is refused (two dialogs for + // one decision), so the param-collecting member carries its question on + // `description` instead. + ['a member collecting params', 'action:menu', { + actions: [{ + name: 'close_case', label: 'Close', type: 'api', target: '/api/close', + description: 'Close this case? Say why.', params: [{ name: 'reason', label: 'Reason', type: 'text' }], }], }], diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index ac6a3197004..3eec6db7ac2 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -4163,7 +4163,8 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location'), patch: rowPatchRetired("An `action:icon`'s"), - confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText), + confirmText: actionRowCopy('confirmText').optional() + .describe(`${ACTION_ROW_COPY_DESCRIBE.confirmText} Beside a non-empty array \`params\` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on \`description\` instead. Correct with no \`params\`, beside \`params: []\`, and beside an object \`params\` (static values, no param dialog)`), successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage), errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage), refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), @@ -4178,7 +4179,14 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ }).superRefine(rowDeclarativeUpdate('actionType', "An `action:icon`'s")) .superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s")) .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:icon`'s `actionType: 'navigation'`")) - .superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:icon`'s"))); + .superRefine(refuseRowOnSuccessBesideExecutor('actionType', "An `action:icon`'s")) + // #30017 (ruling record 6624208841, letter A): the fourth door of the + // `confirmText` + non-empty `params` refusal, `action:button`'s predicate and + // message. This block forwards `confirmText` and an ARRAY `params` (as + // `actionParams`) to the same runner, which awaits the confirm and then opens + // the param dialog. An object `params` is the static values, which open no + // dialog, and the predicate's `Array.isArray` read leaves it alone. + .refine((data) => !declaresConfirmBesideParams(data), CONFIRM_BESIDE_PARAMS_REFUSAL)); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionIconProps = z.input; /** @@ -4313,7 +4321,8 @@ function actionContainerMemberShape() { bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location'), patch: rowPatchRetired('An `action:group` or `action:menu` member\'s'), - confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText), + confirmText: actionRowCopy('confirmText').optional() + .describe(`${ACTION_ROW_COPY_DESCRIBE.confirmText} Beside a non-empty array \`params\` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on \`description\` instead. Correct with no \`params\`, beside \`params: []\`, and beside a \`type: 'api'\` member's object \`params\` (its request payload, no param dialog)`), successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage), errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage), refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), @@ -4413,7 +4422,15 @@ function buildActionGroupMember() { .superRefine(actionContainerMemberParamsFitType('action:group')) .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's")) .superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`")) - .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:group` member's")); + .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:group` member's")) + // #30017 (ruling record 6624208841, letter A): `action:button`'s + // `confirmText` + non-empty ARRAY `params` refusal, its predicate and its + // message. The container forwards a member's `confirmText` and an array + // `params` (as `actionParams`) to the same runner, which awaits the confirm + // and then opens the param dialog. A `type: 'api'` member's object `params` + // is its request payload, which opens no dialog. On the member, so the + // issue lands at `actions.N.confirmText`. + .refine((member) => !declaresConfirmBesideParams(member), CONFIRM_BESIDE_PARAMS_REFUSAL); } let actionGroupMemberOnce: ReturnType | undefined; /** The one {@link buildActionGroupMember} instance. */ @@ -4436,7 +4453,15 @@ function buildActionMenuMember() { .superRefine(actionContainerMemberParamsFitType('action:menu')) .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's")) .superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`")) - .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:menu` member's")); + .superRefine(refuseRowOnSuccessBesideExecutor('type', "An `action:menu` member's")) + // #30017 (ruling record 6624208841, letter A): `action:button`'s + // `confirmText` + non-empty ARRAY `params` refusal, its predicate and its + // message. The container forwards a member's `confirmText` and an array + // `params` (as `actionParams`) to the same runner, which awaits the confirm + // and then opens the param dialog. A `type: 'api'` member's object `params` + // is its request payload, which opens no dialog. On the member, so the + // issue lands at `actions.N.confirmText`. + .refine((member) => !declaresConfirmBesideParams(member), CONFIRM_BESIDE_PARAMS_REFUSAL); } let actionMenuMemberOnce: ReturnType | undefined; /** The one {@link buildActionMenuMember} instance. */ From 73fb6b963e5377ff50ef0ed0f75a77832dce3afe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:07:01 +0000 Subject: [PATCH 2/4] chore(spec): regenerate the component reference for the confirmText describe lines Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- content/docs/references/ui/component.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7a85a1df5dc..6ea2036078c 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -103,7 +103,7 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. Beside a non-empty array `params` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on `description` instead. Correct with no `params`, beside `params: []`, and beside a `type: 'api'` member's object `params` (its request payload, no param dialog) | | **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | @@ -139,7 +139,7 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:icon`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. Beside a non-empty array `params` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on `description` instead. Correct with no `params`, beside `params: []`, and beside an object `params` (static values, no param dialog) | | **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | @@ -193,7 +193,7 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | -| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. Beside a non-empty array `params` it is refused — the runner would show the confirm and then the param dialog, two dialogs for one decision; put the question on `description` instead. Correct with no `params`, beside `params: []`, and beside a `type: 'api'` member's object `params` (its request payload, no param dialog) | | **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | From 34eb15a15d1adb8e83bb4247b2ed06a8b74cf988 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:08:03 +0000 Subject: [PATCH 3/4] test(spec): pin the container members' refusal on the template-envelope confirmText form Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- .../spec/src/ui/action-confirm-params-guard.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/ui/action-confirm-params-guard.test.ts b/packages/spec/src/ui/action-confirm-params-guard.test.ts index 04a66dd4f3f..52bb7673bd7 100644 --- a/packages/spec/src/ui/action-confirm-params-guard.test.ts +++ b/packages/spec/src/ui/action-confirm-params-guard.test.ts @@ -455,14 +455,18 @@ describe('`confirmText` + non-empty ARRAY `params` is refused on `action:icon` a expect(issues[0]?.message).toContain('`description`'); }); - it(`an \`${row}\` member refuses the localized-map form of \`confirmText\` too`, () => { + it(`an \`${row}\` member refuses the template-envelope form of \`confirmText\` too`, () => { + // A row's `confirmText` is a template slot: the bare string and the + // `{ dialect: 'template', source }` envelope are its two spellings (a + // localized map is refused by the slot itself, for its own reason). const result = schema.safeParse(asContainer({ ...memberReject, - confirmText: { en: 'Reject this request?', 'zh-CN': '拒绝该请求?' }, + confirmText: { dialect: 'template', source: 'Reject this request?' }, })); expect(result.success).toBe(false); - expect(result.success ? [] : result.error.issues.map((i) => i.path.join('.'))).toEqual(['actions.1.confirmText']); + expect(result.success ? [] : result.error.issues.map((i) => [i.code, i.path.join('.')])) + .toEqual([['custom', 'actions.1.confirmText']]); }); } From 9d69f747cd545a5e11690329ea16436a77d8a49a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 18:46:06 +0000 Subject: [PATCH 4/4] fix(spec): register the page action rows' confirmText-beside-params refusal as a step-18 D3 entry One SemanticMigration naming all four page action rows (action:button, action:icon, each action:group / action:menu member); the changeset's ADR-0087 marker becomes registered. The shared predicate module's docblock now counts the five doors that read it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- ...n-members-confirm-beside-params-refused.md | 4 +-- ...-row-confirm-text-beside-params-refused.ts | 34 +++++++++++++++++++ .../src/ui/action-confirm-beside-params.ts | 21 +++++++----- 3 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-confirm-text-beside-params-refused.ts diff --git a/.changeset/30017-action-icon-members-confirm-beside-params-refused.md b/.changeset/30017-action-icon-members-confirm-beside-params-refused.md index 0711d618a8c..564d2095e97 100644 --- a/.changeset/30017-action-icon-members-confirm-beside-params-refused.md +++ b/.changeset/30017-action-icon-members-confirm-beside-params-refused.md @@ -10,7 +10,7 @@ Clause-②: no (narrowing) — `ActionIconPropsSchema` and the `action:group` / Metadata-protocol: narrowed (a page `action:icon`, and an `action:group` or `action:menu` member, may no longer declare `confirmText` beside a non-empty array `params`) - + **BREAKING** accept-set narrowing at `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows (`@objectstack/spec`), served where a page's component `properties` are judged: the component-props gate (`component-props-invalid`, `@objectstack/lint`) at every CLI command (`os validate`, `os compile`, the publish gate) and at the runtime `page` save door. `defineStack` does not parse a component's `properties` bag, so it does not refuse the pair. @@ -19,4 +19,4 @@ Metadata-protocol: narrowed (a page `action:icon`, and an `action:group` or `act - **Unchanged.** `confirmText` with no `params`, beside `params: []`, or beside an object `params` (an icon's static parameter values; a `type: 'api'` member's request payload — neither opens a dialog) parses as before; so does an array `params` with `description`. - **Who sees it.** An author whose page declares an `action:icon`, or an `action:group` / `action:menu` member, with both keys. A stored page keeps loading and serving; its next save is refused, with the remedy in the finding. -**The remedy.** Move the confirm sentence to the action's `description`, which the param dialog draws under its title, and drop `confirmText`. On an `action:icon` with no `label`, `description` is also the tooltip text. +**The one-line fix: move the confirm sentence to the action's `description`, which the param dialog draws under its title, and drop `confirmText`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-confirm-text-beside-params-refused`, which names all four page action rows (`action:button`, `action:icon`, and each `action:group` / `action:menu` member) — the `action:button` refusal shipped earlier in 18.x is covered by the same entry. The rewrite is by hand, because whether the sentence reads as the dialog's description line is the author's call. On an `action:icon` with no `label`, `description` is also the tooltip text; give the icon a `label` to keep them apart. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-confirm-text-beside-params-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-confirm-text-beside-params-refused.ts new file mode 100644 index 00000000000..56a1ce26ad6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-confirm-text-beside-params-refused.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-row-confirm-text-beside-params-refused', + surface: 'the `confirmText` of a page action row beside a non-empty array `params`: `properties.confirmText` ' + + 'beside a non-empty array `properties.params` on an `action:button` or `action:icon`, and `confirmText` ' + + 'beside a non-empty array `params` on each `action:group` / `action:menu` member of `properties.actions`. ' + + 'Not `params: []`, not an object `params` (an `action:button` / `action:icon` row\'s static parameter ' + + 'values, a `type: \'api\'` member\'s request payload), and not a row with no `params`', + replacement: 'the confirm question on the row\'s `description`, which the param dialog draws under its title, ' + + 'and no `confirmText`: one dialog that asks the question and collects the inputs. A row that collects ' + + 'nothing keeps its `confirmText` and drops the `params`. ⛔ NOT mechanically convertible: whether the ' + + 'confirm sentence reads as the param dialog\'s description line, or should be reworded, is the ' + + 'author\'s call.', + reason: + 'The console\'s action runner awaits the confirm dialog and only then opens the param dialog for an array ' + + '`params`, and all four rows forward both keys to it (an array `params` as `actionParams`), so a row ' + + 'carrying both showed the user TWO dialogs for one decision: a confirm that already read as "the action ' + + 'ran" while nothing had been sent, then the param prompt. `ActionSchema` and `InlineActionSchema` already ' + + 'refuse the pair; the four page rows now carry that rule with the same predicate and the same refusal ' + + 'text. On an `action:icon` with no `label`, `description` is also the tooltip text, so moving the ' + + 'question there changes the hover text too; give the icon a `label` to keep them apart. A page\'s ' + + '`properties` is not parsed on the metadata load path, so a stored page keeps loading; `os validate`\'s ' + + 'props gate, which the metadata save door also runs on the next save of the page, parses it against ' + + 'these same rows and reports the refusal at the row\'s `confirmText`. Measured producers: none shipped ' + + 'in this repository; the one carrier was a spec test fixture, respelled.', + acceptanceCriteria: + 'No `action:button` / `action:icon` `properties` and no `action:group` / `action:menu` member declares ' + + '`confirmText` beside a non-empty array `params`; `os validate` reports no `component-props-invalid` ' + + 'finding at `properties.confirmText` or `properties.actions.N.confirmText` for the pair, and every row ' + + 'with no `params`, `params: []` or an object `params` parses as before.', +}; diff --git a/packages/spec/src/ui/action-confirm-beside-params.ts b/packages/spec/src/ui/action-confirm-beside-params.ts index 44732edf784..d0087ffbfb5 100644 --- a/packages/spec/src/ui/action-confirm-beside-params.ts +++ b/packages/spec/src/ui/action-confirm-beside-params.ts @@ -2,23 +2,28 @@ /** * `confirmText` beside a non-empty `params`: TWO sequential dialogs for ONE - * decision. ONE predicate and ONE refusal, read by the three doors that refuse + * decision. ONE predicate and ONE refusal, read by the five doors that refuse * the pair: `ActionSchema` and `InlineActionSchema` (`action.zod.ts`), and the - * page `action:button` row, `ActionButtonPropsSchema` (`component.zod.ts`). - * Pinned in `action-confirm-params-guard.test.ts`, which asserts the three + * page action rows in `component.zod.ts` — `action:button` + * (`ActionButtonPropsSchema`), `action:icon` (`ActionIconPropsSchema`) and each + * `action:group` / `action:menu` member. Pinned in + * `action-confirm-params-guard.test.ts`, which asserts the five doors' * messages equal. * * The console action runner awaits the confirm, THEN the param prompt (objectui * `packages/core/src/actions/ActionRunner.ts`), so the first dialog already * reads as "the action ran" while nothing has been sent. The `element:button` * renderer hands an inline action to that same runner, and the `action:button` - * block forwards its row's `confirmText` and an array `params` (as - * `actionParams`) to it too, so the pair opens the same two dialogs on all - * three doors. + * and `action:icon` blocks forward their row's `confirmText` and an array + * `params` (as `actionParams`) to it too, as the `action:group` / + * `action:menu` containers do for each member, so the pair opens the same two + * dialogs on all five doors. * * An empty `params` collects nothing and opens no second dialog, so it is not - * the pair. Nor is an object-form `params`: on an `action:button` row an object - * is the static parameter values, which open no dialog, and the + * the pair. Nor is an object-form `params`: on an `action:button` / + * `action:icon` row an object is the static parameter values, and on a + * `type: 'api'` container member the request payload; neither opens a + * dialog, and the * `Array.isArray` read below leaves it alone. * * Lives in its own module, not in `action.zod.ts`, so `component.zod.ts` can