Skip to content

Commit 5602211

Browse files
authored
fix(automation): close the default-routable footgun on refuse-to-execute guards (#3863) (#3889)
#3881 stopped a `fault` edge swallowing a guard refusal, keyed on `errorClass`. That field defaults to 'runtime' — right for compatibility, since every executor written before the split keeps its routing — but it leaves the footgun pointing the other way: a NEW guard is routable unless its author remembers to classify it, and forgetting is silent. `refuseNode(reason)` returns a guard-class failure, so writing a guard and marking it un-routable become one act. Its doc states the test for using it (re-running unchanged can never succeed AND the fix is to edit metadata) and the inverse, because over-marking turns a recoverable integration into a dead run. Five guards that were never marked are now un-routable — all missing required config or a defective graph, none able to succeed on a retry: - `http` with no url - `subflow` with no flowName, and subflow past max nesting depth (a recursive graph nests exactly as deep next run) - `map` with no flowName - `connector_action` with no connectorId/actionId The seven crud-nodes guards from #3881 move to the helper — same behaviour, one spelling. A behavioural inventory test drives every known guard through the engine with a fault edge attached and asserts it stays fatal, matching the refusal text so a guard failing for a different reason cannot pass vacuously. Verified to have teeth: un-marking one fails its row immediately. The negative half is pinned too — a plain failure and a thrown error must still route. Deliberately not marked: a degraded connector (#3017 recovers automatically), a collection that did not resolve to an array, a collection over the iteration cap, a subflow that failed on its own. The world caused those. The flows.mdx and skill callouts now state the guard/runtime TEST rather than a closed list, which this PR had already made stale within its own branch. Considered and rejected: making `errorClass` required on the result type — compile-time enforcement, but it breaks 281 call sites plus third-party executors for a type-only gain over the helper.
1 parent acbf364 commit 5602211

10 files changed

Lines changed: 369 additions & 32 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": patch
3+
---
4+
5+
fix(automation): close the default-routable footgun on refuse-to-execute guards (#3863)
6+
7+
#3881 stopped a `fault` edge from swallowing a guard refusal, keyed on
8+
`NodeExecutionResult.errorClass`. That field defaults to `'runtime'`, which was
9+
right for compatibility — every executor written before the split keeps its
10+
routing — but it leaves the footgun pointing the other way: **a new guard is
11+
routable unless its author remembers to classify it**, and forgetting is silent.
12+
Nothing in the type system catches it.
13+
14+
Three changes close that for the guards that exist and make the next one hard to
15+
get wrong.
16+
17+
**`refuseNode(reason)`** — one call that returns a guard-class failure, so
18+
"write a guard" and "mark it un-routable" become the same act. Its doc states
19+
the test for using it: re-running unchanged can never succeed AND the fix is to
20+
edit metadata. It also states the inverse, because over-marking is not the safe
21+
direction — classifying a handleable condition as `guard` turns a recoverable
22+
integration into a dead run.
23+
24+
**Five guards that were never marked** are now un-routable. All are missing
25+
required config or a defective graph, none can succeed on a retry:
26+
27+
- `http` with no `url`
28+
- `subflow` with no `config.flowName`, and `subflow` exceeding max nesting depth
29+
(a recursive graph nests exactly as deep next run)
30+
- `map` with no `config.flowName`
31+
- `connector_action` with no `connectorId` / `actionId`
32+
33+
The seven `crud-nodes` guards from #3881 move to the helper — same behaviour,
34+
one spelling.
35+
36+
**A behavioural inventory test** drives every known guard through the engine
37+
with a fault edge attached and asserts it is still fatal, matching on the
38+
refusal text so a guard failing for a different reason cannot pass vacuously.
39+
Verified to have teeth: un-marking one guard fails its row immediately. The
40+
negative half is pinned too — a plain node failure and a thrown error must still
41+
route, since that is what fault edges are for.
42+
43+
Deliberately **not** marked, and why: a degraded connector (#3017 says recovery
44+
is automatic), a collection that did not resolve to an array, a collection over
45+
the iteration cap, and a subflow that failed on its own. Those are conditions
46+
the world caused, and an author must be able to handle them.
47+
48+
Considered and rejected: making `errorClass` required on the result type. It
49+
would enforce classification at compile time, but it breaks every node executor
50+
returning a failure — 281 call sites across the repo plus third-party
51+
executors — for a type-only gain over the helper.

content/docs/automation/flows.mdx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -593,15 +593,22 @@ recovery never erases the record of what failed.
593593
**A fault edge does not disable a guardrail.** Failures come in two classes, and
594594
only one is routable.
595595

596-
- **Runtime** — the world did not cooperate: an `http` node got a 404, a
597-
connector rate-limited, the data engine rejected a write. Routed to the fault
598-
edge.
599-
- **Guard** — the *metadata* is wrong, and a refuse-to-execute check said so: a
600-
filter token resolved to nothing so the condition was dropped from the query,
601-
a data node names no object, or the run would execute unscoped. **Never
602-
routed.** These stay fatal whether or not a `fault` edge exists, and the run
596+
- **Runtime** — the world did not cooperate, and a later run could succeed: an
597+
`http` node got a 404, a connector rate-limited or is degraded, the data engine
598+
rejected a write, a collection arrived the wrong shape, a subflow failed on its
599+
own. Routed to the fault edge.
600+
- **Guard** — a refuse-to-execute check found the *metadata* wrong. **Never
601+
routed:** these stay fatal whether or not a `fault` edge exists, and the run
603602
fails with the guard's own message.
604603

604+
A failure is a guard when both hold: re-running the flow unchanged could never
605+
succeed, **and** the fix is to edit metadata. In practice that is a missing
606+
required config key (a data node with no `objectName`, an `http` node with no
607+
`url`, a `subflow` or `map` with no `flowName`, a `connector_action` with no
608+
`connectorId`/`actionId`), a filter token that resolved to nothing so the
609+
condition was dropped from the query, a graph that recurses past the nesting
610+
ceiling, or a run that would execute unscoped.
611+
605612
The split is deliberate. A dropped filter condition does not narrow a query, it
606613
widens it — so if guards were routable, one `fault` edge on a `delete_record`
607614
would turn off the protection against emptying the object while the run still

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { AutomationEngine, ConnectorActionContext } from '../engine.js';
6+
import { refuseNode } from '../guard-refusal.js';
67

78
/**
89
* Connector built-in node — `connector_action` (generic integration dispatch).
@@ -49,10 +50,9 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont
4950
async execute(node, variables, context) {
5051
const cfg = node.connectorConfig;
5152
if (!cfg?.connectorId || !cfg?.actionId) {
52-
return {
53-
success: false,
54-
error: `connector_action '${node.id}': connectorConfig.connectorId and .actionId are required`,
55-
};
53+
return refuseNode(
54+
`connector_action '${node.id}': connectorConfig.connectorId and .actionId are required`,
55+
);
5656
}
5757

5858
const handler = engine.resolveConnectorAction(cfg.connectorId, cfg.actionId);

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data';
77
import type { AutomationEngine } from '../engine.js';
88
import { interpolate, interpolateFilter, type VariableMap } from './template.js';
99
import { readAliasedConfig } from './config-aliases.js';
10+
import { refuseNode } from '../guard-refusal.js';
1011
import { resolveRunDataContext } from '../runtime-identity.js';
1112

1213
/**
@@ -161,7 +162,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
161162
async execute(node, variables, context) {
162163
const cfg = (node.config ?? {}) as Record<string, unknown>;
163164
const objectName = String(readAliasedConfig(cfg, 'get_record', 'objectName', ['object'], ctx.logger) ?? '');
164-
if (!objectName) return { success: false, error: 'get_record: objectName required', errorClass: 'guard' };
165+
if (!objectName) return refuseNode('get_record: objectName required');
165166

166167
// `filters` → `filter` is now handled at load by the ADR-0087 D2
167168
// conversion layer ('flow-node-crud-filter-alias'), so the executor
@@ -170,7 +171,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
170171
cfg.filter, variables, context, 'get_record',
171172
'would have read rows the filter was written to exclude',
172173
);
173-
if ('error' in filterResult) return { success: false, error: filterResult.error, errorClass: 'guard' };
174+
if ('error' in filterResult) return refuseNode(filterResult.error);
174175
const filter = filterResult.filter;
175176
const fields = cfg.fields as string[] | undefined;
176177
const limit = typeof cfg.limit === 'number' ? cfg.limit : undefined;
@@ -222,7 +223,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
222223
async execute(node, variables, context) {
223224
const cfg = (node.config ?? {}) as Record<string, unknown>;
224225
const objectName = String(readAliasedConfig(cfg, 'create_record', 'objectName', ['object'], ctx.logger) ?? '');
225-
if (!objectName) return { success: false, error: 'create_record: objectName required', errorClass: 'guard' };
226+
if (!objectName) return refuseNode('create_record: objectName required');
226227

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

308309
// `filters` → `filter` converted at load (ADR-0087 D2); read canonical.
309310
const filterResult = resolveNodeFilter(
310311
cfg.filter, variables, context, 'update_record',
311312
'would have matched — and overwritten — rows the filter was written to exclude',
312313
);
313-
if ('error' in filterResult) return { success: false, error: filterResult.error, errorClass: 'guard' };
314+
if ('error' in filterResult) return refuseNode(filterResult.error);
314315
const filter = filterResult.filter;
315316
// `fields` is the single canonical write-map key — no alias (the wrong key
316317
// `fieldValues` is corrected at the authoring source + rejected by graph-lint).
@@ -376,7 +377,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
376377
async execute(node, variables, context) {
377378
const cfg = (node.config ?? {}) as Record<string, unknown>;
378379
const objectName = String(readAliasedConfig(cfg, 'delete_record', 'objectName', ['object'], ctx.logger) ?? '');
379-
if (!objectName) return { success: false, error: 'delete_record: objectName required', errorClass: 'guard' };
380+
if (!objectName) return refuseNode('delete_record: objectName required');
380381

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

391392
const data = getData();

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import { randomUUID } from 'node:crypto';
66
import type { AutomationEngine } from '../engine.js';
7+
import { refuseNode } from '../guard-refusal.js';
78
import { interpolate } from './template.js';
89

910
/**
@@ -95,7 +96,7 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
9596
const cfg = interpolate(raw, variables, context) as Record<string, unknown>;
9697

9798
const url = cfg.url as string | undefined;
98-
if (!url) return { success: false, error: 'http: url is required' };
99+
if (!url) return refuseNode('http: url is required');
99100

100101
const durable = cfg.durable === true;
101102
const headers = cfg.headers as Record<string, string> | undefined;

packages/services/service-automation/src/builtin/map-node.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { AutomationContext } from '@objectstack/spec/contracts';
66
import type { AutomationEngine } from '../engine.js';
7+
import { refuseNode } from '../guard-refusal.js';
78
import { interpolate } from './template.js';
89

910
/** Hard cap on map fan-out — turns a runaway collection into a clean error. */
@@ -74,7 +75,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
7475
const flowName =
7576
typeof cfg.flowName === 'string' ? cfg.flowName : typeof cfg.flow === 'string' ? cfg.flow : undefined;
7677
if (!flowName) {
77-
return { success: false, error: `map '${node.id}': config.flowName (the per-item subflow) is required` };
78+
return refuseNode(`map '${node.id}': config.flowName (the per-item subflow) is required`);
7879
}
7980

8081
const iteratorVariable =

packages/services/service-automation/src/builtin/subflow-node.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { AutomationContext } from '@objectstack/spec/contracts';
66
import type { AutomationEngine } from '../engine.js';
77
import { interpolate } from './template.js';
8+
import { refuseNode } from '../guard-refusal.js';
89

910
/** Hard cap on subflow nesting — turns an accidental cycle into a clean error. */
1011
const MAX_SUBFLOW_DEPTH = 16;
@@ -55,16 +56,17 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext
5556
const flowName =
5657
typeof cfg.flowName === 'string' ? cfg.flowName : typeof cfg.flow === 'string' ? cfg.flow : undefined;
5758
if (!flowName) {
58-
return { success: false, error: `subflow '${node.id}': config.flowName is required` };
59+
return refuseNode(`subflow '${node.id}': config.flowName is required`);
5960
}
6061

6162
// Cycle guard: depth rides on the context so it accumulates across nesting.
6263
const depth = Number((context as { $subflowDepth?: number } | undefined)?.$subflowDepth ?? 0);
6364
if (depth >= MAX_SUBFLOW_DEPTH) {
64-
return {
65-
success: false,
66-
error: `subflow '${flowName}': max nesting depth (${MAX_SUBFLOW_DEPTH}) exceeded — recursive subflow?`,
67-
};
65+
// A graph that recurses past the ceiling is a metadata defect, not a
66+
// transient condition — the next run nests exactly as deep (#3863).
67+
return refuseNode(
68+
`subflow '${flowName}': max nesting depth (${MAX_SUBFLOW_DEPTH}) exceeded — recursive subflow?`,
69+
);
6870
}
6971

7072
// Map inputs (resolve `{var}` against the parent's variables/context).

0 commit comments

Comments
 (0)