Skip to content

fix(actions): dispatch on the declared action type over REST (#3915) - #3919

Merged
os-zhuang merged 1 commit into
mainfrom
claude/rest-actions-type-dispatch-y5vqfj
Jul 29, 2026
Merged

fix(actions): dispatch on the declared action type over REST (#3915)#3919
os-zhuang merged 1 commit into
mainfrom
claude/rest-actions-type-dispatch-y5vqfj

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3915.

The gap

POST /api/v1/actions/:object/:action had no action-type branching at all — every action went to ql.executeAction, the script-handler registry — while the MCP run_action bridge has implemented the flow branch since #2849 (action-execution.ts). The spec is unambiguous that every non-script type dispatches on target (packages/spec/src/ui/action.zod.ts), so a REST/SDK caller who followed it and invoked a type: 'flow' action got

Action '' on object '*' not found

and had to know, out of band, to call POST /api/v1/automation/:target/trigger themselves.

It is worse than "the branch is missing" for the Studio-authored case: resyncAuthoredActions deliberately registers no handler for a flow-typed action (skippedNoHandler, "no body (target/flow/url action)"), so there was never anything in the registry to find.

A second, related gap surfaced while fixing it: the route resolved declarations only from object.actions[], so a standalone declaration's requiredPermissions was declared-but-unenforced on REST while MCP honoured it (ADR-0066 D4, Prime Directive #10 "declared ≠ enforced").

The fix

Both headless surfaces now share one dispatch implementation.

typeOver REST
scriptHandler registry — unchanged. An action with no resolvable declaration is handler-only by definition and keeps this path.
flowautomation.execute(action.target, …) via the new shared dispatchFlowAction (the MCP path now calls it too). The caller's identity (userId/positions/permissions/tenantId) is forwarded, so a runAs: 'user' flow enforces RLS as the invoker instead of falling into the user-less UNSCOPED path (ADR-0049). No automation service on the kernel → 503, not a { success: false } body.
api400 naming the target endpoint to call directly.
url / modal / form400 — client-side navigation, nothing for the server to run.

The route also resolves standalone declarations: defineAction artifacts in the ObjectQL registry and Studio-authored action metadata rows, neither of which appears in any object's actions[]. A standalone declaration is accepted only when it belongs to the routed object or to the 'global' wildcard — the same key rotation the handler lookup performs.

Ordering is deliberate: the ADR-0066 D4 gate still runs before the type check, so an unauthorized caller learns nothing about how an action dispatches (pinned by a test). The trusted-mode [action-audit] line stays on the script branch only — RLS/FLS-bypassing elevation is a script-body property; a flow does not get it.

Files

  • packages/runtime/src/action-execution.ts — new dispatchFlowAction (extracted from the MCP path, which now calls it), headlessActionTypeError, resolveAutomationService / flowActionUnavailableError, resolveRouteActionDeclaration (3-source declaration resolution).
  • packages/runtime/src/domains/actions.ts — type dispatch + the new declaration source.
  • packages/runtime/src/domains/mcp.tslistActions uses the shared automation probe (which also requires a usable .execute, closing a latent "service present but not usable" hole).
  • content/docs/ui/actions.mdx — "Call it over REST" now documents the per-type behaviour + a troubleshooting row.
  • .changeset/rest-actions-type-dispatch.md.

Tests

New packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts (16 cases): flow dispatch instead of the registry, identity forwarding, no object key for /actions/global/:action, flow-failure envelope, the 503, the four client-side types' 400s, the gate-before-type ordering, script + undeclared-action regression guards, standalone declarations via both the registry and the metadata service, a cross-object standalone that must not match, and the 403 on a standalone requiredPermissions.

One existing fixture was corrected: the ADR-0066 D4 gate suite declared its gated action as type: 'api' with no target (not spec-valid) and asserted the handler ran — now type: 'script' + target, since an api action legitimately never reaches executeAction.

Local runs, all green: runtime 736/736 · objectql 1163/1163 · spec 6823/6823 · rest 419/419 · turbo build (incl. DTS typecheck) · eslint on the changed files.

Behaviour change to note

A caller invoking a url/modal/form/api action through this endpoint used to get { success: false, error: "Action '' on object '*' not found" } (HTTP 200) and now gets a 400 that says what to call instead. No spec-faithful action changes behaviour.

Out of scope: the HTTP-200 error envelope (#3913) is untouched — a flow's business failure still returns the existing { success: false } 200 envelope, consistent with the script path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1


Generated by Claude Code

`POST /api/v1/actions/:object/:action` had no action-type branching: every
action went to `ql.executeAction` (the script-handler registry), while the
MCP `run_action` bridge has implemented the `flow` branch since #2849. Per
spec every non-`script` type dispatches on `target`, so a REST/SDK caller
invoking a `type: 'flow'` action got `Action '' on object '*' not found` and
had to know to call `POST /api/v1/automation/:target/trigger` itself — and
for a Studio-authored flow action there was never a handler to find at all
(`resyncAuthoredActions` registers none for target/flow/url actions).
Both headless surfaces now share one dispatch:
- `flow` → `automation.execute(action.target, …)` through the new
`dispatchFlowAction`, which the MCP path calls too. The caller's identity
is forwarded so a `runAs: 'user'` flow enforces RLS as the invoker
(ADR-0049). No automation service on the kernel → 503, not a
`{ success: false }` body.
- `script` → the handler registry, unchanged; an action with no resolvable
declaration is handler-only by definition and keeps that path.
- `url` / `modal` / `form` / `api` → 400 naming the type and the
prescription, instead of a registry miss that reads as "no such action".
The route also resolves standalone declarations — `defineAction` artifacts in
the ObjectQL registry and Studio-authored `action` metadata rows, neither of
which appears in any object's `actions[]`. That is what made a flow-typed one
undispatchable, and separately left its `requiredPermissions`
declared-but-unenforced on REST while MCP honoured them. The ADR-0066 D4 gate
still runs before the type check, so an unauthorized caller learns nothing
about how an action dispatches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1
@vercel

vercelBot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 29, 2026 9:23am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

18 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/index.mdx(via @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/plugins/packages.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx(via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 3ba8d77 into mainJul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/rest-actions-type-dispatch-y5vqfj branch July 29, 2026 09:28
os-zhuang pushed a commit that referenced this pull request Jul 29, 2026
#3919 (REST action-type dispatch, #3915) landed on main and touched the same
handler. Resolved `packages/runtime/src/domains/actions.ts` to keep both:
- main's type dispatch (flow → automation service, url/modal/form/api → 400)
and its relocation of the `[action-audit]` line into the try block, after
the flow branch — a flow is not trusted-elevated, so it must not print it;
- this branch's #3914 wiring — `ctx.api`, `ctx.executionContext`, and the
shared context-carrying `buildActionEngineFacade` on the action context —
plus the corrected audit wording, now applied to main's relocated line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TufbW1LBcSwpta4gfX7Uso
os-zhuang added a commit that referenced this pull request Jul 29, 2026
…ow-up) (#3934)
Seeds `recordId`, the `<objectName>Id` camelCase alias, and any declared
`recordIdParam` into a flow action's params bag on both headless surfaces
(REST /actions and MCP run_action), matching what the automation trigger
route already seeds. Caller-supplied params still win.
Found by dogfooding #3919 against a running app: the actions endpoint
reached the flow engine but died at the first node because the bag carried
the record's `id` and never `recordId`, the key the flow actually reads.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude