Skip to content

Commit f2920e1

Browse files
os-warrenclaude
andauthored
fix(service-automation): route every retry attempt through the node input-schema guard (#9889) (#10024)
executeWithoutRetry — the method retryExecution re-runs the flow through on every retry attempt — never called validateNodeInputSchemas, so a flow whose node config violates its own declared inputSchema under errorHandling.strategy: 'retry' was refused on attempt 1 and executed for real on attempts 2..N. Both attempt paths now call the same guard (the seedRunVariables chokepoint discipline from #9704); retry accounting is unchanged. Pins assert the side-effecting node never runs on any attempt, and that a valid flow still retries normally. Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 899052a commit f2920e1

3 files changed

Lines changed: 212 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): node input-schema validation now guards EVERY attempt of a retried flow, not only the first (#9889)
6+
7+
Before this fix, `validateNodeInputSchemas` — the guard that refuses to run a
8+
flow whose node `config` violates its own declared `inputSchema` — was called
9+
only by `execute()` (attempt 1). Under `errorHandling.strategy: 'retry'`, the
10+
guard's throw routed into `retryExecution`, and every retry attempt ran
11+
through `executeWithoutRetry` with no guard at all: the nodes attempt 1
12+
refused to run were executed for real, with the config the guard rejected. A
13+
side-effecting node (a data write, an HTTP call, an email) behind a
14+
mis-declared `inputSchema` was reachable simply by declaring `retry`.
15+
16+
Now both attempt paths call the same guard, so flows that were previously
17+
running on retry with a mis-declared `inputSchema` will be refused on every
18+
attempt (`success: false`, `status: 'failed'`, with the guard's message).
19+
Retry accounting is unchanged: each refused attempt still consumes retry
20+
budget, and valid flows retry exactly as before. If a flow of yours starts
21+
failing with `missing required input parameter` or `expected type ... but
22+
got ...` after this release, it was already being refused on its first
23+
attempt — fix the node's `config` to match its declared `inputSchema`.

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3184,7 +3184,10 @@ export class AutomationEngine implements IAutomationService {
31843184
reentryHeld = true;
31853185
}
31863186

3187-
// Validate node input schemas before execution
3187+
// Validate node input schemas before execution. [#9889] The same
3188+
// call sits in `executeWithoutRetry` — every retry attempt must be
3189+
// refused by the same guard that refused attempt 1; see the guard's
3190+
// own doc for the chokepoint contract.
31883191
this.validateNodeInputSchemas(flow, variables);
31893192

31903193
// DAG traversal execution
@@ -5257,6 +5260,18 @@ export class AutomationEngine implements IAutomationService {
52575260
/**
52585261
* Validate node input schemas before execution.
52595262
* Checks that node config matches declared inputSchema if present.
5263+
*
5264+
* [#9889] The ONE definition-level input-schema chokepoint, called by BOTH
5265+
* attempt paths — `execute()` (attempt 1) and `executeWithoutRetry` (every
5266+
* retry attempt) — the same chokepoint discipline `seedRunVariables`
5267+
* carries for the variable environment (#9704). Its verdict is a pure
5268+
* function of the flow definition (`_variables` is deliberately unused),
5269+
* so a refusal on attempt 1 must hold on every attempt: when only
5270+
* `execute()` called it, `errorHandling.strategy: 'retry'` ran the very
5271+
* nodes attempt 1 refused to run. ⛔ A repair to the validation rules
5272+
* belongs HERE, in the shared method — re-inlining either caller's copy
5273+
* re-opens the execute/executeWithoutRetry drift this file has now paid
5274+
* for six times (#9378, #9415, #9414, #9510, #9704, #9889).
52605275
*/
52615276
private validateNodeInputSchemas(flow: FlowParsed, _variables: Map<string, unknown>): void {
52625277
for (const node of flow.nodes) {
@@ -6433,6 +6448,29 @@ export class AutomationEngine implements IAutomationService {
64336448
return { success: false, code: 'FLOW_NO_START_NODE', error: 'Flow has no start node' };
64346449
}
64356450

6451+
// [#9889] The SAME definition-level guard attempt 1 runs under —
6452+
// the sixth instance of this method drifting from `execute()`
6453+
// (#9378, #9415, #9414, #9510, #9704 before it), and the first
6454+
// that skipped a GUARD rather than an exit or the environment.
6455+
// Without this call, a flow whose node config violates its own
6456+
// declared `inputSchema` under `errorHandling.strategy: 'retry'`
6457+
// was refused on attempt 1 (the guard throws before any node
6458+
// executes) and then RUN FOR REAL on attempts 2..N, because the
6459+
// retry handoff lives in `execute()`'s catch and every retry
6460+
// attempt comes back through here — a refusal that holds only
6461+
// until the flow is retried, i.e. `retry` as a way past
6462+
// authoring-time validation. The guard's verdict is a pure
6463+
// function of the flow definition (`_variables` is unused), so
6464+
// re-running it cannot refuse anything attempt 1 would have
6465+
// allowed; the throw lands in this method's generic failure arm
6466+
// below, so each refused attempt still consumes retry budget and
6467+
// retry accounting is unchanged. Same chokepoint discipline as
6468+
// `seedRunVariables` (#9704): ONE method holds the rules, both
6469+
// attempt paths call it — `input-schema-retry-parity.test.ts`
6470+
// pins the per-attempt refusal so this call cannot be dropped
6471+
// silently.
6472+
this.validateNodeInputSchemas(flow, variables);
6473+
64366474
await this.executeNode(startNode, flow, variables, runContext, steps);
64376475

64386476
const output: Record<string, unknown> = {};
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { AutomationEngine } from './engine.js';
5+
6+
/**
7+
* #9889 — node input-schema validation must hold on EVERY attempt, not only
8+
* the first.
9+
*
10+
* `validateNodeInputSchemas` reports by throwing, and the retry handoff lives
11+
* inside `execute()`'s catch. Before the repair, only `execute()` called the
12+
* guard: for a flow whose node config violates its own declared `inputSchema`
13+
* under `errorHandling.strategy: 'retry'`, attempt 1 threw in the guard before
14+
* any node executed, the catch routed to `retryExecution`, and attempts 2..N
15+
* ran through `executeWithoutRetry` — which never called the guard — so the
16+
* nodes attempt 1 refused permission to run were executed for real, with the
17+
* config the guard rejected. A `retry` strategy was a way past authoring-time
18+
* validation.
19+
*
20+
* The pins here are written against the OBSERVABLE side effect (an executor
21+
* spy counting real node executions), not only the thrown error: the defect's
22+
* whole harm is a side-effecting node (a data write, an HTTP call, an email)
23+
* running with rejected config, and an assertion on the returned error alone
24+
* stays green while that node runs.
25+
*
26+
* On the envelope: the refusal is asserted as `success: false` +
27+
* `status: 'failed'` + the guard's own message. There is no ADR-0112 `code`
28+
* to assert — deliberately: #9378's classification gives `code` to the
29+
* NEVER-DISPATCHED exits (`FLOW_DISABLED`, `FLOW_NO_START_NODE`) and `status:
30+
* 'failed'` to the dispatched-and-failed exits, and the guard's throw rides
31+
* the latter family on both attempt paths. Whether a definition-level refusal
32+
* should instead be classified non-retryable (its verdict cannot change per
33+
* attempt) is the open question #9889 leaves to a maintainer ruling; these
34+
* pins assert the parity floor only.
35+
*/
36+
37+
function createTestLogger(): any {
38+
return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => createTestLogger() };
39+
}
40+
41+
/**
42+
* An engine holding one flow whose single work node counts every REAL
43+
* execution — the observable the negative pins are written against.
44+
*/
45+
function countingFlowEngine(opts: {
46+
config: Record<string, unknown>;
47+
inputSchema: Record<string, { type: string; required?: boolean }>;
48+
/** What the spy executor returns; defaults to success. */
49+
executeResult?: (attempt: number) => { success: boolean; error?: string };
50+
}) {
51+
const engine = new AutomationEngine(createTestLogger());
52+
const runs = { count: 0 };
53+
54+
engine.registerNodeExecutor({
55+
type: 'script',
56+
async execute() {
57+
runs.count++;
58+
return opts.executeResult ? opts.executeResult(runs.count) : { success: true };
59+
},
60+
} as any);
61+
62+
engine.registerFlow('guarded', {
63+
name: 'guarded',
64+
label: 'Guarded',
65+
type: 'autolaunched',
66+
errorHandling: { strategy: 'retry', maxRetries: 2, backoffMs: 0 },
67+
nodes: [
68+
{ id: 'start', type: 'start', label: 'Start' },
69+
{
70+
id: 'work',
71+
type: 'script' as any,
72+
label: 'Work',
73+
config: opts.config,
74+
inputSchema: opts.inputSchema,
75+
},
76+
{ id: 'end', type: 'end', label: 'End' },
77+
],
78+
edges: [
79+
{ id: 'e0', source: 'start', target: 'work' },
80+
{ id: 'e1', source: 'work', target: 'end' },
81+
],
82+
} as any);
83+
84+
return { engine, runs };
85+
}
86+
87+
describe("#9889 — input-schema refusal holds on every attempt under strategy: 'retry'", () => {
88+
it('never executes a node whose config mis-types its declared inputSchema — on ANY attempt', async () => {
89+
const { engine, runs } = countingFlowEngine({
90+
config: { count: 'not_a_number' },
91+
inputSchema: { count: { type: 'number', required: true } },
92+
});
93+
94+
const result = await engine.execute('guarded');
95+
96+
// The refusal, as the caller sees it (see header for why no `code`).
97+
expect(result.success).toBe(false);
98+
expect(result.status).toBe('failed');
99+
expect(result.error).toContain("expected type 'number' but got 'string'");
100+
101+
// The point of the card: the side-effecting node ran ZERO times.
102+
// Pre-repair this was 2 — refused on attempt 1, executed for real on
103+
// attempts 2 and 3.
104+
expect(runs.count).toBe(0);
105+
106+
// And the refusal happened PER ATTEMPT, not by short-circuiting the
107+
// retry loop: every attempt still dispatched and consumed budget
108+
// (retry accounting unchanged — the non-retryable classification is
109+
// the open question, not this repair), so the run log holds one
110+
// failed row per attempt (1 initial + maxRetries), each carrying the
111+
// guard's own message.
112+
const attemptRows = await engine.listRuns('guarded', { status: 'failed' });
113+
expect(attemptRows).toHaveLength(3);
114+
for (const row of attemptRows) {
115+
expect(row.error).toContain("expected type 'number' but got 'string'");
116+
}
117+
});
118+
119+
it('never executes a node missing a required declared input — on ANY attempt', async () => {
120+
const { engine, runs } = countingFlowEngine({
121+
config: {},
122+
inputSchema: { url: { type: 'string', required: true } },
123+
});
124+
125+
const result = await engine.execute('guarded');
126+
127+
expect(result.success).toBe(false);
128+
expect(result.status).toBe('failed');
129+
expect(result.error).toContain("missing required input parameter 'url'");
130+
expect(runs.count).toBe(0);
131+
});
132+
133+
it('still retries a VALID flow normally — the guard refuses nothing attempt 1 allowed', async () => {
134+
const { engine, runs } = countingFlowEngine({
135+
config: { count: 42 },
136+
inputSchema: { count: { type: 'number', required: true } },
137+
// Attempt 1 fails downstream (a transient error, the case retry
138+
// exists for); attempt 2 succeeds.
139+
executeResult: attempt =>
140+
attempt === 1 ? { success: false, error: 'downstream 503' } : { success: true },
141+
});
142+
143+
const result = await engine.execute('guarded');
144+
145+
expect(result.success).toBe(true);
146+
// Attempt 1 ran and failed, attempt 2 ran and succeeded — the fix must
147+
// not turn a legitimate retry into a refusal.
148+
expect(runs.count).toBe(2);
149+
});
150+
});

0 commit comments

Comments
 (0)