Data flow
This section follows a value through a workflow, end to end: into a frame,
across its Steps, down and back up the middleware stack, over subflow and
Gather boundaries, and along the failure path. The values that move this way
are the data plane. Every rule composed here is
defined in an owning section, and each passage points to its owner; what this
section adds is the connected picture.
Three threads run through that picture. Every shaping default is a passthrough,
so a value moves unchanged until a field says otherwise. Nothing crosses a
boundary implicitly except a Result. And wherever a value would outlive the seam
that can see it, it is carried forward explicitly, captured into the frame’s
variables with assign.
Through a frame: input to Result
One round trip carries a value through a frame: in at the frame’s creation, Step to Step through the graph, and out as the frame’s Result.
the caller's input
│
▼
frame input set at creation, immutable
│
▼
flow-level middleware each entry's onEntry output
(the descent) reshapes the value downward
│
▼
entry Step within a Step: step.input →
│ input shaping → action →
│ step.result → output shaping
│
│ next the emitted value becomes
▼ the successor's step.input
… Steps …
│
▼
Return's value the value the Result carries
│
▼
flow-level middleware each entry's onSuccess value
(the ascent) reshapes the value upward
│
▼
the frame's Result
│
▼
the caller's call.result,
or the platform's at the rootA frame is created with its input: the execution input, for the root frame; the
call’s evaluated input, for a called Flow
(Where Flows appear). The input is set
once and never changes, so an expression anywhere in the frame can recover the
value the frame was given as frame.input
(frame).
Between the frame and its first Step sits the Flow-level middleware stack. The
frame’s input descends it, each entry’s onEntry output reshaping the value
on the way down (the next section); what
emerges from the innermost entry is what the entry Step receives
(The frame lifecycle).
Inside the graph, each transition hands exactly one value: the completed Step’s
output becomes its successor’s step.input, and that handoff is the only
data-plane path between Steps
(Frames and sequential execution).
Within a Step, the value crosses a fixed sequence of seams: input shapes what
the action consumes, the action produces the Step’s Result, and output shapes
what the Step emits from it
(Data flow: input and output).
Every field on this path has a passthrough default, the received value in and
the action’s product out
(Field defaults and passthrough),
so a Step reshapes the value only where its definition says so.
Two channels travel beside this path rather than on it, on
the control plane. Configuration rides with,
validated against the target’s declared parameters and kept out of the payload
(The three axes).
Capture rides assign, which writes values into the frame’s variables for later
expressions to read back (The vars model,
Variables: assign). The handoff between
Steps carries one value and nothing else; a value needed two Steps from now
crosses by capture, not by any implicit channel.
The trip ends at a terminal Step. A Return’s value is the value the frame’s
success Result carries
(How a Flow completes,
Success Result); the Result ascends the
Flow-level stack, and what the outermost entry emits is delivered to the parent
context—the caller’s call.result, one of a Gather’s collected Results, or
the platform’s, at the root
(The frame lifecycle).
Because every default on the path passes through, the composition is transparent
end to end: a Flow whose fields shape nothing delivers its input to a bare
Return unchanged and yields it back as its Result’s value. Data transforms
exactly where a definition says it does, and nowhere else.
Through the middleware stack
A middleware stack is an onion around an operation—the Call a Call Step
dispatches, or a Flow’s Step graph—with a defined value at every layer
(Where middleware attaches,
How values thread the stack).
On the way down, the operation input—the Call Step’s shaped input, or the
frame’s input—enters the outermost entry, and each entry’s onEntry output
becomes the next inner entry’s input. What the innermost entry emits is what the
operation receives: at a Call Step it arrives at the dispatch as call.input
(call); at the Flow level it is the value the
entry Step receives.
On the way up, the operation’s Result ascends one position at a time. Where it
is a success, each entry’s onSuccess value becomes the value the next outer
entry sees as its middleware.result.value
(onSuccess); what the outermost entry
emits is the value the Step’s Result carries—or the frame’s, at the Flow level.
Where the rising Result is a failure, the envelope ascends unchanged unless an
entry’s onFailure block writes envelope fields, constructing a new failure:
the new one supersedes the rising one, and the engine chains what it displaced
as previous (onFailure,
Chaining). A failure is reshaped by
supersession, never by mutation; the original is intact one link down.
onAlways stands outside this traffic entirely: whatever its action and
expressions produce is discarded, and the in-flight Result continues to rise
unchanged—unless the phase itself fails, the one case where it supersedes
(onAlways).
What a middleware learns or measures stays at its position unless the author
carries it out: contributed metadata is readable only as middleware.metadata
in the entry’s own phase blocks, and a phase’s assign into vars is how any
of it outlives the entry
(Middleware-contributed metadata).
The stack obeys the same transparency rule as the Step path: every shaping
default passes through, so a stack whose blocks shape nothing leaves the data
plane untouched.
Into and out of a subflow
A call that targets a Flow starts a new frame, and data crosses into it on
exactly two channels, both written at the call site: the call’s input product
becomes the new frame’s input, and its with arguments are validated against
the Flow’s parameters and seed the new frame’s variables
(The three axes,
Where Flows appear,
The vars model). Nothing else crosses:
frames are isolated, and the inner frame can read nothing of the frame that
dispatched it—not its variables, not its input
(frame).
Coming back, one value crosses on its own: the Result. A Return arrives as a
success Result carrying its value; a Raise, or an unhandled failure, arrives
as a failure envelope; and the caller consumes either as call.result, exactly
as it would a provider’s
(Flow-Call Result parity).
For everything else there is the window, and it is open briefly. In the call’s
two arms, and nowhere else, the completed target stands exposed whole: a flow
target as flow, the completed frame with its result, vars, input, and
metadata; a provider target as provider, with its input, result, and
declared metadata
(The target windows,
flow,
provider). The window is in scope in both
arms, and promotion out of it is explicit and immediate: the arm’s assign
captures flow.vars.<name>, a metadata record, or whatever else later fields
need into the caller’s variables, and nothing of the window outlives the call
object.
A failed subflow communicates through its failure Result plus whatever its caller’s failure arm captured at the boundary. Past the call object, the envelope is the only carrier: an inner Flow that must surface more than its caller captures places it in the envelope it raises (The failure envelope).
Fan-out and fan-in: Gather
A Gather multiplies the call boundary. Every dispatch is one execution of a
call object, and the crossing rules above apply to each: the dispatch’s
inbound payload arrives as its call.input—the element, in the iterate form;
the value the Step received, in the scatter form—with call.index carrying its
position (The dispatch model). A
flow-targeted dispatch is a subflow like any other: input and with cross,
nothing else does, and a dispatch whose Flow needs the element or the index is
given it explicitly.
While the fan-out is in flight, the frame’s data plane holds still. A dispatch’s fields evaluate as it starts and only read, against the variable state at the action’s start; nothing writes until the fan-out completes, and no dispatch can observe a sibling’s writes, progress, or outcome (Frames and sequential execution).
Fan-in happens in two motions. The arms run first, deferred to fan-out
completion, one dispatch at a time in dispatch order: each onSuccess value
shapes the value its dispatch’s Result carries, and each arm’s assign captures
into the frame’s variables, in a fixed order, so cross-dispatch accumulation is
deterministic
(The arms at fan-out completion).
Then the record is read: step.results holds one Result per dispatch, flat and
in dispatch order, every dispatch in its true position whatever its outcome,
complete by the time any expression can read it
(The collected Results).
The projection idiom
step.results comes with no machinery: no success list beside it, no failure
list, no per-outcome counts. It needs none: because the record is flat, uniform,
and position-faithful, every consumer is a one-line expression over it, a
projection. The canonical one is the success projection,
{{ step.results.filter(r, r.type == 'success').map(r, r.value) }}the succeeded dispatches’ values, in dispatch order. It is the Gather’s
output default (Step actions): a fan-out that
shapes nothing emits exactly its dispatches’ values. Its dual collects the
failures,
{{ step.results.filter(r, r.type != 'success') }}and the two are the model for whatever else the fan-in must mean:
- The whole record, one slot per dispatch, success or failure, positionally
aligned with the input:
{{ step.results }}. - Each element’s value where it succeeded and a placeholder where it did not:
{{ step.results.map(r, r.type == 'success' ? r.value : null) }}. - The codes that failed:
{{ step.results.filter(r, r.type != 'success').map(r, r.code) }}. - A count by type:
{{ size(step.results.filter(r, r.type == 'error')) }}.
There is no engine-provided projection: the record is kept flat, uniform, and position-faithful precisely so that the projection an author writes is the whole interface. Shaping the fan-in is writing the expression that says what the fan-out meant.
A dispatch’s failure is data here, not an event: it fills its slot in the record
like any Result, it never sets the frame’s failure context, and the Gather’s
catch never matches it
(Failures and catch,
Lifecycle). Dispatch failures reach beyond
the frame only when the completion policy fails: the Gather’s own
System.GatherCompletionUnmet failure carries the fan-out’s non-success Results
in its details, each entry pairing the dispatch’s index with its result
(System.GatherCompletionUnmet).
The duplication with step.results is deliberate, and it is a data-flow fact:
an envelope propagates to parent frames, and a failed Step’s step.results does
not—the evidence travels in the value that crosses.
On the failure path
When an outcome is not success, what moves is the failure envelope: an ordinary
structured value—type, code, message, details, retryable,
previous—carried by the same machinery that carries any value
(The failure envelope). Routing keys
on its code, expressions read its members, and it propagates whole.
The first seam a failure crosses is the call’s failure arm, and it is the last
place the failed dispatch’s context is alive. There the envelope is read as
call.result, and the arm’s assign captures what would otherwise vanish with
the call execution—a provider’s metadata, the call’s timing record, a failed
frame’s window
(onFailure: capture only).
From there the envelope ascends the middleware stack, transformed only by
supersession (above), and the Step resolves to
it. A failed Step shapes nothing: its output and assign belong to the
success exit, and what runs instead is catch, matching the outermost emitted
Result (The failure exit,
Where catch sits). The matched clause
is the failure edge’s only shaping: its output produces the value the handler
Step receives, defaulting to the value the failed Step received, since no
success value exists, and its assign captures
(catch clauses).
Along the handler path the envelope stays in reach as the failure binding,
frame-wide, until the first successful Step completion clears it; a handler that
needs the failure beyond that point captures what it needs into vars first
(failure,
Lifecycle).
The envelope also carries its own history. Whenever one failure supersedes
another—a handler Step fails during recovery, an onFailure block or a Raise
constructs a successor, a cleanup fails with a failure in flight, a cancellation
is imposed over the work it interrupts—the engine links the superseded failure
as the new one’s previous, and the chain rides the envelope wherever it goes
(Chaining).
A failure no clause matches leaves the frame as its Result, and there it is
ordinary data: the calling Step’s catch matches it like any failure Result,
per the scoping rule—what cannot be caught within a frame is plain data one
level up (The scoping rule,
catch and frame-level failures).
What must cross frames travels in the envelope its producer writes, the same
rule System.GatherCompletionUnmet instantiates with its collected failures.
One exit moves no data at all. An interrupted scope is torn down, not completed:
on the unwind no arm runs, no catch is consulted, and no shaping evaluates;
established entries’ onAlways phases run, and the Result that emerges is the
unwind’s to determine (The unwind). Where data
flows, it flows by the rules above; where execution is interrupted, the data
plane simply stops.