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
17 changes: 17 additions & 0 deletions .changeset/24914-flow-inner-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/service-automation': patch
'@objectstack/runtime': patch
---

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`

Clause-②: yes (widening)
Metadata-protocol: none

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.

- **`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.
- **`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.
- **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.

**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.
55 changes: 34 additions & 21 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { relationConditionSites } from '@objectstack/core';
// result, and a second definition of the rule is what let the doors diverge.
import {
classifyFlowRefusal,
flowFailureDetails,
flowFailureEnvelope,
flowIsUnknown,
flowNotFoundMessage,
FLOW_NOT_FOUND_STATUS,
Expand All @@ -50,7 +52,7 @@ import {
// Who may START a flow by name — the one rule the trigger door, this door and
// the declared endpoint share (the maintainer's ruling, letters B and A).
import { ELEVATED_START_REFUSAL, refusesElevatedSelfTriggeredStart } from './flow-start-admission.js';
import type { FlowRunSummary } from '@objectstack/spec/automation';
import type { FlowFailureDetails } from '@objectstack/spec/api';
// [#5138] The ONE 404 envelope a single-record path answers. Imported rather
// than re-spelled so `callData`'s ObjectQL fallback and the protocol service it
// falls back FROM cannot disagree about what "this id names no row" looks like.
Expand Down Expand Up @@ -1044,22 +1046,17 @@ const FLOW_ACTION_REFUSAL_BRAND = Symbol.for('objectstack.runtime.flowActionRefu

/**
* 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`). The #9585 ruling
* capped this at those two keys; ruling B′ on #24914 (decision batch #379
* item 5) relaxes the cap by exactly one, `innerRefusal`, and the spec now
* declares the three-key set once for every flow door as
* `FlowFailureDetailsSchema` (`@objectstack/spec/api`). Carrying that third
* key is #24914's emission half and is not done here yet; any key beyond the
* three is a contract widening neither ruling covers.
* — EXACTLY the fields the trigger door ships in `error.details` on its own
* `400 FLOW_FAILED` arm (`domains/automation.ts`), same names, same source,
* built by the same `flowFailureDetails` (`./flow-dispatch-status.ts`). The
* #9585 ruling capped this at two keys (`errorMessage`, `summary`); ruling B′
* on #24914 (decision batch #379 item 5) relaxes the cap by exactly one,
* `innerRefusal`, and the spec declares the three-key set once for every flow
* door as `FlowFailureDetailsSchema` (`@objectstack/spec/api`). This type IS
* that declaration, so a key beyond the three — a contract widening neither
* ruling covers — fails the build rather than reaching the wire.
*/
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;
}
export type FlowActionRunDetails = FlowFailureDetails;

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

constructor(
message: string,
refusal: { status: number; code: FlowRefusalCode },
runDetails: FlowActionRunDetails,
userMessage?: string,
) {
super(message);
this.name = 'FlowActionRefusal';
this.status = refusal.status;
this.code = refusal.code;
this.runDetails = runDetails;
if (userMessage !== undefined) this.userMessage = userMessage;
(this as Record<PropertyKey, unknown>)[FLOW_ACTION_REFUSAL_BRAND] = true;
}
}
Expand Down Expand Up @@ -1290,13 +1299,17 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
// there would be this door inventing run evidence for a run that
// never started, which is why the two rows below stay plain
// throws.
//
// [#24914, ruling B′] Built by the trigger door's own builder
// (`flowFailureDetails`), so the `innerRefusal` a node's data call
// met rides here too, and the producer-marked `userMessage` rides
// the carrier for the handler to put at the envelope's top level.
// The message keeps this door's wording byte for byte.
throw new FlowActionRefusal(
`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`,
refusal,
{
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}),
},
flowFailureDetails(result),
flowFailureEnvelope(result)?.userMessage,
);
}
const err: any = new Error(refusal.message);
Expand Down
13 changes: 12 additions & 1 deletion packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,19 @@ export interface DomainHandlerDeps {
* `error.code`). `error.code` is the semantic string: pass yours as
* `details.code` and it is promoted into the field, otherwise one is derived
* from the status. See `./error-envelope.ts`.
*
* `extra` carries the declared SIBLINGS of `code` / `message` a domain may
* put on the envelope — today exactly one, the producer-marked
* `userMessage` (`ApiErrorSchema.userMessage`, `@objectstack/spec/api`):
* end-user text that never replaces `message`. It is the slot ruling B′ on
* #24914 names for the four flow doors, which relay the refusal a flow's
* data node met (`AutomationResult.userMessage`) the way the data door
* relays a record save's. Closed on purpose: a sibling the spec does not
* declare has no slot here, so a domain cannot put an undeclared key on
* the envelope through it. Omit it (or leave `userMessage` absent) and the
* envelope is byte-identical to the three-argument call.
*/
error(message: string, httpStatus?: number, details?: any): { status: number; body: any };
error(message: string, httpStatus?: number, details?: any, extra?: { userMessage?: string }): { status: number; body: any };
/** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */
routeNotFound(route: string): { status: number; body: any };
/**
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime/src/domains/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,9 +824,16 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
// other thrower (#8016 / #9106 — its closed `details` list is
// deliberate, not a gap this branch works around).
if (actionExec.isFlowActionRefusal(err)) {
// [#24914, ruling B′] `runDetails` now carries the
// `innerRefusal` a node's data call met (the declared third
// key), and the carrier's producer-marked `userMessage`
// goes to the envelope's top level through the deps slot.
return {
handled: true,
response: deps.error(err.message, err.status, { code: err.code, ...err.runDetails }),
response: deps.error(
err.message, err.status, { code: err.code, ...err.runDetails },
err.userMessage !== undefined ? { userMessage: err.userMessage } : undefined,
),
};
}
throw err;
Expand Down
41 changes: 31 additions & 10 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { capabilityUnavailable } from './unavailable.js';
// [#9446] The ONE #9378 status table, now shared with the `/actions` door.
import {
classifyFlowRefusal,
flowFailureDetails,
flowFailureEnvelope,
flowIsUnknown,
flowNotFoundMessage,
isPausedRun,
Expand Down Expand Up @@ -2033,15 +2035,20 @@ async function respondToFlowTrigger(
// that happened. 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.
const runDetails = refusal.code === 'FLOW_FAILED'
? {
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}),
}
: {};
//
// [#24914, ruling B′] Built by the one shared builder, typed as
// `FlowFailureDetailsSchema`: `errorMessage` / `summary` as before, and
// the `innerRefusal` a node's data call met; the producer-marked
// `userMessage` rides the envelope's top level, beside an unchanged
// `message`.
const failed = refusal.code === 'FLOW_FAILED';
const runDetails = failed ? flowFailureDetails(result) : {};
return {
handled: true,
response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...runDetails }),
response: deps.error(
refusal.message, refusal.status, { code: refusal.code, ...runDetails },
failed ? flowFailureEnvelope(result) : undefined,
),
};
}
// [#9510] THE THIRD STATE, answered deliberately. A run that dispatched and
Expand Down Expand Up @@ -2252,6 +2259,14 @@ export interface ResumeRefusal {
message: string;
status: number;
details: { code: string } & Record<string, unknown>;
/**
* [#24914, ruling B′] The envelope's declared top-level siblings — the
* producer-marked `userMessage` of the refusal the run's node met, set on
* the `FLOW_FAILED` row only and only when the engine result carries one.
* Passed as `DomainHandlerDeps.error`'s `extra` by both doors this table
* serves.
*/
extra?: { userMessage: string };
}

/**
Expand Down Expand Up @@ -2349,15 +2364,21 @@ export async function classifyResumeResult(
// code it matched, so the builder promotes it and never derives one.
if (code !== undefined && row) return { message: result.error ?? row.fallback, status: row.status, details: { code } };
const verdict = await resumeFailureDetails(deps, automationService, runId, result);
// [#24914, ruling B′] The run's artefacts through the shared builder
// (`FlowFailureDetailsSchema`: `errorMessage` / `summary` / `innerRefusal`),
// the verdict beside them in the same object, and the producer-marked
// `userMessage` at the envelope's top level. The screen-flow leg — a write
// behind a screen, refused after the pause was consumed — answers HERE.
const extra = flowFailureEnvelope(result);
return {
message: result.error ?? 'Flow run failed',
status: 400,
details: {
code: 'FLOW_FAILED',
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}),
...flowFailureDetails(result),
...verdict,
},
...(extra !== undefined ? { extra } : {}),
};
}

Expand Down Expand Up @@ -3326,7 +3347,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
// it answers what it answers, is documented there.
const refusal = await classifyResumeResult(deps, automationService, parts[2], result);
if (refusal) {
return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details) };
return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details, refusal.extra) };
}
return { handled: true, response: deps.success(result) };
}
Expand Down
14 changes: 11 additions & 3 deletions packages/runtime/src/domains/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,13 +808,21 @@ function resumeDoorError(
message: string,
httpStatus: number,
details?: Record<string, unknown>,
extra?: { userMessage?: string },
): Error {
const response = deps.error(message, httpStatus, details);
const envelope = (response?.body as { error?: { code?: unknown; message?: unknown; details?: unknown } } | undefined)?.error;
const response = deps.error(message, httpStatus, details, extra);
const envelope = (response?.body as {
error?: { code?: unknown; message?: unknown; details?: unknown; userMessage?: unknown };
} | undefined)?.error;
return Object.assign(new Error(typeof envelope?.message === 'string' ? envelope.message : message), {
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
status: response?.status ?? httpStatus,
...(envelope?.details !== undefined ? { details: envelope.details } : {}),
// [#24914, ruling B′] The producer-marked end-user text, carried onto
// the thrown error as the `userMessage` field every boundary reads
// through `declaredUserMessage` — exactly what the REST door put at the
// envelope's top level, never folded into `message`.
...(typeof envelope?.userMessage === 'string' ? { userMessage: envelope.userMessage } : {}),
});
}

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

Expand Down
18 changes: 12 additions & 6 deletions packages/runtime/src/endpoint-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { validationFailure } from './validation-failure.js';
import { buildAutomationContext } from './domains/automation.js';
import {
classifyFlowRefusal,
flowFailureDetails,
flowFailureEnvelope,
flowIsUnknown,
flowNotFoundMessage,
FLOW_NOT_FOUND_STATUS,
Expand Down Expand Up @@ -585,17 +587,21 @@ async function executeFlow(
// 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 } : {}),
}
: {};
//
// [#24914, ruling B′] The trigger door's own builder, so the
// `innerRefusal` a node's data call met rides here as it does there,
// and the producer-marked `userMessage` takes the envelope's declared
// top-level slot (`extra`, `ApiErrorSchema.userMessage`) — authored
// end-user text, so the message's leak guard does not apply to it.
const failed = refusal.code === 'FLOW_FAILED';
const runDetails = failed ? flowFailureDetails(result) : {};
const extra = failed ? flowFailureEnvelope(result) : undefined;
return apiErrorResponse({
message: sanitizeMessage(refusal.message, refusal.status),
httpStatus: refusal.status,
code: refusal.code,
...(Object.keys(runDetails).length > 0 ? { details: runDetails } : {}),
...(extra !== undefined ? { extra } : {}),
});
}

Expand Down
Loading
Loading