Documentation / Conception / Explications / Two levels of validation

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, the flow.guard condition and each flow.switch case 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, a ET/OU or 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 of coded) 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.

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