Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/reject-body-on-non-script-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/spec": patch
---

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.
6 changes: 4 additions & 2 deletions content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The `type` field selects how an action is dispatched. The complete enum is **`sc

### Script Actions

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`.
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.

```yaml
name: greet_user
Expand Down Expand Up @@ -142,7 +142,7 @@ target: customer_quick_edit

### Modal Actions

Open a modal/page by name:
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.

```yaml
name: edit_customer
Expand All @@ -151,6 +151,8 @@ type: modal
target: customer_edit_modal
```

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.

## Action Configuration

### Action Properties
Expand Down
11 changes: 11 additions & 0 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ rejected at authoring time.) Also note the handler's `engine` facade is
caller-specific rules yourself.
</Callout>

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

## Bind it to the UI

`locations` is the primary binding — the action appears wherever it declares:
Expand Down
27 changes: 27 additions & 0 deletions packages/spec/src/ui/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,33 @@ describe('ActionSchema - execute → target migration', () => {
body: { language: 'expression', source: 'true' },
})).not.toThrow();
});

it('should reject a body on a non-script action (it would never run)', () => {
// #3530: `type: 'modal'` + `params` + `body` was authored expecting the
// body to run when the modal is submitted. A modal action dispatches on
// `target` (the page to open), so the body is silently skipped — the modal
// opens and nothing is ever written. Fail at author time and point at the
// fix rather than shipping a button that does nothing.
for (const type of ['modal', 'url', 'flow', 'api', 'form'] as const) {
expect(() => ActionSchema.parse({
name: `${type}_with_body`,
label: 'Has a body',
type,
target: 'some_target',
body: { language: 'js', source: 'return 1;', capabilities: ['api.write'] },
}), `type: '${type}'`).toThrow(/script/);
}
});

it('should allow a non-script action without a body', () => {
expect(() => ActionSchema.parse({
name: 'log_call',
label: 'Log a Call',
type: 'modal',
target: 'log_call',
params: [{ name: 'subject', label: 'Call Subject', type: 'text', required: true }],
})).not.toThrow();
});
});

describe('ActionSchema - target required for non-script types', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,27 @@ export const ActionSchema = lazySchema(() => z.object({
message:
"A 'script' action requires either an inline `body` (sandboxed L1/L2 handler) or a `target` (a registered bundle function name).",
path: ['body'],
}).refine((data) => {
// The mirror image of the rule above: a `body` on a NON-script action never
// runs. `type: 'modal' | 'url' | 'flow' | 'api' | 'form'` all dispatch on
// `target` (the page to open, the URL, the flow, the endpoint), so the
// renderer has no point at which it would invoke a body — the action opens
// its target and the body is silently skipped.
//
// This is the same invisible-failure shape as #2169: it passes build, passes
// shape tests, and only shows up as "the modal opened but nothing was
// written" (#3530, where `type: 'modal'` + `params` + `body` was authored
// expecting the body to run on submit). Reject it at author time and name the
// fix — `type: 'script'` collects the same `params` and DOES run the body.
if (data.type !== 'script' && data.body) {
return false;
}
return true;
}, {
message:
"`body` only runs for `type: 'script'` — a non-script action dispatches on `target` and silently ignores its body. " +
"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.",
path: ['body'],
}).refine((data) => {
// ADR-0011: an exposed action must carry an LLM-facing description.
if (data.ai?.exposed === true && !data.ai.description) {
Expand Down