Skip to content

Commit f07808c

Browse files
authored
feat(spec): reject a body on a non-script action — it would never run (#3530) (#3548)
`Action.body` is documented as "only meaningful when `type === 'script'`", but nothing enforced it. A `type: 'modal'` action authored with `params` and a `body` — expecting the modal to collect the input and the body to write the record on submit — passed validation, passed shape tests, and shipped a button that opened a modal and silently wrote nothing. Non-script types all dispatch on `target`, so there is no point at which a renderer would invoke the body. Enforced the same way as the existing rule rejecting a `script` action with neither `body` nor `target` (#2169): a parse-time error naming the fix. Also updates content/docs/ui/actions.mdx and content/docs/protocol/objectui/actions.mdx to document the rule and the page → object → server-handler resolution order for a modal `target`.
1 parent 376a061 commit f07808c

5 files changed

Lines changed: 82 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
feat(spec): reject a `body` on a non-script action — it would never run (#3530)
6+
7+
`Action.body` is documented as "only meaningful when `type === 'script'`", but
8+
nothing enforced it. A `type: 'modal'` action authored with `params` and a
9+
`body` — expecting the modal to collect the input and the body to write the
10+
record on submit — passed validation, passed shape tests, and shipped a button
11+
that opened a modal and silently wrote nothing. Non-script types all dispatch on
12+
`target` (the page to open, the URL, the flow, the endpoint); there is no point
13+
at which a renderer would invoke the body.
14+
15+
This is the same invisible-failure shape as the existing rule that rejects a
16+
`script` action with neither `body` nor `target` (#2169), so it is enforced the
17+
same way: a parse-time error that names the fix — `type: 'script'` collects the
18+
same `params` and does run the body, and a modal that only opens a page should
19+
drop the `body` and keep `target` naming the page.

content/docs/protocol/objectui/actions.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ The `type` field selects how an action is dispatched. The complete enum is **`sc
5555

5656
### Script Actions
5757

58-
Run logic with no server round trip via `body` (an L1 CEL expression or L2 sandboxed JS). `body` is only meaningful when `type` is `script`.
58+
Run logic with no server round trip via `body` (an L1 CEL expression or L2 sandboxed JS). `body` is only meaningful when `type` is `script`, and declaring one on any other type is a parse-time error — those types dispatch on `target`, so the body would never be invoked.
5959

6060
```yaml
6161
name: greet_user
@@ -142,7 +142,7 @@ target: customer_quick_edit
142142

143143
### Modal Actions
144144

145-
Open a modal/page by name:
145+
Open a modal/page by name. `target` is resolved as a **page** first, then as an object (which opens that object's create/edit form); when it names neither, the action falls through to its server-side handler.
146146

147147
```yaml
148148
name: edit_customer
@@ -151,6 +151,8 @@ type: modal
151151
target: customer_edit_modal
152152
```
153153

154+
A modal action may also declare `params` — the renderer collects them in a dialog before opening the target. It may **not** declare a `body`: see [Script Actions](#script-actions) above, and use `type: script` when the goal is to collect input and then run logic.
155+
154156
## Action Configuration
155157

156158
### Action Properties

content/docs/ui/actions.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ rejected at authoring time.) Also note the handler's `engine` facade is
126126
caller-specific rules yourself.
127127
</Callout>
128128

129+
<Callout type="warn">
130+
**`body` belongs to `script` only.** Every other type dispatches on `target`
131+
— the page to open, the URL, the flow, the endpoint — so a `body` on a
132+
`modal`/`url`/`flow`/`api`/`form` action would never be invoked. Writing one
133+
(typically `type: 'modal'` with `params` and a `body`, expecting the body to
134+
run when the modal is submitted) is rejected at authoring time rather than
135+
shipping a button that opens a modal and silently writes nothing. To collect
136+
input *and* run logic, use `type: 'script'` with `params` — the same dialog
137+
is collected, then the body runs with those values as its `input`.
138+
</Callout>
139+
129140
## Bind it to the UI
130141

131142
`locations` is the primary binding — the action appears wherever it declares:

packages/spec/src/ui/action.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,33 @@ describe('ActionSchema - execute → target migration', () => {
10571057
body: { language: 'expression', source: 'true' },
10581058
})).not.toThrow();
10591059
});
1060+
1061+
it('should reject a body on a non-script action (it would never run)', () => {
1062+
// #3530: `type: 'modal'` + `params` + `body` was authored expecting the
1063+
// body to run when the modal is submitted. A modal action dispatches on
1064+
// `target` (the page to open), so the body is silently skipped — the modal
1065+
// opens and nothing is ever written. Fail at author time and point at the
1066+
// fix rather than shipping a button that does nothing.
1067+
for (const type of ['modal', 'url', 'flow', 'api', 'form'] as const) {
1068+
expect(() => ActionSchema.parse({
1069+
name: `${type}_with_body`,
1070+
label: 'Has a body',
1071+
type,
1072+
target: 'some_target',
1073+
body: { language: 'js', source: 'return 1;', capabilities: ['api.write'] },
1074+
}), `type: '${type}'`).toThrow(/script/);
1075+
}
1076+
});
1077+
1078+
it('should allow a non-script action without a body', () => {
1079+
expect(() => ActionSchema.parse({
1080+
name: 'log_call',
1081+
label: 'Log a Call',
1082+
type: 'modal',
1083+
target: 'log_call',
1084+
params: [{ name: 'subject', label: 'Call Subject', type: 'text', required: true }],
1085+
})).not.toThrow();
1086+
});
10601087
});
10611088

10621089
describe('ActionSchema - target required for non-script types', () => {

packages/spec/src/ui/action.zod.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,27 @@ export const ActionSchema = lazySchema(() => z.object({
603603
message:
604604
"A 'script' action requires either an inline `body` (sandboxed L1/L2 handler) or a `target` (a registered bundle function name).",
605605
path: ['body'],
606+
}).refine((data) => {
607+
// The mirror image of the rule above: a `body` on a NON-script action never
608+
// runs. `type: 'modal' | 'url' | 'flow' | 'api' | 'form'` all dispatch on
609+
// `target` (the page to open, the URL, the flow, the endpoint), so the
610+
// renderer has no point at which it would invoke a body — the action opens
611+
// its target and the body is silently skipped.
612+
//
613+
// This is the same invisible-failure shape as #2169: it passes build, passes
614+
// shape tests, and only shows up as "the modal opened but nothing was
615+
// written" (#3530, where `type: 'modal'` + `params` + `body` was authored
616+
// expecting the body to run on submit). Reject it at author time and name the
617+
// fix — `type: 'script'` collects the same `params` and DOES run the body.
618+
if (data.type !== 'script' && data.body) {
619+
return false;
620+
}
621+
return true;
622+
}, {
623+
message:
624+
"`body` only runs for `type: 'script'` — a non-script action dispatches on `target` and silently ignores its body. " +
625+
"To collect `params` and then run the body, use `type: 'script'`; to open a page/modal, drop the `body` and keep `type: 'modal'` with `target` naming the page.",
626+
path: ['body'],
606627
}).refine((data) => {
607628
// ADR-0011: an exposed action must carry an LLM-facing description.
608629
if (data.ai?.exposed === true && !data.ai.description) {

0 commit comments

Comments
 (0)