Skip to content

Commit 2f47489

Browse files
authored
fix(automation): a fault edge must not switch off a guardrail (#3863) (#3881)
A `fault` edge routes a failed node to a handler instead of aborting the run — the right primitive for the world not cooperating (a 404, a rate-limit, a rejected write). It was also routing the refuse-to-execute family. Those guards report that the METADATA is wrong, not that an operation failed: #3810 (interpolation erased a filter condition), ADR-0049/#1888 (the run would execute unscoped), a data node naming no object. Surfacing as ordinary node failures, one declared edge silently disabled them. Reproduced in a test before fixing: a `fault` edge on a `delete_record` whose filter has a typo (`{record.ownr}`) made #3810's protection against emptying the object vanish — guard fired, handler swallowed it, run reported success: true. The exact fail-open direction #3810 was opened to close, reachable from one edge. Failures now carry a class. `NodeExecutionResult.errorClass` is 'runtime' (default, so every existing executor keeps its routing) or 'guard'. Guard-class failures are never routed: fatal with or without a fault edge, failing with the guard's own message. Thrown guards are covered too — UnscopedRunDataAccessError is branded through a shared `guard-refusal` module, so the catch path cannot become the bypass the return path no longer is. Marked guard-class: three resolveNodeFilter refusals, four `objectName required` refusals, UnscopedRunDataAccessError. Genuine engine failures stay runtime-class and keep routing. Also here: - `{<nodeId>.error}` carries a failed node's message alongside run-wide `{$error}`, which names only the most recent failure — a handler shared by two fault edges could not tell which node it was handling. Additive. - Fault edges are documented for the first time (flows.mdx + automation skill), including the routable/not-routable split and an explicit "do not add one to silence a guard error". A run taking a fault branch still reports success, and the failed step keeps status 'failure' and its message in the trace (#3356/#3407).
1 parent 4d00b13 commit 2f47489

8 files changed

Lines changed: 523 additions & 12 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
fix(automation): a `fault` edge must not switch off a guardrail (#3863)
6+
7+
A `fault` edge routes a failed node to a handler instead of aborting the run.
8+
That is the right primitive for the world not cooperating — an `http` node that
9+
404s, a connector that rate-limited, a rejected write.
10+
11+
It was also, until now, routing the **refuse-to-execute** family. Those guards
12+
report that the METADATA is wrong, not that an operation failed: #3810
13+
(interpolation erased a filter condition), ADR-0049/#1888 (the run would execute
14+
unscoped), a data node naming no object. Because they surfaced as ordinary node
15+
failures, one declared edge silently disabled them.
16+
17+
**The live consequence, reproduced in a test before the fix:** attach a `fault`
18+
edge to a `delete_record` whose filter has a typo (`{record.ownr}`), and #3810's
19+
protection against emptying the object was gone — the guard fired, the handler
20+
swallowed it, and the run reported `success: true`. That is the exact fail-open
21+
direction #3810 was opened to close, reachable from a single edge, and it is the
22+
kind of suppression an AI authoring loop reaches for first when trying to make a
23+
diagnostic go away.
24+
25+
**Failures now carry a class.** `NodeExecutionResult.errorClass` is `'runtime'`
26+
(default — every existing executor keeps its current routing) or `'guard'`.
27+
Guard-class failures are never routed: they stay fatal with or without a `fault`
28+
edge, and the run fails with the guard's own message. Thrown guards are covered
29+
too — `UnscopedRunDataAccessError` is branded via a shared `guard-refusal`
30+
module, so the engine's catch path cannot become the bypass the return path no
31+
longer is.
32+
33+
Marked as guard-class: the three `resolveNodeFilter` refusals (#3810), the four
34+
`objectName required` refusals, and `UnscopedRunDataAccessError` (ADR-0049).
35+
Genuine engine failures (`get_record(x) failed: …`) stay runtime-class and keep
36+
routing.
37+
38+
**Also in this change**
39+
40+
- `{<nodeId>.error}` now carries a failed node's message alongside the run-wide
41+
`{$error}`. `$error` names only the most recent failure, so a handler shared by
42+
two fault edges could not tell which node it was handling; `{charge_card.error}`
43+
is addressable from any downstream template. Additive — `$error` is unchanged.
44+
- Fault edges are **documented** for the first time (`content/docs/automation/flows.mdx`
45+
and the automation skill), including the routable/not-routable split. The skill
46+
entry says plainly not to add a fault edge to silence a guard error, since that
47+
is the misuse the class split now makes impossible.
48+
49+
A run that takes a fault branch still reports success, and the failed step still
50+
carries `status: 'failure'` and its message in the trace — recovery does not
51+
erase the record of what failed (#3356/#3407).

content/docs/automation/flows.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,49 @@ Edges connect nodes and define the execution path:
560560
| `condition` | `string` | optional | Boolean expression for branching |
561561
| `label` | `string` | optional | Label displayed on the connector |
562562

563+
### Fault edges — handling a failed node
564+
565+
A `fault` edge routes a **failed** node to a handler instead of aborting the
566+
run. Without one, any node failure ends the run.
567+
568+
```typescript
569+
edges: [
570+
{ id: 'e_ok', source: 'charge_card', target: 'mark_paid' },
571+
{ id: 'e_fault', source: 'charge_card', target: 'flag_for_review', type: 'fault' },
572+
]
573+
```
574+
575+
The handler reads what went wrong from two variables:
576+
577+
| Variable | Scope | Use |
578+
| :--- | :--- | :--- |
579+
| `{<nodeId>.error}` | the failing node | `{charge_card.error}` — addressable by name, so one handler shared by several fault edges can tell which node it is handling |
580+
| `{$error}` | run-wide | `{$error.nodeId}` / `{$error.message}` — the most recent failure only |
581+
582+
A run that takes a fault branch and completes reports **success**, but the
583+
failed step stays in the run trace with `status: 'failure'` and its message —
584+
recovery never erases the record of what failed.
585+
586+
<Callout type="warn">
587+
**A fault edge does not disable a guardrail.** Failures come in two classes, and
588+
only one is routable.
589+
590+
- **Runtime** — the world did not cooperate: an `http` node got a 404, a
591+
connector rate-limited, the data engine rejected a write. Routed to the fault
592+
edge.
593+
- **Guard** — the *metadata* is wrong, and a refuse-to-execute check said so: a
594+
filter token resolved to nothing so the condition was dropped from the query,
595+
a data node names no object, or the run would execute unscoped. **Never
596+
routed.** These stay fatal whether or not a `fault` edge exists, and the run
597+
fails with the guard's own message.
598+
599+
The split is deliberate. A dropped filter condition does not narrow a query, it
600+
widens it — so if guards were routable, one `fault` edge on a `delete_record`
601+
would turn off the protection against emptying the object while the run still
602+
reported success. Re-running changes nothing either: the fix is to correct the
603+
metadata, which `objectstack validate` will point at.
604+
</Callout>
605+
563606
## Variables
564607

565608
Flows use variables to pass data between nodes and to/from callers:

packages/services/service-automation/src/builtin/crud-nodes.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
161161
async execute(node, variables, context) {
162162
const cfg = (node.config ?? {}) as Record<string, unknown>;
163163
const objectName = String(readAliasedConfig(cfg, 'get_record', 'objectName', ['object'], ctx.logger) ?? '');
164-
if (!objectName) return { success: false, error: 'get_record: objectName required' };
164+
if (!objectName) return { success: false, error: 'get_record: objectName required', errorClass: 'guard' };
165165

166166
// `filters` → `filter` is now handled at load by the ADR-0087 D2
167167
// conversion layer ('flow-node-crud-filter-alias'), so the executor
@@ -170,7 +170,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
170170
cfg.filter, variables, context, 'get_record',
171171
'would have read rows the filter was written to exclude',
172172
);
173-
if ('error' in filterResult) return { success: false, error: filterResult.error };
173+
if ('error' in filterResult) return { success: false, error: filterResult.error, errorClass: 'guard' };
174174
const filter = filterResult.filter;
175175
const fields = cfg.fields as string[] | undefined;
176176
const limit = typeof cfg.limit === 'number' ? cfg.limit : undefined;
@@ -222,7 +222,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
222222
async execute(node, variables, context) {
223223
const cfg = (node.config ?? {}) as Record<string, unknown>;
224224
const objectName = String(readAliasedConfig(cfg, 'create_record', 'objectName', ['object'], ctx.logger) ?? '');
225-
if (!objectName) return { success: false, error: 'create_record: objectName required' };
225+
if (!objectName) return { success: false, error: 'create_record: objectName required', errorClass: 'guard' };
226226

227227
const fields = interpolate(cfg.fields ?? {}, variables, context) as Record<string, unknown>;
228228
const outputVariable = cfg.outputVariable as string | undefined;
@@ -303,14 +303,14 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
303303
async execute(node, variables, context) {
304304
const cfg = (node.config ?? {}) as Record<string, unknown>;
305305
const objectName = String(readAliasedConfig(cfg, 'update_record', 'objectName', ['object'], ctx.logger) ?? '');
306-
if (!objectName) return { success: false, error: 'update_record: objectName required' };
306+
if (!objectName) return { success: false, error: 'update_record: objectName required', errorClass: 'guard' };
307307

308308
// `filters` → `filter` converted at load (ADR-0087 D2); read canonical.
309309
const filterResult = resolveNodeFilter(
310310
cfg.filter, variables, context, 'update_record',
311311
'would have matched — and overwritten — rows the filter was written to exclude',
312312
);
313-
if ('error' in filterResult) return { success: false, error: filterResult.error };
313+
if ('error' in filterResult) return { success: false, error: filterResult.error, errorClass: 'guard' };
314314
const filter = filterResult.filter;
315315
// `fields` is the single canonical write-map key — no alias (the wrong key
316316
// `fieldValues` is corrected at the authoring source + rejected by graph-lint).
@@ -376,7 +376,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
376376
async execute(node, variables, context) {
377377
const cfg = (node.config ?? {}) as Record<string, unknown>;
378378
const objectName = String(readAliasedConfig(cfg, 'delete_record', 'objectName', ['object'], ctx.logger) ?? '');
379-
if (!objectName) return { success: false, error: 'delete_record: objectName required' };
379+
if (!objectName) return { success: false, error: 'delete_record: objectName required', errorClass: 'guard' };
380380

381381
// `filters` → `filter` converted at load (ADR-0087 D2); read canonical.
382382
// The highest-stakes of the three: an erased condition here is the
@@ -385,7 +385,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
385385
cfg.filter, variables, context, 'delete_record',
386386
'would have matched every remaining row and deleted it',
387387
);
388-
if ('error' in filterResult) return { success: false, error: filterResult.error };
388+
if ('error' in filterResult) return { success: false, error: filterResult.error, errorClass: 'guard' };
389389
const filter = filterResult.filter;
390390

391391
const data = getData();

packages/services/service-automation/src/engine.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ConnectorSchema } from '@objectstack/spec/integration';
1818
// engine at module load in both ESM and CJS builds.
1919
import { ExpressionEngine, validateExpression } from '@objectstack/formula';
2020
import { runIsUnscopedUserMode, flowTouchesData } from './runtime-identity.js';
21+
import { isGuardRefusal } from './guard-refusal.js';
2122

2223
// ─── Node Executor Interface (Plugin Extension Point) ───────────────
2324

@@ -52,10 +53,37 @@ export interface NodeExecutor {
5253
): Promise<NodeExecutionResult>;
5354
}
5455

56+
/**
57+
* Why a node failed — the question a `fault` edge's routing decision turns on
58+
* (#3863).
59+
*
60+
* - `runtime` — the world did not cooperate: an `http` node got a 404, a
61+
* connector rate-limited, the data engine rejected a write. The metadata is
62+
* fine and a later run could succeed. A declared `fault` edge routes it.
63+
* - `guard` — the METADATA is wrong, and a refuse-to-execute guard said so:
64+
* interpolation erased a filter condition (#3810), a data node names no
65+
* object, a run would execute unscoped (ADR-0049/#1888). Re-running changes
66+
* nothing, and the refusal IS the safety property. Not routable — it stays
67+
* fatal whether or not a `fault` edge exists.
68+
*
69+
* The split exists because without it a `fault` edge is a one-edge switch that
70+
* turns off the platform's data-safety guarantees: attach one to a
71+
* `delete_record` and #3810's protection against emptying the object is gone,
72+
* while the run still reports success. Absent, this defaults to `runtime`, so
73+
* every executor written before the field keeps its current routing behaviour.
74+
*/
75+
export type NodeFailureClass = 'runtime' | 'guard';
76+
5577
export interface NodeExecutionResult {
5678
success: boolean;
5779
output?: Record<string, unknown>;
5880
error?: string;
81+
/**
82+
* #3863 — why this failed, which decides whether a `fault` edge may route it.
83+
* Only meaningful when `success` is false; defaults to `runtime`.
84+
* See {@link NodeFailureClass}.
85+
*/
86+
errorClass?: NodeFailureClass;
5987
/**
6088
* #3407: advisory warnings surfaced on the step's log entry. The step still
6189
* SUCCEEDS — a warning flags a legal-but-surprising outcome (e.g. an
@@ -2777,6 +2805,24 @@ export class AutomationEngine implements IAutomationService {
27772805
}
27782806
}
27792807

2808+
/**
2809+
* #3863 — publish a failed node's message under `<nodeId>.error` alongside
2810+
* the run-wide `$error`.
2811+
*
2812+
* `$error` names only the most recent failure, so a flow with more than one
2813+
* `fault` edge converging on a shared handler cannot tell which node it is
2814+
* handling. Keying by node id makes `{cleanup.error}` addressable from any
2815+
* downstream template, which is what a handler needs to branch or report.
2816+
* Merges into an existing entry so a node's earlier `output` survives.
2817+
*/
2818+
private setNodeError(variables: Map<string, unknown>, nodeId: string, message: string): void {
2819+
const prior = variables.get(nodeId);
2820+
const base = prior && typeof prior === 'object' && !Array.isArray(prior)
2821+
? (prior as Record<string, unknown>)
2822+
: {};
2823+
variables.set(nodeId, { ...base, error: message });
2824+
}
2825+
27802826
/**
27812827
* Execute a node with timeout support, fault edge handling, and step logging.
27822828
*/
@@ -2859,10 +2905,16 @@ export class AutomationEngine implements IAutomationService {
28592905
error: { code: 'EXECUTION_ERROR', message: errMsg },
28602906
});
28612907

2862-
// Check for fault edges
2863-
const faultEdge = flow.edges.find(e => e.source === node.id && e.type === 'fault');
2908+
// #3863 — a guard that THROWS is as un-routable as one that
2909+
// returns: `UnscopedRunDataAccessError` (ADR-0049/#1888) reports
2910+
// that the metadata would run unscoped, and rerouting it would
2911+
// let a `fault` edge disable the elevation check.
2912+
const faultEdge = isGuardRefusal(execErr)
2913+
? undefined
2914+
: flow.edges.find(e => e.source === node.id && e.type === 'fault');
28642915
if (faultEdge) {
28652916
variables.set('$error', { nodeId: node.id, message: errMsg });
2917+
this.setNodeError(variables, node.id, errMsg);
28662918
const faultTarget = flow.nodes.find(n => n.id === faultEdge.target);
28672919
if (faultTarget) {
28682920
await this.executeNode(faultTarget, flow, variables, context, steps);
@@ -2887,9 +2939,16 @@ export class AutomationEngine implements IAutomationService {
28872939

28882940
// Write error output to variable context for downstream nodes
28892941
variables.set('$error', { nodeId: node.id, message: errMsg, output: result.output });
2890-
2891-
// Check for fault edges
2892-
const faultEdge = flow.edges.find(e => e.source === node.id && e.type === 'fault');
2942+
this.setNodeError(variables, node.id, errMsg);
2943+
2944+
// #3863 — only a `runtime` failure may be routed. A `guard`
2945+
// refusal says the METADATA is wrong (#3810 erased a filter
2946+
// condition, a data node names no object); rerouting it would
2947+
// make a single `fault` edge a switch that turns the guard off
2948+
// while the run still reports success.
2949+
const faultEdge = result.errorClass === 'guard'
2950+
? undefined
2951+
: flow.edges.find(e => e.source === node.id && e.type === 'fault');
28932952
if (faultEdge) {
28942953
const faultTarget = flow.nodes.find(n => n.id === faultEdge.target);
28952954
if (faultTarget) {

0 commit comments

Comments
 (0)