Skip to content

Commit 9cfc575

Browse files
fix(service-automation,runtime): a refused write inside a flow reaches every FLOW_FAILED door as details.innerRefusal and a top-level userMessage, message unchanged (#25147)
Fixes #24914 Clause-②: yes (widening) Metadata-protocol: none Step (2) of ruling B′ on #24914 (the maintainer's 「同意」, ruling record 6303788292, decision batch #379 item 5). The spec half landed in #24996 (PR #25037): `AutomationResult.userMessage`, `AutomationResult.innerRefusal` (`FlowInnerRefusal`) and one `FlowFailureDetailsSchema` for the four doors. This PR emits exactly what those declare. The console half is objectstack-ai/objectui#13285 (step 3). It stays open; this PR does not touch objectui. The object API name inside the unique-refusal sentence is #24951 (engine), unchanged here. ## What changes **Before:** a refused write inside a flow reached the caller as one string. The node's catch kept only the message, the engine prefixed the node id, and each door served that string as the `FLOW_FAILED` message: > Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': … **After:** the same 400 also carries the refusal structurally. `message` is byte-identical to before. ```json { "success": false, "error": { "code": "FLOW_FAILED", "httpStatus": 400, "message": "Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': …", "userMessage": "(present only when the producer marked one, e.g. an app hook)", "details": { "summary": { "...": "..." }, "innerRefusal": { "code": "UNIQUE_VIOLATION", "message": "Duplicate record refused on 'crm_account': …" } } } } ``` ### `@objectstack/service-automation` - **New `src/inner-refusal.ts`.** `refusalCarriedBy(err)` is the one helper the four CRUD catch arms call. It returns `{ userMessage?, innerRefusal? }`: - `userMessage` comes from `declaredUserMessage(err)` (`@objectstack/types`), the one read every boundary uses. - `innerRefusal` is set only for a refusal, meaning a throw that declared a 4xx answer (`resolveThrownHttpError(err).declaredStatus`). A fault carries none: that covers an undeclared throw and a declared 5xx. - Its `code` is the one the data door's `errorFromThrown` answers for the same throw. That is the resolver's code (`VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names), except that the engine's duplicate envelope takes the ruled wire spelling. That spelling is read off the shared arm (`structuredCodeAnswer`), never typed here. - Nothing is read from message text. - **`builtin/crud-nodes.ts`.** The four catch arms (`get_record`, `create_record`, `update_record`, `delete_record`) spread `refusalCarriedBy(err)` beside an unchanged `error`. `create_record` keeps its in-flow `code: 'DUPLICATE_RECORD'` for `$error.code`. - **`engine.ts`.** - `NodeExecutionResult` gains optional `userMessage` / `innerRefusal`. - The returned-failure arm throws the same `Node 'ID' failed: …` error, now branded with the members. This is a non-enumerable symbol brand, the `partial-steps.ts` / `guard-refusal.ts` idiom, so identity, message and `instanceof` are unchanged. - The members are spread onto the result at four exits: `execute()`'s failure exit, the `resumeInternal` stranded exit, `executeWithoutRetry`'s failure exit, and `retryExecution`'s exhausted exit. The last one reports the last attempt's members, paired with its text. - A `loop` / `parallel` frame rethrows the same object, so the brand survives them. ### `@objectstack/runtime` - **`flow-dispatch-status.ts`.** `flowFailureDetails(result)` is one builder, typed as `FlowFailureDetails`, for the three declared keys. `flowFailureEnvelope(result)` gives the top-level `userMessage`. - **Trigger and resume doors** (`domains/automation.ts`), **`/actions`** (`action-execution.ts`, plus its handler in `domains/actions.ts`) and **declared endpoint** (`endpoint-executor.ts`). Each door builds its `FLOW_FAILED` details through the shared builder and puts `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). - `FlowActionRunDetails` is now the spec's `FlowFailureDetails`, so a fourth key fails the build. - `FlowActionRefusal` carries `userMessage` as a field, which is the channel `declaredUserMessage` reads. - **The `deps.error` slot.** `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }` (`domain-handler-registry.ts`), and `http-dispatcher.ts` wires it to the existing `extra` of the envelope builder. A call without it builds the same envelope as before. - **MCP `resume_run`** (`domains/mcp.ts`). `resumeDoorError` passes the slot and copies the envelope's `userMessage` onto the thrown error. `details.innerRefusal` already travels with `details`. ## Why `Clause-②: yes (widening)`, not the claim's `no` The claim said `no` because #24996 already declared the wire members, and it asked me to re-declare if the built declaration closure showed a key beyond `FlowFailureDetailsSchema`. Measured on the built `dist/index.d.ts` of both packages (built at the head this PR cites), two published types widen: - `@objectstack/runtime`'s `DomainHandlerDeps.error` gains the optional `extra` argument (the slot the ruling names); - `@objectstack/service-automation`'s exported `NodeExecutionResult` gains optional `userMessage` / `innerRefusal`. Both are additive and optional. No export is added. The wire members are the ones #24996 declared. The changeset is `patch` for both packages, with the same two declaration lines. ## Tests New pins, each asserting `code` + `status` on refusal cases: - `packages/services/service-automation/src/flow-inner-refusal.test.ts` (13 tests). A real `AutomationEngine` and the real CRUD executors run over a data double that throws the real classes (`DuplicateRecordError`, `ValidationError`, `PermissionDeniedError`) after accepting the call through the engine's dispatch predicates. - Unique refusal: `UNIQUE_VIOLATION`, while in-flow stays `DUPLICATE_RECORD`. Validation: `VALIDATION_FAILED`. Permission: `PERMISSION_DENIED`. - A marked app-hook refusal: `userMessage` arrives verbatim, with `RESOURCE_CONFLICT` for its unregistered code. - An unmarked refusal has no `userMessage` key. A fault and a declared 5xx carry neither member. - The other three arms each carry the members. - The exits: stranded resume (the card's write-behind-a-screen leg), the retry ladder, a loop frame, and a fault edge that handles the failure (no members). - `error` is asserted byte-for-byte everywhere. - `packages/runtime/src/flow-failed-inner-refusal.test.ts` (10 tests). Covers trigger, resume, `/actions` and endpoint. - `details.innerRefusal` parses through `FlowFailureDetailsSchema`, and the error parses through `ApiErrorSchema`. - `userMessage` is top-level only, never a details key. `message` keeps each door's wording. - A door-against-door drift pin, and the unrefused control. - `packages/runtime/src/mcp-resume-run.test.ts`: one appended test. The MCP `resume_run` bridge carries `innerRefusal` and `userMessage` exactly as the REST resume door does. - `packages/verify/src/automation-flow-inner-refusal.test.ts` (9 tests). This runs end to end: real engine and CRUD executors, the real `HttpDispatcher` trigger and resume doors, and the real error classes. - Each inner `code` is compared with what the data door's `errorFromThrown` answers for the same thrown value. This is door-against-door parity with the record save. The existing wording pins are untouched and green, including `create-record-duplicate-code.test.ts`, `try-catch-returned-failure-steps.test.ts`, `actions-flow-dispatch-status.test.ts` (its exact-equality details pin still describes a run that met no refusal), `automation-resume-stranded-details.test.ts`, `automation-trigger-route-status.test.ts` and `endpoint-flow-dispatch-status.test.ts`. ### Local readings These are on a shared box, so wall times are contention readings. - Final head `0a2d128cd`. The targeted re-run is green: - service-automation: 3 files / 29 tests, plus `try-catch-returned-failure-steps.test.ts` 17/17; - runtime: 6 files / 108 tests (the new suite, `mcp-resume-run`, and the four door suites); - verify: 1 file / 9 tests. - Full suites at `bbca8a681`: - `@objectstack/service-automation`: 198 files / 2618 tests passed; - `@objectstack/runtime --project local`: 433 files / 6805 passed, 19 skipped. The difference from the final head is a changeset, the service-automation test file above (re-run green at the final head), `scripts/engine-double-contract.pinned.json`, and a main merge that touches only driver-sql tests. - `pnpm --filter … typecheck` for service-automation, runtime and verify: exit 0. That covers `tsc --noEmit` and each package's test-typecheck ratchet. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 80 commands at `0a2d128cd`. All 80 ran after the final commit and exited 0, with each exit code captured before any pipe. `--ran` reconciliation: "80 derived famil(ies) accounted for — 80 run, 0 NOT-MEASURED (a DERIVED zero …)". On an earlier head, `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched into this shallow clone. `check:engine-double-contract` asked for the new test's double to be pinned, which is done with `--write`. - `test:repo` and repo-wide `pnpm lint` were not run locally; both are CI's. ### Ablations Each ablation went through `scripts/ablation-replace.mjs`, which wraps the mutation, proves it landed on disk, restores it and proves the restore by HEAD blob hash. `git diff HEAD` was empty after every leg. Every leg turned the pins red: | Leg | Mutation | Result | |:--|:--|:--| | SA1 | `isRefusal` forced false | 10 of 13 red; the 3 absence controls stay green | | SA2 | `userMessage` read dropped | 2 red | | SA3 | engine brand dropped | 10 red | | SA4 | `execute()` exit spread dropped | 8 red | | SA5 | stranded exit spread dropped | 1 red | | SA6 | retry exhausted carry dropped | 1 red | | SA7 | per-attempt exit spread dropped | 1 red | | RT1 | details `innerRefusal` dropped | 10 of 32 red | | RT2 | envelope `userMessage` dropped | 6 red | | RT3 | `deps.error` slot dropped | 5 red | | RT4 | MCP copy-back dropped | 1 red | | RT5 | `/actions` handler slot dropped | 2 red | | V1 | verify suite, through service-automation's built `dist/` (SA1's mutation, rebuilt) | 8 of 9 red | - **Dist check for V1.** `ablation-dist-preflight` showed the marker present in `dist/index.js` and `dist/index.cjs`. After restoring and rebuilding, the marker was absent and the tree was clean, and the suite was green again at 9 of 9. - **First passes that did not run.** The first passes of SA4, SA5, SA7, RT3 and RT5 were refused by the tool as no-ops, because their replacement text already occurred in the file. They did not run, so their readings were discarded. They were re-run with unique markers; the results in the table are from those re-runs. The first re-run of RT5 never acquired the lock (exit 99) and was run again. ## Acceptance notes - **File surface beyond the claim, and why.** - `http-dispatcher.ts` and `domain-handler-registry.ts` are where the `deps.error` slot is defined and wired. - `domains/actions.ts` is where `/actions` turns `FlowActionRefusal` into the envelope (one argument). - `scripts/engine-double-contract.pinned.json` records the new test's pinned double, written by the gate's `--write`. - `packages/verify/src/automation-flow-inner-refusal.test.ts`: `@objectstack/verify` is the one package depending on both the engine and the doors, so it is where the end-to-end pin lives. - **Containers that re-word a child failure do not forward the members yet.** These are `try_catch` with no catch region, and `subflow` on both legs (synchronous, and the resume delegation arm). The run then carries no member, which the spec's "present only when a refusal was met" allows. Carrier: none. - **Refusals a CRUD node makes itself are not carried.** These are the stored-metadata family guards, which return a `code` before any data call. They are not catch arms, and the ruling names the four catch arms. Carrier: none. - **MCP `run_action`.** Its tool error still carries no run details, as before (#9585 scoped the details to `/actions`). The refusal's `userMessage` rides the thrown `FlowActionRefusal` field. `packages/mcp`'s tool-error renderer shows code, message, status and details, but not `userMessage`, on either bridge. Carrier: none. - The step log still records `NODE_FAILURE` for a returned node failure, unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f35b4e1 commit 9cfc575

17 files changed

Lines changed: 1220 additions & 48 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text.
12+
13+
- **`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text.
14+
- **`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built.
15+
- **Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union.
16+
17+
**For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import { relationConditionSites } from '@objectstack/core';
4242
// result, and a second definition of the rule is what let the doors diverge.
4343
import {
4444
classifyFlowRefusal,
45+
flowFailureDetails,
46+
flowFailureEnvelope,
4547
flowIsUnknown,
4648
flowNotFoundMessage,
4749
FLOW_NOT_FOUND_STATUS,
@@ -50,7 +52,7 @@ import {
5052
// Who may START a flow by name — the one rule the trigger door, this door and
5153
// the declared endpoint share (the maintainer's ruling, letters B and A).
5254
import { ELEVATED_START_REFUSAL, refusesElevatedSelfTriggeredStart } from './flow-start-admission.js';
53-
import type { FlowRunSummary } from '@objectstack/spec/automation';
55+
import type { FlowFailureDetails } from '@objectstack/spec/api';
5456
// [#5138] The ONE 404 envelope a single-record path answers. Imported rather
5557
// than re-spelled so `callData`'s ObjectQL fallback and the protocol service it
5658
// falls back FROM cannot disagree about what "this id names no row" looks like.
@@ -1044,22 +1046,17 @@ const FLOW_ACTION_REFUSAL_BRAND = Symbol.for('objectstack.runtime.flowActionRefu
10441046

10451047
/**
10461048
* The run artefacts a failed flow dispatch carries beside its status and code
1047-
* — EXACTLY the two fields the trigger door ships in `error.details` on its
1048-
* own `400 FLOW_FAILED` arm (`domains/automation.ts`), same names, same
1049-
* source (`AutomationResult.errorMessage` / `.summary`). The #9585 ruling
1050-
* capped this at those two keys; ruling B′ on #24914 (decision batch #379
1051-
* item 5) relaxes the cap by exactly one, `innerRefusal`, and the spec now
1052-
* declares the three-key set once for every flow door as
1053-
* `FlowFailureDetailsSchema` (`@objectstack/spec/api`). Carrying that third
1054-
* key is #24914's emission half and is not done here yet; any key beyond the
1055-
* three is a contract widening neither ruling covers.
1049+
* — EXACTLY the fields the trigger door ships in `error.details` on its own
1050+
* `400 FLOW_FAILED` arm (`domains/automation.ts`), same names, same source,
1051+
* built by the same `flowFailureDetails` (`./flow-dispatch-status.ts`). The
1052+
* #9585 ruling capped this at two keys (`errorMessage`, `summary`); ruling B′
1053+
* on #24914 (decision batch #379 item 5) relaxes the cap by exactly one,
1054+
* `innerRefusal`, and the spec declares the three-key set once for every flow
1055+
* door as `FlowFailureDetailsSchema` (`@objectstack/spec/api`). This type IS
1056+
* that declaration, so a key beyond the three — a contract widening neither
1057+
* ruling covers — fails the build rather than reaching the wire.
10561058
*/
1057-
export interface FlowActionRunDetails {
1058-
/** The flow AUTHOR's own failure text (`flow.errorMessage`). */
1059-
errorMessage?: string;
1060-
/** The run's per-node accounting — WHICH node failed (#4354). */
1061-
summary?: FlowRunSummary;
1062-
}
1059+
export type FlowActionRunDetails = FlowFailureDetails;
10631060

10641061
/**
10651062
* [#9585] The typed refusal carrier for a flow ACTION that ran and failed.
@@ -1090,19 +1087,31 @@ export class FlowActionRefusal extends Error {
10901087
readonly status: number;
10911088
/** ADR-0112 `error.code` — registered, never minted here. */
10921089
readonly code: FlowRefusalCode;
1093-
/** The two run artefacts the door carries into `error.details`. */
1090+
/** The run artefacts the door carries into `error.details`. */
10941091
readonly runDetails: FlowActionRunDetails;
1092+
/**
1093+
* [#24914, ruling B′] The producer-marked END-USER text of the refusal the
1094+
* run's node met (`AutomationResult.userMessage`), absent when the run
1095+
* carries none. A FIELD of the thrown error on purpose: it is the channel
1096+
* `declaredUserMessage` (`@objectstack/types`) reads on every boundary, so
1097+
* the `/actions` handler relays it to the envelope's top level and a door
1098+
* that does not recognise this carrier still reads it off the throw,
1099+
* exactly as it reads an app hook's own marking.
1100+
*/
1101+
readonly userMessage?: string;
10951102

10961103
constructor(
10971104
message: string,
10981105
refusal: { status: number; code: FlowRefusalCode },
10991106
runDetails: FlowActionRunDetails,
1107+
userMessage?: string,
11001108
) {
11011109
super(message);
11021110
this.name = 'FlowActionRefusal';
11031111
this.status = refusal.status;
11041112
this.code = refusal.code;
11051113
this.runDetails = runDetails;
1114+
if (userMessage !== undefined) this.userMessage = userMessage;
11061115
(this as Record<PropertyKey, unknown>)[FLOW_ACTION_REFUSAL_BRAND] = true;
11071116
}
11081117
}
@@ -1290,13 +1299,17 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
12901299
// there would be this door inventing run evidence for a run that
12911300
// never started, which is why the two rows below stay plain
12921301
// throws.
1302+
//
1303+
// [#24914, ruling B′] Built by the trigger door's own builder
1304+
// (`flowFailureDetails`), so the `innerRefusal` a node's data call
1305+
// met rides here too, and the producer-marked `userMessage` rides
1306+
// the carrier for the handler to put at the envelope's top level.
1307+
// The message keeps this door's wording byte for byte.
12931308
throw new FlowActionRefusal(
12941309
`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`,
12951310
refusal,
1296-
{
1297-
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
1298-
...(result.summary !== undefined ? { summary: result.summary } : {}),
1299-
},
1311+
flowFailureDetails(result),
1312+
flowFailureEnvelope(result)?.userMessage,
13001313
);
13011314
}
13021315
const err: any = new Error(refusal.message);

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,19 @@ export interface DomainHandlerDeps {
309309
* `error.code`). `error.code` is the semantic string: pass yours as
310310
* `details.code` and it is promoted into the field, otherwise one is derived
311311
* from the status. See `./error-envelope.ts`.
312+
*
313+
* `extra` carries the declared SIBLINGS of `code` / `message` a domain may
314+
* put on the envelope — today exactly one, the producer-marked
315+
* `userMessage` (`ApiErrorSchema.userMessage`, `@objectstack/spec/api`):
316+
* end-user text that never replaces `message`. It is the slot ruling B′ on
317+
* #24914 names for the four flow doors, which relay the refusal a flow's
318+
* data node met (`AutomationResult.userMessage`) the way the data door
319+
* relays a record save's. Closed on purpose: a sibling the spec does not
320+
* declare has no slot here, so a domain cannot put an undeclared key on
321+
* the envelope through it. Omit it (or leave `userMessage` absent) and the
322+
* envelope is byte-identical to the three-argument call.
312323
*/
313-
error(message: string, httpStatus?: number, details?: any): { status: number; body: any };
324+
error(message: string, httpStatus?: number, details?: any, extra?: { userMessage?: string }): { status: number; body: any };
314325
/** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */
315326
routeNotFound(route: string): { status: number; body: any };
316327
/**

‎packages/runtime/src/domains/actions.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,9 +824,16 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
824824
// other thrower (#8016 / #9106 — its closed `details` list is
825825
// deliberate, not a gap this branch works around).
826826
if (actionExec.isFlowActionRefusal(err)) {
827+
// [#24914, ruling B′] `runDetails` now carries the
828+
// `innerRefusal` a node's data call met (the declared third
829+
// key), and the carrier's producer-marked `userMessage`
830+
// goes to the envelope's top level through the deps slot.
827831
return {
828832
handled: true,
829-
response: deps.error(err.message, err.status, { code: err.code, ...err.runDetails }),
833+
response: deps.error(
834+
err.message, err.status, { code: err.code, ...err.runDetails },
835+
err.userMessage !== undefined ? { userMessage: err.userMessage } : undefined,
836+
),
830837
};
831838
}
832839
throw err;

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import { capabilityUnavailable } from './unavailable.js';
4747
// [#9446] The ONE #9378 status table, now shared with the `/actions` door.
4848
import {
4949
classifyFlowRefusal,
50+
flowFailureDetails,
51+
flowFailureEnvelope,
5052
flowIsUnknown,
5153
flowNotFoundMessage,
5254
isPausedRun,
@@ -2033,15 +2035,20 @@ async function respondToFlowTrigger(
20332035
// that happened. A never-dispatched refusal has no author failure text
20342036
// and no node log to point at, so emitting either there would be this
20352037
// door inventing run evidence for a run that never started.
2036-
const runDetails = refusal.code === 'FLOW_FAILED'
2037-
? {
2038-
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
2039-
...(result.summary !== undefined ? { summary: result.summary } : {}),
2040-
}
2041-
: {};
2038+
//
2039+
// [#24914, ruling B′] Built by the one shared builder, typed as
2040+
// `FlowFailureDetailsSchema`: `errorMessage` / `summary` as before, and
2041+
// the `innerRefusal` a node's data call met; the producer-marked
2042+
// `userMessage` rides the envelope's top level, beside an unchanged
2043+
// `message`.
2044+
const failed = refusal.code === 'FLOW_FAILED';
2045+
const runDetails = failed ? flowFailureDetails(result) : {};
20422046
return {
20432047
handled: true,
2044-
response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...runDetails }),
2048+
response: deps.error(
2049+
refusal.message, refusal.status, { code: refusal.code, ...runDetails },
2050+
failed ? flowFailureEnvelope(result) : undefined,
2051+
),
20452052
};
20462053
}
20472054
// [#9510] THE THIRD STATE, answered deliberately. A run that dispatched and
@@ -2252,6 +2259,14 @@ export interface ResumeRefusal {
22522259
message: string;
22532260
status: number;
22542261
details: { code: string } & Record<string, unknown>;
2262+
/**
2263+
* [#24914, ruling B′] The envelope's declared top-level siblings — the
2264+
* producer-marked `userMessage` of the refusal the run's node met, set on
2265+
* the `FLOW_FAILED` row only and only when the engine result carries one.
2266+
* Passed as `DomainHandlerDeps.error`'s `extra` by both doors this table
2267+
* serves.
2268+
*/
2269+
extra?: { userMessage: string };
22552270
}
22562271

22572272
/**
@@ -2349,15 +2364,21 @@ export async function classifyResumeResult(
23492364
// code it matched, so the builder promotes it and never derives one.
23502365
if (code !== undefined && row) return { message: result.error ?? row.fallback, status: row.status, details: { code } };
23512366
const verdict = await resumeFailureDetails(deps, automationService, runId, result);
2367+
// [#24914, ruling B′] The run's artefacts through the shared builder
2368+
// (`FlowFailureDetailsSchema`: `errorMessage` / `summary` / `innerRefusal`),
2369+
// the verdict beside them in the same object, and the producer-marked
2370+
// `userMessage` at the envelope's top level. The screen-flow leg — a write
2371+
// behind a screen, refused after the pause was consumed — answers HERE.
2372+
const extra = flowFailureEnvelope(result);
23522373
return {
23532374
message: result.error ?? 'Flow run failed',
23542375
status: 400,
23552376
details: {
23562377
code: 'FLOW_FAILED',
2357-
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
2358-
...(result.summary !== undefined ? { summary: result.summary } : {}),
2378+
...flowFailureDetails(result),
23592379
...verdict,
23602380
},
2381+
...(extra !== undefined ? { extra } : {}),
23612382
};
23622383
}
23632384

@@ -3326,7 +3347,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
33263347
// it answers what it answers, is documented there.
33273348
const refusal = await classifyResumeResult(deps, automationService, parts[2], result);
33283349
if (refusal) {
3329-
return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details) };
3350+
return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details, refusal.extra) };
33303351
}
33313352
return { handled: true, response: deps.success(result) };
33323353
}

‎packages/runtime/src/domains/mcp.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -808,13 +808,21 @@ function resumeDoorError(
808808
message: string,
809809
httpStatus: number,
810810
details?: Record<string, unknown>,
811+
extra?: { userMessage?: string },
811812
): Error {
812-
const response = deps.error(message, httpStatus, details);
813-
const envelope = (response?.body as { error?: { code?: unknown; message?: unknown; details?: unknown } } | undefined)?.error;
813+
const response = deps.error(message, httpStatus, details, extra);
814+
const envelope = (response?.body as {
815+
error?: { code?: unknown; message?: unknown; details?: unknown; userMessage?: unknown };
816+
} | undefined)?.error;
814817
return Object.assign(new Error(typeof envelope?.message === 'string' ? envelope.message : message), {
815818
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
816819
status: response?.status ?? httpStatus,
817820
...(envelope?.details !== undefined ? { details: envelope.details } : {}),
821+
// [#24914, ruling B′] The producer-marked end-user text, carried onto
822+
// the thrown error as the `userMessage` field every boundary reads
823+
// through `declaredUserMessage` — exactly what the REST door put at the
824+
// envelope's top level, never folded into `message`.
825+
...(typeof envelope?.userMessage === 'string' ? { userMessage: envelope.userMessage } : {}),
818826
});
819827
}
820828

@@ -1001,7 +1009,7 @@ export async function resumeActionRun(
10011009
if (values !== undefined) signal.variables = values;
10021010
const result = await automation.resume(runId, signal);
10031011
const refusal = await classifyResumeResult(deps, automation, runId, result);
1004-
if (refusal) throw resumeDoorError(deps, refusal.message, refusal.status, refusal.details);
1012+
if (refusal) throw resumeDoorError(deps, refusal.message, refusal.status, refusal.details, refusal.extra);
10051013
return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: result ?? null };
10061014
}
10071015

‎packages/runtime/src/endpoint-executor.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ import { validationFailure } from './validation-failure.js';
5656
import { buildAutomationContext } from './domains/automation.js';
5757
import {
5858
classifyFlowRefusal,
59+
flowFailureDetails,
60+
flowFailureEnvelope,
5961
flowIsUnknown,
6062
flowNotFoundMessage,
6163
FLOW_NOT_FOUND_STATUS,
@@ -585,17 +587,21 @@ async function executeFlow(
585587
// failure text nor a node log to point at. Byte-identical to the
586588
// trigger door's details (`domains/automation.ts`), because #5040 §4
587589
// makes that route's answer this seam's contract.
588-
const runDetails = refusal.code === 'FLOW_FAILED'
589-
? {
590-
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
591-
...(result.summary !== undefined ? { summary: result.summary } : {}),
592-
}
593-
: {};
590+
//
591+
// [#24914, ruling B′] The trigger door's own builder, so the
592+
// `innerRefusal` a node's data call met rides here as it does there,
593+
// and the producer-marked `userMessage` takes the envelope's declared
594+
// top-level slot (`extra`, `ApiErrorSchema.userMessage`) — authored
595+
// end-user text, so the message's leak guard does not apply to it.
596+
const failed = refusal.code === 'FLOW_FAILED';
597+
const runDetails = failed ? flowFailureDetails(result) : {};
598+
const extra = failed ? flowFailureEnvelope(result) : undefined;
594599
return apiErrorResponse({
595600
message: sanitizeMessage(refusal.message, refusal.status),
596601
httpStatus: refusal.status,
597602
code: refusal.code,
598603
...(Object.keys(runDetails).length > 0 ? { details: runDetails } : {}),
604+
...(extra !== undefined ? { extra } : {}),
599605
});
600606
}
601607

0 commit comments

Comments
 (0)