Documentation / Conception / Référence / Workflow DSL (meridian/v3)

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 the following fields: six are required, description and labels are optional. WorkflowSpec is a plain z.object (not .strict()), so parsing drops any other top-level key instead of rejecting it.

Field Type Required Description
apiVersion string literal "meridian/v3" yes Grammar version discriminator.
name string yes Human-readable workflow name.
description string no Free-text description of the workflow. See Organisation metadata.
labels map of string no Key/value labels for grouping and filtering in the console. See Organisation metadata.
context array of string yes Ambient context kinds established by the trigger (open, plugin-contributed).
trigger object one of two forms One entry node (reserved id trigger), exclusive with triggers.
triggers map of trigger instances one of two forms At least two entries, keyed by stable id, exclusive with 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.

A trigger on a declared event establishes every context kind of the event, required and optional: a workflow on trigger.medication-prescribed can list encounter, which MedicationPrescribed declares optional. At run time a kind is present only when the event carries its identifier (encounterId for encounter); a run without it has no encounter context. See Events and triggers — context kinds.

triggers

An ordered map of two or more instances with the same fields as trigger below. Ids match [A-Za-z_][\w-]*; __proto__, prototype and constructor are forbidden. Trigger ids and ordinary node ids are disjoint. Connections use <entryId>.<port>. Exactly one entry is selected per run; an entry always emits done when fired, even when its optional data is absent.

Combination and branch rules, editing and examples: Start a workflow from several events. trigger remains the canonical form for a single entry; existing specs require no migration.

trigger

Zod: z.object({ type: z.string(), description: z.string().optional(), selector: z.string().optional(), config: z.record(z.unknown()).optional(), position: NodePosition.optional() }), where NodePosition is z.object({ x: z.number(), y: z.number() }).

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.
selector string no JEXL filter evaluated on the event payload, bound to event: true, the workflow starts on the event; false or failing, it does not. Without a selector, the workflow receives every event of its type. A filter restricts its own workflow only: every other published workflow that receives the type still starts. An empty selector, or one that reads no event field, is refused by static validation; an empty or blank selector that reaches the active set without it (seed, definitions import) counts as a failing filter: the workflow receives nothing. See JEXL expressions and Choose which events start a workflow.
config object (z.record(z.unknown())) no Configuration of the trigger. Its keys are defined by the trigger type, for example webhookId, slug and auth for trigger.webhook, fields for trigger.workflow-called, connectionId for an EAI trigger, rules and timezone for trigger.schedule, event for trigger.event-received.
position object (NodePosition) no Position of the trigger on the editor canvas. Ignored by the engine.

The trigger instance is addressed in connections by the reserved node id trigger (see Reserved identifiers) — e.g. trigger.result.

The trigger instance receives one event type: the declared event chosen in config.event for Événement reçu (trigger.event-received), a declared event its plugin references with event: for a dedicated trigger (the trigger inspector shows its contract under Declared event), or, for a trigger with its own address, the address it names with eventType. config.event is a string, the event type as declared (no spaces removed); an absent, empty or non-string value, or a type no loaded plugin declares, is a static validation error:

trigger:
  type: trigger.event-received
  config: { event: PatientTransferred }
  selector: event.unit == "4050"

POST /api/events refuses an event whose type no loaded trigger claims (a type that is neither declared nor an address: every declared event is claimed by trigger.event-received), a type that no published workflow receives, and a type claimed only by triggers with their own address (webhook, schedule, sub-workflow call, EAI reception) sent without __workflowFile. See Event ingestion API and Events and triggers; the plugin side is in 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.
position object (NodePosition) no Position of the node on the editor canvas. Ignored by the engine.
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).
connection string no Connection the node uses, for a node type that requires one. Absent: the default connection of the type, or the port bound by the instance.
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. Text fields are templates.

Templates in text fields

A text config field of a node (String, Code or Uri) is a template: {name} inserts the value of the node's input name, and {context.<kind>.id} the run's context. When the node has no input of that name, the variable becomes a required input port, wired like any other. A variable can go through transforms, as in conditions: {url|trim}, {nom|default("—")} — see JEXL — transforms.

alerte:
  type: notify.send
  config:
    channel: "garde-{service|lower}"   # `service` becomes an input of the node
connections:
  - { from: trigger.service, to: alerte.service }

Rules:

  • Between braces: a path (nom, obs.value, context.<kind>.id) followed by transforms whose arguments are written as literals. No computation, no condition, no function call.
  • {{ and }} write a brace. A { followed by a quote or a digit stays text, so JSON with quoted keys reads as is.
  • A variable whose input is absent, or that resolves to nothing ({context.sejour.id} on a run without a stay), skips the node — trace code node-skipped-missing-input or node-skipped-missing-variable.
  • The value is inserted as text: a quantity as 3.2 mg, a list joined with , , an object as JSON. An inserted value is never read again as a template.
  • A value wired onto the field (expose) replaces the template and is not rendered.
  • A context kind read by a template must be in the workflow's context.
  • Not templates: structural fields (typeHint), closed vocabularies (options), a trigger's configuration, and four core fields — flow.guard.condition (a JEXL expression, which already reads the inputs and the context), transform.format.template (rendered by the node itself), workflow.execute.workflow (resolved before the run) and flow.wait.credentialId.
  • Static validation checks the syntax, the allowed constructs and the transform names, and reports Nœud « id » : « champ » : …. Syntax errors and unknown transforms read as in conditions (« {a ET b} » : « ET » inattendu. Opérateurs logiques : && et ||.); see JEXL — Static validation.

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

connection

Zod: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/).optional().

The identifier of a connection declared in the instance manifest or created in the console, for a node type whose plugin declares connection (see Plugin manifest — Connection types). A type that requires a connection type accepts a connection of that type; a type that requires a port (connection: { port }) accepts any connection whose type an adapter of the port requires. Without connection, the node uses the default connection of the type, or the port bound by the instance.

nodes:
  alerte_garde: { type: notify.send, connection: slack-pharma, config: { severity: critical } }

validateSpec rejects connection on a node type that requires none (le type « X » ne prend pas de connexion). Saving and publishing also check that the connection exists and fits the node (connexion « x » introuvable, … de type « t », « u » attendu, … qui ne sert pas le port « p »). A connection cited by a published workflow cannot be deleted.

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? }.
97 documents20 sectionssource : /docs · généré au build