Branching and absence propagation
There is no if node in the Meridian DSL, no else port on any node, and no
keyword anywhere in the grammar for "otherwise." Runs still take different
paths depending on data — a lab result that isn't a creatinine, an eGFR that
isn't low enough to worry about, an alert count of zero. This page explains
why branching was deliberately built out of absence rather than a
control-flow construct, and what that choice does and doesn't cover. For the
wiring itself, see Branch with guards and switch
and Sequence and join with after/done.
Why there is no if
The dataflow model explains the larger bet: a
workflow is data, not a program, so that it can be validated, visualised,
simulated, and generated by a single generic interpreter. An if/else
keyword would puncture that bet in a small but corrosive way. It would need
two mandatory outgoing paths, both drawn, both reviewed, both simulated — even
on the many clinical branches where the honest answer to "what happens
otherwise" is nothing. It would also need to be a special form the
interpreter, the validator, and the visual editor each know about, alongside
every other node kind they already handle uniformly.
Meridian takes the opposite bet: reuse the one fact the dataflow model already
has to track — whether a port received a value this run — as the entire
branching vocabulary. A node's required input either arrived or it didn't.
Nothing else is needed to decide whether that node, and everything wired
downstream of it, executes. flow.guard and flow.switch don't add a second
mechanism on top of this; they are two disciplined ways of producing
absence from a condition. after/done push the same idea one step further,
to sequencing that carries no data at all. Under all of it sits one rule.
Absence propagation: the "else" you never draw
A node's input ports are typed, and each one is required unless declared
otherwise (default true — see PortDef.required in
libs/shared/src/engine/catalog-metadata.ts).
When a run reaches a node, the interpreter resolves each declared input from
the connections wired to it; if a required port resolves to nothing, the
node is marked skipped rather than run at all:
// libs/engine-core/src/engine/interpreter.ts — resolveInputs()
if (value === undefined) { if (port.required !== false) missing = port.name; }
skipped is not an error and not a special code path bolted on afterwards —
it is the ordinary outcome for any node whose data didn't show up, logged
under a stable code (node-skipped-missing-input, with map-skipped-…
variants inside a loop body) that you can read straight off a simulation
overlay or a live run — see
Monitor executions. Because a skipped
node emits nothing, any node downstream that depends on its output resolves
its required input to nothing too, and is skipped in turn. The interpreter
doesn't special-case this — resolveConn simply returns undefined whenever
the producing node's status isn't "done"
(libs/engine-core/src/engine/interpreter.ts).
One check, applied uniformly to every node in topological order, is enough to
prune an entire arm of the graph. No one writes that pruning; it falls out of
the wiring, the same way execution order falls out of the wiring in the
dataflow model.
This is also why absence is scoped so precisely: it only fires on inputs
declared required. An optional input left unfed doesn't skip anything —
the node runs with that value simply missing. flow.guard's own value
input is optional for exactly this reason (see below): its absence changes
what pass carries, not whether the guard runs.
Guard and switch are ordinary nodes, not special forms
flow.guard and flow.switch carry the kind: "gateway" label in the
catalogue — a grouping for the editor's node palette, nothing more. Inside the
interpreter's execution loop, a gateway node is executed exactly like a
compute, a transform, a source, or a sink: the only kind that gets
distinct handling is human-task (it suspends on a validation), and the only
other special case is the map dynamic kind (it drives a loop). Guard and
switch are plain behaviors with a run function, catalogued alongside
clinical.egfr.ckd-epi-2021 or patient.load-demographics
(libs/engine-core/src/engine/catalog.ts):
"flow.guard": {
run: ({ inputs, config, context }) => {
const ok = evalExpression(String(config.condition ?? "true"), { criteria: inputs.criteria, value: inputs.value, context });
if (!ok) return {};
return { pass: inputs.value !== undefined ? inputs.value : inputs.criteria };
},
},
A false condition returns {} — the exact same shape as a clinical compute
that decided it has nothing to say (clinical.condition.detect-from-egfr
returning {} when the eGFR isn't low enough, walked through in
Branch with guards and switch).
There is no engine-level difference between "a condition wasn't met" and "a
computation had nothing to report." Both are a node that produced no output,
and absence propagation takes it from there. Even a human-task's propose
returning nothing (a validation with nothing to validate) is skipped the same
way, under its own code (node-nothing-to-validate) — the convention reaches
every node kind, not only gateways.
flow.switch is the same idea shaped for more than two outcomes: it tests
its cases in order and always returns exactly one key — the first matching
case's name, or default if none match
(libs/engine-core/src/engine/catalog.ts).
That is the one structural difference between the two gateways. A guard's
pass fires or it doesn't — zero or one path continues. A switch always
fires exactly one of its N+1 output ports — routing is exhaustive by
construction, never a silent "none of the above." Whatever you leave
unwired on the losing ports (or on default) is simply absent, and prunes
downstream exactly as a guard's closed branch does — no different mechanism,
just more names for it.
Sequencing is absence too: after and done
The same rule extends to ordering that carries no data. Every node exposes an
implicit done output and an implicit after input — added by nodePorts()
(libs/shared/src/engine/dynamic-ports.ts),
never seen by a behavior's run. done fires once a node has produced: at
least one declared output was emitted, or the node declares no outputs at all
(a pure sink). after is an AND-join — wire one or more sources into it and
the node runs only once every one of them has fired; if any is still absent,
the node is skipped, gated by afterBlocked in
libs/engine-core/src/engine/interpreter.ts
before the node's own declared inputs are even resolved. Both ports are typed
any, deliberately transparent to whatever real output feeds them, so a
switch case or a guard's pass can drive an after join directly.
This could have been a second, BPMN-shaped mechanism — explicit tokens that
flow along control edges independently of data. The dataflow
model already
explains why that path was rejected wholesale: it recentres the graph on
control flow instead of data, at the cost of the static type-compatibility
check the whole model exists to give you. after/done avoid that cost by
not being a second mechanism at all — they reuse the identical
produced-or-not, arrived-or-not vocabulary absence propagation already uses,
just on ports that happen to carry no payload. The full mechanics and recipes
live in Sequence and join with after/done.
What absence does not cover
Absence is a runtime phenomenon layered on top of a graph that must
already be well-formed. A required port left completely unconnected is not
something absence propagation forgives — it's a static validation error
raised before any run starts (Nœud « … » : entrée requise « … » non connectée., in
libs/engine-core/src/engine/validate.ts).
Absence governs whether a wired input receives data on a given run, never
whether the author bothered to wire it at all. That split — a static pass
that checks the graph's shape, and a runtime pass that decides what actually
flows through it — is the subject of
Two-level validation.
Absence is also deliberately distinct from a data error. A value that
arrives but doesn't match its port's declared type is a WorkflowDataError,
thrown, not skipped (checkOutputs in
libs/engine-core/src/engine/interpreter.ts).
Silence and a wrong shape are different failure modes on purpose: "the branch
had nothing to report" is routine in clinical automation — no anomaly, no
threshold crossed, no interaction found — and treating every one of those as
a caught exception would only trade one control-flow smell for a noisier one,
while burying genuine data bugs in the same log. The line is drawn at
requiredness, nothing else: no value on a required port is silence; any value
present is checked, and a malformed one still stops the run loudly. This also
means the discipline sits with whoever writes a node's behavior: a clinical
result of zero is a value ({ value: 0 }), not an absence, and a plugin
that conflates the two will silently drop a real branch. See
Implement a node behavior
and the behavior contract
for that responsibility in full.
Finally, absence propagates node-by-node along the acyclic order the
interpreter computes; it says nothing about how many times a node runs. A
flow.map body still skips an individual iteration's node the same way (its
own node-skipped-…/map-skipped-… codes) — the element then lands in the
map's skipped output instead of collected — but the shape of the loop itself
— no cycles, no nested maps, item/result confined to one body — is
enforced by a separate static graph analysis, not by absence at runtime. See
Loop with map for the recipe and
Static validation engine
for how those structural limits are checked.
Alternatives considered
- An explicit
elseport on every gateway. Rejected: most branches in a real clinical workflow have nothing to do on the untaken path — the unwireddefaultincontent/clinical-demo/workflows/analyse-posos.workflow.yaml, or the missing proposal inclinical.condition.detect-from-egfr, are the common case, not the exception. Forcing anelseto be drawn every time would mean authoring dead-end wiring purely to satisfy a keyword — the opposite of a graph that only shows what actually happens. - Chained/nested
ifs. Rejected for the same reason the dataflow model rejects a scripting DSL in general: nesting brings back scoping and evaluation-order questions a flat dataflow graph doesn't have.flow.switch's ordered, first-matchcasesgives you if/else-if/else without reintroducing nesting. - Treat a missing value as an error, not a skip. Rejected: it would erase the distinction between "this run had nothing to report" (routine) and "a node produced a malformed value" (a bug), collapsing two very different severities into one, and would make every ordinary "nothing happened" path through a clinical workflow read like a caught exception.
- A separate control-flow layer for sequencing (BPMN-style tokens for
after/done). Rejected because it would split the graph into two execution models instead of one — see the fuller argument in The dataflow model.
Related
- The dataflow model — the larger case for typed dataflow over scripting, and the alternatives rejected at that level.
- Two-level validation — the static/runtime split that absence propagation sits on the runtime side of.
- Branch with guards and switch
— the recipe for wiring
flow.guardandflow.switch. - Sequence and join with after/done — the recipe for ordering and joining branches without data.
- Loop with map — the third flow primitive, and how absence behaves inside a loop body.
- Monitor executions — reading skip codes off a simulated or live run.
- Workflow DSL and
Core primitives — the full schema for
flow.guard,flow.switch,after, anddone. - Implement a node behavior and the behavior contract — a plugin author's side of deciding when to emit nothing.
- Interpreter internals and Static validation engine — how skip, join-gating, and graph-shape checks are actually implemented.
- Glossary — guard, switch,
after/done, absence propagation.