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
22 changes: 22 additions & 0 deletions .changeset/actions-flow-dispatch-status-table.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
'@objectstack/runtime': minor
---

`POST /api/v1/actions/:object/:action` answers the flow-dispatch status table instead of one blanket `400 FLOW_FAILED` (#9446).

**What a caller sees differently.** A `type: 'flow'` action whose dispatch is REFUSED no longer reports a failed run. Three answers changed:

| the flow behind the action | before | now |
|---|---|---|
| is not registered | `400` `FLOW_FAILED` | `404` `RESOURCE_NOT_FOUND` |
| is switched off | `400` `FLOW_FAILED` | `409` `FLOW_DISABLED` |
| has no `start` node | `400` `FLOW_FAILED` | `422` `FLOW_NO_START_NODE` |
| ran and was rejected | `400` `FLOW_FAILED` | `400` `FLOW_FAILED` (unchanged) |

These are the same four rows `POST /api/v1/automation/:name/trigger` has answered since #9378 + #9415, and they now come from one shared definition both doors read, so the two cannot drift apart again.

**Behaviourally breaking for a caller that branches on the status or the code.** Every one of these was a `400` before, so a caller treating `400` as "the run failed" was being told something false in three of the four cases: nothing had dispatched and no node had executed. A client that lumps all four together keeps working — they are all still refusals, all still `success: false` with no inner envelope — but one that reports "the flow failed" on a `400` should now distinguish. **Retry semantics differ per row**, which is the practical reason to: `409 FLOW_DISABLED` is reversible operational state (enable the flow and the identical request succeeds), while `404` and `422 FLOW_NO_START_NODE` are authoring defects that no retry fixes. `400 FLOW_FAILED` remains terminal, exactly as the console already treats it.

**Unchanged on purpose.** A successful run still answers `200` with the single `data` wrap (#3962). The `400 FLOW_FAILED` message keeps its existing wording (`Flow '<target>' failed: …`), which names the flow the action dispatches — the trigger route's URL carries that name and this route's does not. A `success: false` result the automation engine did not classify still refuses with `400 FLOW_FAILED` rather than falling back to `200 {success:true,data:{success:false}}` — the double envelope #3962 removed from this route.

**Not in scope.** Declared endpoints (`type: 'flow'` endpoints, `endpoint-executor.ts`) still answer `200` for every outcome. That door converges in its own change (#9462), where the envelope flip is a breaking change for consumers of the current double envelope and is sequenced against them.
8 changes: 8 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1310,6 +1310,14 @@ to do it — never the message text:
The first three never dispatched anything: no node executed, no record was
written, and there is no run to look up. Only `400` describes a run.

**The same table answers at the action door.** Invoking a `type: 'flow'`
[action](/docs/ui/actions) through `POST /api/v1/actions/:object/:action`
dispatches the same flow through the same service call, and it classifies the
outcome the same way — the table above has one definition that both doors read,
so they cannot answer one engine outcome differently. Declared endpoints
(`type: 'flow'`) are the remaining exception: they still answer `200` for every
outcome, so read `data.success` there rather than the status.

```
POST /api/v1/automation/order_approval/trigger

Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/kernel/http-protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1216,7 +1216,7 @@ declaration to shadow a built-in route:
| Endpoint declares | Answer |
|:---|:---|
| `type: 'object_operation'` | delegated to the same `callData` binding that serves `/api/v1/data/{object}` — byte-identical `data` |
| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder and the same `execute` call, so the run itself is identical. **The response is not**: the trigger route classifies a refused or failed run into real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), while this seam still answers `200` with the result in `data` for every outcome ([#9446](https://github.com/objectstack-ai/objectstack/issues/9446)). Read `data.success` here, not the status |
| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder and the same `execute` call, so the run itself is identical. **The response is not**: the trigger route classifies a refused or failed run into real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), and `POST /api/v1/actions/{object}/{action}` answers that same table since #9446, while this seam still answers `200` with the result in `data` for every outcome ([#9462](https://github.com/objectstack-ai/objectstack/issues/9462)). Read `data.success` here, not the status |
| `authRequired: true` (or omitted) + anonymous caller | `401` `UNAUTHENTICATED`, the same envelope every seam answers |
| `rateLimit` armed and exhausted | `429` + `Retry-After`, never with a cache directive |
| `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/actions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -343,7 +343,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
| `type` | Over REST |
|:---|:---|
| `script` | Runs the registered handler / inline body. |
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name. ⚠️ It does **not** answer the same way: any unsuccessful outcome comes back as **400** `FLOW_FAILED`, where the trigger route separates a run that failed (400) from one that was never dispatched (404 / 409 / 422) — see [#9446](https://github.com/objectstack-ai/objectstack/issues/9446). |
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE`). See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. |
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |

Expand Down
83 changes: 77 additions & 6 deletions packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,15 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } fr
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts';
import { checkApiExposure } from './api-exposure.js';
// [#9446] The ONE #9378 status table. Imported rather than re-read here: this
// door's blanket `FLOW_FAILED` was the second of three readings of one engine
// result, and a second definition of the rule is what let the doors diverge.
import {
classifyFlowRefusal,
flowIsUnknown,
flowNotFoundMessage,
FLOW_NOT_FOUND_STATUS,
} from './flow-dispatch-status.js';
// [#5138] The ONE 404 envelope a single-record path answers. Imported rather
// than re-spelled so `callData`'s ObjectQL fallback and the protocol service it
// falls back FROM cannot disagree about what "this id names no row" looks like.
Expand DownExpand Up@@ -561,10 +570,33 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,
* The ONE implementation both headless surfaces share — the MCP `run_action`
* tool and the REST `/actions/:object/:action` route (#3915, which is exactly
* the asymmetry that let this branch exist on only one of them). Throws on a
* missing automation service and converts a `{ success: false }` engine result
* into a throw so both callers report failure the same way; returns the raw
* missing automation service and converts a refused or failed dispatch into a
* throw so both callers report failure the same way; returns the raw
* automation result otherwise.
*
* [#9446] **The refusal it throws is the #9378 table, read from the ONE
* definition** (`./flow-dispatch-status.js`) that the trigger door reads too:
*
* | engine exit | this door answers |
* |------------------------|----------------------------|
* | flow not found | `404` |
* | flow disabled | `409` `FLOW_DISABLED` |
* | flow has no start node | `422` `FLOW_NO_START_NODE` |
* | ran and failed | `400` `FLOW_FAILED` |
*
* Maintainer ruling (2026-08-18, verbatim 「同意」): the table is a property of
* the flow-dispatch CONTRACT, not of the trigger route, so this door converges
* on it rather than keeping its own reading. It used to map EVERY
* `success: false` to `400 FLOW_FAILED` under a comment asserting "The flow
* RAN and rejected" — a false statement for the two never-dispatched exits it
* caught, told to a caller whose only machine-readable signal is that code.
*
* The throw carries `status` and `code` and the route serves them through
* `errorFromThrown`; `error.details` is whatever `resolveThrownHttpError`
* reads off a thrown value, so the trigger door's `errorMessage` / `summary`
* details do NOT ride this door — see the shared module's note on what the
* table deliberately does not answer.
*
* Forwarding the caller's identity (rather than just executing the flow) is
* what lets a `runAs: 'user'` flow enforce RLS as the invoker instead of
* falling into the user-less UNSCOPED path (#2849, ADR-0049 / #1888; mirrors
Expand DownExpand Up@@ -593,6 +625,16 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
if (!automation) {
throw new Error(flowActionUnavailableError(action));
}
// [#9446] Row 1 of the table, answered by the SAME optional `getFlow`
// registry probe the trigger door uses — the engine's not-found exit
// carries no classification, so this is the only way to read it that is not
// a regex over its message. A service that omits `getFlow` cannot be asked
// and dispatches as before.
if (await flowIsUnknown(automation, action.target)) {
const err: any = new Error(flowNotFoundMessage(action.target));
err.status = FLOW_NOT_FOUND_STATUS;
throw err;
}
// Pass a proper AutomationContext (the engine never read the former
// `triggerData` envelope).
const result: any = await automation.execute(action.target, {
Expand All@@ -604,11 +646,40 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
...(ec?.tenantId ? { tenantId: ec.tenantId } : {}),
params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }),
});
// [#9446] Rows 2-4, read off the PRODUCER's classification through the one
// shared table. What stood here mapped every `success: false` to
// `400 FLOW_FAILED` under a comment claiming "the flow RAN and rejected" —
// false for two of the exits it caught, and the producer's own `code` was
// available and ignored. A disabled flow invoked through an action told the
// caller a run had failed when no node ever executed.
const refusal = classifyFlowRefusal(action.target, result);
if (refusal) {
const err: any = new Error(
// The ran-and-failed row keeps THIS door's wording, byte for byte:
// it has been on the wire since #3962, the ruling is about status
// and code, and re-labelling a message nobody asked about would be
// an unruled change riding along. It also names the flow, which
// this door needs and the trigger door does not — the flow name is
// in that route's URL and is nowhere in this one. The two
// never-dispatched rows are NEW here, so they take the shared
// table's message: the producer's own words, exactly as the
// trigger door serves them.
refusal.code === 'FLOW_FAILED'
? `Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`
: refusal.message,
);
err.status = refusal.status;
err.code = refusal.code;
throw err;
}
// An UNCLASSIFIED `success: false` still refuses, and `FLOW_FAILED` stays
// its answer — deliberately NOT the trigger door's 200. This route settled
// in #3962 that failures speak HTTP, so the alternative residual here is
// the `200 {success:true,data:{success:false}}` double envelope that
// ruling removed. `FLOW_FAILED` is what this exit has answered all along;
// narrowing which refusals reach it is this card's change, re-labelling
// the residual is not.
if (result && typeof result === 'object' && 'success' in result && result.success === false) {
// The flow RAN and rejected — a deliberate business rejection, served
// as a 400 (#3962). Tagging the status/code here (rather than relying
// on the route's name heuristic) keeps the semantic `FLOW_FAILED` on
// the wire for callers that branch on `err.code`.
const err: any = new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`);
err.status = 400;
err.code = 'FLOW_FAILED';
Expand Down
Loading
Loading