Skip to content

Commit 48032c9

Browse files
os-zhuangclaude
andauthored
fix(automation): answer real HTTP status codes on both trigger routes (#9413)
* fix(automation): answer real HTTP status codes on both trigger routes POST /api/v1/automation/:name/trigger and the legacy POST /api/v1/automation/trigger/:name both ended `deps.success(result)` unconditionally, so a flow that ran and failed came back as HTTP 200 wrapping an inner {success:false} — the double envelope #3962 ruled out for /actions and #8684 closed on the resume route. Producer first: the engine stamps `status: 'failed'` on the exits that dispatched a run and were rejected (the same verdict it already writes to the run log); its never-dispatched exits carry no status. The route reads that verdict rather than sniffing summary/durationMs, and both doors now answer through one shared mapper. - ran and failed (incl. the retry-strategy exit) -> 400 FLOW_FAILED, with the author's errorMessage and the run summary in error.details - unknown flow -> 404, through the same registry probe /:name/toggle and GET /:name use, before anything is dispatched The ruling's disabled (409) and no-start-node (422) rows are NOT implemented: both are never-dispatched exits and the closed AutomationResult.code union has no honest member to tell them apart. Escalated on the card rather than guessed; they keep today's behaviour and are pinned as unchanged. * test(dogfood): migrate the flow-runAs write leg to the ruled 400 FLOW_FAILED The `runAs:'user'` WRITE leg triggers a flow whose `update_record` node is refused at the record layer, so the run fails — and since the trigger route answers real HTTP status codes it answers 400 FLOW_FAILED instead of 200 wrapping an inner {success:false}. `memberTrigger`'s blanket `< 300` was written against the old contract and is the only dogfood consumer this change touches. The semantic the file pins is unchanged and still primary: the run executes AS the member, so the admin's note stays 'new'. What moves is the transport expectation, and it is asserted precisely rather than as a widened band — status 400, error.code FLOW_FAILED, the node-first access-refusal text, and the `touch` node's failure entry in error.details.summary. A 403 or 500 here would mean de-elevation broke differently and must not pass. The READ leg is deliberately NOT migrated: an RLS-scoped read is FILTERED, not refused, so that run still succeeds with an empty `found` and keeps its `< 300` expectation. Measured locally, not assumed. That leg also gains discrimination for free: until now a failed run and an empty read were indistinguishable there, because both left `found` falsy under HTTP 200. Verified locally on a built workspace closure: vitest run test/flow-runas.dogfood.test.ts → 5 passed (was 1 failed) flow-node · flow-function-effect · flow-durable-suspend · showcase-declarative-mcp → 17 passed showcase-anonymous-deny-surfaces · authz-conformance → 46 passed pnpm --filter @objectstack/dogfood typecheck → green Every other dogfood trigger caller expects the run to SUCCEED (or asserts an anonymous 401), so none is touched by this contract. No changeset change: @objectstack/dogfood is private internal QA and the wire change is already documented in .changeset/automation-trigger-status-unification.md. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2d9e04 commit 48032c9

9 files changed

Lines changed: 794 additions & 13 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/client': minor
5+
---
6+
7+
**BREAKING** — both automation `trigger` routes answer real HTTP status codes for a flow
8+
that ran and failed, instead of HTTP 200 wrapping an inner `{success: false}`.
9+
10+
This is the second and wider half of the migration the resume route shipped in the same
11+
release (#8684, merged 2026-08-17): one SDK-visible behaviour change, one migration note.
12+
The resume flip touched the screen-flow runner; this one touches the door every app
13+
dispatches flows through.
14+
15+
Until now a flow driven to a node failure answered:
16+
17+
```
18+
HTTP 200
19+
{"success":true,"data":{"success":false,"error":"Node 'create_opportunity' failed: …"}}
20+
```
21+
22+
The run genuinely failed; the transport reported success. A scripted or integration caller
23+
that branches on the HTTP status alone read a failed run as a successful one. This applies
24+
the `/actions` ruling (business failures must not ride HTTP 200 inside a double envelope)
25+
to `POST /api/v1/automation/:name/trigger` and to the legacy
26+
`POST /api/v1/automation/trigger/:name` — the shape `client.automation.trigger()` calls.
27+
Both doors answer through one mapper, so they cannot drift.
28+
29+
What changes on the wire:
30+
31+
- **A flow that ran and then failed ⇒ `400` with `error.code: 'FLOW_FAILED'`.** The node
32+
failure stays the human-readable `error.message`. The flow author's own `errorMessage`
33+
travels in `error.details.errorMessage` — one documented location, the same one the
34+
console reads — and the run's per-node accounting in `error.details.summary`. A flow
35+
whose `errorHandling.strategy` is `retry` answers the same way once its attempts are
36+
exhausted. `durationMs` is no longer carried on this response.
37+
- **A flow name the deployment does not hold ⇒ `404`,** answered before anything is
38+
dispatched, through the same registry probe `POST /:name/toggle` and `GET /:name` use.
39+
- **Unchanged:** a successful run still answers 200 with its result, and a run that PAUSED
40+
at a `screen` node still answers 200 with the next screen — a pause is not a failure.
41+
- **Also unchanged, pending a ruling:** a DISABLED flow and one with no start node still
42+
answer 200 with the inner failure. Both are exits that never dispatched anything, and
43+
telling them apart needs a producer-side classification the closed
44+
`AutomationResult.code` union cannot yet express. Tracked on #9378.
45+
46+
**`@objectstack/service-automation`:** `execute()` now stamps `status: 'failed'` on the
47+
results of runs that dispatched and were rejected — the same lifecycle verdict it already
48+
writes to the run log. Its never-dispatched exits carry no `status`, which is what lets a
49+
transport answer the two classes differently without inspecting the result's internals.
50+
51+
**`@objectstack/client`:** `client.automation.trigger()`, `client.automation.execute()` and
52+
`client.project(id).automation.execute()` now **reject** on a failed run instead of
53+
resolving with `{ success: false, error }` — the SDK throws on every non-2xx before
54+
unwrapping. Callers that inspected the resolved value must move to a `catch`:
55+
56+
```ts
57+
try {
58+
await client.automation.execute(flow, { params });
59+
} catch (err: any) {
60+
err.code; // 'FLOW_FAILED' (400) — the run ran and failed
61+
err.httpStatus; // 400 | 404
62+
err.message; // the node failure, verbatim
63+
err.details?.errorMessage; // the flow author's own message, when the flow declares one
64+
err.details?.summary; // which node failed
65+
}
66+
```
67+
68+
Raw-HTTP callers that treated `2xx` as success and never opened the inner envelope now see
69+
the failure they were already being told about, one level up.
70+
71+
<!-- adr-0087: not-required (no-migration-prescription) retires no metadata surface: no Zod schema, no authorable key, no stored sys_metadata row changes shape, so `objectstack migrate meta` has nothing to rewrite and no ledger entry can be written for it. What changes is an HTTP status plus an SDK method's promise contract, and the only channel that reaches those consumers is this changeset itself. -->

packages/client/src/client.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,90 @@ describe('ObjectStackClient.automation', () => {
13541354
expect(err.message).toMatch(/no longer exists in flow/);
13551355
});
13561356

1357+
// [#9378] BREAKING, and the wide half of the same flip: DISPATCHING a flow.
1358+
// `client.automation.trigger()` (legacy route) and `.execute()` used to
1359+
// resolve with `{ success: false, error }` under HTTP 200 for a run that
1360+
// ran and failed — every app dispatches flows through this door, so a
1361+
// caller that never opened the inner envelope read every failed run as a
1362+
// successful one. The route answers 400 `FLOW_FAILED` now and this SDK's
1363+
// fetch layer throws on non-2xx before any unwrapping, so both surfaces
1364+
// REJECT. No SDK code changed; the contract did, and these are its pins.
1365+
//
1366+
// Both spellings are pinned, not one: `trigger()` reads `res.json()` while
1367+
// `execute()` reads `unwrapResponse()`, so a regression in either unwrap
1368+
// path would be invisible from the other's test.
1369+
const failedRunBody = {
1370+
success: false,
1371+
error: {
1372+
code: 'FLOW_FAILED',
1373+
message: "Node 'create_opportunity' failed: Amount must be greater than zero",
1374+
httpStatus: 400,
1375+
details: {
1376+
errorMessage: 'We could not create the opportunity — check the amount and try again.',
1377+
summary: { nodes: [{ nodeId: 'create_opportunity', status: 'failure' }] },
1378+
},
1379+
},
1380+
};
1381+
1382+
it('should reject with FLOW_FAILED when a triggered flow runs and fails (legacy trigger)', async () => {
1383+
const { client } = createMockClient(failedRunBody, 400);
1384+
1385+
const err: any = await client.automation
1386+
.trigger('my_flow', { amount: 0 })
1387+
.then(() => { throw new Error('expected the failed trigger to reject'); }, (e) => e);
1388+
1389+
// The classification, not merely that it threw: an SDK rejecting with a
1390+
// bare `Error` would satisfy `.rejects.toThrow()` while losing
1391+
// everything a caller branches on.
1392+
expect(err.code).toBe('FLOW_FAILED');
1393+
expect(err.httpStatus).toBe(400);
1394+
expect(err.message).toMatch(/Node 'create_opportunity' failed/);
1395+
// The flow author's own text keeps its one documented location — the
1396+
// ADR-0112 envelope has no `data`, and the console reads it from here.
1397+
expect(err.details?.errorMessage)
1398+
.toBe('We could not create the opportunity — check the amount and try again.');
1399+
});
1400+
1401+
it('should reject with FLOW_FAILED when execute() dispatches a flow that fails', async () => {
1402+
const { client } = createMockClient(failedRunBody, 400);
1403+
1404+
const err: any = await client.automation
1405+
.execute('my_flow', { params: { amount: 0 } })
1406+
.then(() => { throw new Error('expected the failed execute to reject'); }, (e) => e);
1407+
1408+
expect(err.code).toBe('FLOW_FAILED');
1409+
expect(err.httpStatus).toBe(400);
1410+
expect(err.details?.summary?.nodes?.[0]?.status).toBe('failure');
1411+
});
1412+
1413+
it('should reject with 404 when the triggered flow does not exist', async () => {
1414+
const { client } = createMockClient({
1415+
success: false,
1416+
error: { code: 'RESOURCE_NOT_FOUND', message: "Flow 'no_such_flow' not found", httpStatus: 404 },
1417+
}, 404);
1418+
1419+
const err: any = await client.automation
1420+
.execute('no_such_flow', {})
1421+
.then(() => { throw new Error('expected the unknown flow to reject'); }, (e) => e);
1422+
1423+
expect(err.httpStatus).toBe(404);
1424+
expect(err.code).not.toBe('FLOW_FAILED');
1425+
expect(err.message).toContain('no_such_flow');
1426+
});
1427+
1428+
it('should still resolve when a triggered flow succeeds', async () => {
1429+
// The other half of the contract: a successful dispatch is untouched,
1430+
// including the paused screen-flow shape the runner drives.
1431+
const { client } = createMockClient({
1432+
success: true,
1433+
data: { success: true, status: 'paused', runId: 'run_1', screen: { nodeId: 'collect', fields: [] } },
1434+
}, 200);
1435+
1436+
const result: any = await client.automation.execute('my_flow', {});
1437+
expect(result.status).toBe('paused');
1438+
expect(result.runId).toBe('run_1');
1439+
});
1440+
13571441
it('should fetch the screen a paused run awaits', async () => {
13581442
const { client, fetchMock } = createMockClient({
13591443
success: true,

packages/client/src/index.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3016,6 +3016,28 @@ export class ObjectStackClient {
30163016
automation = {
30173017
/**
30183018
* Trigger a named automation flow (legacy endpoint)
3019+
*
3020+
* **BREAKING since #9378 — a failed run REJECTS instead of resolving.**
3021+
* A flow that ran and then failed used to come back as a resolved
3022+
* `{ success: false, error }` riding HTTP 200, so a caller that did not
3023+
* open the inner envelope read a failed run as a successful one. The
3024+
* route answers **400** `FLOW_FAILED` now (inheriting #3962's ruling for
3025+
* `/actions`, applied to the resume route by #8684), and every non-2xx
3026+
* throws out of this SDK's fetch layer — so this promise **rejects**:
3027+
*
3028+
* ```ts
3029+
* try { await client.automation.trigger(flow, payload); }
3030+
* catch (err: any) {
3031+
* err.code; // 'FLOW_FAILED'
3032+
* err.httpStatus; // 400
3033+
* err.message; // the node failure, verbatim
3034+
* err.details?.errorMessage; // the flow author's `errorMessage`
3035+
* err.details?.summary; // per-node accounting of the failed run
3036+
* }
3037+
* ```
3038+
*
3039+
* A flow name the deployment does not hold rejects with **404** instead.
3040+
* A run that PAUSED at a screen node is not a failure and still resolves.
30193041
*/
30203042
trigger: async (triggerName: string, payload: any) => {
30213043
const route = this.getRoute('automation');
@@ -3168,7 +3190,15 @@ export class ObjectStackClient {
31683190
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(name)}`);
31693191
return this.unwrapResponse(res) as Promise<T>;
31703192
},
3171-
/** Execute (trigger) a flow with an execution context. */
3193+
/**
3194+
* Execute (trigger) a flow with an execution context.
3195+
*
3196+
* **BREAKING since #9378**: a run that ran and failed now REJECTS with
3197+
* `400` `FLOW_FAILED` (author text on `err.details.errorMessage`, per-node
3198+
* accounting on `err.details.summary`) instead of resolving with an inner
3199+
* `{ success: false }` under HTTP 200; an unknown flow rejects with `404`.
3200+
* See `automation.trigger` for the full shape — both call the same door.
3201+
*/
31723202
execute: async <T = any>(name: string, ctx?: Record<string, any>): Promise<T> => {
31733203
const route = this.getRoute('automation');
31743204
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(name)}/trigger`, {
@@ -5342,6 +5372,12 @@ export class ScopedProjectClient {
53425372
/**
53435373
* Execute (trigger) a flow by name. The request body is forwarded as the
53445374
* automation execution context (e.g. `{ params, trigger }`).
5375+
*
5376+
* Mirrors the unscoped `client.automation.execute` — including its
5377+
* **breaking #9378 behaviour**: a run that ran and then failed REJECTS with
5378+
* `400` `FLOW_FAILED` (author text on `err.details.errorMessage`) instead of
5379+
* resolving with an inner `{ success: false }` under HTTP 200, and an
5380+
* unknown flow rejects with `404`. See that method for the full shape.
53455381
*/
53465382
execute: async <T = any>(name: string, ctx?: Record<string, any>): Promise<T> => {
53475383
const res = await this.parent._fetch(this.url(`/automation/${encodeURIComponent(name)}/trigger`), {

packages/qa/dogfood/test/flow-runas.dogfood.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
// object the member cannot read or write directly:
2020
// • system flows succeed on the admin's note → elevation is REAL,
2121
// • user flows are RLS-denied on the same note → de-elevation is REAL.
22+
// The two user-mode legs are denied DIFFERENTLY, and both shapes are asserted:
23+
// the WRITE is refused at the record layer, so the run fails and the trigger
24+
// route answers 400 `FLOW_FAILED` (#9378 — it rode HTTP 200 with an inner
25+
// `{success:false}` until then); the READ is filtered by RLS, so the run
26+
// SUCCEEDS with an empty `found`. Neither is a status band: a write leg that
27+
// started answering 403/500, or a read leg that started failing outright, is a
28+
// different bug and must not pass here.
2229
// Before the #1888 fix the user flows wrongly succeed (CRUD nodes passed no
2330
// identity → security skipped) → this file is RED; after the fix → GREEN.
2431

@@ -61,7 +68,18 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', ()
6168
return (j.record ?? j).status;
6269
}
6370

64-
/** Trigger a flow as the restricted member; returns the inner AutomationResult. */
71+
/**
72+
* Trigger a flow as the restricted member and require the run to have
73+
* COMPLETED; returns the inner AutomationResult.
74+
*
75+
* [#9378] Since the trigger route answers real HTTP status codes, this helper
76+
* is also the discriminator it could not be before: a run that fails now
77+
* comes back 400 and is rejected HERE. Until then a failed run rode HTTP 200
78+
* with `{success:false}` inside, so the read leg below — which only checks
79+
* that `found` is falsy — passed identically whether the RLS-scoped read
80+
* returned EMPTY (the thing it means to prove) or the run DIED before
81+
* reading anything at all.
82+
*/
6583
async function memberTrigger(flow: string, noteId: string): Promise<{ success?: boolean; output?: any }> {
6684
const res = await stack.apiAs(memberToken, 'POST', `/automation/${flow}/trigger`, { params: { noteId } });
6785
expect(res.status, `trigger ${flow} HTTP failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300);
@@ -70,6 +88,50 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', ()
7088
return body.data ?? {};
7189
}
7290

91+
/**
92+
* Trigger a flow as the restricted member and require the run to have RUN AND
93+
* FAILED on a record-access refusal — the de-elevation proof's write leg.
94+
*
95+
* [#9378] The transport contract this asserts, in full rather than by status
96+
* band: the route answers **400** `FLOW_FAILED` (ADR-0112 envelope), the node
97+
* failure is `error.message` verbatim, and the per-node accounting in
98+
* `error.details.summary` names WHICH node failed. Asserting the band
99+
* (`>= 400`) or the throw alone would keep passing if the refusal turned into
100+
* a 403 authorization verdict or a 500 fault — both of which would mean the
101+
* de-elevation broke in a different way, and both of which this file exists
102+
* to catch.
103+
*
104+
* Before #9378 this same run answered `HTTP 200 {"success":true,"data":{
105+
* "success":false,…}}`, so the ONLY visible trace of the denial was the
106+
* record staying unchanged. That assertion is still below and still the
107+
* primary proof; this one is the transport half that used to be invisible.
108+
*/
109+
async function memberTriggerExpectingAccessRefusal(flow: string, noteId: string, failingNodeId: string) {
110+
const res = await stack.apiAs(memberToken, 'POST', `/automation/${flow}/trigger`, { params: { noteId } });
111+
const text = await res.clone().text();
112+
expect(res.status, `trigger ${flow} should be 400 FLOW_FAILED: ${res.status} ${text}`).toBe(400);
113+
114+
const body = (await res.json()) as {
115+
success?: boolean;
116+
data?: unknown;
117+
error?: { code?: string; message?: string; httpStatus?: number; details?: { summary?: { nodes?: any[] } } };
118+
};
119+
expect(body.success).toBe(false);
120+
expect(body.error?.code, `expected FLOW_FAILED: ${text}`).toBe('FLOW_FAILED');
121+
expect(body.error?.httpStatus).toBe(400);
122+
// The double envelope is GONE, not re-labelled: nothing is left for a
123+
// status-blind caller to misread as a successful run.
124+
expect(body.data).toBeUndefined();
125+
// The failure is the RLS refusal on the note, named node-first — not a
126+
// generic "flow failed", which would pass while the run died of anything.
127+
expect(body.error?.message).toContain(`Node '${failingNodeId}' failed`);
128+
expect(body.error?.message).toMatch(/do not have access to this record/i);
129+
// Which node failed survives the envelope change — the reason `summary`
130+
// rides in `details` at all.
131+
const failed = body.error?.details?.summary?.nodes?.find((n) => n?.nodeId === failingNodeId);
132+
expect(failed?.status, `no failure entry for node '${failingNodeId}' in ${text}`).toBe('failure');
133+
}
134+
73135
it('precondition: the automation service is wired and a flow is registered', async () => {
74136
const res = await stack.apiAs(memberToken, 'GET', '/automation/runas_system_touch');
75137
expect(res.status, `automation service not wired: ${res.status}`).toBe(200);
@@ -102,7 +164,13 @@ describe('objectstack verify FLOW: runAs identity enforcement (#flow-runas)', ()
102164

103165
it("runAs:'user' DE-ELEVATES — member-triggered user flow is RLS-DENIED on the same record", async () => {
104166
const id = await adminCreateNote('user-touch');
105-
await memberTrigger('runas_user_touch', id);
167+
// The de-elevated run reaches the record layer as the MEMBER and the write
168+
// is refused there, so the run fails on its `touch` node — surfaced since
169+
// #9378 as 400 `FLOW_FAILED` instead of a 200 wrapping the inner failure.
170+
// The refusal text is asserted in the helper: this leg proves the identity
171+
// switch is real, so "denied because of WHO ran it" is the load-bearing
172+
// part, not merely "something went wrong".
173+
await memberTriggerExpectingAccessRefusal('runas_user_touch', id, 'touch');
106174
// The run executed as the member; the by-id write to the admin's note is
107175
// RLS-denied, so the record is unchanged. (Before the fix it would read
108176
// 'touched-user' — the privilege-boundary surprise this gate pins.)

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,12 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => {
620620
*/
621621
it('both trigger routes translate the body and forward the caller identity', async () => {
622622
const execute = vi.fn().mockResolvedValue({ success: true });
623-
const automation = { execute, listFlows: vi.fn(), getFlow: vi.fn() };
623+
// [#9378] `getFlow` resolves the flow rather than `undefined`: both
624+
// trigger doors now answer 404 through the same shared existence probe
625+
// `POST /:name/toggle` and `GET /:name` use, so a fixture whose
626+
// registry claims the flow does not exist never reaches `execute` —
627+
// which is this test's subject.
628+
const automation = { execute, listFlows: vi.fn(), getFlow: vi.fn().mockResolvedValue({ name: 'nurture' }) };
624629
const ctx: any = {
625630
executionContext: {
626631
userId: 'u-1',
@@ -668,7 +673,9 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => {
668673
it('never calls a non-contract `trigger` method, even when one exists', async () => {
669674
const trigger = vi.fn().mockResolvedValue({ success: true });
670675
const execute = vi.fn().mockResolvedValue({ success: true });
671-
const automation = { trigger, execute, listFlows: vi.fn(), getFlow: vi.fn() };
676+
// [#9378] See the note on the fixture above: the trigger door consults
677+
// the shared existence probe before dispatching.
678+
const automation = { trigger, execute, listFlows: vi.fn(), getFlow: vi.fn().mockResolvedValue({ name: 'nurture' }) };
672679

673680
const result = await makeDispatcher({ automation, auth })
674681
.dispatch('POST', '/automation/trigger/nurture', {}, {}, {} as any);

0 commit comments

Comments
 (0)