Composing flows
Five fences reuse one flow from another: call_flow and include inline a shared flow,
foreach and split run one per element of a list, fork runs one without waiting.
All of them are resolved at compile time. The called flow’s main steps are copied into the
calling artifact, so there is no run-time lookup, no version skew, and no network hop. The
consequence is that a change to a shared flow reaches its callers only when you recompile and
redeploy them. Its ## Fault: sections are never copied, so a flow that any of the five fences
reaches may not have one: see A library flow has no handler.
The library directory
Shared flows live in a directory you pass to the compiler:
$ nexus compile order-intake.flow.md --libs ./lib$ nexus deploy order-intake.flow.md --version 1.0.0 --libs ./lib$ nexus validate order-intake.flow.md --libs ./libLibrary flows are never deployed on their own, but they are not tenant-free. A reference
resolves within the calling flow’s tenant: call_flow: normalize-address from a flow whose
front matter says tenant: acme looks for a library .flow.md that declares both
flow: normalize-address and tenant: acme. A file that declares the name under a different
tenant is not a match — the compiler refuses it, naming both tenants, rather than pulling in
another tenant’s flow silently. A bare name is a reference within your own tenant, never across
tenants; if two tenants need the same helper, each keeps its own copy.
Two files in the library directory cannot share one tenant and flow:. A flow that reaches a
library resolving to a different file with its own identity is refused at publish time, naming that
file.
A library flow is an ordinary .flow.md file. It is referenced by its flow: name, not by its
filename. A reference the compiler cannot resolve — wrong name, or right name under the wrong
tenant — is a compile error, so it never reaches deployment.
A library flow has no handler
A library flow reached by include, call_flow, foreach, split or fork may not have a
## Fault: section. Only its main steps are copied into the calling flow, so the section would
never run. The calling flow is refused instead, by nexus validate, nexus compile and
nexus deploy alike:
$ nexus validate order-intake.flow.md --libs ./liberror: library flow 'notify' (`./lib/notify.flow.md`) has a `## Fault:` section (`alert`); a flow that reaches it by include, call_flow, foreach, split or fork copies only its main steps, so that section would never run. Handle the failure in the `## Fault:` section of the calling flow, or keep the shared steps in a library flow without a `## Fault:` section and put the handler in the flow that is published on its own (ADR-0162)error: 1 validation error(s)The refusal comes once per library, however many steps reach it, and lists every ## Fault:
section the library has. A library flow in the directory that the published flow does not reach is
not refused for its sections.
There are two ways out, one for each thing the section can mean:
- It handles a failure of these steps wherever they run. Write the handler in the
## Fault:section of each calling flow. A failure in steps thatinclude,call_flow,foreachorsplitbrings in fails the calling step, and that handler runs as for a failure in the caller’s own steps: see Handling failures. Aforkbranch’s failure does not reach the caller, so no handler runs for it. - It handles a failure of
notifywhennotifyis published on its own. Keep the shared steps in a library flow without a## Fault:section.notifyincludes them and declares the handler, and the callers include the shared steps, notnotify.
The second way takes two files, which can sit in the same directory. notify-steps.flow.md holds
the shared steps and no handler:
---flowmarkdown_version: "0.1"flow: notify-stepstenant: acmeeffects: [http_egress]---
## Step: sendeffects: [http_egress]endpoint: https://alerts.example.com/sendnotify.flow.md, published on its own, includes them and declares the handler:
---flowmarkdown_version: "0.1"flow: notifytenant: acmeeffects: [http_egress]---
## Step: steps```includenotify-steps```
## Fault: alerteffects: [http_egress]endpoint: https://alerts.example.com/alertA calling flow names notify-steps in its include fence. Its artifact holds send; the artifact
of notify holds send and the handler alert.
call_flow — inline, with parameters
The first line is the flow name. Every line after it is a parameter: a key: value pair that
fills one of the {{ ctx.key }} slots the called flow leaves open.
## Step: normalise-address```call_flownormalise-addresscountry_default: ROkeep_original: true```Inside normalise-address, {{ ctx.country_default }} becomes RO and {{ ctx.keep_original }}
becomes true, wherever they appear.
A parameter’s value is fixed when the flow is built — a word, a number, true/false, null.
It is not an expression: source: $.order.id sets the parameter to the literal text $.order.id,
it does not read order.id from the message. There is no message to read from yet —
substitution happens at compile time, not per call. The called flow still sees the live message as
$; parameters are only the fixed knobs.
The called flow’s steps then run in place, on the current message, and what its last step produces becomes the message for the next step here.
include — inline without parameters
The whole fence body is a flow name.
## Step: shared-validation```includestandard-order-checks```Same inlining as call_flow, with no substitution. Use it for a fixed block — a set of validation
rules, a standard enrichment — that needs no configuring.
An include or call_flow step carries no keys of its own. The step is replaced by the called
flow’s steps, so effects:, fault_status: or any other key written on it would be dropped.
Publication refuses such a step, in the main steps and in a ## Fault: section alike, and names the
keys:
$ nexus validate order-intake.flow.md --libs ./liborder-intake.flow.md:15:1: error: step 'shared-validation': invalid configuration — the `include` step is replaced by the steps of 'standard-order-checks', so the keys written on it would be dropped: `effects`, `fault_status`. Write them on the steps of 'standard-order-checks', or on a step of their own (ADR-0163)error: 1 validation error(s)foreach, split and fork keep their keys: their step stays in the artifact and wraps the
called flow.
foreach — one call per element, in order
The first line is an expression that yields an array. call: names the flow. Any lines after that
are parameters, exactly as in call_flow — fixed key: value fill-ins for the called flow’s
{{ ctx.key }} slots.
## Step: process-lines```foreach$.itemscall: process-linesource: order-intake```Each element of the array becomes the message for one run of process-line, and on every run
{{ ctx.source }} is order-intake. The results are collected into an array, which becomes the
message for the next step.
Each iteration gets its own context, with the outer variables copied in. Variables written inside an iteration do not leak back out, and one iteration cannot see another’s variables. Use the returned array to carry results forward.
An element whose iteration ends in a false condition is omitted from the results rather than
included as its pre-filter value. So the output array can be shorter than the input, which is often
what you want — and worth remembering when you compare lengths.
The iterator must evaluate to an array. Anything else fails the step.
split — the same, in parallel
Same structure as foreach, plus an optional timeout_ms.
## Step: fan-out```split$.itemscall: process-linetimeout_ms: 5000```Branches run concurrently, at most fanout_max_parallel at a time (10 unless the installation
sets it), and the step waits for all of them before continuing. A longer array runs in waves:
the next wave starts when the previous one has finished. The results come back as one array in
element order, whatever order the branches finished in. Like foreach, a branch that ends in a
false condition is omitted: it has no place in the array, and the rest keep element order. When
every branch is stopped the result is an empty array, not a fault.
If any branch fails, the step fails, and the results of the other branches are lost. Every wave
still runs after a branch failed, and the step reports the first element that failed. There is no
other way to gather the branches: no merge by key, no stop at the first answer or at K of N, and no
partial result. To shape the array into one document, add a step after the split.
timeout_ms is checked after the last wave has finished, over all the waves together, not
while they run. It is a guard against a slow batch, not a way to cancel a hung branch: a branch
that never returns holds the step regardless of the value here. Bound the work itself with
read_timeout_ms or deadline_ms on the steps inside the called flow.
Omit timeout_ms, or set it to 0, to disable the check.
Prefer foreach unless the branches are genuinely independent and slow enough to be worth the
concurrency. Both return their results in element order, but only foreach also makes its calls in
that order, one after the other, which is easier to reason about.
fork — start it and move on
## Step: notify```forkcall: send-notificationcontinue_parent: true```The branch is started and not waited for. Its result is discarded, and its failure does not fail the calling flow — so use it only for work whose outcome nobody depends on.
A branch counts against the installation’s execution budget, max_concurrent_flow_executions,
from its start to its end, like a delivery. When that budget is full, or the operating system
refuses the thread, the branch is not started: the calling flow goes on as continue_parent
says, its caller is not told, and the platform writes branch_refused to the log. Nothing
retries the branch, so fork is for work that may be lost under load.
continue_parent |
Effect |
|---|---|
true (default) |
The calling flow continues to the next step |
false |
The calling flow stops here |
Because nothing waits for the branch, nothing reports its outcome to the caller either. Anything you need to know about must be visible in the branch’s own log and audit records.
What a body may promise about the response
response_status: and response_header_*: written on a step inside a called body behave
differently per fence, and the difference follows from what each fence is:
foreachcarries them back. The loop is sequential and belongs to this delivery, so its iterations are positions in execution like any other steps. The parent’s accumulated header set and status are handed to each iteration and taken back after it: last writer wins per header name, across iterations and parent steps alike, and aresponse_header_remove:inside a body operates on the delivery’s real set — it can remove a header the parent declared earlier. An iteration dropped by a falseconditionstill hands its declarations back; an iteration that fails says nothing (the step fails first).splitdoes not. Branches are threads; “last writer” over their completion order would mean nothing to the caller. Declarations inside asplitbody stay inside it.forkdoes not. The branch runs after the response has left — a verdict from there would arrive at a response already sent.
call_flow and include are not in this list because they are not bodies: their steps are inlined
into the calling sequence at compile time and behave exactly like steps you wrote yourself.
Time budgets
Every flow run has a deadline (flow_max_duration_secs). The fences divide it like this:
foreachandsplitbodies inherit the parent’s remaining deadline. They are part of this delivery, so they cannot buy it more time: a parent that has already spent most of its budget has little left to hand to the loop. Asplitchecks it before each wave, so a long array stops between waves.- A detached
forkbranch gets its own full budget — fresh, the same size as any flow’s — and is never unlimited. It outlives the parent’s response, but not the platform’s patience.
Choosing between them
| Fence | Waits | Order | Result | Response status/headers | Failure |
|---|---|---|---|---|---|
call_flow, include |
yes | inline | replaces the message | as your own steps | fails the flow |
foreach |
yes | sequential, preserved | array, without filtered elements | carried back | fails the flow |
split |
yes | concurrent, at most fanout_max_parallel at a time, results in element order |
array, without the elements a false condition stopped |
not carried | fails the flow |
fork |
no | — | discarded | not carried | ignored; not started at capacity |