Two levels of validation
Every place a workflow document is accepted — the editor's Publish button
(POST /api/workflows/:slug/publish), POST /api/workflows/save,
POST /api/workflows/import, 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 of non-empty strings (context kinds are open, contributed by plugins, and
checked at level 2); 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.
The same pass checks the text a workflow carries for later evaluation and the codes it fixes:
- Expressions: the trigger
selector, theflow.guardcondition and eachflow.switchcase condition are compiled and their identifiers checked against the scope of their site (expression-check.ts). A syntax error is reported with its column and, for a decimal comma, aET/OUor a single=, with the correct form. An empty or constant selector, an empty guard, and a switch case without a valid, unique name or without a condition are refused. See JEXL — Static validation. - Templates: each
{…}placeholder of a text config field is a path followed by registered transforms with literal arguments (template.ts). Its syntax errors and unknown transforms get the same messages as a condition. See Workflow DSL — templates. - Coded config values: a config field typed
coded(or a list ofcoded) on an internal value set only takes that set's codes (coded-config.ts). The error lists the allowed codes and, when the value matches exactly one code or label once case, accents and separators are ignored, names it:code « INN » absent du jeu drug-coding (codes admis : …). Code correspondant : « inn ».
A field fed by a connection is skipped by the expression and code checks: its value exists only at run time.
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. 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 or imported
.workflow.yaml file, the JSON an LLM returns from POST /api/workflows/assist,
or the spec the editor sends when it saves a draft
(PUT /api/workflows/:slug/draft) and publishes it. Put differently: the editor
itself only needs the wiring half of 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 the workflow routes (apps/api/src/server/routes/workflows.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.
The event that starts a run is a boundary of its own. Ingestion checks an event
of a declared type against its contract according to the instance's
EVENT_CONTRACTS mode: under observe, the default, a non-conforming event
starts its workflows and its issues are recorded; under enforce, it is
refused. A field the contract does not list is passed on as received (see
Events and triggers). The
declarations are checked earlier, when the plugin loads: field types, context
kinds, the example and the bindings that produce the event. Inside the run, the
trigger's outputs go through validateValue like any other node's outputs. A
declarative trigger (event: without behavior) projects the contract and
drops a value it cannot read as the declared type, so that output is absent
rather than invalid. In @posos/clinical, the stay triggers and
trigger.discharge-prescription-edited are declarative; the triggers that build
a resource read the event in their own code.
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, so the coded
config check of level 2 skips it. Rather than defer that entirely to runtime,
POST /api/workflows/save, draft publication, the import check and the AI
assistant run an additional, narrower check — validateTerminology (apps/api/src/server/terminology-check.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: type compatibility live, per connection, and the trigger
filter, guard and switch expressions as you type
(
POST /api/expressions/check, nothing written); Save the draft runs level 1 only and Publish the full check below — see Use the visual editor. - Save (
POST /api/workflows/save, the list's fallback for a workflow without a store identity): level 1, then level 2, then the terminology check above, in that order — nothing is written unless all three pass. - Draft (
PUT /api/workflows/:slug/draft): level 1 only, so a draft can be saved with errors. A workflow created in the console starts as a draft; its publication (POST /api/workflows/:slug/publish) runs level 1, level 2, the terminology check and the webhook address check (an identifier another published workflow already uses is refused). Other published workflows on the same event are not a refusal: the publication returns them as a non-blocking note, since each one starts on the event. - Import (
POST /api/workflows/import): level 1 with the YAML line and column of each syntax error, then level 2 and the terminology check, reported without blocking and without writing anything. The response also gives the declared event the file's trigger names (triggerEvent, declared by a loaded plugin or not), which the creation dialog shows as the event of Événement reçu. - AI assistant (
POST /api/workflows/assist): both levels and the terminology check, on each of up to four attempts (ASSIST_MAX_ATTEMPTS) within a 150 s budget (ASSIST_BUDGET_MS). Each retry receives the previous proposal and its errors — 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.