From 85ea69f93b59bbee2bdc5f1befe3263996dca891 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:48:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20reject=20a=20`body`=20on=20a?= =?UTF-8?q?=20non-script=20action=20=E2=80=94=20it=20would=20never=20run?= =?UTF-8?q?=20(#3530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01KYuc9N6YRbvoDMbCvJiX9f --- .../reject-body-on-non-script-action.md | 19 +++++++++++++ packages/spec/src/ui/action.test.ts | 27 +++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 21 +++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 .changeset/reject-body-on-non-script-action.md diff --git a/.changeset/reject-body-on-non-script-action.md b/.changeset/reject-body-on-non-script-action.md new file mode 100644 index 0000000000..54f721e411 --- /dev/null +++ b/.changeset/reject-body-on-non-script-action.md @@ -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. diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 65c70c7226..329ac3d502 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -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', () => { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 35ddc7ddd1..6b2b298398 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -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) { From 34c4df7dc835ca0e877a97b1889beb8604e939e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:51:01 +0000 Subject: [PATCH 2/2] docs(actions): document that `body` is script-only and how a modal `target` resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the two action docs in sync with the parse-time rule added in this PR (and with the console-side target resolution in objectui#2826): - ui/actions: a second "trap" callout next to the existing dead-button one — a `body` on a non-script action is rejected, with `type: 'script'` + `params` named as the shape for "collect input, then run logic". - protocol/objectui/actions: `body` on a non-script type is now a parse-time error, not just "not meaningful"; the Modal Actions section states the page → object → server-handler resolution order and that `params` are collected before the target opens. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KYuc9N6YRbvoDMbCvJiX9f --- content/docs/protocol/objectui/actions.mdx | 6 ++++-- content/docs/ui/actions.mdx | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index aa9bca78cb..b250440efa 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -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 @@ -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 @@ -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 diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 3c3c918a73..6633b769c6 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -126,6 +126,17 @@ rejected at authoring time.) Also note the handler's `engine` facade is caller-specific rules yourself. + +**`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`. + + ## Bind it to the UI `locations` is the primary binding — the action appears wherever it declares: