From d9c4f0d3cf3b29d1774c6b6749f03040be1b8b9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:09:15 +0000 Subject: [PATCH 1/2] fix(runtime,spec,lint): bind action.body only for type 'script' (#4352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ActionSchema.body` has always said "Only used when type is `script`", but the runtime read `body` alone: `actionBodyRunnerFactory` bound a handler the moment the body parsed, so a `type: 'url'` action carrying a leftover body was registered and executed. Declared != enforced, in its nastiest shape — an author flips `type` away from `script`, reasonably concludes the body is dead, and it keeps running. - runtime: `actionBodyRunnerFactory` refuses to bind unless the type is `script` (omitted `type` = the spec's own `ActionType.default('script')`), and logs the refusal with the schema's prescription rather than dropping it silently. The gate lives at the single bind point, not the collector — `collectBundleActions` stays type-blind so governance surfaces still see every declared action, and the second binder (`engine.setDefaultActionRunner`) never walks the collector at all. - spec: pins that the publish gate RESOLVES to the rejecting schema — `getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of either registration cannot silently reopen the hole. - lint: `validate-action-body-writes` filters by `type` again (#4344's provisional type-blindness is over) and its stale rationale is rewritten. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- .../src/validate-action-body-writes.test.ts | 24 +++- .../lint/src/validate-action-body-writes.ts | 23 +++- .../runtime/src/action-body-type-gate.test.ts | 122 ++++++++++++++++++ packages/runtime/src/app-plugin.ts | 9 ++ .../runtime/src/sandbox/body-runner.test.ts | 58 +++++++++ packages/runtime/src/sandbox/body-runner.ts | 46 ++++++- packages/spec/src/ui/action.test.ts | 64 +++++++++ packages/spec/src/ui/action.zod.ts | 10 ++ 8 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/action-body-type-gate.test.ts diff --git a/packages/lint/src/validate-action-body-writes.test.ts b/packages/lint/src/validate-action-body-writes.test.ts index b2305b8fb3..f4dd7e81c5 100644 --- a/packages/lint/src/validate-action-body-writes.test.ts +++ b/packages/lint/src/validate-action-body-writes.test.ts @@ -390,10 +390,32 @@ describe('validateActionBodyWrites — where action bodies live', () => { expect(findings[0].where).toBe('action "close_deal" › body'); }); - it('checks a body on a non-script action — the runtime binds one regardless of `type`', () => { + // [#4352] The inversion of #4344's original assertion. That test pinned + // "checks a body on a non-script action — the runtime binds one regardless of + // `type`", which was true of the runtime at the time and was recorded as + // provisional. The ruling closed the gap at the producer instead: the body + // no longer binds (`actionBodyRunnerFactory`) and the pair no longer + // publishes (`ActionSchema`). Nothing runs, so there is no write set to + // advise about — and a finding here would point at the write when the defect + // is the `type`. + it('skips a body on a non-script action — nothing binds it, so there is no write to check', () => { const findings = validateActionBodyWrites( stackWith("await ctx.api.object('crm_deal').update({ stag: 'won' });", { type: 'url', target: '/x' }), ); + expect(findings).toEqual([]); + }); + + it('still checks a body on an action that omits `type` — the spec default is `script`', () => { + const findings = validateActionBodyWrites( + stackWith("await ctx.api.object('crm_deal').update({ stag: 'won' });"), + ); + expect(findings).toHaveLength(1); + }); + + it('still checks a body on an explicit `type: "script"` action', () => { + const findings = validateActionBodyWrites( + stackWith("await ctx.api.object('crm_deal').update({ stag: 'won' });", { type: 'script' }), + ); expect(findings).toHaveLength(1); }); }); diff --git a/packages/lint/src/validate-action-body-writes.ts b/packages/lint/src/validate-action-body-writes.ts index 463666fad9..f5cd752592 100644 --- a/packages/lint/src/validate-action-body-writes.ts +++ b/packages/lint/src/validate-action-body-writes.ts @@ -218,10 +218,20 @@ function actionObjectBinding(action: AnyRec, parentObject?: string): string | un * The top-level entry is walked first, so a merged action reports at * `actions[i]` — the authored location, not the derived copy. * - * `type` is deliberately not consulted: the runtime binds a handler from - * `action.body` alone (`actionBodyRunnerFactory` never reads `type`), so a body - * on a non-`script` action still runs and still fails silently. Checking what - * executes beats checking what the schema says should. + * Only `type: 'script'` bodies are walked (`type` omitted counts, since + * `ActionType.default('script')` makes that the same declaration). + * + * This rule USED to be deliberately type-blind, on the grounds that the + * runtime bound a handler from `action.body` alone and so a body on a + * non-`script` action still ran and still failed silently — checking what + * executes beat checking what the schema said should. That comment predicted + * its own revision ("定了之后 lint 那边要跟着调"), and #4352 is the ruling: + * `actionBodyRunnerFactory` now refuses to bind a handler unless the type is + * `script`, and `ActionSchema` rejects the contradictory pair at publish. So + * what executes and what the schema says are the same set again, and walking + * a non-`script` body here would produce advice about writes that provably + * never happen — noise pointing at metadata whose real defect is the `type`, + * which the publish gate already names with its own prescription. */ function collectActionBodies(stack: AnyRec): ActionBodySite[] { const sites: ActionBodySite[] = []; @@ -229,6 +239,11 @@ function collectActionBodies(stack: AnyRec): ActionBodySite[] { const collect = (actions: unknown, pathPrefix: string, parentObject?: string): void => { asArray(actions).forEach((action, index) => { + // Same default the spec declares, and the same one the runtime gate + // applies — a stack may reach lint unparsed, so an omitted `type` is + // `'script'`, not "unknown". + const type = typeof action.type === 'string' ? action.type : 'script'; + if (type !== 'script') return; const body = action.body; if (!isRec(body) || body.language !== 'js') return; const source = body.source; diff --git a/packages/runtime/src/action-body-type-gate.test.ts b/packages/runtime/src/action-body-type-gate.test.ts new file mode 100644 index 0000000000..32a1aa53ca --- /dev/null +++ b/packages/runtime/src/action-body-type-gate.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4352] `action.body` binds a handler ONLY for `type: 'script'`. + * + * `ActionSchema.body` has always said "Only used when type is `script`", and + * its JSDoc is more explicit still ("Only meaningful when `type === 'script'`"). + * The runtime read neither: `collectBundleActions` collected any named action + * and `actionBodyRunnerFactory` bound a handler the moment `body` parsed. So a + * `type: 'url'` action carrying a leftover body was registered in the action + * registry and executed in the sandbox — the declared ≠ enforced shape of + * Prime Directive #10, in its nastiest form: an author flips `type` away from + * `script`, reasonably concludes the body is now dead, and it is not. + * + * The sibling tests in `sandbox/body-runner.test.ts` pin the factory in + * isolation. THIS file pins the composition AppPlugin actually performs — + * `collectBundleActions(bundle)` → `actionBodyRunnerFactory(...)` → skip when + * no handler → `ql.registerAction(...)` — because that loop is where the + * registration decision is really made, and a factory that returns `undefined` + * only matters if the loop honours it (it does: `if (!handler) continue`). + * + * The bind loop is replicated rather than driven through a booted AppPlugin on + * purpose: booting one needs a kernel, an ObjectQL engine and a QuickJS + * sandbox, none of which participate in the decision under test. The + * replication is kept honest by asserting the collector's own output too, so a + * change to how actions are collected still surfaces here. + */ + +import { describe, it, expect } from 'vitest'; +import { collectBundleActions } from './app-plugin.js'; +import { actionBodyRunnerFactory } from './sandbox/body-runner.js'; +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; + +const jsBody = { language: 'js', source: 'return { ran: true };', capabilities: [] } as const; + +/** The exact registration loop from `AppPlugin.bindDeclarativeActions`. */ +function bindActions(bundle: unknown, logger?: { warn: (msg: string) => void }) { + const registered: Array<{ object: string; name: string }> = []; + const actions = collectBundleActions(bundle); + const runner = actionBodyRunnerFactory(new QuickJSScriptRunner(), { + ql: {}, + appId: 'crm', + logger, + }); + for (const action of actions) { + const handler = runner(action); + if (!handler) continue; + registered.push({ object: action.object ?? 'global', name: action.name }); + } + return { collected: actions, registered }; +} + +describe('#4352 — a non-script action with a body binds no handler', () => { + it("registers the script action and skips the `type: 'url'` one", () => { + const warnings: string[] = []; + const { collected, registered } = bindActions( + { + actions: [ + // The regression population: an explicit non-script type + a body. + { name: 'open_docs', label: 'Docs', type: 'url', target: 'https://x', body: jsBody }, + // The overwhelmingly common case — unchanged. + { name: 'close_deal', label: 'Close', type: 'script', object: 'crm_deal', body: jsBody }, + ], + }, + { warn: (msg: string) => warnings.push(msg) }, + ); + + // The collector stays type-blind by design — it feeds governance surfaces + // that must see every declared action, bound or not. + expect(collected.map((a) => a.name)).toEqual(['open_docs', 'close_deal']); + + // ...but only the script action becomes an executable handler. + expect(registered).toEqual([{ object: 'crm_deal', name: 'close_deal' }]); + + // And the refusal is audible: silence here would just move the invisibility. + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('open_docs'); + expect(warnings[0]).toContain("type: 'url'"); + }); + + it('skips a non-script body declared under an object', () => { + const { registered } = bindActions({ + objects: [ + { + name: 'crm_lead', + actions: [ + { name: 'open_portal', label: 'Portal', type: 'url', target: '/p', body: jsBody }, + { name: 'score_lead', label: 'Score', type: 'script', body: jsBody }, + ], + }, + ], + }); + expect(registered).toEqual([{ object: 'crm_lead', name: 'score_lead' }]); + }); + + it('binds an action that omits `type` — `ActionType.default(\'script\')`', () => { + // Bundles reach the collector RAW. A `strict: false` `defineStack` and a + // legacy `manifest.actions[]` never pass through `ActionSchema`, so the + // schema's default has to be applied here or the common shape breaks. + const { registered } = bindActions({ + manifest: { actions: [{ name: 'legacy_untyped', label: 'Legacy', body: jsBody }] }, + }); + expect(registered).toEqual([{ object: 'global', name: 'legacy_untyped' }]); + }); + + it('leaves bodyless non-script actions exactly as they were', () => { + const warnings: string[] = []; + const { collected, registered } = bindActions( + { + actions: [ + { name: 'open_docs', label: 'Docs', type: 'url', target: 'https://x' }, + { name: 'convert', label: 'Convert', type: 'flow', target: 'crm_convert' }, + ], + }, + { warn: (msg: string) => warnings.push(msg) }, + ); + expect(collected).toHaveLength(2); + // They never bound a handler before this change either — nothing to warn about. + expect(registered).toEqual([]); + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 955e24a9c0..c0b35a2351 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1441,6 +1441,15 @@ export function collectBundleHooks(bundle: any): any[] { * * Each returned record is a shallow copy with `object` set when the action * originated under an object (and not already present on the action itself). + * + * Deliberately type-BLIND, and it must stay that way: this collects every + * declared action, most of which (`url`, `modal`, `flow`, `api`, `form`) + * legitimately have no body and bind nothing. The `type: 'script'` gate that + * decides whether a `body` becomes an executable handler lives at the single + * bind point — `actionBodyRunnerFactory` (#4352) — because the other binder + * (`engine.setDefaultActionRunner`, for Studio-authored actions) never walks + * this collector at all. Re-filtering here would duplicate half the rule and + * leave the other binder ungated. */ export function collectBundleActions( bundle: any, diff --git a/packages/runtime/src/sandbox/body-runner.test.ts b/packages/runtime/src/sandbox/body-runner.test.ts index a163e5461f..0d84e489fc 100644 --- a/packages/runtime/src/sandbox/body-runner.test.ts +++ b/packages/runtime/src/sandbox/body-runner.test.ts @@ -166,6 +166,64 @@ describe('actionBodyRunnerFactory', () => { expect(factory({ name: 'noop' })).toBeUndefined(); }); + // ─── [#4352] the `type` gate ────────────────────────────────────────────── + // `ActionSchema.body` always said "Only used when type is `script`"; the + // runtime never read `type`, so a `type: 'url'` action carrying a leftover + // body still bound a handler and still executed. These pin the enforcement. + describe('binds a body only for `type: "script"` (#4352)', () => { + const body = { language: 'js', source: 'return { ran: true };', capabilities: [] } as const; + + for (const type of ['url', 'modal', 'flow', 'api', 'form'] as const) { + it(`binds no handler for type: '${type}' and says why`, () => { + const warnings: string[] = []; + const factory = actionBodyRunnerFactory(runner, { + ql: {}, + appId: 'crm', + logger: { warn: (msg: string) => warnings.push(msg) }, + }); + expect(factory({ name: 'leftover', object: 'lead', type, body })).toBeUndefined(); + // Refusing silently would only relocate the invisibility the issue is about. + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("type: '" + type + "'"); + expect(warnings[0]).toContain('#4352'); + }); + } + + it("binds for an explicit type: 'script'", async () => { + const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'crm' }); + const fn = factory({ name: 'ok', object: 'lead', type: 'script', body }); + expect(typeof fn).toBe('function'); + await expect(fn!({ params: {} })).resolves.toEqual({ ran: true }); + }); + + it('binds when `type` is omitted — the spec default is `script`', async () => { + // The collectors walk RAW bundle objects; a `strict: false` defineStack or + // a legacy `manifest.actions[]` never passed through `ActionType.default`, + // so an omitted type must still mean `script` here. + const warnings: string[] = []; + const factory = actionBodyRunnerFactory(runner, { + ql: {}, + appId: 'crm', + logger: { warn: (msg: string) => warnings.push(msg) }, + }); + const fn = factory({ name: 'ok', object: 'lead', body }); + expect(typeof fn).toBe('function'); + await expect(fn!({ params: {} })).resolves.toEqual({ ran: true }); + expect(warnings).toEqual([]); + }); + + it('stays silent for a non-script action with no body — nothing is contradictory', () => { + const warnings: string[] = []; + const factory = actionBodyRunnerFactory(runner, { + ql: {}, + appId: 'crm', + logger: { warn: (msg: string) => warnings.push(msg) }, + }); + expect(factory({ name: 'open_docs', type: 'url' })).toBeUndefined(); + expect(warnings).toEqual([]); + }); + }); + it('runs an L2 action body and returns its value', async () => { const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'crm' }); const fn = factory({ diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index d47d40ff90..6e6d042489 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -102,17 +102,61 @@ export function hookBodyRunnerFactory( * Returns a handler with the shape ObjectQL's `executeAction` expects: * `(actionCtx) => Promise`. The action's return value bubbles up * to the HTTP dispatcher which JSON-serialises it back to the caller. + * + * This is the ONE choke point where an `action.body` becomes an executable + * handler — both bind paths go through it (`AppPlugin`'s bundle walk over + * `collectBundleActions`, and `engine.setDefaultActionRunner` for the + * Studio-authored `action` metadata ObjectQLPlugin re-syncs). So the + * `type` gate below is enforced here rather than at either call site: a + * second copy at the collector would be a rule that can drift from this one. */ export function actionBodyRunnerFactory( runner: ScriptRunner, opts: FactoryOptions, -): (action: { name: string; body?: unknown; object?: string; timeoutMs?: number }) => +): (action: { name: string; body?: unknown; object?: string; type?: string; timeoutMs?: number }) => | ((actionCtx: any) => Promise) | undefined { return (action) => { const raw = action.body; if (!raw) return undefined; + // [#4352] `body` binds a handler ONLY for `type: 'script'` — the rule the + // spec always stated (`ActionSchema.body`: "Only used when type is + // `script`") and the runtime never enforced. Every other type dispatches + // on `target` (the URL, page, flow or endpoint), so a body alongside one + // is self-contradictory metadata: two implementations, only one of which + // the author can see running. + // + // Binding it anyway produced the worst-shaped bug this repo has a name + // for — an author flips `type` from `script` to `url`, reasonably reads + // that as "the body no longer runs", and it keeps running, reachable + // through `ql.object(o).execute(name)` (the ObjectQL proxy calls + // `executeAction` with no type branching of its own) and counted by the + // ADR-0110 D5 governance inventory as a live handler. + // + // `?? 'script'` is the schema's own default (`ActionType.default('script')`), + // not a tolerant fallback: the collectors walk RAW bundle objects, which + // for a `strict: false` `defineStack` or a legacy `manifest.actions[]` + // never went through `ActionSchema`, so an omitted `type` still has to + // mean what the spec says it means. An action that EXPLICITLY declares + // another type is the only one whose behavior changes. + // + // The publish gate rejects this shape at authoring time (`ActionSchema`'s + // non-script-body refinement, #4438), so anything arriving here is either + // data at rest published before that gate existed or a bundle that never + // parsed. Refusing silently would just relocate the invisibility, so the + // refusal is logged with the same prescription the schema gives. + const type = action.type ?? 'script'; + if (type !== 'script') { + opts.logger?.warn?.( + `[BodyRunner] action '${action.name}' declares \`type: '${type}'\` and carries a \`body\` — ` + + `no handler was bound. \`body\` only runs for \`type: 'script'\`; a '${type}' action dispatches ` + + `on \`target\`. Set \`type: 'script'\` to run the body, or drop the \`body\`. See #4352.`, + { appId: opts.appId, action: action.name, object: action.object, type }, + ); + return undefined; + } + const parsed = HookBodySchema.safeParse(raw); if (!parsed.success) { opts.logger?.warn?.('[BodyRunner] invalid action.body shape', { diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index de4cc1749b..7faa15a425 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; import { ActionSchema, ActionParamSchema, Action, type Action as ActionType, ACTION_LOCATIONS, ActionLocationSchema, type ActionLocation } from './action.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { ObjectSchema } from '../data/object.zod'; describe('ActionParamSchema', () => { it('should accept minimal action parameter', () => { @@ -1192,6 +1194,68 @@ describe('ActionSchema - target validation', () => { }); }); +/** + * [#4352] The refinement above is only half the guarantee. The other half is + * the WIRING: the publish gate does not import `ActionSchema` directly — it + * resolves the judging schema by metadata type through + * `getMetadataTypeSchema()` (`metadata-protocol`'s save-time validation and + * `metadata-diagnostics` both go through it), and an object's inline actions + * are judged by `ObjectSchema.actions`. + * + * So a re-point of either registration would silently reopen the hole while + * every test above stayed green — the schema would still reject, and nothing + * would still ask it. #4352's ruling puts the rejection ON the publish gate, + * which is what these pin. + */ +describe('ActionSchema - the publish gate resolves to it (#4352)', () => { + const contradictory = { + name: 'open_docs', + label: 'Open Docs', + type: 'url' as const, + target: 'https://docs.example.com', + body: { language: 'js' as const, source: 'return 1;', capabilities: ['api.write'] }, + }; + + it("rejects `body` + `type: 'url'` through getMetadataTypeSchema('action')", () => { + const schema = getMetadataTypeSchema('action'); + expect(schema, "the 'action' metadata type must resolve to a schema").toBeDefined(); + const result = schema!.safeParse(contradictory); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toMatch(/script/); + }); + + it("accepts the same action once its `body` is dropped", () => { + const { body: _body, ...withoutBody } = contradictory; + expect(getMetadataTypeSchema('action')!.safeParse(withoutBody).success).toBe(true); + }); + + it('rejects the same contradiction nested in `object.actions[]`', () => { + const result = ObjectSchema.safeParse({ + name: 'crm_deal', + label: 'Deal', + fields: { stage: { label: 'Stage', type: 'text' } }, + actions: [contradictory], + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toMatch(/script/); + }); + + it("accepts a `type: 'script'` body nested in `object.actions[]` — unchanged", () => { + const result = ObjectSchema.safeParse({ + name: 'crm_deal', + label: 'Deal', + fields: { stage: { label: 'Stage', type: 'text' } }, + actions: [{ + name: 'close_deal', + label: 'Close Deal', + type: 'script', + body: { language: 'js', source: 'return 1;', capabilities: ['api.write'] }, + }], + }); + expect(result.success).toBe(true); + }); +}); + describe('ActionSchema - target required for non-script types', () => { it('should require target for url type', () => { expect(() => ActionSchema.parse({ diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 56a05e6457..438b109c9e 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -556,6 +556,16 @@ const actionObject = () => strictObject({ * the body inside the sandbox as `(input, ctx) => Promise` and * ignores `target`. * + * That condition is ENFORCED at both ends, not merely documented (#4352). + * Authoring: the refinement on {@link ActionSchema} rejects `body` alongside + * any other `type` — the publish gate resolves this same schema through + * `getMetadataTypeSchema('action')`, so the contradiction cannot be stored. + * Runtime: `actionBodyRunnerFactory` binds no handler unless the type is + * `script`, which covers metadata published before that gate existed and + * bundles that never parsed. Until #4352 only the sentence existed: the + * runtime bound a handler from `body` alone, so flipping `type` from + * `script` to `url` left the body running with nothing to say so. + * * - `{ language: 'expression', source: '...' }` — pure formula (L1). * - `{ language: 'js', source: '...', capabilities: [...] }` — sandboxed JS (L2). * From ad888cf73b6385c6c439d9bafc1e5fb9f29173ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:13:47 +0000 Subject: [PATCH 2/2] docs(changeset): action.body binds only for type 'script' (#4352) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- .changeset/action-body-type-gate.md | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .changeset/action-body-type-gate.md diff --git a/.changeset/action-body-type-gate.md b/.changeset/action-body-type-gate.md new file mode 100644 index 0000000000..22db02e026 --- /dev/null +++ b/.changeset/action-body-type-gate.md @@ -0,0 +1,80 @@ +--- +"@objectstack/runtime": minor +"@objectstack/lint": minor +"@objectstack/spec": patch +--- + +fix(runtime,lint): `action.body` binds a handler only for `type: 'script'` (#4352) + +`ActionSchema.body` has always described itself as "Only used when type is +`script`", and its JSDoc went further — "Only meaningful when +`type === 'script'`. When set, the runtime invokes the body inside the sandbox +… and ignores `target`." The runtime read none of it: +`actionBodyRunnerFactory` bound a handler the moment `body` parsed, and +`collectBundleActions` collected any named action. A `type: 'url'` action +carrying a leftover `body` was therefore registered in the action registry and +executed in the sandbox — reachable through +`POST /api/v1/actions/:object/:action` and through +`ql.object(o).execute(name)`, and counted by the governance inventory as a live +handler. + +Declared ≠ enforced, in the shape that is hardest to debug: an author flips +`type` from `script` to `url`, reasonably concludes the body is now dead code, +and it keeps running with nothing anywhere saying so. + +**Behaviour change.** `body` now runs only under `type: 'script'`: + +| Action | Before | After | +|:--|:--|:--| +| `type: 'script'` + `body` | body runs | unchanged — body runs | +| `type` omitted + `body` | body runs | unchanged — body runs (`ActionType.default('script')`) | +| `type: 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'` + `body` | body ran | **no handler is bound**; the refusal is logged | + +Only an action that **explicitly** declares a non-`script` type *and* carries a +`body` changes behaviour. An omitted `type` still means `script`, because the +collectors walk raw bundle objects — a `strict: false` `defineStack` or a legacy +`manifest.actions[]` never passes through `ActionSchema`, so the schema's own +default has to be applied at the gate rather than assumed to have been applied +already. + +**FROM → TO.** If you have an action whose body you want to keep running, set +`type: 'script'` and move the navigation/dispatch target elsewhere; if you want +the target behaviour, delete the now-inert `body`: + +```diff + { + name: 'open_portal', +- type: 'url', ++ type: 'script', + target: '/portal', + body: { language: 'js', source: "await ctx.api.object('lead').update(…)", capabilities: ['api.write'] }, + } +``` + +The refusal is **not** silent — silence would only relocate the invisibility the +issue is about. `actionBodyRunnerFactory` logs a warning naming the action, its +declared `type`, and both fixes. + +Authoring-time rejection of the same contradiction already shipped in #4438 +(`ActionSchema` rejects `body` alongside a non-`script` `type`), so what remains +reachable here is data at rest published before that gate existed, plus bundles +that never parsed. This release closes that half. New tests also pin that the +**publish gate resolves to the rejecting schema** — through +`getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of +either registration cannot silently reopen the hole while the schema's own unit +tests stay green. + +`@objectstack/lint`'s `validate-action-body-writes` filters by `type` again. +#4344 deliberately made that rule type-blind on the grounds that "the runtime +binds a handler from `action.body` alone … checking what executes beats checking +what the schema says should" — true then, and the comment predicted its own +revision. Execution and declaration are the same set again, so a non-`script` +body no longer produces write-set advice about writes that provably never +happen; the publish gate names that metadata's real defect (`type`) with its own +prescription. + +`collectBundleActions` stays deliberately type-blind: it feeds governance +surfaces that must enumerate every declared action, bound or not, and the other +bind path (`engine.setDefaultActionRunner`, for Studio-authored actions) never +walks it. The gate lives at the single point where a `body` becomes an +executable handler, so there is no second copy of the rule to drift.