Documentation / Conception / Explications / Two levels of validation

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.

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