Skip to content

Commit 333a374

Browse files
os-zhuangclaude
andauthored
feat(spec): refuse the confirmText + params pair on ActionSchema, scoped by schema boundary (#7972)
* feat(spec): refuse `confirmText` + non-empty `params` on ActionSchema An action declaring both opens TWO sequential dialogs for one decision — the console runner awaits the confirm, then the param prompt, so the first already reads as "the action ran" while nothing has been sent. #7278 and #7309 repaired the 16 shipped sites (PRs #7592, #7827); neither stops the next one being written. This is the structural half: a refusal on ActionSchema whose message names both keys and points the confirm question at the action's top-level `description`. Scoped by schema boundary, not by a param-optionality heuristic. The pair is INTENDED on `BulkActionDefSchema`, where params and confirm render one dialog (a `required` param blocks that dialog's own Confirm button), so the four `examples/app-showcase` bulk defs are correct as written. The refusal lives on ActionSchema's refine chain and is structurally incapable of reaching either `BulkActionDefSchema` or `InlineActionSchema`; both directions are pinned. * docs(spec): declare the confirmText/params refusal on the key, + changeset The `confirmText` describe() now names the refusal, so an author meets the constraint in the generated reference rather than only in the error. Worded "on a registered action" deliberately: the same field factory renders the InlineAction reference table, where the guard does not apply — an unqualified claim there would be a documented refusal that does not exist. Regenerates content/docs/references/ui/action.mdx (gen:docs). * docs(objectui): two action examples taught metadata the spec now refuses `content/docs/protocol/objectui/actions.mdx` showed `disable_oauth_application` and `rotate_client_secret` pairing `confirmText` with `params`. Both were already stale — the real actions moved the question to `description` in #7827 — and the guard in this PR makes the documented shape fail to parse. They are labelled "Real-World Examples", so they now mirror what the platform objects actually ship, and the `confirmText` bullet above them states the constraint: param-LESS actions only, question on `description` otherwise, not `ai.description`, and bulk defs unaffected. Found via the PR's docs-drift list; the brace-balanced census could not see these because the examples are YAML. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 606d577 commit 333a374

5 files changed

Lines changed: 356 additions & 6 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): an action may no longer pair `confirmText` with a non-empty `params` (#7428)
6+
7+
**Acceptance narrowing — this refuses metadata that parsed before.** An action
8+
declaring `confirmText` beside a non-empty `params` array shows the user **two
9+
sequential dialogs for one decision**: the console action runner awaits the
10+
confirm, *then* the param prompt, so the first dialog already reads as "the
11+
action ran" while nothing has been sent yet.
12+
13+
The maintainer's 2026-08-10 ruling on #7278 settled the shape: carry the confirm
14+
question in the action's top-level `description` — which the param dialog renders
15+
under its title — and drop `confirmText`. One condition, one wording, one dialog.
16+
17+
Two PRs repaired the sites that shipped this (#7592 for `plugin-approvals`,
18+
#7827 for the fourteen in `platform-objects`). Repairing instances does not stop
19+
the next one being written, which is what this refusal is for. It ships as a
20+
**refusal rather than a warning** because the in-repo `ActionSchema` census is
21+
now **0** — nothing legal breaks — and a warning that fires on every build of an
22+
untouched project is a check nobody reads.
23+
24+
**Migrating.** Move the sentence, do not delete it:
25+
26+
```diff
27+
defineAction({
28+
name: 'ban_user',
29+
label: 'Ban User',
30+
- confirmText: 'Ban this user? They will be signed out until unbanned.',
31+
+ description: 'Ban this user? They will be signed out until unbanned.',
32+
params: [{ name: 'reason', label: 'Reason', type: 'textarea' }],
33+
})
34+
```
35+
36+
Not `ai.description` — that is the LLM-facing tool contract (≥40 chars, required
37+
when `ai.exposed`), and putting the question there arms a tool description while
38+
the dialog falls back to its generic line.
39+
40+
**What is deliberately NOT refused:**
41+
42+
- **`confirmText` on a param-LESS action** stays correct and untouched — there is
43+
no second dialog to fold the question into, and stripping it would delete the
44+
only warning the user ever sees.
45+
- **`confirmText` beside an empty `params: []`** — nothing is collected, so no
46+
second dialog opens.
47+
- **A view's `bulkActionDefs`.** `BulkActionDefSchema` is a separate schema on
48+
which the pair is *intended*: its params are inputs collected once before the
49+
run, `confirmText` sits above the affected-record summary, and a `required`
50+
param blocks that same dialog's Confirm button — one dialog, so there is
51+
nothing to collapse. The guard lives on `ActionSchema`'s refinement chain and
52+
is structurally incapable of reaching it; a pinning test asserts the bulk
53+
pairing still parses, so a future widening of the guard goes red rather than
54+
landing on correct declarations.
55+
- **Requiring `description` whenever `params` is present.** Forbidding the pair
56+
is the narrowest guard with measured pull behind it; the wider demand has no
57+
measured failure behind it and would be its own decision.
58+
59+
`InlineActionSchema` is likewise unaffected — it picks fields from the shared
60+
factory rather than deriving from this refinement chain, and it does not pick
61+
`description`, so the remedy has no slot on that surface yet.

content/docs/protocol/objectui/actions.mdx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ successMessage: Customer deleted.
376376
refreshAfter: true
377377
```
378378

379-
- `confirmText` — message shown in a confirm dialog before the action runs.
379+
- `confirmText` — message shown in a confirm dialog before the action runs. **Param-LESS actions only.** On a registered action, pairing it with a non-empty `params` is *refused* at authoring time (#7428): the runner chains confirmation *then* param collection, so the pair opens two dialogs for one decision and the first already reads as "the action ran" while nothing has been sent. When the action collects params, put the question on `description` instead — the param dialog renders it under its title, so the user gets one dialog with the question intact. Not `ai.description`, which is the LLM-facing tool contract. (A view's `bulkActionDefs` are a different surface, where the pair renders a single dialog and stays correct.)
380380
- `successMessage` — toast shown after a successful run. When omitted the UI shows a generic "Action completed" toast, so set this for any action whose outcome isn't self-evident.
381381
- `errorMessage` — toast shown when the action fails; overrides the raw server error with author-controlled copy.
382382
- `undoable` — marks a single-record update action as offering an **Undo** affordance in the success toast, which restores the record's prior field values. The action runtime snapshots the record before the update and only builds the undo operation when this flag is set, so an action that omits it gets no Undo. Single-record updates only: there is nothing to snapshot when the action isn't scoped to one record.
@@ -480,7 +480,10 @@ ai:
480480
type: api
481481
method: POST
482482
target: /api/v1/auth/admin/oauth2/toggle-disabled
483-
confirmText: Disable this OAuth application? Existing integrations will stop working immediately.
483+
# The confirm question rides `description`, not `confirmText`: this action
484+
# collects params, and pairing the two keys is refused (#7428) because it
485+
# would open two dialogs for one decision.
486+
description: Disable this OAuth application? Existing integrations will stop working immediately.
484487
params:
485488
- { name: client_id, field: client_id, defaultFromRow: true, required: true }
486489
```
@@ -497,7 +500,11 @@ ai:
497500
type: api
498501
method: POST
499502
target: /api/v1/auth/sys-oauth-application/rotate-secret
500-
confirmText: Rotate this application's client secret? The current secret stops working immediately.
503+
# Three dialogs collapse to two, and the survivors are the two the user needs:
504+
# ONE param dialog (question + `client_id`), then the post-run `resultDialog`
505+
# that reveals the new secret. A post-run reveal is not a second pre-run
506+
# decision, so it is not part of the pair `description` replaces (#7428).
507+
description: Rotate this application's client secret? The current secret stops working immediately, and the new one is shown only once.
501508
params:
502509
- { name: client_id, field: client_id, defaultFromRow: true, required: true }
503510
resultDialog:

content/docs/references/ui/action.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const result = ActionSchema.parse(data);
7575
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
7676
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
7777
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
78-
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution |
78+
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
7979
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
8080
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
8181
| **refreshAfter** | `boolean` | optional | Refresh view after execution |
@@ -245,7 +245,7 @@ const result = ActionSchema.parse(data);
245245
| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. |
246246
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
247247
| **bodyExtra** | `Record<string, any>` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.<var>}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. |
248-
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution |
248+
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
249249
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
250250
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
251251
| **refreshAfter** | `boolean` | optional | Refresh view after execution |
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7428 — the authoring-time guard on the `confirmText` + `params` PAIR.
5+
*
6+
* #7278 and #7309 repaired the 16 shipped sites that opened two dialogs for one
7+
* decision (PRs #7592 and #7827). Repairing instances does not stop the next one
8+
* being written; this refusal is the structural half, and it is the reason the
9+
* card exists as a separate issue from either migration.
10+
*
11+
* **What these tests pin is the BOUNDARY, not just the refusal.** The pair is
12+
* wrong on `ActionSchema` and CORRECT on `BulkActionDefSchema` — measured on
13+
* #7428 (2026-08-11), and the distinction is the schema the def is validated by,
14+
* not a heuristic about whether the params happen to be optional. A guard
15+
* written against the raw `confirmText` + `params: [` key pair would land red on
16+
* four correct `examples/app-showcase` bulk defs on day one, which is the
17+
* permanently-noisy-check shape the card was filed to avoid. So the acceptance
18+
* direction is pinned as hard as the rejection direction: a future "helpful"
19+
* widening of this guard onto the bulk surface goes RED here.
20+
*
21+
* The rejection tests assert the issue PATH and the MESSAGE SUBSTANCE rather
22+
* than a bare `success === false`. One condition, one wording: a refusal whose
23+
* message does not name both keys and point at the remedy sends the author
24+
* looking for a different bug.
25+
*/
26+
27+
import { describe, expect, it } from 'vitest';
28+
import { ActionSchema, InlineActionSchema, defineAction } from './action.zod';
29+
import { BulkActionDefSchema } from './bulk-action.zod';
30+
31+
/**
32+
* Minimum legal registered action. `type` defaults to `script`, whose own
33+
* refinement requires an inline `body` or a `target` naming a bundle function —
34+
* leaving both off would fail for a reason that has nothing to do with this
35+
* guard and would make every assertion below unreadable.
36+
*/
37+
const base = { name: 'approval_reject', label: 'Reject', target: 'rejectApproval' } as const;
38+
39+
// `type` is narrowed rather than widened to `string` so this literal is also
40+
// assignable to `defineAction`'s typed input, which the last test below calls.
41+
const oneParam = [{ name: 'reason', label: 'Reason', type: 'textarea' as const, required: true }];
42+
43+
/** The single issue this guard raises, or `undefined` if it did not fire. */
44+
const guardIssue = (result: ReturnType<typeof ActionSchema.safeParse>) =>
45+
result.success
46+
? undefined
47+
: result.error.issues.find((i) => i.path.join('.') === 'confirmText');
48+
49+
describe('#7428 — `confirmText` + non-empty `params` is refused on ActionSchema', () => {
50+
it('refuses the pair, at the `confirmText` path', () => {
51+
const result = ActionSchema.safeParse({
52+
...base,
53+
confirmText: 'Reject this request?',
54+
params: oneParam,
55+
});
56+
57+
expect(result.success).toBe(false);
58+
// The path is asserted because it is what an editor/CLI underlines. Pointing
59+
// at `params` would tell the author to delete the inputs they need; the key
60+
// that has to go is `confirmText`.
61+
expect(guardIssue(result)?.path).toEqual(['confirmText']);
62+
});
63+
64+
it('says WHY, naming both keys and the remedy — not a bare rejection', () => {
65+
const result = ActionSchema.safeParse({
66+
...base,
67+
confirmText: 'Reject this request?',
68+
params: oneParam,
69+
});
70+
const message = guardIssue(result)?.message ?? '';
71+
72+
// Both halves of the offending pair, so the author can see what collided.
73+
expect(message).toContain('`confirmText`');
74+
expect(message).toContain('`params`');
75+
// The consequence, in user-visible terms rather than schema terms.
76+
expect(message).toContain('TWO dialogs');
77+
// The remedy, which is the whole point of the #7278 ruling.
78+
expect(message).toContain('`description`');
79+
// …and the remedy's own trap: the LLM-facing key one level down is NOT it.
80+
expect(message).toContain('ai.description');
81+
// The exception that keeps `confirmText` a live key rather than a retired
82+
// one — an author who reads only this message must not conclude otherwise.
83+
expect(message).toContain('param-LESS');
84+
});
85+
86+
it('fires on the localized-map form of `confirmText` too, not just a string', () => {
87+
// `confirmText` is `I18nLabelSchema`, so a bare truthiness check written
88+
// against a string would miss the map form and let the defect back in
89+
// through the localized door.
90+
const result = ActionSchema.safeParse({
91+
...base,
92+
confirmText: { en: 'Reject this request?', 'zh-CN': '拒绝该请求?' },
93+
params: oneParam,
94+
});
95+
96+
expect(result.success).toBe(false);
97+
expect(guardIssue(result)?.path).toEqual(['confirmText']);
98+
});
99+
100+
it('cannot be smuggled in through an ALIAS spelling — that door is shut upstream', () => {
101+
// `confirm` → `confirmText` and `inputs` → `params` are declared aliases on
102+
// this surface, and this repo REJECTS a near-miss with a rename arrow rather
103+
// than folding it silently (Prime Directive #12 — one contract, no dialects).
104+
// So the aliased pair never reaches this refinement at all: it is refused one
105+
// layer earlier, by key recognition. Pinned because the guard's coverage claim
106+
// depends on it — if aliases ever became a silent fold, the pair would arrive
107+
// post-fold and this test is where that change gets noticed.
108+
const result = ActionSchema.safeParse({
109+
...base,
110+
confirm: 'Reject this request?',
111+
inputs: oneParam,
112+
});
113+
114+
expect(result.success).toBe(false);
115+
const issue = result.success ? undefined : result.error.issues[0];
116+
expect(issue?.code).toBe('unrecognized_keys');
117+
expect(issue?.message).toContain('Did you mean `confirm` → `confirmText`');
118+
expect(issue?.message).toContain('`inputs` → `params`');
119+
});
120+
121+
it('throws from `defineAction`, which is where an author meets it', () => {
122+
// A refusal is only worth having if it reaches the authoring call site —
123+
// `defineAction` is what the platform objects and every app actually call.
124+
expect(() =>
125+
defineAction({ ...base, confirmText: 'Reject this request?', params: oneParam }),
126+
).toThrow(/TWO dialogs/);
127+
});
128+
});
129+
130+
describe('#7428 — what the guard must NOT touch', () => {
131+
it('accepts `confirmText` on a param-LESS action — the confirm is the only dialog', () => {
132+
const result = ActionSchema.safeParse({
133+
...base,
134+
confirmText: 'Reject this request?',
135+
});
136+
137+
expect(result.success).toBe(true);
138+
});
139+
140+
it('accepts `confirmText` beside an EMPTY `params` array', () => {
141+
// An empty array collects nothing, so no second dialog opens. Refusing it
142+
// would be a refusal with no user-visible defect behind it.
143+
const result = ActionSchema.safeParse({ ...base, confirmText: 'Sure?', params: [] });
144+
145+
expect(result.success).toBe(true);
146+
});
147+
148+
it('accepts `params` + `description` — the shape #7278 migrated TO', () => {
149+
// If this ever goes red the guard has swallowed its own remedy and the two
150+
// migrations have nowhere to land.
151+
const result = ActionSchema.safeParse({
152+
...base,
153+
description: 'Reject this request? Say why — the requester sees it.',
154+
params: oneParam,
155+
});
156+
157+
expect(result.success).toBe(true);
158+
});
159+
160+
it('does NOT additionally require `description` when `params` is present', () => {
161+
// Deliberately not widened (#7428 ruling 3): forbidding the pair is the
162+
// narrowest guard with measured pull behind it. Requiring dialog copy on
163+
// every param-collecting action is a strictly bigger authoring demand with
164+
// no measured failure behind it — it would need its own card.
165+
const result = ActionSchema.safeParse({ ...base, params: oneParam });
166+
167+
expect(result.success).toBe(true);
168+
});
169+
});
170+
171+
describe('#7428 — the guard is scoped to ActionSchema by SCHEMA BOUNDARY', () => {
172+
it('BulkActionDefSchema still ACCEPTS `confirmText` + non-empty `params`', () => {
173+
// The pinning test the boundary ruling asks for. This pairing is INTENDED
174+
// on the bulk surface: per that schema's own describe() text the params are
175+
// "inputs collected once before the run", `confirmText` is shown "above the
176+
// affected-record summary", and a `required` param "blocks the Confirm
177+
// button until a value is present" — one dialog, so there is no second one
178+
// to collapse. `examples/app-showcase`'s four defs are this shape and are
179+
// correct as written. A widening of the guard onto this schema lands here.
180+
const result = BulkActionDefSchema.safeParse({
181+
name: 'set_labels',
182+
label: 'Set Labels',
183+
operation: 'update',
184+
confirmText: 'Set these labels on every selected project?',
185+
params: [
186+
{
187+
name: 'labels',
188+
label: 'Labels',
189+
type: 'select',
190+
multiple: true,
191+
required: true,
192+
options: [{ label: 'Frontend', value: 'frontend' }],
193+
},
194+
],
195+
});
196+
197+
expect(result.success).toBe(true);
198+
expect(result.success && result.data.confirmText)
199+
.toBe('Set these labels on every selected project?');
200+
expect(result.success && result.data.params?.length).toBe(1);
201+
});
202+
203+
it('the two schemas are independent — the bulk def is not validated by ActionSchema', () => {
204+
// The structural claim behind the pin above, asserted rather than assumed:
205+
// `BulkActionDefSchema` is its own `strictObject`, so the same literal is
206+
// not even a legal ACTION (no `operation` key on that surface). If the two
207+
// were ever unified, this goes red before the guard silently widens.
208+
const asAction = ActionSchema.safeParse({
209+
name: 'set_labels',
210+
label: 'Set Labels',
211+
operation: 'update',
212+
confirmText: 'Set these labels on every selected project?',
213+
params: [{ name: 'labels', label: 'Labels', type: 'select' }],
214+
});
215+
216+
expect(asAction.success).toBe(false);
217+
});
218+
219+
it('InlineActionSchema is out of reach too — it picks fields, not this refine chain', () => {
220+
// Recording the guard's real blast radius rather than assuming it. Inline
221+
// actions derive from the shared field factory via `.pick()`, so no
222+
// refinement on `ActionSchema` reaches them — and the pick deliberately
223+
// omits `description`, so the #7278 remedy has no slot on that surface to
224+
// move a question into. Same reason the bulk defs were struck from the
225+
// target set: a refusal whose remedy is unreachable is a dead end, not a
226+
// guard. Whether the inline surface should gain `description` FIRST and the
227+
// guard SECOND is left open on #7428 rather than presumed here.
228+
const result = InlineActionSchema.safeParse({
229+
type: 'url',
230+
target: '/approvals?reject=1',
231+
label: 'Reject',
232+
confirmText: 'Reject this request?',
233+
params: [{ name: 'reason', label: 'Reason', type: 'textarea' }],
234+
});
235+
236+
expect(result.success).toBe(true);
237+
});
238+
});

0 commit comments

Comments
 (0)