Found while implementing #9704 (the variable-environment divergence on the same two methods). Filed rather than fixed there: a different defect class — a skipped guard, not a divergent variable environment — and the repair changes whether a flow runs at all, which #9704's ruling does not cover.
Measured
packages/services/service-automation/src/engine.ts, origin/main @ 55d29935b.
execute() calls the guard before traversing the DAG:
this.validateNodeInputSchemas(flow, variables); // execute() only
await this.executeNode(startNode, flow, variables, runContext, steps);
executeWithoutRetry() — the method retryExecution re-runs the flow through on every retry attempt — goes straight to executeNode with no such call. Only two call sites exist in the file: the definition, and execute()'s single invocation.
The guard is not a per-attempt-varying check: its signature is validateNodeInputSchemas(flow: FlowParsed, _variables: Map<string, unknown>) and the variables parameter is unused (underscore-prefixed). It reads node.inputSchema against the static node.config, so its verdict is a pure function of the flow definition — the same definition every attempt of the dispatch runs under.
Why it matters
The two facts compose into a reachable, user-visible sequence, because the retry handoff lives inside execute()'s catch:
if (flow.errorHandling?.strategy === 'retry') {
return this.retryExecution(flowName, context, startTime, flow.errorHandling, flow.errorMessage);
}
validateNodeInputSchemas reports by throwing (Node 'x' missing required input parameter 'y', ... expected type 'number' but got 'string'). So for a flow whose node config violates its own declared inputSchema, under errorHandling.strategy: 'retry':
- attempt 1 throws in the guard — before any node executes, which is the guard's whole purpose;
- the catch routes to
retryExecution;
- attempts 2..N run through
executeWithoutRetry, which never calls the guard — so the nodes attempt 1 was refused permission to run are executed for real, with the config the guard rejected.
A refusal that holds only until the flow is retried is not a refusal; a retry strategy becomes a way to get past authoring-time validation. Any side-effecting node (a data write, an HTTP call, an email) behind a mis-declared inputSchema is reachable this way.
This is the same drift shape as the four cards before it — the copy that is not execute() is the one a repair forgets (#9378, #9415, #9414, #9510), and now #9704 for the variable seeding.
Not decided here
Whether the guard belongs in executeWithoutRetry too (the parity reading, matching how #9704 resolved the seeding — one chokepoint both methods call), or whether a definition-level validation failure should be classified as non-retryable so execute() never hands off to retryExecution for it at all (arguably the stronger fix: re-running a check whose verdict cannot change wastes the whole retry budget on a certainty). The second changes retry accounting, so it wants a ruling rather than a guess.
Refs
Found while implementing #9704 (the variable-environment divergence on the same two methods). Filed rather than fixed there: a different defect class — a skipped guard, not a divergent variable environment — and the repair changes whether a flow runs at all, which #9704's ruling does not cover.
Measured
packages/services/service-automation/src/engine.ts,origin/main@55d29935b.execute()calls the guard before traversing the DAG:executeWithoutRetry()— the methodretryExecutionre-runs the flow through on every retry attempt — goes straight toexecuteNodewith no such call. Only two call sites exist in the file: the definition, andexecute()'s single invocation.The guard is not a per-attempt-varying check: its signature is
validateNodeInputSchemas(flow: FlowParsed, _variables: Map<string, unknown>)and the variables parameter is unused (underscore-prefixed). It readsnode.inputSchemaagainst the staticnode.config, so its verdict is a pure function of the flow definition — the same definition every attempt of the dispatch runs under.Why it matters
The two facts compose into a reachable, user-visible sequence, because the retry handoff lives inside
execute()'s catch:validateNodeInputSchemasreports by throwing (Node 'x' missing required input parameter 'y',... expected type 'number' but got 'string'). So for a flow whose node config violates its own declaredinputSchema, undererrorHandling.strategy: 'retry':retryExecution;executeWithoutRetry, which never calls the guard — so the nodes attempt 1 was refused permission to run are executed for real, with the config the guard rejected.A refusal that holds only until the flow is retried is not a refusal; a
retrystrategy becomes a way to get past authoring-time validation. Any side-effecting node (a data write, an HTTP call, an email) behind a mis-declaredinputSchemais reachable this way.This is the same drift shape as the four cards before it — the copy that is not
execute()is the one a repair forgets (#9378, #9415, #9414, #9510), and now #9704 for the variable seeding.Not decided here
Whether the guard belongs in
executeWithoutRetrytoo (the parity reading, matching how #9704 resolved the seeding — one chokepoint both methods call), or whether a definition-level validation failure should be classified as non-retryable soexecute()never hands off toretryExecutionfor it at all (arguably the stronger fix: re-running a check whose verdict cannot change wastes the whole retry budget on a certainty). The second changes retry accounting, so it wants a ruling rather than a guess.Refs
executeWithoutRetryseeds none of the engine-owned variables #9704 — the variable-environment divergence on the same method pair, and where this was foundexecuteWithoutRetryhas noisSuspendSignalarm #9510 / bug(service-automation):execute()never carries the flow author'ssuccessMessage/errorMessage— onlyresume()does, so a triggered run's friendly text is silently dropped #9414 / spec: addFLOW_DISABLED/FLOW_NO_START_NODEtoAutomationResult.codeand deliver the trigger ruling's remaining two rows (409/422) #9415 / automation: bothtriggerroutes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378 — the prior drift cards on these two methods