Skip to content
Steps and step mechanics

Steps and step mechanics

A Step is a named entry in a Flow’s steps map: the unit of execution inside a frame. Each Step names an action, the verb it performs, and carries a small set of shared mechanical fields. The actions themselves, and the fields specific to each, are cataloged in Step actions; this section defines the shared mechanics.

Those shared mechanics are the three roles a Step plays apart from its action: a Step routes, it shapes data crossing its boundary on the way in and on the way out, and it captures values into the frame’s variables. Its fields are confined to those roles: routing, shaping data-plane values, and writing control-plane state. The work itself, the Call a Step dispatches and the middleware that wraps it, lives in the Call object (see The Call interface and Result) and the middleware stack (see Middleware mechanics), not in the Step’s own fields.

What a Step is

A Step definition is a JSON object pairing an action with the shared fields below and whatever fields that action defines. The shared fields are:

FieldTypeRequiredDefaultExpression
actionstringrequiredno (structural)
inputanyoptional (Call/Match){{ step.input }}yes
outputanyoptionalper action (Step actions)yes
assignobjectoptional{}yes (per value)
middlewarearrayoptional (Call)[]no (structural)
nextstring (Step name)optionalno (structural)
catcharrayoptional (Call/Gather)[]no (structural; see catch clauses)

Not every shared field applies to every action: input is carried only by Call and Match; catch only by the call-dispatching actions, Call and Gather (see Failures and catch); middleware only by Call — a stack wraps a single dispatch, and Call is the action that makes exactly one, where a Gather’s dispatches carry their wrappers inside a flow target (see Step actions); and the applicability and passthrough default of output and assign vary by action. Each action’s complete field set is given in Step actions.

action is a PascalCase discriminator naming the Step’s action; its permitted values and their semantics are defined in Step actions. A Step may additionally carry a comment for documentation.

next is optional because not every Step transitions; a terminal Step ends the Flow instead (see Routing). The remaining fields — input, output, assign, and catch — are defined in the sections that follow.

Routing: next and terminal Steps

A Step is either transitioning or terminal. A transitioning Step names a successor and, on success, passes control to it. A terminal Step ends the Flow, producing the Flow’s Result.

A transitioning Step names its successor in next. The value MUST be a key of the same steps map; routing is scoped to that map, and a Step cannot transition to a Step in a parent, child, or sibling Flow (see Step-name scoping).

A terminal Step carries no next. Two actions end a Flow: Return completes it with a success Result, and Raise completes it with a failure Result. Both are defined in Step actions; the Result they produce is defined in The Call interface and Result.

Every Step MUST have a defined exit path. A transitioning Step satisfies this with next; a terminal Step satisfies it by ending the Flow; and a Step’s catch clauses supply exit paths for its failure routing (see catch clauses). A Step that can neither transition nor terminate — a non-terminal action with no next — is ill-formed. This is a structural property of the definition, checkable without running the Flow; it is one of the constraints described under Static checks.

Data flow: input and output

Two fields shape the data crossing a Step’s boundary. input shapes the value entering the Step; output shapes the value leaving it on success.

input produces the value the Step works on. Its default, {{ step.input }}, passes through the value the Step received unchanged. On a Call Step, the shaped input is what threads into the Call and its middleware stack on the way down (see Middleware mechanics); on a Match Step, it is the value the clause predicates test (see Step actions).

output produces the value the Step emits on success — the value its successor receives through next. What it reads when absent is the action’s default: a Call’s default, {{ step.result.value }}, reads the value the Step’s Result carries (see Success Result); a Gather’s projects its collected Results’ successes; each action’s default is given with the action (see Step actions). A terminal Step has no successor to hand an output to: a Return shapes the value its success Result carries with its own value field, and a Raise constructs a failure envelope instead (see Step actions).

What each field yields when it is absent, and the rule that a present expression replaces it, are a property of the field defined under Absent fields and passthrough.

The pairing of output (shape the value emitted) with assign (capture into variables) is a uniform discipline: wherever a construct shapes what it emits — an output, a value, a constructed failure envelope — an assign sits beside it. It recurs at the Step, the middleware phase (see Middleware mechanics), and the call’s arms (see The arms). The terminal actions Return and Raise are the exception: they end the frame, leaving no later Step to read a captured variable (see Step actions).

Variables: assign

assign is the Step’s writer into the frame’s variables. It is a map from name to an expression; each expression’s value is bound to that name in vars. The variable model — what vars is, how it is scoped to the frame, how it is seeded from parameters, and how a written name replaces an existing binding — is defined in The Flow object. This section defines only assign’s timing relative to the Step.

"assign": { "registeredCount": "{{ size(step.result.value.features) }}" }

Bindings declared in assign take effect on successful Step exit. assign runs after output, so an assignment is not visible to the Step’s output expression. Within a single assign block, every expression is evaluated against the variable state as it existed before the block ran: assignments in the same block are not visible to one another, and an expression that reads a name being reassigned sees the prior value, not the new one. This follows from the data model: an assign block is a JSON object, and object key order is not significant (The JSON data model), so the block defines no order in which one entry could see another’s result. Evaluating every entry against the prior state makes the outcome the same in whatever order an implementation evaluates them. Subsequent Steps in the frame see the new bindings.

To carry a value out of a completed inner Flow, the dispatching call’s assign captures flow.vars.<name> at the call boundary, where the window is in scope, and later fields read the captured variable back from vars; nothing crosses the boundary on its own (see The target windows).

Failures and catch

A Result-consuming Step’s fields encode a two-armed match on its Result. output, assign, and next are the success arm; catch is the failure arm; exactly one arm applies, selected by the Result’s type. Actions that consume no Result have no arms, only sequencing. The same two-armed match appears on the language’s stack constructs — middleware entries and call objects — encoded there as onSuccess/onFailure blocks (see The phase model and The arms); a routing construct encodes it as flat success fields beside a catch, whose distinct name marks the difference in kind: a clause list that matches codes and routes the graph, not a shaping block.

catch is a Step’s failure-path routing: the failure analogue of next. Where next routes on success, a catch clause routes when the Step produces a failure Result whose code matches the clause. The failure Result itself — its type, code, and the rest of the envelope catch matches against — is defined in The Call interface and Result. This section owns the matching grammar and the clause mechanics.

catch is a field of the call-dispatching actions, Call and Gather (see Step actions): the actions whose ordinary work is to run a Call and route on the Result it yields, including its recoverable failures. Any Step can fail regardless of its action, since any Step may evaluate an expression and an evaluation that faults produces a System.* failure (see Evaluation errors). A failure on a Step that carries no catch clause has no local edge to match it, and so propagates out of the frame.

Where catch sits

A Step resolves to a single Result, and catch matches that Result. For a Call Step the Result emerges from the bottom of the Step’s machinery and rises: the inner Call runs, the middleware stack processes it on the way out (onEntry on the way down; onSuccess or onFailure, then onAlways, on the way up), and the Result the outermost middleware emits is what catch sees. catch sits outside that stack; it does not participate in it. The ordering and semantics of the stack are defined in Middleware mechanics; here it is enough that catch matches the Step’s outermost emitted Result.

When that Result is a success, next and output apply and catch does not run. When it is a failure, the Step’s own output and assign do not run, since they shape and capture a successful exit. Instead, catch is consulted before the failure would propagate out of the frame. On the failure path the only shaping that runs is the matched clause’s own output and assign (see catch clauses).

Failure matching

A catch clause selects failures with a failure matcher: an object whose members each constrain one contract field of the failure envelope (see The failure envelope). The matcher is the language’s one failure-selection grammar: catch clauses match with it here, and the Retry middleware’s policies match with it against rising failures (see The Retry middleware).

"match": {
  "codes": ["Provider.Call.Payments.CardDeclined", "System.*"],
  "types": ["error", "timeout"],
  "retryable": true
}
MemberTypeRequiredMatches when
codesarray of patterns (non-empty)optionalthe failure’s code matches any pattern in the list
typesarray of strings (non-empty)optionalthe failure’s type is any of the named non-success types
retryablebooleanoptionalthe failure’s retryable is the same explicit value

A matcher MUST have at least one member, and it matches a failure when every member present matches. Each member constrains its field independently; the only cross-field grammar is the conjunction. The match-any matcher is spelled { "codes": ["*"] }.

codes matches over the failure’s code with a closed pattern grammar:

  • "Prefix.Code" — exact match on a fully-qualified code.
  • "Prefix.*" — match any code with the given prefix.
  • "*" — match any failure code.

A pattern’s prefix is one or more leading segments. The first segment is a code namespace, defined with the failure envelope in The Call interface and Result; a deeper prefix matches a finer grain of the taxonomy: Provider.* matches any provider failure, Provider.Call.* any called target’s failure, and Provider.Call.Http.* a single provider’s codes. Matching is lexical over the code alone; no pattern is scoped by the failure’s origin. On a Step whose Call targets a provider directly, the only call-provider codes that can arrive are that provider’s own, per its catalog. On a Step whose Call targets a Flow, a Provider.* failure arising anywhere inside the called Flow propagates up unchanged and matches the same way; there the code’s identity segment is what tells one origin’s codes from another’s.

types matches over the failure’s type. Its entries name non-success Result types — the spec-defined types or extension types (see Result types). success is not a permissible entry: a matcher selects among failures, and no success reaches one.

retryable matches over the envelope’s advisory signal (see The failure envelope): true matches only a failure that asserts retryable: true, and false only one that asserts retryable: false. A failure whose retryable is unset — absent or null, which the envelope makes equivalent — matches neither value: the signal’s silence satisfies no assertion about it.

Note

The matcher is deliberately closed over the envelope’s contract fields. message and details carry unstructured, provider-specific context and are not matchable; a workflow that must distinguish failures by something in details first names the distinction as a code — a middleware onFailure block that constructs a successor failure with a new code (see onFailure) — and then matches the name.

Implementations MUST accept any syntactically valid codes pattern. Tooling MAY use provider and middleware catalogs to warn about patterns that reference undeclared codes, particularly on a Call Step targeting a provider, where the set of possible codes is statically computable. On a Step whose Call targets a Flow, the codes that can propagate from inside the called Flow are not statically bounded, which limits such analysis.

catch clauses

Each catch clause is a conditional failure edge: when the Step’s failure matches, control transitions to the clause’s next, the way next transitions on success.

"catch": [
  {
    "match": {
      "codes": [
        "Provider.Call.Payments.CardDeclined",
        "Provider.Call.Payments.InsufficientFunds"
      ]
    },
    "next": "retry-with-backup"
  },
  { "match": { "codes": ["*"] }, "next": "failed" }
]
FieldTypeRequiredDefaultExpression
matchobject (failure matcher)requiredno (structural)
outputanyoptional{{ step.input }}yes
assignobjectoptional{}yes (per value)
nextstring (Step name)requiredno (structural)
commentstringoptionalno (literal)

Clauses are evaluated in order; the first clause whose match matches the failure wins (see Failure matching). Validators MAY warn about a clause made unreachable by an earlier, broader one.

When a clause matches, control transitions to next, which resolves against the same steps map as the Step it guards (see Step-name scoping). output shapes the value the handler Step receives, and assign captures into vars. Because the Step failed, no success value is available, so output defaults to {{ step.input }}: the value the Step received passes through (see Absent fields and passthrough).

The clause’s output and assign expressions, and the Step catch routes to, can read the matched failure envelope. That failure remains the live failure context across the handler path; if a handler Step itself fails, the new failure’s previous chains the one being handled, recording a failed recovery. The binding that exposes the failure and the lifecycle by which it is set, cleared, and chained are defined in Execution context.

catch and frame-level failures

catch handles Step-level failures only. A failure that arises at the frame level — from the Flow’s own middleware, or from an external cancellation — bypasses the step graph and therefore its catch clauses. Flow-level middleware wraps the step graph as a whole, outside any individual Step’s routing (see Middleware mechanics); cancellation and frame completion are defined in Execution model.