Workflow DSL (meridian/v3)
This is the complete grammar of a Meridian workflow document. A workflow is a
single declarative YAML file — the sole source of truth for a pipeline,
versioned in git and executed by the generic interpreter. The grammar is defined
by the Zod schema WorkflowSpec in
libs/shared/src/domain/workflow-spec.ts;
this page mirrors that schema field by field.
Documents live in the definition store, seeded from *.workflow.yaml files in
git (content/clinical-demo/workflows/ for the shipped demos,
apps/api/workflows/ for the host's system definitions).
For the why behind the dataflow model, see The dataflow model. For task-oriented authoring, see the how-to guides.
Top-level document
The document root is an object with exactly the following fields. All six are required; there are no other top-level keys.
| Field | Type | Required | Description |
|---|---|---|---|
apiVersion |
string literal "meridian/v3" |
yes | Grammar version discriminator. |
name |
string | yes | Human-readable workflow name. |
context |
array of string | yes | Ambient context kinds established by the trigger (open, plugin-contributed). |
trigger |
object | yes | The entry node (reserved id trigger). |
nodes |
map of node instances | yes | Node instances keyed by id. |
connections |
array of connections | yes | Port-to-port dataflow edges. |
apiVersion: meridian/v3
name: Réception résultat biologie
context: [patient, encounter]
trigger:
type: trigger.bio-result-received
description: Un résultat de biologie est reçu.
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 }
apiVersion
Zod: z.literal("meridian/v3").
Must be the exact string meridian/v3. Any other value fails schema
validation. This field discriminates the grammar version.
name
Zod: z.string().
Free-form human-readable name. No format constraint beyond being a string.
context
Zod: z.array(z.string().min(1)).
The ambient variables the trigger establishes and that nodes may read. The
vocabulary is open: each element must be a context kind contributed by a
loaded plugin (contributes.contextKinds — @posos/common contributes
patient, encounter, order, document); the array may contain any subset
(including empty) and any order.
Static validation (validateSpec) checks that every element is a registered
kind (an unknown kind is an error naming the known ones), that the trigger
establishes each one (a trigger declaring establishes: "*" — webhook,
workflow-called — establishes any registered kind opportunistically), and that
context covers what the workflow's nodes require.
trigger
Zod: z.object({ type: z.string(), description: z.string().optional() }).
The entry node of the workflow. It establishes the ambient context and emits
the initial outputs consumed by downstream connections.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Id of a node type of kind trigger in the catalogue. |
description |
string | no | Optional human-readable description. |
The trigger instance is addressed in connections by the reserved node id
trigger (see Reserved identifiers) — e.g.
trigger.result. The trigger's type maps to a registered event type; if no
trigger with a matching event type is registered (core or plugin), inbound
events are rejected. The event model is open — see
The open event model.
nodes
Zod: z.record(WorkflowNodeInstance).
A map from node id to a node instance. Keys are the node ids used on both sides of connection port references. Each value conforms to the node-instance schema below.
Node instance
Zod: WorkflowNodeInstance.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Id of a node type from the catalogue. |
config |
object (z.record(z.unknown())) |
no | Static configuration for the node type. |
inputs |
object (z.record(z.unknown())) |
no | Inline constants for input ports (port → parameter). |
expose |
array of string | no | Config field names shown as input ports in the editor (parameter → port). |
nodes:
load_demographics: { type: patient.load-demographics }
guard_creatinine:
type: flow.guard
config: { condition: 'criteria.code.code == "creatinine"' }
type
Zod: z.string().
The id of a node type in the catalogue (core or a loaded plugin). Static validation checks the type exists. Node types are documented per plugin; see the behavior contract and core primitives.
config
Zod: z.record(z.unknown()).optional().
Static configuration consumed by the node type. Its shape is defined by the node
type, not by this schema. Config fields may be overridden at runtime by a wired
connection targeting that field — see expose below and
Connections.
inputs
Zod: z.record(z.unknown()).optional().
Inline constants for input ports, in the "port → parameter" direction. A key is
an input port name; the value is a constant that the port takes when it is not
wired. This removes the need for a dedicated value.* node to supply a
constant.
nodes:
dbl: { type: clinical.double, inputs: { value: 21 } } # required input satisfied inline
Rules (enforced by static validation):
- The inline value is validated statically against the port's type.
- A port that is both wired and given an inline value is a validation error.
- For a wired port whose upstream branch is absent, absence propagation governs — there is no fallback to the inline constant (the wire is authoritative).
expose
Zod: z.array(z.string()).optional().
A list of config field names to display as input ports in the visual editor,
in the "parameter → port" direction. This is a UX hint only: whether a field is
listed here or not, any connection … → node.<configField> is valid and
overrides the config at runtime (falling back to the static config value if
the upstream branch is absent).
nodes:
build_cond: { type: transform.format, config: { template: "criteria > {seuil}" }, inputs: { seuil: 90 } }
garde: { type: flow.guard, config: { condition: "criteria > 1000" }, expose: [condition] }
connections:
- { from: trigger.value, to: garde.criteria }
- { from: build_cond.value, to: garde.condition } # wired config field
connections
Zod: z.array(Connection), where Connection is
z.object({ from: PortRef, to: PortRef }).
An array of dataflow edges. Each edge carries the typed output of one node's port to the typed input of another's. Order within the array is not significant.
| Field | Type | Required | Description |
|---|---|---|---|
from |
port reference | yes | Source: an output port. |
to |
port reference | yes | Target: an input port (or an exposed config field). |
connections:
- { from: compute_dfg.egfr, to: store_dfg.egfr }
- { from: compute_dfg.egfr, to: detect_condition.egfr }
Static validation checks both endpoints exist and that their types are compatible (structural subtyping, value-set, dimension). See Two-level validation and the type system.
Port references
Zod: PortRef = z.string().regex(/^[\w-]+\.[\w-]+(\.[\w-]+)?$/).
A port reference is a dot-separated string. \w matches [A-Za-z0-9_]; a hyphen
is also allowed. There are two forms:
| Form | Grammar | Meaning |
|---|---|---|
nodeId.portName |
two segments | A whole port. |
nodeId.portName.fieldName |
three segments | A split-pin field of an object port. |
Each segment matches [A-Za-z0-9_-]+. A reference with fewer than two segments,
or more than three, fails the regex.
The helper parsePortRef(ref) (same file) parses a reference into
{ node, port, field? }: it splits on the first dot for node, then on the
next dot for port and the optional field.
| Input | node |
port |
field |
|---|---|---|---|
trigger.result |
trigger |
result |
— |
trigger.result.value |
trigger |
result |
value |
Split-pin field references
The three-segment form nodeId.portName.fieldName addresses a single field of an
object-typed port without an intermediate node. Field-level types are validated;
the interpreter extracts (on a source) or assembles (on a target) at runtime.
connections:
- { from: trigger.result.value, to: log_value.input } # field `value` of the Observation
Reserved identifiers
| Identifier | Kind | Meaning |
|---|---|---|
trigger |
node id | The trigger instance. Addressed as trigger.<port> in connections. |
done |
implicit output port | Present on every node. Emitted when the node has produced (at least one output, or no declared outputs). |
after |
implicit input port | Present on every node. Multi-wire join (AND): waits for all wired branches; skipped if any is absent. Received data is ignored. |
done and after are flow pins managed by the engine; node behaviors never see
them. They are used to sequence and join without transporting data — see
Sequence with after and done.
Exported types and helpers
From workflow-spec.ts:
| Export | Kind | Description |
|---|---|---|
WorkflowSpec |
Zod schema + inferred type | The document schema. |
WorkflowNodeInstance |
Zod schema + inferred type | A single node instance. |
Connection |
Zod schema + inferred type | A single edge. |
parsePortRef(ref) |
function | Parses a port reference into { node, port, field? }. |
Related
- Core primitives — the built-in
flow.*andtransform.*node types. - JEXL — the expression language used in
flow.guard/flow.switchconditions. - TypeRef — how port types are named.
- Wire ports, Branch with guards and switch, Sequence with after and done, Loop with map, Add human validation, Use agents in a workflow.
- Validate a workflow.
- The dataflow model, Branching and absence, Two-level validation.