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

fix(runtime): a declared `type: 'flow'` endpoint answers the #9378 flow-dispatch status table, from the one shared definition (#9462)

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable
changes: no spec schema, no metadata key, no stored `sys_metadata` shape. The
change is which HTTP status and `error.code` one transport seam writes for a
flow dispatch that was refused or failed, so `objectstack migrate meta` has
nothing it could rewrite and there is no conversion-layer entry to register. The
existing declaration for such an endpoint is byte-for-byte valid before and
after. -->

**BREAKING** for any caller that reads a declared endpoint's flow result out of
the response body instead of the HTTP status.

`POST /api/v1/apps/<namespace>/<subpath>` with `type: 'flow'` used to answer
`200` for every outcome, with the raw engine result in `data` — so a flow that
was disabled, had no start node, could not be found, or ran and was rejected all
reached the caller as `{"success":true,"data":{"success":false,…}}`. That is the
double envelope #3962 removed from `POST /api/v1/actions/:object/:action`, and
it was still standing on the surface an app publishes as its own public API: a
client branching on the HTTP status read every one of those failures as a
success.

It now answers the same four rows the other two flow doors answer, read from the
one shared definition in `packages/runtime/src/flow-dispatch-status.ts` rather
than from a third private copy of the rule:

| engine exit | reality | the endpoint answers |
|:---|:---|:---|
| flow not found | never dispatched | `404` |
| flow disabled | never dispatched | `409` `FLOW_DISABLED` |
| flow has no start node | never dispatched | `422` `FLOW_NO_START_NODE` |
| ran and was rejected | ran, rejected | `400` `FLOW_FAILED` |

What a caller sees differently:

- **A failed or refused flow is now a 4xx.** The body is the platform's declared
error envelope, `{"success":false,"error":{"code","message","httpStatus"}}`;
there is no inner `data.success` left to read. A caller that already branched
on the status now sees the failure it was previously told was a success; a
caller that branched on `data.success` gets the same fact from `error.code`.
- **A `400` carries the run's own artefacts** in `error.details`
(`errorMessage`, `summary`), exactly as `POST /api/v1/automation/:name/trigger`
carries them. The three never-dispatched rows carry neither, because no run
happened to describe.
- **A successful run is unchanged** — still `200` with the result in `data`.
- **An `outputMapping` declaration is no longer applied to a failure.** The
projection was already restricted to answers with a status below 400, so the
refusal rows fall outside it by the rule that was already written. This closes
a real hole: an `outputMapping` used to be applied to the `200`-wrapped failure
body and could present a refused dispatch as data.
- Both policy behaviours keyed on the same test move with it: `cacheTtl`'s
`Cache-Control` no longer rides a flow failure, and the `rateLimit` /
`authRequired` chain is untouched — it runs before execution either way.

This is the third and last door of the #9446 ruling (maintainer, 2026-08-18,
verbatim 「同意」: the status table is a property of the flow-dispatch CONTRACT,
not of the trigger route). All three doors now read one definition, and the
suite asserts that by driving the same engine result through all three and
comparing.
15 changes: 8 additions & 7 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1310,13 +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.
**The same table answers at every door that dispatches a flow.** Invoking a
`type: 'flow'` [action](/docs/ui/actions) through
`POST /api/v1/actions/:object/:action`, and calling a declared `type: 'flow'`
[endpoint](/docs/protocol/kernel/http-protocol) under `/api/v1/apps/`, both
dispatch the same flow through the same service call and classify the outcome
the same way — the table above has one definition that all three doors read, so
they cannot answer one engine outcome differently. Branch on the status and
`error.code` at any of them.

```
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`), 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 |
| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder, the same `execute` call, and **the same response contract**: a refused or failed run is classified into the same real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), from one shared definition all three flow doors read. Branch on the status and `error.code`, never on an inner success flag |
| `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
98 changes: 97 additions & 1 deletion packages/runtime/src/endpoint-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,12 @@ import { apiErrorResponse } from './error-envelope.js';
import { isServiceServeable } from './service-serveable.js';
import { validationFailure } from './validation-failure.js';
import { buildAutomationContext } from './domains/automation.js';
import {
classifyFlowRefusal,
flowIsUnknown,
flowNotFoundMessage,
FLOW_NOT_FOUND_STATUS,
} from './flow-dispatch-status.js';
import type { HttpProtocolContext } from './http-dispatcher.js';

// ============================================================================
Expand DownExpand Up@@ -446,6 +452,41 @@ async function executeObjectOperation(
*
* The request body is the flow input, exactly as on `POST
* /automation/:name/trigger`.
*
* ## [#9462] The outcome is the #9378 status table, from the ONE definition
*
* | engine exit | reality | this door answers |
* |------------------------|------------------|----------------------------|
* | flow not found | never dispatched | `404` |
* | flow disabled | never dispatched | `409` `FLOW_DISABLED` |
* | flow has no start node | never dispatched | `422` `FLOW_NO_START_NODE` |
* | ran and failed | ran, rejected | `400` `FLOW_FAILED` |
*
* Read from `../flow-dispatch-status.js` — the third and last door to converge
* on it (maintainer ruling, 2026-08-18, verbatim 「同意」: the table is a
* property of the flow-dispatch CONTRACT, not of the trigger route). ⛔ A
* fourth copy of the table, here or anywhere, is the defect by construction:
* three private readings of one engine result is exactly how a DISABLED flow
* came to be `409` at the trigger door, `400 FLOW_FAILED` at `/actions`, and
* `200` here, all at once.
*
* **This is a BREAKING change to what this seam answers.** Until now every
* outcome was `200` with the raw result in `data`, so a failing flow reached
* the caller as `{"success":true,"data":{"success":false,…}}` — the double
* envelope #3962 ruled out for `/actions`, on a surface an app publishes as
* its own public API. A consumer that branched on `data.success` now gets a
* 4xx whose `error.code` carries the same fact; one that branched on the HTTP
* status alone was reading failures as successes and now reads them correctly.
*
* `outputMapping` needs nothing here and deliberately gets nothing: it is
* applied by `api-endpoint-step.ts` on `answer.status < 400`, so the refusal
* rows fall outside it by the rule that was already written — which is also
* the fix for a real hole, since an `outputMapping` projection used to be
* applied to the `200`-wrapped FAILURE body and could present it as data. The
* policy chain is upstream of this function and is untouched: a refusal here
* is reached only by a request that already passed `rateLimit` /
* `authRequired`, and `Cache-Control` from `cacheTtl` rides success only,
* again on the same `status < 400` test.
*/
async function executeFlow(
ctx: EndpointExecutionContext,
Expand DownExpand Up@@ -479,8 +520,63 @@ async function executeFlow(
});
}

// [#9462] Row 1 of the table, answered by the SAME optional `getFlow`
// registry probe the trigger door and `/actions` use — the engine's
// not-found exit carries neither a `code` nor a `status`, so this is the
// only reading of it that is not a regex over its message (PD #12). A
// service that omits `getFlow` cannot be asked and dispatches as before,
// exactly as at the other two doors.
if (await flowIsUnknown(service, plan.flow)) {
return apiErrorResponse({
message: sanitizeMessage(flowNotFoundMessage(plan.flow), FLOW_NOT_FOUND_STATUS),
httpStatus: FLOW_NOT_FOUND_STATUS,
});
}

const automationContext = buildAutomationContext(ctx.body, ctx.protocolContext) as AutomationContext;
return successAnswer(await automation.execute(plan.flow, automationContext));
const result = await automation.execute(plan.flow, automationContext);

// [#9462] Rows 2-4, read off the PRODUCER's classification through the one
// shared table. What stood here was an unconditional `successAnswer`, so
// EVERY refusal — a flow that never dispatched included — reached the
// caller as `200 {success:true,data:{success:false,…}}`: the double
// envelope #3962 removed from `/actions`, on a surface an app publishes as
// its own public API.
const refusal = classifyFlowRefusal(plan.flow, result);
if (refusal) {
// The run's own artefacts ride the 400 arm ONLY — they describe a run
// that happened, and a never-dispatched refusal has neither an author
// failure text nor a node log to point at. Byte-identical to the
// trigger door's details (`domains/automation.ts`), because #5040 §4
// makes that route's answer this seam's contract.
const runDetails = refusal.code === 'FLOW_FAILED'
? {
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}),
}
: {};
return apiErrorResponse({
message: sanitizeMessage(refusal.message, refusal.status),
httpStatus: refusal.status,
code: refusal.code,
...(Object.keys(runDetails).length > 0 ? { details: runDetails } : {}),
});
}

// An UNCLASSIFIED `success: false` keeps today's 200 — this door reads it
// the TRIGGER door's way, not `/actions`'s, and the difference is decided
// by what a declared endpoint IS. #5040 §4 (this module's opening rule)
// makes a `type: 'flow'` endpoint a stable URL plus a policy layer over
// `POST /automation/:name/trigger`: same context builder, same `execute`
// call, so the same answer, or the alias has become the second execution
// dialect the whole module exists to prevent. `/actions` refuses the
// residual under its own #3962 ruling about ITS route; adopting that here
// would PROMOTE an exit the producer never classified — the one thing the
// shared table's note says a door must not do — and would do it by
// borrowing a ruling about a different door. If the residual should speak
// HTTP everywhere, that is a change to the shared table for all three
// doors, not a fourth reading invented at this one.
return successAnswer(result);
}

/**
Expand Down
Loading
Loading