Documentation / Conception / Explications / The dataflow model

The dataflow model

A Meridian workflow is a typed dataflow graph written as declarative YAML. You describe what data flows where — the typed output of one node wired to the typed input of another — and a single generic interpreter runs it. You do not write code for a workflow. This page explains why that shape was chosen, what it buys you, and what it deliberately gives up.

For the grammar itself, see the Workflow DSL reference; for the built-in nodes, Core primitives; for a hands-on start, Your first workflow.

The problem with automation-as-script

The obvious way to automate a clinical process is to write a script: fetch the result, if it's a creatinine, load demographics, compute the eGFR, if it's low, ask a pharmacist, then write it back. It works — once. The trouble shows up later.

A script fuses three concerns that want to move at different speeds: the clinical logic (which computation, which threshold), the plumbing (how to read the patient record, how to call a terminology server), and the control flow (what runs after what). Every new workflow re-implements the plumbing and re-invents the control flow, and every workflow becomes a small program that has to be reviewed, tested, and trusted as code. A wrong field name or an incompatible unit is a bug you discover at runtime, in production, on a real patient's data.

Meridian takes the opposite stance. Clinical capability is packaged once, as typed nodes contributed by plugins. A workflow only composes those nodes. The composition is data, not code — so it can be validated, visualized, simulated, versioned, and generated, none of which you can do reliably with an arbitrary program.

Nodes and typed ports

The unit of composition is the node. A node has named input ports and named output ports, and every port carries a declared type — an Observation, an EgfrResult, a Quantity in the filtration-rate dimension, a list of DetectedIssue. The engine core ships no clinical nodes or types at all; the catalogue is filled at boot by plugins (see What plugins are and The type system). Authoring a workflow is choosing nodes from that catalogue and connecting their ports.

A connection is one edge: from an output port to an input port.

apiVersion: meridian/v3
name: Réception résultat biologie
context: [patient, encounter]
trigger:
  type: trigger.bio-result-received
nodes:
  load_demographics: { type: patient.load-demographics }
  guard_creatinine:
    type: flow.guard
    config: { condition: 'criteria.code.code == "creatinine"' }
  compute_dfg: { type: clinical.egfr.ckd-epi-2021 }
connections:
  - { from: trigger.result,                 to: guard_creatinine.criteria }
  - { from: guard_creatinine.pass,          to: compute_dfg.result }
  - { from: load_demographics.demographics, to: compute_dfg.demographics }

There is no ordering statement anywhere in that document. The interpreter derives execution order from the edges: compute_dfg needs a result and demographics, so it runs after whatever produces them. Order is a consequence of the data dependencies, not something you script. This is the essence of the dataflow model, and the reason it reads like a wiring diagram rather than a program — the lineage is the DaVinci Resolve node graph and n8n, not a step-by-step macro.

Why typed, and why static

The payoff of putting a type on every port is that the whole graph can be checked before it ever runs. When you wire compute_dfg.egfr into a node expecting a Condition, that is not a runtime surprise — it is a static error, reported at load time and, live, inside the visual editor:

compute_dfg.egfr → record_condition.condition : incompatible (EgfrResult → Condition)

Compatibility is structural, not nominal: the checker compares the shape of the types (fields, value-set membership, physical dimension), so a producer and a consumer that agree on structure connect even if they were authored by different plugins that never knew about each other. The editor uses the same isAssignable logic as the engine, so a graph that draws is a graph that validates. This static pass, and the complementary runtime one, are the subject of Two-level validation.

Typed ports are what make the other conveniences possible at all. The editor can offer only the connections that would type-check. The "+" on an edge can deduce which adapter node (transform.break, transform.make, transform.convert) would bridge two otherwise-incompatible ends. Field-level autocomplete in JEXL expressions knows the shape of the data flowing past. None of that is possible over untyped values.

Control flow without if

A dataflow graph still has to branch, and it does so without a control-flow statement — because introducing if/else would drag imperative scripting back in through the side door. Two mechanisms carry all branching:

  • A guard (flow.guard) tests its criteria with a JEXL condition and either forwards the value on pass or stops the branch.
  • Absence propagation: a node whose required input is never supplied is simply skipped, and so is everything downstream of it. A compute that has "nothing to say" (it emits no output) prunes its own branch — there is no else to write.

So clinical.condition.detect-from-egfr emitting a proposal only when the eGFR is low means the human confirmation and the record write downstream just don't happen otherwise. The "else" path is the absence of data, not a coded alternative. For multi-way routing there is flow.switch, and for ordering or joining branches that carry no data there are the implicit after/done flow pins. The full reasoning — why absence is the natural complement of a dataflow graph — lives in Branching and absence.

No code per workflow

Because a workflow is data conforming to a schema (the WorkflowSpec Zod schema in libs/shared/src/domain/workflow-spec.ts), a single generic interpreter (libs/engine-core/src/engine/interpreter.ts) runs every workflow. There is no per-workflow code to compile, ship, or trust. Workflow files seeding the definition store live in git like any other artifact (the shipped demos under content/clinical-demo/workflows/, the host's system definitions under apps/api/workflows/) — a diff on a workflow is a diff on a wiring diagram, not on a program whose behavior you have to simulate in your head.

You write code only when a node type is missing from the catalogue — and that code is a plugin contribution, authored and tested once against a stable behavior contract, then reused by any number of workflows. This is the clean seam the model is built to protect: workflow authors compose; plugin developers build blocks. The two audiences never edit the same files, and the boundary between them is the typed port. See What plugins are.

The trigger keeps the same discipline at the boundary. A workflow starts from a trigger node whose type maps to a registered event type; the platform's event model is open, so triggers are contributed by plugins rather than hard-coded into the engine — see The open event model.

What the shape buys you

Treating the workflow as typed data — rather than as a script — is what unlocks everything around it:

  • Errors before runtime. Type-incompatible wiring, missing required inputs, and unsatisfied context are caught statically, not on a live patient.
  • A real visual editor. The graph is the source of truth, so it renders and edits directly, with live validation. See Use the visual editor.
  • Faithful simulation. The same interpreter can run the graph against a forged event with side effects captured instead of applied, so you test escalation branches and record writes without touching production. See Simulate a workflow.
  • AI authoring. A machine can propose a graph from a natural-language description precisely because the target is a validatable schema, not free code — the assistant re-validates and retries. See Use the AI assistant.
  • Durable execution for free. Because ordering is derived and nodes are pure compositions, the runtime can suspend at a human task or a timer and resume exactly where it left off. See Why durable execution.

Trade-offs and alternatives considered

The model is deliberately less expressive than a general-purpose language, and that is the point. A few paths were considered and rejected:

  • A scripting DSL (a small imperative language per workflow). It would be more expressive but would reintroduce code review, untyped runtime failures, and a compilation step — and it resists visual editing and machine generation. The cost of the missing expressiveness turned out to be small: guards, absence propagation, flow.switch, and flow.map cover the branching and iteration real clinical workflows need.
  • A BPMN-style orchestration with explicit control-flow tokens. It centres on control flow rather than data, so it loses the static type-compatibility check that is the whole reason to type the ports — the property we most wanted to keep.
  • Arbitrary code with a library of helpers. Maximum power, minimum safety: no static graph to validate, no editor, no simulation, and the plumbing/logic/ control-flow fusion returns in full.

What you give up is unrestricted control flow. Loops are constrained to flow.map over a list, and the static validator refuses cycles, nested loops, and cross-body wiring by name rather than letting them run. In exchange you get a composition that is safe to validate, visualize, simulate, generate, and diff — which, for automation that touches patient care, is the trade worth making.

75 documents8 sectionssource : /docs · généré au build