Documentation / Conception / Explications / Branching and absence propagation

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 else port on every gateway. Rejected: most branches in a real clinical workflow have nothing to do on the untaken path — the unwired default in content/clinical-demo/workflows/analyse-posos.workflow.yaml, or the missing proposal in clinical.condition.detect-from-egfr, are the common case, not the exception. Forcing an else to 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-match cases gives 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.
75 documents7 sectionssource : /docs · généré au build