Skip to content

bug(service-automation): node input-schema validation guards only the FIRST attempt — a retry attempt runs the nodes attempt 1 refused to run #9889

Description

@os-warren

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':

  1. attempt 1 throws in the guard — before any node executes, which is the guard's whole purpose;
  2. the catch routes to retryExecution;
  3. 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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions