Skip to content
Step actions

Step actions

Every Step names an action: the verb the Step performs. This section is the catalog of the seven actions — Call, Gather, Match, Pass, Sleep, Return, and Raise — each with its complete field set. The mechanics every Step shares — routing, data flow, variable capture, and catch — are defined in Steps and step mechanics; each action’s entry here states how those shared fields apply to it and defines the fields that are its own.

action is a structural discriminator and does not accept an expression (see Where expressions may appear). The table below refines the shared-field table of Steps and step mechanics per action:

FieldCallGatherMatchPassSleepReturnRaise
input
output(c)
assign(c)
middleware
catch
next(c)
comment

(c): on the matched clause, not at Step level.

Each action’s own fields — Call’s call; Gather’s over, call, calls, completion, and concurrency; Match’s cases and default; Sleep’s for and until; Return’s value; Raise’s result — are defined with the action below. Every action accepts a comment. Two actions define action-specific metadata, given with each: a Call’s dispatch instants (kept on the Call’s own record, not the Step’s) and a Gather’s dispatch count. The rest record only the universal members of their metadata record (see Metadata records).

Call

A Call Step dispatches a single call object and routes on the Result it yields. The call itself — its target, input, with, and its onSuccess/onFailure arms — is the dispatch unit defined in The Call interface and Result; the Step contributes routing (next, catch), boundary shaping (input, output, assign), and the middleware stack that wraps the dispatch.

{
  "action": "Call",
  "call": {
    "provider": "mwl:provider.call/example/container/v1",
    "with": {
      "image": "repo/modis-l0-to-l1-stac-task:latest",
      "command": ["run"]
    }
  },
  "next": "process-granules",
  "catch": [{ "match": { "codes": ["*"] }, "next": "failed" }]
}
FieldTypeRequiredDefaultExpression
callobjectrequiredno (structural)
inputanyoptional{{ step.input }}yes
outputanyoptional{{ step.result.value }}yes
assignobjectoptional{}yes (per value)
middlewarearrayoptional[]no (structural)
catcharrayoptional[]no (structural)
nextstring (Step name)requiredno (structural)

call names exactly one target, a provider or a Flow, and carries the dispatch’s own fields, each of which accepts an expression per its definition in The call object.

The Step’s input shapes the value entering the Step’s machinery. That value descends the middleware stack, and what the innermost entry emits arrives at the dispatch as call.input (see How values thread the stack); with no middleware, the shaped input arrives directly. The Result the call yields ascends the same stack, and the Result the outermost entry emits is the Step’s step.result (see Where catch sits). On success, output shapes the emitted value from it and assign captures into vars; on failure, catch matches it (see Failures and catch).

The Step’s middleware wraps the dispatch: the stack establishes once per Step pass, wraps the single Call, and a retrying entry re-runs the Call inside it (see Where middleware attaches). Call is the only action that carries the field; wrapping a Gather’s dispatches is a flow target’s work (see Wrapping a dispatch).

A flow-targeted call additionally exposes the completed frame to the call’s arms, as the flow window; capturing out of it happens in an arm’s assign (see The target windows).

Call metadata

A Call Step’s own record carries no action-specific members; the dispatch timing belongs to the Call’s record (see call), where two context-specific members sit beside the universal pair:

MemberTypeDescription
dispatchedAtstring (timestamp)The instant dispatch to the target was initiated.
acceptedAtstring (timestamp)The instant the platform accepted the target’s Result.

dispatchedAt follows the record’s enteredAt: the call’s fields have evaluated and the request leaves for the target. acceptedAt is the instant the platform commits the target’s Result — the acceptance instant that a control action racing the dispatch defines its semantics against (see Author shaping and the middleware action). Both are readable in the call’s arms and are carried onward with an arm’s assign. A re-dispatched Call is a new Call execution with a fresh record (see Re-execution evaluates afresh).

Gather

A Gather Step fans work out and gathers the Results back in. It makes one or more concurrent dispatches, each one execution of a call object — every dispatch is a call, with everything that entails (see The Call interface and Result) — collects every dispatch’s Result into step.results, and routes on a completion policy. The Gather contributes the fan-out, in one of two forms; the completion policy (completion); and the collected record.

{
  "action": "Gather",
  "over": "{{ step.input.features }}",
  "call": { "flow": "ProcessGranule", "with": { "collection": "modis-l1" } },
  "concurrency": 10,
  "next": "summarize",
  "catch": [{ "match": { "codes": ["*"] }, "next": "failed" }]
}

This Gather is in the iterate form: one dispatch per feature, each running ProcessGranule in a frame of its own, at most ten in flight at once.

FieldTypeRequiredDefaultExpression
overarrayiterate form, with callyes
callobjectiterate form, with overno (structural)
callsarray of call objectsscatter formno (structural)
completionobjectoptionalall dispatches succeedper field (see completion)
concurrencyinteger ≥ 1 | nulloptionalunlimitedno (literal)
outputanyoptionalthe success projection (see step.results)yes
assignobjectoptional{}yes (per value)
catcharrayoptional[]no (structural)
nextstring (Step name)requiredno (structural)

A Gather MUST carry exactly one of its two forms: over together with call, the iterate form, or calls, the scatter form — never both and never neither. calls MUST be a non-empty array: an empty calls is a fan-out, written literally, that dispatches nothing — almost certainly an authoring error, statically detectable, and the definition is ill-formed (see Static checks). An over that produces an empty array is data-dependent and legal; it makes zero dispatches (below).

A Gather carries no step-level input: the value the Step received is in scope as step.input for the Step’s own fields, and what each dispatch receives is its call’s to shape.

concurrency caps the number of dispatches active at once. A dispatch is active from its entry until its Result settles; the order in which pending dispatches start under a cap is implementation-defined. A present cap MUST be a positive integer; a value below 1 is ill-formed, statically detectable like the non-emptiness of calls (see Static checks). The fan-out is unlimited — every dispatch may be active at once — when concurrency is absent or null; the explicit null is the in-band way to write unlimited where omitting the field is inconvenient.

concurrency caps the dispatches active at once, not the fan-out’s size. An implementation MAY bound the number of dispatches a single fan-out may enumerate, as a platform-defined limit; the bound and the failure produced on exceeding it are the implementation’s to document — a fan-out limit is a platform constraint, not an engine semantic, so it does not mint a System. code (see Code namespaces). An implementation that imposes a bound MUST fail the Gather rather than truncate the fan-out, and SHOULD fail it at enumeration, where the dispatch count is known before any dispatch starts.

The iterate form: over and call

The iterate form makes one dispatch per element of a collection. over is evaluated once, when the Gather’s action begins, against the Step’s bindings. Its result MUST be an array; a non-array result is a value of the wrong type for a field whose type is known, and the Gather MUST fail with System.ParameterValidationFailed (see Evaluation errors). An empty array makes zero dispatches: the action completes with step.results empty.

call is the form’s one call template. Each element of the over result makes one dispatch of it: the element enters the dispatch as call.input, and call.index is the element’s 0-based position. The call’s fields evaluate afresh per dispatch, so a with that reads call.input or call.index configures each dispatch from its own element and position.

The scatter form: calls

The scatter form makes one dispatch per entry of calls, a literal array of call objects:

{
  "action": "Gather",
  "calls": [
    {
      "provider": "mwl:provider.call/example/http/v1",
      "with": { "method": "GET", "path": "/a" }
    },
    {
      "provider": "mwl:provider.call/example/http/v1",
      "with": { "method": "GET", "path": "/b" }
    },
    { "flow": "Summarize" }
  ],
  "next": "combine"
}

Each entry is a complete call object, independently targeted and configured. The value the Step received enters every dispatch — arriving at the call boundary as call.input — and call.index is the entry’s 0-based position in calls.

Wrapping a dispatch: flows, not middleware

A Gather carries no middleware: among the actions, only Call accepts the field (see Steps and step mechanics). The reason is the frame. A middleware that re-runs its inner scope, such as Retry, restores the frame’s variables on each re-entry, which is well-defined only because a frame evaluates serially, one block at a time (see Frames and sequential execution). Concurrent dispatches sharing one frame’s variables have no such serial point to restore to — a per-dispatch re-run would race every sibling’s writes — so a re-runnable dispatch needs a variable scope of its own, which is to say a frame of its own. While the fan-out is in flight, then, nothing in the parent frame gates, re-runs, or observes a dispatch; a dispatch that needs wrapped behavior targets a Flow and carries the wrapper inside it: the target Flow’s own middleware, or a stack on a Call Step within it, wraps the work inside the dispatch’s frame (see Where middleware attaches). A need for middleware around a dispatch is a need for a frame around it, and the frame is exactly what gives the re-run its own variables to restore. The cost is that a dispatch’s variables stay within its frame: a value the dispatch must surface is captured across the boundary by the call’s arm, like any flow target (see The target windows). Wrapping the fan-out as a whole is the same composition one level up: a stack around the entire Gather — a duration bound over the whole fan-out, say — wraps a Flow whose graph contains the Gather, attached at the Flow level.

Per-dispatch retry is the common case, and an inline flow target is its idiom:

{
  "action": "Gather",
  "over": "{{ step.input.features }}",
  "call": {
    "flow": {
      "entrypoint": "register",
      "steps": {
        "register": {
          "action": "Call",
          "call": {
            "provider": "mwl:provider.call/example/http/v1",
            "with": { "method": "POST", "path": "/granules" }
          },
          "middleware": [
            {
              "provider": "mwl:provider.middleware/mwl/retry/v1",
              "onEntry": {
                "with": {
                  "policies": [
                    {
                      "match": { "codes": ["Provider.Call.*"] },
                      "attempts": 3
                    }
                  ]
                }
              }
            }
          ],
          "next": "done"
        },
        "done": { "action": "Return" }
      }
    }
  },
  "next": "summarize"
}

Each feature’s dispatch runs the wrapper Flow in a frame of its own; inside it, the retrying stack re-runs that feature’s registration, so every dispatch retries independently, and completion counts each dispatch’s one emerging Result: a dispatch that succeeds on its third attempt is one success. The element flows through on the default passthroughs — the dispatch’s call.input seeds the frame, the frame’s input reaches the inner Call Step, and the inner call delivers it to the provider — so the wrapper adds no data plumbing. Cleanup that must run on a dispatch’s teardown lives the same way: a Finally entry in the target Flow’s stack runs onAlways within the dispatch’s frame on every exit, including when the Gather cancels the dispatch (see The Finally middleware).

The dispatch model

Every dispatch is one execution of a call object: a fresh evaluation of its fields against that dispatch’s bindings, with its own metadata record and clock pin (see Expression evaluation timing). The call MAY target a provider or a Flow, exactly as a Call Step’s call does; a flow-targeted dispatch runs in a frame of its own (see Frames and sequential execution).

Both forms present one surface to the call. call.input is the dispatch’s inbound payload — the element, in the iterate form; the value the Step received, in the scatter form — and call.index its 0-based position, available on Gather-dispatched calls in both forms (see call). A call template therefore moves between the forms unchanged.

The dispatch’s Result is consumed where every call’s is: in the call’s own arms (see The arms). onSuccess.value shapes the value the dispatch’s success Result carries into step.results, and either arm’s assign captures into the frame’s variables at the seam where the dispatch’s context — call.result, the target window — is alive.

Dispatches run their work concurrently; the frame’s variables hold still beneath them (see Frames and sequential execution). A dispatch’s call fields evaluate as it starts and only read, against the variable state at the action’s start — every dispatch reads the same state, however concurrency staggers the starts — and the arms, the fan-out’s only write-capable expressions, are deferred to fan-out completion (below). Dispatches are therefore isolated by construction: a dispatch cannot observe a sibling’s writes, progress, or outcome, and nothing inside the fan-out reads step.results. Coordination among dispatches is not expressible; what the fan-out must achieve is completion’s to say, and anything more is graph structure — separate Steps, or a flow target composing its own.

A dispatched Flow does not see the Gather’s frame: data crosses into its frame only through the call’s input and with, so a subflow that needs the element or the index is given it explicitly.

The arms at fan-out completion

Under Gather, the arms do not run as each Result settles: arm evaluation is deferred to fan-out completion. Once every dispatch has resolved, the arms run one dispatch at a time, in dispatch order, each arm one block under the discipline of Variables: assign, each reading the variable state every lower-indexed dispatch’s arm left. Each settled dispatch’s arm runs exactly once. An interrupted dispatch — one the Gather cancelled — evaluates no arms, and a skipped dispatch evaluates nothing at all (see When the arms run).

Deferral splits the dispatch’s Result into two stages. Settlement yields the target’s Result: what the completion arithmetic counts as Results arrive, and what stamps the call record’s exitedAt and acceptedAt. The arm then finalizes the Result that fills the dispatch’s slot in step.results: onSuccess.value shapes the success value, and an arm that faults makes the fault’s failure that dispatch’s Result (see Faults in the call object’s fields). Wherever step.results is read, a dispatch’s Result is the arm-finalized one. The dispatch’s context is still alive when its arm runs: call.result, the completed call.metadata record, and the target window are in scope at fan-out completion exactly as at settlement (see The target windows). What an arm never reads is step.results itself: when it runs, its own slot is not yet final.

Dispatch order makes cross-dispatch accumulation deterministic. An onSuccess.assign of "ids": "{{ vars.ids + [call.result.value.id] }}" grows the list in dispatch order — element order, calls order — reproducibly, whatever order the dispatches completed in.

The collected Results: step.results

step.results is the complete record of the fan-out: one Result per dispatch, flat, in dispatch order — element order in the iterate form, calls order in the scatter form. Every dispatch holds its true position whatever its outcome: settled, cancelled, and skipped dispatches alike, a skipped dispatch resolving System.GatherDispatchSkipped without evaluating anything.

step.results is available after the action, like step.result: the Step’s output, assign, and catch clauses read it, and no expression inside the fan-out can. By the time any expression reads it, every slot is a Result — the resolution guarantee: the Gather’s action does not complete until every dispatch has resolved, settled, cancelled, or skipped. The guarantee covers the Gather failing from its own machinery — a completion.successes or over fault with dispatches in flight: the Gather cancels the in-flight dispatches and resolves all of them, by the wait: false mechanics, before constructing its own failure Result.

Because the record is flat, uniform, and position-faithful, its consumers are one-line projections. The success projection

{{ step.results.filter(r, r.type == 'success').map(r, r.value) }}

produces the succeeded dispatches’ values, in order. It is also the output default: a Gather under the default completion, where every dispatch must succeed, emits exactly its dispatches’ values.

Under a non-default completion the default still projects successes only. A Gather that succeeds with some dispatches failed — completion met without every dispatch succeeding — therefore emits just the succeeded values by default; the failed dispatches’ Results are not in the emitted value, and their positions are not held. A partial-success fan-out that must carry the failures forward shapes its own output over step.results, where every dispatch is still present in its true position. To keep one slot per input element, success or failure, emit the whole record:

"output": "{{ step.results }}"

To emit each element’s value where it succeeded and a placeholder where it did not, map over the record by outcome:

"output": "{{ step.results.map(r, r.type == 'success' ? r.value : null) }}"

The successor then receives a list positionally aligned with the input, the failed positions distinguishable. Its failure dual

{{ step.results.filter(r, r.type != 'success') }}

collects the non-success Results alone, and the same shape serves any projection — partitioning by code, counting by type. Like every field default, the output default is stated as an expression as precise notation for the behavior, and realizing it requires no expression evaluator (see Absent fields and passthrough).

completion: the completion policy

completion defines what the fan-out must achieve for the Gather to succeed, and it is the only path by which dispatch failures affect the Gather: the Gather observes its dispatches’ Results, it does not catch them. A failing dispatch does not by itself fail the Step; the Step fails when the policy becomes unachievable.

FieldTypeRequiredDefaultExpression
successesnumber (integer)requiredyes
waitbooleanoptionaltrueno (literal)

An absent completion requires every dispatch to succeed, equivalent to { "successes": <the dispatch count>, "wait": true }, so under the default a single failure makes the policy unachievable and fails the Gather.

successes is the number of dispatches that must succeed. It is evaluated once, when the action begins and after the dispatches are enumerated, so the Step’s dispatch count is available to it: {{ step.metadata.dispatchCount }} restates the default explicitly, and 1 succeeds on the first success.

wait governs the dispatches still pending once the outcome is determined — successes reached, or unreachable. When true, every dispatch runs to completion regardless. When false, the Gather cancels what is in flight and abandons what has not started: a cancelled dispatch resolves with a Result of type cancellation and code System.GatherDispatchCancelled (see External cancellation), and a never-started dispatch resolves skipped, below. This cancellation is the Gather interrupting its own dispatches, not a Result arriving from a target: the dispatch’s scope unwinds (see The unwind), no arm of its call runs — a flow-targeted dispatch’s frame is torn down, its established entries running onAlways only; a provider-targeted dispatch is cancelled through the platform — and the System.GatherDispatchCancelled Result exists for the outside observers, step.results and the completion arithmetic, without any seam inside the dispatch having evaluated (see When the arms run).

Cancellation races completion: a dispatch whose Result the platform has already accepted — the acceptedAt instant of its call’s record (see Call metadata) — resolves as that Result, not as cancelled; the cancellation takes only the dispatches not yet accepted.

completion reads settled Results; the Gather’s own outcome is determined only after the arms run, from the final step.results (see The arms at fan-out completion). The two determinations can differ only by tightening: an arm fault turns a success into that dispatch’s failure, never the reverse, so a fan-out that met its policy at settlement can still fail with System.GatherCompletionUnmet — and under wait: false the dispatches cancelled once the policy was met keep their cancelled Results. The arms run whatever the determination will be: when the Gather fails from its own machinery, the settled dispatches’ arms still evaluate at fan-out completion, before the failure Result is constructed.

System.GatherDispatchSkipped

A dispatch never started when the Gather completed — held behind concurrency, or moot once the outcome was determined under wait: false — resolves with a Result of type skipped and code System.GatherDispatchSkipped, without ever executing: none of its fields evaluate and no record is kept. The Result exists so an unrun dispatch is a concrete, countable outcome rather than an absence; it holds the dispatch’s slot in step.results like any other.

System.GatherCompletionUnmet

When successes can no longer be reached, the Gather fails with a Result of type error and code System.GatherCompletionUnmet. The envelope’s details carry the evidence:

  • failures — the fan-out’s non-success Results, each entry pairing the dispatch’s index with its result.
  • failureCount — the total number of non-success dispatches.

The list deliberately duplicates the non-success subset of step.results: the envelope propagates to parent frames, which can never read the failed Step’s step.results.

Under wait: false, the list holds both the failures that made the policy unachievable and the cancelled and skipped Results imposed in consequence; each entry’s result carries its type, so causes are told apart from aftermath.

An implementation MAY cap the failures list at a platform-defined size; failureCount is not subject to the cap, so a capped list is always distinguishable from a complete one. The collected failures ride details, not previous: they are evidence the policy was unmet, not failures this one superseded (see Chaining).

Failures and catch

A Gather’s catch matches failures the Gather itself produces, and only those: System.GatherCompletionUnmet, and faults in its own fields, such as an over or successes that fails to evaluate (see Evaluation errors) or yields the wrong type. A dispatch’s failure is never matched by the Gather’s catch: it is data the Gather observes — a slot in step.results, evidence in System.GatherCompletionUnmet’s details — and it does not set the frame’s failure context; the Gather’s own failure does (see Lifecycle).

A dispatch’s with is validated against its target’s parameters like any call’s; a validation failure is that dispatch’s Result, System.ParameterValidationFailed (see System.ParameterValidationFailed), counted by completion like any other dispatch failure.

When the Gather fails, every dispatch has already resolved — the resolution guarantee — so a matching catch clause’s expressions read step.results complete.

Gather metadata

step.metadata carries one Gather-specific member beside the universal pair:

MemberTypeDescription
dispatchCountnumberThe total number of dispatches: the length of the over result, or of calls.

dispatchCount settles at enumeration, before successes evaluates, and the record holds nothing that accrues: an expression reading step.metadata during the fan-out observes nothing unsettled, since dispatchCount is fixed and the universal pair are instants. Per-outcome counts are projections over step.results{{ size(step.results.filter(r, r.type == 'error')) }} — like any other partition of the record.

Each dispatch keeps its own record, call.metadata, bracketing that call execution: enteredAt as its fields evaluate, exitedAt when its Result settles, both readable in the call’s arms, beside the dispatch instants (dispatchedAt, acceptedAt; see Call metadata). A flow-targeted dispatch’s inner frame keeps its own record besides, observable through the flow window (see flow). A skipped dispatch keeps no record.

Match

A Match Step routes to one of several successors by testing predicates against a single shaped value. It performs no work beyond selection: clauses are tried in order, the first whose when holds is selected, and that clause routes, shapes, and captures.

{
  "action": "Match",
  "input": "{{ step.input.order }}",
  "cases": [
    {
      "when": "{{ match.input.status == 'approved' && match.input.amount > 1000.0 }}",
      "next": "manual-review"
    },
    {
      "when": "{{ match.input.status == 'approved' }}",
      "next": "auto-approve"
    }
  ],
  "default": { "next": "reject" }
}
FieldTypeRequiredDefaultExpression
inputanyoptional{{ step.input }}yes
casesarray of clause objectsrequiredno (structural)
defaultclause (no when)requiredno (structural)

input produces the value the predicates test. It is evaluated once, at input shaping (see The Step lifecycle); every clause reads it as match.input (see match), and step.input still recovers the value the Step received. A Match carries no step-level output, assign, or next — shaping and routing belong to the selected clause, which may shape differently per target — and no catch (see Failures and catch). The match context is coextensive with its Step: its record’s instants equal the Step’s (see match). Match is the one action with no expression-free form: every cases clause requires a when expression, so a definition that uses Match requires an expression evaluator (see Conformance).

Clauses

FieldTypeRequiredDefaultExpression
whenbooleanrequired in cases; not accepted on defaultyes (predicate)
outputanyoptional{{ match.input }}yes
assignobjectoptional{}yes (per value)
nextstring (Step name)requiredno (structural)
commentstringoptionalno (literal)

cases is ordered and the first match wins: predicates evaluate one clause at a time until one holds, and no later predicate evaluates. default is the required fallback — the same clause shape, without when — selected when no case matches. The selected clause’s output shapes the value its next receives, and its assign captures into vars, each evaluated once, under the Step’s disciplines (see Variables: assign).

Predicates and failure

when holds an expression evaluated as a predicate (see Predicates and when).

A when that fails to evaluate is an evaluation error like any other: it fails the frame with System.ExpressionEvaluationError (see Evaluation errors). Failure is not absorbed into a non-match, and evaluation does not fall through to the next clause. An author routing on data whose shape is uncertain must write predicates that tolerate that uncertainty rather than relying on a failed predicate to skip its clause.

A clause’s output and assign are likewise ordinary expressions: a fault in either fails the frame the same way. default protects against fallthrough — no case matching — not against an evaluation fault in any clause.

Predicates can be opaque to static analysis. Validators MAY warn about apparent tautologies, contradictions, or unreachable clauses, but MUST NOT reject a definition on that basis.

Pass

A Pass Step performs no action work: it exists for the shaping phases around it (see The Step lifecycle). It emits a value and captures variables, then transitions.

{
  "action": "Pass",
  "output": "{{ {'wrapped': step.input} }}",
  "next": "deliver"
}
FieldTypeRequiredDefaultExpression
outputanyoptional{{ step.input }}yes
assignobjectoptional{}yes (per value)
nextstring (Step name)requiredno (structural)

Typical uses: naming a point in the graph, reshaping a value between two Steps, staging variable bindings. A Pass carries no input field; with no action to feed, its one shaping seam is output, which reads the received value directly.

Sleep

A Sleep Step pauses the frame: for a duration, or until an instant. Exactly one of for and until MUST be present.

{ "action": "Sleep", "for": "PT30S", "next": "poll-status" }
{ "action": "Sleep", "until": "{{ vars.resumeAt }}", "next": "send-reminder" }
FieldTypeRequiredDefaultExpression
forstring (duration)one of for/untilyes
untilstring (timestamp)one of for/untilyes
nextstring (Step name)requiredno (structural)

for is an ISO 8601 duration (see Temporal format profile): the Step completes at step.metadata.enteredAt plus the duration, immediately if that duration is zero or negative. until is an RFC 3339 timestamp: the Step completes at that instant, immediately if it is already past. Either way a moment already reached completes the Step at once rather than failing it.

Sleep is a pure pause. It carries no input, output, or assign — the value it received passes through unchanged (see Field defaults and passthrough) — and no middleware or catch. It cannot fail on its own; its only failure surface is its one field: an expression in it that fails to evaluate (see Evaluation errors), or a value, literal or computed, that is not a valid duration or timestamp (see System.ParameterValidationFailed). An interruption during the pause is frame-level, like any interruption: the Step is abandoned, not failed (see Cancellation).

Return

Return is the terminal success: the frame completes with a success Result, and value shapes the value that Result carries (see Success Result). How the completion is consumed — by a calling Step, a Gather, or the platform at the root — is the frame contract (see How a Flow completes).

{ "action": "Return", "value": "{{ step.input.summary }}" }
FieldTypeRequiredDefaultExpression
valueanyoptional{{ step.input }}yes

The default passes the Step’s received value through: a bare { "action": "Return" } returns what reached it. Return carries no next, no output — there is no successor to emit to; the frame’s product is the Result’s value — and no assign, since the frame ends with the Step, leaving no later reader (see Data flow: input and output).

Raise

Raise is the terminal failure: the frame completes with a failure Result. With a result, it constructs the failure; bare, it re-raises the failure being handled.

{
  "action": "Raise",
  "result": {
    "type": "error",
    "code": "Pipeline.ManualReject",
    "message": "Order flagged for manual review"
  }
}
FieldTypeRequiredDefaultExpression
resultobject (envelope)optionalre-raise (see Bare re-raise)yes (per field)

result’s members are the authorable fields of the failure envelope (see The failure envelope): type, code, message, details, retryable, and previous. code is required. type is optional, defaults to "error", and MUST be a non-success type: a Raise cannot complete a frame with success. Each field accepts an expression, evaluated against the Step’s bindings; an active failure is readable as failure (see failure).

Constructing a failure while one is active supersedes it: unless result writes previous itself, the engine links the active failure as the new one’s previous, and writing previous overrides the link — typically severing it with null (see Chaining). These are the same construct-a-new-failure semantics as a middleware onFailure block (see onFailure), with one difference: an onFailure block transforms a rising failure, so its unwritten fields inherit from the failure it supersedes, while a Raise constructs from nothing, so its unwritten fields are simply absent — which is why code is required here and inheritable there.

A Raise MAY use any code. The namespace convention, including why an author SHOULD NOT mint System. codes, is defined with the envelope (see Code namespaces). Validators MAY warn where a result’s code trespasses the convention — a System. code, or a Provider. code minted by workflow logic — but MUST NOT reject the definition.

Bare re-raise

A Raise without result re-emits the frame’s active failure unchanged: no new failure is constructed and no previous link is added (see Chaining). It is the natural terminal for a handler path that deals with what it can and propagates the rest:

"failed": { "action": "Raise" }

System.EmptyRaise

A bare Raise reached with no active failure — outside any handler path, or after a success cleared the context (see Lifecycle) — has nothing to re-emit. The frame completes with a failure Result of type error and code System.EmptyRaise, so the situation surfaces as a concrete, matchable failure rather than a silent no-op.