From 149470048594d9738ffb48e72290b2dbf94f61f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:15:08 +0000 Subject: [PATCH 1/2] feat(runtime): carry the flow author's errorMessage and run summary through the /actions door via a typed refusal carrier (#9585) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa --- packages/runtime/src/action-execution.ts | 137 ++++++++++++-- .../src/actions-flow-dispatch-status.test.ts | 167 ++++++++++++++++++ packages/runtime/src/domains/actions.ts | 52 ++++-- packages/runtime/src/flow-dispatch-status.ts | 17 +- packages/runtime/src/http-dispatcher.test.ts | 6 +- 5 files changed, 341 insertions(+), 38 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index b0f9b7ccff..876edd11ce 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -27,7 +27,9 @@ import { flowIsUnknown, flowNotFoundMessage, FLOW_NOT_FOUND_STATUS, + type FlowRefusalCode, } from './flow-dispatch-status.js'; +import type { FlowRunSummary } from '@objectstack/spec/automation'; // [#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. @@ -564,6 +566,85 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps, return { ...seeded, ...params }; } +/** + * Brand for {@link FlowActionRefusal} — `Symbol.for`, so recognition keeps + * working even if two module instances of this file ever coexist (src vs + * dist), where an `instanceof` would silently answer false. + */ +const FLOW_ACTION_REFUSAL_BRAND = Symbol.for('objectstack.runtime.flowActionRefusal'); + +/** + * The run artefacts a failed flow dispatch carries beside its status and code + * — EXACTLY the two fields the trigger door ships in `error.details` on its + * own `400 FLOW_FAILED` arm (`domains/automation.ts`), same names, same + * source (`AutomationResult.errorMessage` / `.summary`). A third key here is + * a contract widening the #9585 ruling does not cover. + */ +export interface FlowActionRunDetails { + /** The flow AUTHOR's own failure text (`flow.errorMessage`). */ + errorMessage?: string; + /** The run's per-node accounting — WHICH node failed (#4354). */ + summary?: FlowRunSummary; +} + +/** + * [#9585] The typed refusal carrier for a flow ACTION that ran and failed. + * + * Maintainer ruling, 2026-08-19 (Option B on #9585): the `/actions` door must + * deliver the flow author's `errorMessage` and the run `summary` the way the + * trigger door already does — but this door THROWS, and the shared resolver + * (`resolveThrownHttpError`, `@objectstack/types`) builds `details` from a + * closed list that deliberately drops a thrown `.details` (#8016 / #9106). + * Widening that list would let ANY throw anywhere declare wire payload — the + * exact thing #9106 narrowed `code` to prevent, and the rejected Option A. + * So the widening stays out of the shared rule: this named, closed carrier is + * recognised by the `/actions` handler AHEAD of its generic catch + * (`domains/actions.ts`, via {@link isFlowActionRefusal}) and mapped to + * `deps.error(message, status, { code, ...runDetails })` — the trigger door's + * own exit, byte for byte. + * + * A door that does NOT recognise it (the MCP `run_action` bridge, or any + * future caller of {@link dispatchFlowAction}) still serves it exactly as it + * served the plain throw this replaces: `status`, `code` and `message` are + * stamped identically, so `resolveThrownHttpError` reads the same + * `400 FLOW_FAILED` and the same text, and only the run details stay behind. + * Degrading to yesterday's answer — never to a different one — is what makes + * this carrier safe to throw on a path two transports share. + */ +export class FlowActionRefusal extends Error { + /** HTTP status this refusal answers with — the #9378 table's 400 row. */ + readonly status: number; + /** ADR-0112 `error.code` — registered, never minted here. */ + readonly code: FlowRefusalCode; + /** The two run artefacts the door carries into `error.details`. */ + readonly runDetails: FlowActionRunDetails; + + constructor( + message: string, + refusal: { status: number; code: FlowRefusalCode }, + runDetails: FlowActionRunDetails, + ) { + super(message); + this.name = 'FlowActionRefusal'; + this.status = refusal.status; + this.code = refusal.code; + this.runDetails = runDetails; + (this as Record)[FLOW_ACTION_REFUSAL_BRAND] = true; + } +} + +/** + * Recognition predicate for {@link FlowActionRefusal} — the `/actions` + * handler asks this BEFORE its generic catch logic runs. Brand-based rather + * than `instanceof` (see {@link FLOW_ACTION_REFUSAL_BRAND}); a foreign object + * that merely copies the field names is not recognised, so no script handler + * can impersonate the flow door's channel by throwing a lookalike. + */ +export function isFlowActionRefusal(e: unknown): e is FlowActionRefusal { + return typeof e === 'object' && e !== null + && (e as Record)[FLOW_ACTION_REFUSAL_BRAND] === true; +} + /** * Dispatch a `type: 'flow'` action through the automation service. * @@ -591,11 +672,15 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps, * 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. + * The never-dispatched throws carry `status` and `code` and the route serves + * them through `errorFromThrown`; `error.details` there is whatever + * `resolveThrownHttpError` reads off a thrown value, and that resolver's + * closed list stays untouched (#8016 / #9106). [#9585] The ran-and-failed row + * is the one exception, by maintainer ruling: it throws the typed + * {@link FlowActionRefusal} carrier, which the `/actions` handler recognises + * ahead of its generic catch and serves with the trigger door's own + * `errorMessage` / `summary` details — see the carrier's doc for the whole + * mechanism and the fallback story. * * 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 @@ -654,20 +739,34 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, // 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, - ); + // 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. + if (refusal.code === 'FLOW_FAILED') { + // [#9585] The typed carrier, run artefacts read EXACTLY as the + // trigger door reads them (`domains/automation.ts`, its 400 arm): + // present when the producer wrote them, never invented. Only this + // row carries them — a never-dispatched refusal has no author + // failure text and no node log to point at, so emitting either + // there would be this door inventing run evidence for a run that + // never started, which is why the two rows below stay plain + // throws. + throw new FlowActionRefusal( + `Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`, + refusal, + { + ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), + ...(result.summary !== undefined ? { summary: result.summary } : {}), + }, + ); + } + const err: any = new Error(refusal.message); err.status = refusal.status; err.code = refusal.code; throw err; diff --git a/packages/runtime/src/actions-flow-dispatch-status.test.ts b/packages/runtime/src/actions-flow-dispatch-status.test.ts index 0379bc66d3..54e8c449d4 100644 --- a/packages/runtime/src/actions-flow-dispatch-status.test.ts +++ b/packages/runtime/src/actions-flow-dispatch-status.test.ts @@ -39,8 +39,10 @@ import { describe, it, expect, vi } from 'vitest'; import type { AutomationResult } from '@objectstack/spec/contracts'; +import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from './http-dispatcher.js'; +import { dispatchFlowAction, isFlowActionRefusal } from './action-execution.js'; const FLOW = 'crm_convert_lead_wizard'; @@ -316,3 +318,168 @@ describe('#9446 — both doors read ONE table, so they cannot drift', () => { expect(action.response.body.error.code).toBe(trigger.response.body.error.code); }); }); + +/** + * #9585 — the payload BESIDE the status. After #9446 the two doors agreed on + * `400 FLOW_FAILED` and diverged on exactly one thing left: the trigger door + * shipped the flow author's `errorMessage` and the run `summary` in + * `error.details`, and `/actions` shipped neither — its throw is served + * through `resolveThrownHttpError`, whose closed `details` list (#8016 / + * #9106) deliberately drops a thrown `.details`. Maintainer ruling + * (2026-08-19, Option B): a typed refusal carrier (`FlowActionRefusal`, + * `action-execution.ts`) that the `/actions` handler recognises AHEAD of its + * generic catch, carrying exactly those two fields; the shared resolver stays + * untouched. + * + * The pins here are door-AGAINST-door wherever the contract is agreement: + * this card exists because the doors diverged, and a suite that only checked + * the `/actions` side would sit green while they drifted apart again. + */ +describe("#9585 — the failed run's artefacts ride BOTH doors' 400 details", () => { + const AUTHOR_MESSAGE = 'We could not create the opportunity — check the amount and try again.'; + const SUMMARY = { + selected: 0, acted: 0, skipped: 0, unmeasured: 0, + nodes: [{ nodeId: 'create_opportunity', nodeType: 'create_record', status: 'failure', runs: 1, failures: 1 }], + } as AutomationResult['summary']; + /** The ran-and-failed exit WITH the artefacts the flow's author declared. */ + const FAILED_WITH_ARTEFACTS: AutomationResult = { + ...RAN_AND_FAILED, + errorMessage: AUTHOR_MESSAGE, + summary: SUMMARY, + }; + + it("/actions delivers the author's errorMessage and the run summary in its 400 details", async () => { + const { dispatcher } = makeDispatcher({ result: FAILED_WITH_ARTEFACTS }); + + const res: any = await viaAction(dispatcher); + + expect(res.response.status).toBe(400); + expect(res.response.body.error.code).toBe('FLOW_FAILED'); + // The author's text is DELIVERED — the declared ≠ delivered gap this + // card closes — and it is not folded into the message either: the raw + // engine error stays the human-readable message, still naming the flow + // (this door's wording, unchanged since #3962). + expect(res.response.body.error.details.errorMessage).toBe(AUTHOR_MESSAGE); + expect(res.response.body.error.message).toContain(FLOW); + expect(res.response.body.error.message).toContain("Node 'create_opportunity' failed"); + expect(res.response.body.error.message).not.toContain(AUTHOR_MESSAGE); + // WHICH node failed survives — the summary's whole job. + expect(res.response.body.error.details.summary).toEqual(SUMMARY); + // `code` is promoted out of `details` into the declared field by the + // shared envelope builder, never duplicated (`error-envelope.ts`). + expect(res.response.body.error.details.code).toBeUndefined(); + }); + + it('one failed run, two doors, ONE details payload — the drift pin', async () => { + const { dispatcher } = makeDispatcher({ result: FAILED_WITH_ARTEFACTS }); + + const action: any = await viaAction(dispatcher); + const trigger: any = await viaTrigger(dispatcher); + + // The #9446 half: same status, same code … + expect(action.response.status).toBe(400); + expect(trigger.response.status).toBe(400); + expect(action.response.body.error.code).toBe('FLOW_FAILED'); + expect(trigger.response.body.error.code).toBe('FLOW_FAILED'); + // … and the #9585 half: the payload beside them, compared DOOR AGAINST + // DOOR rather than against a literal, so a door that drops or renames + // either field reddens here even if its own per-door pin is edited in + // the same change. (The messages differ on purpose — this door names + // the flow, the trigger door's URL already does — so the message is + // deliberately NOT part of this equality.) + expect(action.response.body.error.details.errorMessage) + .toBe(trigger.response.body.error.details.errorMessage); + expect(action.response.body.error.details.summary) + .toEqual(trigger.response.body.error.details.summary); + // Anchor the compared value once, so both doors shipping `undefined` + // can never satisfy the equality above. + expect(trigger.response.body.error.details.errorMessage).toBe(AUTHOR_MESSAGE); + }); + + it('a failed run WITHOUT artefacts invents none, at either door', async () => { + // `errorMessage` is set only when the author wrote one; `summary` only + // when the engine measured one. Neither door manufactures an empty in + // their place — absent means absent, at both doors identically. + const { dispatcher } = makeDispatcher({ result: RAN_AND_FAILED }); + + const action: any = await viaAction(dispatcher); + const trigger: any = await viaTrigger(dispatcher); + + for (const res of [action, trigger]) { + expect(res.response.status).toBe(400); + expect(res.response.body.error.code).toBe('FLOW_FAILED'); + expect(res.response.body.error.details?.errorMessage).toBeUndefined(); + expect(res.response.body.error.details?.summary).toBeUndefined(); + } + }); + + it('a never-dispatched refusal ships NO run artefacts at either door, even when the result carries them', async () => { + // A refused dispatch has no run to report — no author failure text, no + // node log. A producer that stamps the incidental fields anyway must + // not have them served as run evidence: the artefacts ride the + // ran-and-failed row ONLY, the same no-inventing rule the trigger door + // has always stated on its 400 arm. + const { dispatcher } = makeDispatcher({ + result: { ...DISABLED, errorMessage: AUTHOR_MESSAGE, summary: SUMMARY }, + }); + + const action: any = await viaAction(dispatcher); + const trigger: any = await viaTrigger(dispatcher); + + for (const res of [action, trigger]) { + expect(res.response.status).toBe(409); + expect(res.response.body.error.code).toBe('FLOW_DISABLED'); + expect(res.response.body.error.details?.errorMessage).toBeUndefined(); + expect(res.response.body.error.details?.summary).toBeUndefined(); + } + }); + + it("a door that does not recognise the carrier serves yesterday's exact answer — and the shared resolver stays closed", async () => { + // The MCP `run_action` bridge shares `dispatchFlowAction` and has no + // recognition branch — by the ruling's scope, not by accident (#9585 + // is bounded to the one door). Its safety property is that the carrier + // stamps `status`, `code` and `message` exactly as the plain throw it + // replaced, so `resolveThrownHttpError` answers the same + // `400 FLOW_FAILED` with the same text and only the run details stay + // behind: degradation to the PREVIOUS answer, never to a different + // one. The `details: undefined` pin is the ruling's other boundary + // made mechanical — the resolver's closed list (#8016 / #9106) does + // not read a thrown payload, and a future widening that made it do so + // would redden here, surfacing the contradiction instead of landing it + // silently. + const automation = { + execute: vi.fn(async () => FAILED_WITH_ARTEFACTS), + getFlow: vi.fn(async () => ({ name: FLOW })), + }; + const deps: any = { resolveService: async () => automation }; + + let thrown: unknown; + try { + await dispatchFlowAction(deps, {} as any, flowAction, { + objectName: 'crm_lead', record: {}, params: {}, ec: {}, envId: 'platform', + }); + } catch (e) { + thrown = e; + } + + expect(isFlowActionRefusal(thrown)).toBe(true); + const resolved = resolveThrownHttpError(thrown); + expect(resolved.status).toBe(400); + expect(resolved.code).toBe('FLOW_FAILED'); + expect(resolved.message).toContain(FLOW); + expect(resolved.message).toContain("Node 'create_opportunity' failed"); + expect(resolved.details).toBeUndefined(); + }); + + it('recognition is the BRAND, not the field shape — a lookalike throw stays on the generic path', async () => { + // A script handler cannot impersonate the flow door's channel by + // throwing `{ status, code, runDetails }`: the guard reads the + // carrier's own brand. The lookalike still gets the ordinary + // status-honouring exit (`errorFromThrown`), which drops the + // unrecognised payload — exactly what every other thrower gets. + expect(isFlowActionRefusal({ + status: 400, code: 'FLOW_FAILED', message: 'fake', + runDetails: { errorMessage: 'not yours' }, + })).toBe(false); + }); +}); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index cc7a8e4ace..373ce4025e 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -354,19 +354,47 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // here — RLS/FLS-bypassing elevation is a script-BODY property, and a // flow does not get it. if (actionType === 'flow') { - const result = await actionExec.dispatchFlowAction(deps, _context, actionDef, { - objectName, - record, - params: reqParams, - recordId, - ec, - envId: _context?.environmentId, - }); + let result: any; + try { + result = await actionExec.dispatchFlowAction(deps, _context, actionDef, { + objectName, + record, + params: reqParams, + recordId, + ec, + envId: _context?.environmentId, + }); + } catch (err) { + // [#9585] The typed refusal carrier, recognised BEFORE the + // generic catch below (maintainer ruling, Option B): a flow + // that RAN and failed carries the author's `errorMessage` and + // the run `summary`, and they ride `error.details` here + // exactly as the trigger door ships them + // (`domains/automation.ts`) — same exit, same field names, so + // objectui's `flowResponse.ts` reads both doors identically. + // `details.code` is promoted into `error.code` by the shared + // envelope builder (`error-envelope.ts`), never duplicated. + // + // Everything else — the never-dispatched rows (404/409/422), + // the unclassified residual, a crashed handler — rethrows to + // the generic catch unchanged: this branch adds ONE recognised + // shape at ONE door, it does not restructure the catch, and + // the shared `resolveThrownHttpError` stays the rule for every + // other thrower (#8016 / #9106 — its closed `details` list is + // deliberate, not a gap this branch works around). + if (actionExec.isFlowActionRefusal(err)) { + return { + handled: true, + response: deps.error(err.message, err.status, { code: err.code, ...err.runDetails }), + }; + } + throw err; + } // [#3962] Single wrap: `data` is the handler's return value, exactly as - // every other domain serializes. The former inner `{success, data}` - // envelope existed only to carry a failure signal at HTTP 200; failures - // carry a status now, so the extra layer lost its job. - return { handled: true, response: deps.success(result) }; + // every other domain serializes. The former inner `{success, data}` + // envelope existed only to carry a failure signal at HTTP 200; failures + // carry a status now, so the extra layer lost its job. + return { handled: true, response: deps.success(result) }; } // [#2849] Same trusted-mode elevation as the MCP path — keep it audible. diff --git a/packages/runtime/src/flow-dispatch-status.ts b/packages/runtime/src/flow-dispatch-status.ts index 30fd00c5dd..2508165aa4 100644 --- a/packages/runtime/src/flow-dispatch-status.ts +++ b/packages/runtime/src/flow-dispatch-status.ts @@ -51,11 +51,18 @@ * * ## What this table does NOT answer, and why each door still owns it * - * **The envelope.** The trigger door RETURNS a built response and can carry - * `errorMessage` / `summary` in `error.details`; `/actions` THROWS, and a - * throw's structured context is only what `resolveThrownHttpError` - * (`@objectstack/types`) reads off the thrown value. Status and code are the - * contract the #9378 ruling settled; the payload beside them is not. + * **The envelope.** The trigger door RETURNS a built response and carries + * `errorMessage` / `summary` in `error.details` on its 400 arm; `/actions` + * THROWS, and a throw's structured context is only what + * `resolveThrownHttpError` (`@objectstack/types`) reads off the thrown value — + * a closed list that deliberately drops a thrown `.details` (#8016 / #9106). + * [#9585] The ran-and-failed row is the ruled exception: `/actions` throws the + * typed `FlowActionRefusal` carrier (`action-execution.ts`) that its handler + * recognises ahead of the generic catch, so BOTH doors now ship those two + * fields on `400 FLOW_FAILED` — but the mechanism stays each door's own, and + * the shared resolver stays untouched. Status and code are the contract the + * #9378 ruling settled; the payload beside them is #9585's, bounded to that + * one row at those two doors. * * **What an UNCLASSIFIED `success: false` means.** {@link classifyFlowRefusal} * returns `undefined` for a refusal the producer did not classify, and the two diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 3964568e41..00f8479029 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -462,8 +462,10 @@ describe('HttpDispatcher', () => { // of `packages/app-shell/src/utils/flowResponse.ts`, PR #4899): the // ADR-0112 envelope has no `data`, so a producer that builds its message // out of `result.error` alone drops the author's words while every status - // assertion stays green. The live `/actions` producer - // (`action-execution.ts`) does exactly that; this route does not. + // assertion stays green. The live `/actions` producer used to do exactly + // that; since #9585 it ships both fields through its typed refusal + // carrier (`action-execution.ts`), pinned door-against-door in + // `actions-flow-dispatch-status.test.ts`. it('should carry the flow-authored errorMessage and the run summary in the 400 details', async () => { mockAutomationService.resume.mockResolvedValue({ success: false, From 6a5ac829a58b90b5e4f967c0fd7b0fb87c474f68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:40:08 +0000 Subject: [PATCH 2/2] chore: changeset for the #9585 flow-action refusal carrier Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WeN7F6jQFpcqW2BN56RdPa --- .changeset/flow-action-refusal-carrier.md | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/flow-action-refusal-carrier.md diff --git a/.changeset/flow-action-refusal-carrier.md b/.changeset/flow-action-refusal-carrier.md new file mode 100644 index 0000000000..3e74514665 --- /dev/null +++ b/.changeset/flow-action-refusal-carrier.md @@ -0,0 +1,33 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): a flow ACTION that ran and failed now carries the flow author's `errorMessage` and the run `summary` in `error.details` at the `/actions` door (#9585) + +Dispatching a flow through `POST /api/v1/actions/:object/:action` (a `type: 'flow'` +action — the documented way to expose a flow on a record page) answered `400 +FLOW_FAILED` with only the raw engine error. The trigger door +(`POST /api/v1/automation/:name/trigger`) additionally ships two things in +`error.details` of the ADR-0112 envelope: `errorMessage` — the failure text the +flow's AUTHOR wrote for exactly this case (`flow.errorMessage`, the single field +the console reads, objectui `flowResponse.ts`) — and `summary`, the run's +per-node accounting that says WHICH node failed. At the action door the author's +text was declared-but-never-delivered. + +Maintainer ruling (2026-08-19, Option B on #9585): `dispatchFlowAction` now +throws a typed refusal carrier (`FlowActionRefusal`) on the ran-and-failed row, +and the `/actions` handler recognises it ahead of its generic catch, serving +`errorMessage` and `summary` exactly as the trigger door does — same field +names, same source, pinned door-against-door so the two cannot drift apart +again. Bounded deliberately: + +- the shared `resolveThrownHttpError` (`@objectstack/types`) stays untouched — + its closed `details` list remains the rule for every other thrower; no + general "any throw declares wire payload" widening; +- only the ran-and-failed row carries the artefacts — a never-dispatched + refusal (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE`) has no run to + report and ships neither, at both doors; +- a caller that does not recognise the carrier (the MCP `run_action` bridge) + serves exactly the previous answer — the carrier stamps `status`, `code` and + `message` identically to the plain throw it replaces; +- no new schema keys; both doors keep agreeing on `400 FLOW_FAILED`.