Two levels of validation
Every place a workflow document is accepted — the editor's "Enregistrer" button,
POST /api/workflows/save, POST /api/workflows/assist, POST /api/simulate, the
meridian-plugin validate CLI — runs the same pipeline before anything else
happens: first the document is checked against the DSL's schema, then, only if
that passes, it is checked as a typed dataflow graph. Both happen before a
single node runs. This page is about why that pipeline has two distinct stages
instead of one, what each one can and cannot see, and where a third, later check
(runtime conformance) picks up where they leave off.
This is validation of the workflow definition — is this document a well-formed,
type-sound graph? It has nothing to do with the clinical human-task node that
suspends a run for a person's decision; if that's what brought you here, see
Add human validation and SLAs instead.
Level 1 — schema validation
The first gate is WorkflowSpec, a Zod schema
(libs/shared/src/domain/workflow-spec.ts). It knows nothing about node
catalogues, ports, or clinical types. It only knows the shape the DSL is allowed
to take: apiVersion must be the literal "meridian/v3"; context must be an
array drawn from the four known ambient-context values; every port reference must
match nodeId.portName[.fieldName]; nodes must be a map of objects with a
type string and optional config/inputs/expose. See the
workflow DSL reference for the full grammar this
schema mirrors field by field.
That narrowness is the point. Schema validation is cheap, synchronous, and needs no plugin loaded — it can reject a malformed document in microseconds, before the engine has even looked at what node types exist. It answers one question: is this a workflow document at all?
Level 2 — typed-dataflow static checking
Only once a document clears the schema does validateSpec
(libs/engine-core/src/engine/validate.ts) get to look at it. This is where the
node catalogue enters: for every node it resolves the concrete type, checks the
declared context covers what each node type requires and what the trigger
actually establishes, and — the part that makes Meridian a typed dataflow tool —
walks every connection and asks isAssignable(from.type, to.type)
(libs/shared/src/engine/type-system.ts). A connection whose output type can't
structurally satisfy the input type is rejected with a message like:
compute_dfg.egfr → record_condition.condition : incompatible (EgfrResult → Condition)
It also checks that split-pin field references resolve, that no input is both
wired and given a fixed value, that every required input ends up satisfied by
something (a wire or a fixed value), and — via graphStructureErrors
(libs/engine-core/src/engine/graph.ts) — that the graph respects the shape the
interpreter assumes: no cycles, no node inside two map bodies, no map nested
inside another. That structural half of the check is a large topic in its own
right; the design of the dataflow model it protects is covered in
The dataflow model, and the interpreter's own
assumptions are unpacked in
Static validation engine.
Unlike schema validation, this pass needs context the document itself doesn't
carry: the catalogue of node types (built-ins, plugin-contributed, and — where
relevant — agents), assembled differently depending on where the check runs (the
running instance's full catalogue for /save and /simulate; a plugin's own
plugin.yaml plus its dependsOn closure for meridian-plugin validate, with no
engine or infrastructure needed — see
Validate a workflow).
Why not one check
It would be simpler to have a single validator. Two things push against that.
First, a structural reason: the two checks depend on different inputs. Schema validation is self-contained — it needs nothing but the document. Dataflow validation needs the node catalogue, which varies by where and when the check runs (an instance's live plugin set vs. a single plugin's own contributions in CI). Folding both into one function would mean every caller has to supply a catalogue even when all it wants is a shape check, and every schema failure would be reported in the vocabulary of port types instead of the vocabulary of the document itself.
Second, a provenance reason, visible in where each check actually matters.
The visual editor's live wiring feedback
(apps/config-plane/app/components/WorkflowEditor.tsx, onConnect) calls
isAssignable on every drag-to-connect — and only that. It never runs
WorkflowSpec.safeParse, because it never needs to: the editor builds the graph
as in-memory objects that are valid by construction, so a shape violation simply
can't arise there. Schema validation earns its keep exactly at the boundaries
where a workflow arrives as untrusted text or JSON instead of editor state: a
hand-written .workflow.yaml file, the JSON an LLM returns from
POST /api/workflows/assist, or the round trip through YAML the editor's own
"Enregistrer" button makes when it calls POST /api/workflows/save. Put
differently: the editor only ever needs level 2, because level 1 is guaranteed
upstream by the UI; everyone else needs both, in order, because they can't make
that guarantee.
The ordering matters too. Running dataflow validation on a document that hasn't
cleared the schema would mean touching spec.nodes, spec.connections, and
parsePortRef on data whose shape was never checked — trading a clear "expected
string, got object" for a confusing crash three functions deep. Schema validation
is the fast, cheap fail; dataflow validation is the expensive, meaningful one.
Failing fast on the cheap check first is why the two are sequenced, not merged.
One error currency
Both levels report errors the same way: a flat array of human-readable strings.
That uniformity is what lets apps/api/src/server/api.ts merge a schema
rejection and a dataflow rejection into a single { errors: [...] } response
without the caller needing to know which stage produced which line. It's also
what lets assistWorkflow (apps/api/src/server/workflow-assist.ts) feed either
kind of error straight back into the next LLM prompt as "your previous proposal
was invalid" and ask for a corrected document — the retry loop doesn't care
whether the model got the shape wrong or the wiring wrong, only that the combined
error list is empty before it hands a spec back.
And after that: runtime conformance
Passing both static levels means the graph is well-typed — it says nothing yet
about the actual values that will flow through it once a run starts. That's a
third, later check: at every port boundary during execution, validateValue
(libs/shared/src/engine/type-system.ts) confirms a produced value actually
conforms to its port's type — required object fields present, a coded value a
member of its value-set, a quantity's unit inside its declared dimension. This is
necessarily a runtime concern: the static checks compare declared types to each
other, but whether a Quantity really carries mg/dL or an Observation really
has every required field is only knowable once a node has actually produced one.
The type system that both stages share — assignability for the static checks,
conformance for the runtime one — is the subject of
The type system; its kinds
and their runtime shapes are catalogued in TypeRef.
There is a partial exception, and it's instructive: a coded config value
naming an external value-set (SNOMED CT and similar) can't be checked
structurally at all — internal registries don't enumerate its codes. Rather than
defer that entirely to runtime, POST /api/workflows/save runs an additional,
narrower check — validateTerminology (apps/api/src/server/api.ts) — that
calls out to the configured TerminologyPort at save time for every such
constant. It sits between the two worlds: static in when it runs (before the
document is written), but runtime in how it checks (a live call to a
terminology resolver, the same kind of lookup that happens again during
execution). See Configure terminology routing
for how that resolver is wired up.
Where you meet this in practice
- Visual editor: level 2 only, live, per connection — see Use the visual editor.
- Save (
POST /api/workflows/save): level 1, then level 2, then the terminology check above, in that order — nothing is written to disk unless all three pass. - AI assistant (
POST /api/workflows/assist): both levels, on each of up to two attempts, with failures fed back into the next prompt — see Use the AI assistant. - Simulate (
POST /api/simulate): both levels before the interpreter ever touches a forged event — see Simulate a workflow. - CLI (
meridian-plugin validate): both levels, against a plugin's own catalogue, no engine or instance required — see Validate a workflow. - A running instance: runtime conformance, on every port, on every execution — see Run and resolve and Monitor executions for what a violation looks like once a run is live.
Absence — a required input left unconnected because an upstream branch didn't fire — is a related but separate concept from any of these checks: it's a runtime routing decision the interpreter makes, not a validation failure. See Branching and absence for how the two are not to be confused.