Skip to content

Commit 7ef20d0

Browse files
authored
feat(cli,automation): catch label: 'error' written where type: 'fault' was meant (#3863) (#3893)
Two of the three items left open on #3863. Both make the fault-edge contract legible; neither changes routing behaviour. New lint `flow-error-label-not-fault`. `type: 'fault'` is what routes a failure; `label` is cosmetic on an ordinary edge. So an edge written { source: 'charge_card', target: 'flag_for_review', label: 'error' } is an ordinary out-edge, and traverseNext runs every unconditional out-edge in parallel — the handler fires on every SUCCESSFUL run of the source, alongside the real success path, and never on a failure. The run still aborts when the node fails. Silent both ways: the author believes failures are handled, and never notices the handler running when nothing went wrong. Especially natural for an AI author, since the label is exactly what the intent sounds like. Narrow by construction, because a label IS load-bearing on a branching node — a decision/approval executor returns a branchLabel and traversal prefers the edge carrying it. Excluded: edges out of those node types, conditional edges, and edges already typed 'fault'. Matches the obvious synonyms case-insensitively. No findings against the shipped showcase. An alias (accepting label:'error' as type:'fault') was considered and rejected: two spellings for one concept read worse than one spelling plus a diagnostic that names the fix. Also pinned: a handled failure does not consume a flow-level retry. A fault edge handles one node; errorHandling.retry replays the flow FROM THE START, re-running everything that already succeeded. The two must not compound. That held by construction — a routed failure never propagates out of executeNode — and is now a test so a refactor of the catch path cannot quietly change it. Docs and the automation skill gain both points, plus a note that `label` does not select a path except on a branching node.
1 parent 6fdc5c6 commit 7ef20d0

6 files changed

Lines changed: 290 additions & 4 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
feat(cli,automation): catch `label: 'error'` written where `type: 'fault'` was meant (#3863)
7+
8+
Two of the three items left open on #3863. Both are about making the fault-edge
9+
contract legible; neither changes routing behaviour.
10+
11+
**New lint — `flow-error-label-not-fault`.** `type: 'fault'` is what routes a
12+
failure; `label` is cosmetic on an ordinary edge. So this, which reads exactly
13+
like error handling:
14+
15+
```ts
16+
{ source: 'charge_card', target: 'flag_for_review', label: 'error' }
17+
```
18+
19+
is an ordinary out-edge — and `traverseNext` runs every unconditional out-edge
20+
in parallel. The handler fires on every **successful** run of `charge_card`,
21+
concurrently with the real success path, and never on a failure. The run still
22+
aborts when the node fails.
23+
24+
Silent in both directions: the author believes failures are handled, and never
25+
notices the handler running when nothing went wrong. The reading is especially
26+
natural for an AI author, since the label is precisely what the intent sounds
27+
like — which is why this is worth a build-time diagnostic rather than leaving it
28+
to a puzzled look at a run trace.
29+
30+
Deliberately narrow, because a label IS load-bearing on a branching node: a
31+
`decision` / `approval` executor returns a `branchLabel` and traversal then
32+
prefers the edge carrying it. Edges out of those node types are excluded, as are
33+
conditional edges (a guarded path is not the unconditional footgun) and edges
34+
already typed `fault`. Matches the obvious synonyms (`error`, `failure`,
35+
`catch`, `on_error`, …) case-insensitively. Verified against the shipped
36+
showcase: no findings.
37+
38+
An alias — accepting `label: 'error'` as if it were `type: 'fault'` — was
39+
considered and rejected: two spellings for one concept is harder to read than
40+
one spelling plus a diagnostic that names the fix.
41+
42+
**Pinned: a handled failure does not consume a flow-level retry.** The two
43+
recovery mechanisms have different scopes and must not compound — a `fault` edge
44+
handles one node, while `errorHandling.retry` replays the flow **from the
45+
start**, re-running every node that already succeeded (a second notification, a
46+
second created record). A failure a fault edge handled is not a flow failure, so
47+
it does not consume a retry. That already held by construction (a routed failure
48+
never propagates out of `executeNode`); it is now a test, so a refactor of the
49+
catch path cannot quietly change it.
50+
51+
Docs and the automation skill gain both points, plus a note on the edge-property
52+
table that `label` does not select a path except on a branching node.

content/docs/automation/flows.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ Edges connect nodes and define the execution path:
564564
| `target` | `string` || Target node ID |
565565
| `type` | `enum` | optional | `'default'` (success), `'fault'` (error), `'conditional'` (expression-guarded), or `'back'` (declared back-edge, ADR-0044); defaults to `'default'` |
566566
| `condition` | `string` | optional | Boolean expression for branching |
567-
| `label` | `string` | optional | Label displayed on the connector |
567+
| `label` | `string` | optional | Label displayed on the connector — cosmetic only. It does **not** select a path except on a branching node (`decision` / `approval`), which picks its out-edge by label. |
568568

569569
### Fault edges — handling a failed node
570570

@@ -578,6 +578,16 @@ edges: [
578578
]
579579
```
580580

581+
<Callout type="warn">
582+
**`type: 'fault'` is what routes — a label is not.** Writing
583+
`{ source: 'charge_card', target: 'flag_for_review', label: 'error' }` without
584+
the type leaves an ordinary edge, and every unconditional out-edge is traversed
585+
(in parallel) on **success**. The handler then runs whenever the node
586+
*succeeds*, never when it fails, and the run still aborts on failure. Both
587+
halves are silent, so `objectstack validate` reports this as
588+
`flow-error-label-not-fault`.
589+
</Callout>
590+
581591
The handler reads what went wrong from two variables:
582592

583593
| Variable | Scope | Use |
@@ -616,6 +626,21 @@ reported success. Re-running changes nothing either: the fix is to correct the
616626
metadata, which `objectstack validate` will point at.
617627
</Callout>
618628

629+
#### Fault edges vs. `errorHandling.retry`
630+
631+
The two recovery mechanisms operate at different scopes and do not compound:
632+
633+
| | Scope | On failure |
634+
| :--- | :--- | :--- |
635+
| `fault` edge | one node | traversal continues from the handler; the run completes |
636+
| `errorHandling: { strategy: 'retry' }` | the whole flow | the flow re-runs **from the start** |
637+
638+
A failure a fault edge handled is not a flow failure, so it does **not** consume
639+
a retry. That matters because flow-level retry replays every node that already
640+
succeeded — a second notification, a second created record. Prefer a fault edge
641+
where the failure is local; reach for `retry` only when replaying the whole flow
642+
is genuinely safe.
643+
619644
## Variables
620645

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

packages/cli/src/utils/lint-flow-patterns.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
FLOW_APPROVAL_REVISE_UNMARKED_BACKEDGE,
1313
FLOW_APPROVAL_REVISE_DISABLED,
1414
FLOW_RUNAS_UNSCOPED,
15+
FLOW_ERROR_LABEL_NOT_FAULT,
1516
} from './lint-flow-patterns.js';
1617

1718
const CEL = (source: string) => ({ dialect: 'cel', source });
@@ -377,3 +378,73 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
377378
});
378379
});
379380
});
381+
382+
/**
383+
* #3863 — `label: 'error'` without `type: 'fault'`.
384+
*
385+
* The label is cosmetic on an ordinary edge, so the handler runs on every
386+
* SUCCESS (unconditional out-edges all traverse, in parallel) and never on a
387+
* failure. Both halves are silent, which is why this is worth a build-time
388+
* diagnostic rather than a runtime one.
389+
*/
390+
function edgeFlow(edge: Record<string, unknown>, sourceType = 'http') {
391+
return {
392+
flows: [{
393+
name: 'edge_flow',
394+
type: 'autolaunched',
395+
nodes: [
396+
{ id: 'start', type: 'start', config: {} },
397+
{ id: 'op', type: sourceType, config: { url: 'https://x.test' } },
398+
{ id: 'handler', type: 'notify', config: { topic: 'x' } },
399+
{ id: 'end', type: 'end' },
400+
],
401+
edges: [
402+
{ id: 'e1', source: 'start', target: 'op' },
403+
{ id: 'e2', source: 'op', target: 'end' },
404+
{ id: 'e_h', source: 'op', target: 'handler', ...edge },
405+
],
406+
}],
407+
};
408+
}
409+
410+
describe('flow-error-label-not-fault (#3863)', () => {
411+
it("flags label:'error' left at the default type", () => {
412+
const fnds = lintFlowPatterns(edgeFlow({ label: 'error' }));
413+
expect(fnds).toHaveLength(1);
414+
expect(fnds[0].rule).toBe(FLOW_ERROR_LABEL_NOT_FAULT);
415+
// The message must name the actual consequence, not just the mismatch.
416+
expect(fnds[0].message).toContain('SUCCESSFUL');
417+
expect(fnds[0].hint).toContain("type: 'fault'");
418+
});
419+
420+
it.each(['Error', 'FAILURE', 'catch', 'on_error', 'fault'])(
421+
"flags the equivalent label %s",
422+
(label) => {
423+
const fnds = lintFlowPatterns(edgeFlow({ label }));
424+
expect(fnds.map((f) => f.rule)).toContain(FLOW_ERROR_LABEL_NOT_FAULT);
425+
},
426+
);
427+
428+
it("does NOT flag the correct shape (type: 'fault')", () => {
429+
expect(lintFlowPatterns(edgeFlow({ label: 'error', type: 'fault' }))).toHaveLength(0);
430+
});
431+
432+
it('does NOT flag an unlabelled fault edge', () => {
433+
expect(lintFlowPatterns(edgeFlow({ type: 'fault' }))).toHaveLength(0);
434+
});
435+
436+
it('does NOT flag an ordinary labelled edge', () => {
437+
expect(lintFlowPatterns(edgeFlow({ label: 'approved' }))).toHaveLength(0);
438+
});
439+
440+
it('does NOT flag a CONDITIONAL edge — a guarded path is not the footgun', () => {
441+
expect(lintFlowPatterns(edgeFlow({ label: 'error', condition: "status == 'bad'" }))).toHaveLength(0);
442+
});
443+
444+
it.each(['decision', 'approval'])(
445+
"does NOT flag label:'error' out of a %s node — the label IS the branch selector",
446+
(sourceType) => {
447+
expect(lintFlowPatterns(edgeFlow({ label: 'error' }, sourceType))).toHaveLength(0);
448+
},
449+
);
450+
});

packages/cli/src/utils/lint-flow-patterns.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,36 @@ export const FLOW_APPROVAL_REVISE_DISABLED = 'flow-approval-revise-disabled';
7777
* resolves NO USER, and a schedule is only the most obvious such trigger.
7878
*/
7979
export const FLOW_RUNAS_UNSCOPED = 'flow-runas-unscoped';
80+
export const FLOW_ERROR_LABEL_NOT_FAULT = 'flow-error-label-not-fault';
8081

8182
/** Node types that perform a data operation — the ones `flow.runAs` governs (#1888). */
8283
const DATA_NODE_TYPES = new Set(['get_record', 'create_record', 'update_record', 'delete_record']);
8384

85+
/**
86+
* #3863 — an edge LABELLED like an error path but not TYPED as one.
87+
*
88+
* Error routing is `type: 'fault'`. `label` is cosmetic on an ordinary edge, so
89+
* `{ source, target, label: 'error' }` without the type does not mean "go here
90+
* on failure" — it is an unconditional out-edge, and `traverseNext` runs every
91+
* unconditional out-edge in parallel. The handler therefore fires on every
92+
* SUCCESSFUL run of the source node, concurrently with the real success path,
93+
* and never on a failure.
94+
*
95+
* Silent in both directions: the author believes errors are handled (they are
96+
* not — the run still aborts) and never notices the handler running when
97+
* nothing went wrong. The reading is especially natural for an AI author, since
98+
* `label: 'error'` is exactly what the intent sounds like.
99+
*
100+
* Deliberately narrow, because a label IS meaningful on a branching node: a
101+
* `decision`/`approval` executor returns a `branchLabel` and traversal then
102+
* prefers the edge with that label, so `label: 'error'` there is a real branch
103+
* selector. Conditional edges are likewise legitimate. Both are excluded.
104+
*/
105+
const ERROR_LABELS = new Set(['error', 'fault', 'failure', 'failed', 'catch', 'on_error', 'onerror', 'on error']);
106+
107+
/** Node types whose executor selects an out-edge BY LABEL (`branchLabel`). */
108+
const BRANCH_LABEL_NODE_TYPES = new Set(['decision', 'approval', 'screen', 'try_catch']);
109+
84110
/**
85111
* Does this flow auto-launch on a SCHEDULE (so a run carries no trigger user)?
86112
* Accepts the three author-time signals: `flow.type === 'schedule'`, a start-node
@@ -225,6 +251,46 @@ function edgeLabelOf(e: AnyRec): string {
225251
* `type: 'back'`, so `registerFlow` rejects it as an un-declared cycle. The
226252
* lint fires at compile time with the specific fix (mark the resubmit edge).
227253
*/
254+
/**
255+
* #3863 — flag edges labelled like an error path but left at the default type.
256+
* See {@link ERROR_LABELS} for why this is a footgun and what is excluded.
257+
*/
258+
function scanErrorLabelledEdges(
259+
flowName: string,
260+
nodes: AnyRec[],
261+
edges: AnyRec[],
262+
findings: FlowLintFinding[],
263+
): void {
264+
const typeById = new Map<string, string>();
265+
for (const n of nodes) {
266+
if (typeof n.id === 'string') typeById.set(n.id, typeof n.type === 'string' ? n.type : '');
267+
}
268+
269+
for (const e of edges) {
270+
const label = typeof e.label === 'string' ? e.label.trim().toLowerCase() : '';
271+
if (!ERROR_LABELS.has(label)) continue;
272+
if (e.type === 'fault') continue; // already an error path — nothing to say
273+
if (e.condition) continue; // a guarded edge is not the unconditional footgun
274+
const src = typeof e.source === 'string' ? e.source : '';
275+
// A branching node picks its out-edge BY label, so the label is load-bearing.
276+
if (BRANCH_LABEL_NODE_TYPES.has(typeById.get(src) ?? '')) continue;
277+
278+
findings.push({
279+
where: `flow '${flowName}' · edge '${src}' → '${String(e.target)}'`,
280+
message:
281+
`edge is labelled '${String(e.label)}' but its type is '${String(e.type ?? 'default')}', not 'fault' — ` +
282+
`so it is an ORDINARY out-edge. Unconditional out-edges all run in parallel, so '${String(e.target)}' ` +
283+
`executes on every SUCCESSFUL run of '${src}' and never on a failure. The error path the label ` +
284+
`describes does not exist, and the run still aborts when '${src}' fails.`,
285+
hint:
286+
`Add \`type: 'fault'\` to this edge. Only runtime failures route — a guard refusal (a filter token ` +
287+
`that resolved to nothing, a missing required config key, an unscoped run) stays fatal by design and ` +
288+
`must be fixed in the metadata, not handled. (#3863)`,
289+
rule: FLOW_ERROR_LABEL_NOT_FAULT,
290+
});
291+
}
292+
}
293+
228294
function scanApprovalReviseLoops(
229295
flowName: string,
230296
nodes: AnyRec[],
@@ -432,6 +498,11 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
432498

433499
// (c) ADR-0044 — approval send-back-for-revision loop footguns.
434500
scanApprovalReviseLoops(flowName, nodes, edges, findings);
501+
502+
// (d) #3863 — an edge labelled like an error path but typed 'default' is an
503+
// unconditional out-edge: the handler runs on every SUCCESS, in parallel
504+
// with the real path, and never on a failure.
505+
scanErrorLabelledEdges(flowName, nodes, edges, findings);
435506
}
436507
return findings;
437508
}

packages/services/service-automation/src/fault-edge-guard-containment.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,64 @@ describe('#3863 — a fault edge must not swallow a guard refusal', () => {
287287
expect(data.seen.deleted).toBe(false);
288288
});
289289
});
290+
291+
/**
292+
* #3863 — the boundary between the two recovery mechanisms.
293+
*
294+
* Node-level: a `fault` edge, precise — it handles one node and traversal
295+
* continues from the handler.
296+
* Flow-level: `errorHandling.retry`, blunt — it re-runs the flow FROM THE START,
297+
* so every node that already succeeded runs a second time, side effects and all.
298+
*
299+
* They must not compound. A node whose fault edge handled it is not a flow
300+
* failure, so it must not also consume a retry — otherwise declaring a handler
301+
* would silently multiply the side effects of everything upstream of it. That
302+
* holds today by construction (a routed failure never propagates out of
303+
* `executeNode`), and this pins it so a refactor of the catch path cannot
304+
* quietly change it.
305+
*/
306+
describe('#3863 — a handled failure does not trigger flow-level retry', () => {
307+
it('runs the upstream node exactly once when a fault edge handles the failure', async () => {
308+
const engine = new AutomationEngine(createTestLogger());
309+
let upstreamRuns = 0;
310+
let handlerRuns = 0;
311+
312+
engine.registerNodeExecutor({
313+
type: 'script',
314+
async execute(node) {
315+
if (node.id === 'upstream') { upstreamRuns++; return { success: true }; }
316+
if (node.id === 'risky') return { success: false, error: 'upstream 503' };
317+
handlerRuns++;
318+
return { success: true };
319+
},
320+
});
321+
engine.registerFlow('handled_no_retry', {
322+
name: 'handled_no_retry',
323+
label: 'Handled No Retry',
324+
type: 'autolaunched',
325+
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 1 },
326+
nodes: [
327+
{ id: 'start', type: 'start', label: 'Start' },
328+
{ id: 'upstream', type: 'script' as any, label: 'Upstream' },
329+
{ id: 'risky', type: 'script' as any, label: 'Risky' },
330+
{ id: 'handler', type: 'script' as any, label: 'Handler' },
331+
{ id: 'end', type: 'end', label: 'End' },
332+
],
333+
edges: [
334+
{ id: 'e0', source: 'start', target: 'upstream' },
335+
{ id: 'e1', source: 'upstream', target: 'risky' },
336+
{ id: 'e2', source: 'risky', target: 'end' },
337+
{ id: 'e_fault', source: 'risky', target: 'handler', type: 'fault' },
338+
{ id: 'e3', source: 'handler', target: 'end' },
339+
],
340+
} as any);
341+
342+
const result = await engine.execute('handled_no_retry');
343+
344+
expect(result.success).toBe(true);
345+
expect(handlerRuns).toBe(1);
346+
// The point: retry is configured and was NOT consumed. If a handled
347+
// failure counted as a flow failure, `upstream` would have run again.
348+
expect(upstreamRuns).toBe(1);
349+
});
350+
});

skills/objectstack-automation/SKILL.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,15 @@ variables: [
139139
> `label` is **required** on the flow and on every node.
140140
141141
> **Handling a failed node: a `fault` edge.** `{ source, target, type: 'fault' }`
142-
> routes a failed node to a handler instead of ending the run; the handler reads
143-
> `{<nodeId>.error}` (or run-wide `{$error}`). The run then reports success, and
144-
> the failed step stays in the trace.
142+
> routes a failed node to a handler instead of ending the run. **`type: 'fault'`
143+
> is what routes — a `label: 'error'` alone does nothing:** the edge stays
144+
> ordinary, and every unconditional out-edge traverses on SUCCESS, so the
145+
> handler would run when the node succeeds and never when it fails
146+
> (`objectstack validate` reports `flow-error-label-not-fault`).
147+
> A handled failure does NOT consume a flow-level `errorHandling.retry`, which
148+
> replays the flow from the start — prefer a fault edge when the failure is
149+
> local. The handler reads `{<nodeId>.error}` (or run-wide `{$error}`). The run
150+
> then reports success, and the failed step stays in the trace.
145151
>
146152
> **It is not a way past a guardrail.** Only *runtime* failures route — a 404, a
147153
> rate-limit, a rejected write, a subflow that failed on its own. A *guard*

0 commit comments

Comments
 (0)