Skip to content

Commit 85ea69f

Browse files
zhuangjianguoclaude
andcommitted
feat(spec): reject a body on a non-script action — it would never run (#3530)
`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` (the page to open, the URL, the flow, the endpoint); there is no point at which a renderer would invoke the body. This is the same invisible-failure shape as the existing rule that rejects a `script` action with neither `body` nor `target` (#2169), so it is enforced the same way: a parse-time error that names the fix — `type: 'script'` collects the same `params` and does run the body, and a modal that only opens a page should drop the `body` and keep `target` naming the page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KYuc9N6YRbvoDMbCvJiX9f
1 parent 134df4f commit 85ea69f

3 files changed

Lines changed: 67 additions & 0 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.

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)