Validate a workflow
meridian-plugin validate statically checks a workflow YAML against your plugin's
declared nodes (plus its dependsOn closure) and the engine's core primitives
(flow.*, transform.*) — no engine, no infrastructure, nothing is executed
(libs/plugin-cli/src/commands/validate.ts). It reports unknown node types, unknown or
mistyped ports, missing context, and required inputs left unconnected, and exits
non-zero on error so it drops straight into CI.
This is the static half of validation only — it never loads a behavior module and never checks that a port's runtime value actually conforms. For that split, see Two-level validation; for what actually runs your behaviors against sample data, see Simulate a workflow.
It assumes a plugin directory with a plugin.yaml (see
Scaffold a plugin) and a workflow YAML wired against its nodes
(see Your first workflow and the
workflow DSL for the grammar itself). You do
not need gen-types or build first: the validator reads contributes straight out
of plugin.yaml — types, value-sets, dimensions, context kinds, declared events and node
metadata — never the generated/ output or a compiled dist/.
Run the validator
From the plugin's directory, run the CLI directly or the
validatenpm script the scaffold wires up (libs/plugin-cli/src/commands/scaffold.ts):npm run validate workflows/example.workflow.yaml # → meridian-plugin validate …With no
--pluginflag, the plugin directory defaults to.— the current directory (libs/plugin-cli/src/cli.ts).Pass
--plugin <dir>once per additional plugin directory the workflow's nodes come from. You only need this for plugins that aren't reachable through adependsOnclosure already:dependsOnentries are resolved automatically throughnode_modules, the same waygen-typesresolves them (libs/plugin-cli/src/discover.ts,loadPluginClosure). Repeat the flag for each extra root; results are merged and de-duplicated by plugin name.Inside the platform repository, use the root
pnpm pluginscript (there is no globally installedmeridian-pluginbinary there) instead of a package-localnpm run validate. For example,content/clinical-demo/workflows/bio-result.workflow.yamlwires a trigger and a fewpatient.*/human.*nodes from the@posos/clinicalplugin to compute/sink nodes contributed by the external@posos/demoplugin — neither depends on the other, so both roots must be listed:pnpm plugin validate content/clinical-demo/workflows/bio-result.workflow.yaml \ --plugin external-plugins/clinical \ --plugin external-plugins/demoflow.guard, also used in that workflow, needs no--pluginat all — core primitives are baked into the validator's catalog before any plugin is registered (libs/shared/src/engine/catalog-metadata.ts).
Read the result
A valid workflow prints its name and exits 0:
✓ Réception résultat biologie: valid (ports exist, connection types compatible, context, required inputs).
An invalid one lists every error, one per line, and exits 1:
✗ Réception résultat biologie: 2 error(s):
- Connexion trigger.result → ghost.criteria : nœud cible « ghost » inconnu.
- Nœud « guard_creatinine » : entrée requise « criteria » non connectée.
The loaded plugins are checked too, with the host's load checks (plugin contract,
manifest keys the schema does not know, dependsOn ranges against the versions found,
declarations and their default filters, node projection, event: triggers, pinned
addresses, bindings, sdk range; see
Load checks). A problem
is printed as - <plugin> › <name> (<kind>) : <message> under
✗ plugins : <n> problème(s) de chargement :, the workflow is checked as well,
and the command exits 1. An unknown key of a plugin reached only through dependsOn,
neither passed with --plugin nor the current directory when no --plugin is given, is
printed under
dépendances : <n> note(s) de clés inconnues : and leaves the exit code unchanged: the
host loads that plugin without the key, and its author fixes it.
The checker's own diagnostic strings are emitted in French, regardless of your
plugin's locale: or an instance's locale — this is internal tooling output from
libs/engine-core/src/engine/validate.ts and graph.ts, not the runtime ctx.t
translation layer described in the manifest reference's i18n section; don't confuse the
two.
Fix common failures
Unknown node or trigger type (
type inconnu) — a typo in a node'stype, or the owning plugin's directory is missing from--pluginand isn't reachable throughdependsOneither. Add the directory or fix the id.Unknown source/target node on a connection (
nœud … inconnu) — thefrom/toside of a connection names a node id that isn't declared undernodes:(or isn'ttrigger). Check for a rename or a typo.Unknown port, or unknown split-pin field (
sortie/entrée … inexistante,champ … inconnu) — the port name, or the field addressed after the dot on a split port (node.port.field), doesn't exist on that node or type. Cross-check the node's ports in Plugin manifest or the type's fields in TypeRef.Incompatible connection type (
type incompatible) — the output type isn't assignable to the input type under structural subtyping. See TypeRef and The dataflow model. When two distinct types share a name, each label carries its origin:Quantity (type du cœur) → Quantity (@posos/common)is the corequantitykind wired into a node typed with the plugin'sQuantityobject; the two never connect.Unknown type name in a type hint (
type « … » inconnu dans « type ») — atype,valueType,itemType,resultType,fromortoof a dynamic node names no registered type and no primitive, at the exact case. The message lists the names that match ignoring case; a core kind without a name is written as aTypeRef({ kind: quantity }). See Core primitives.Missing or unestablished context — a node's
contextentry isn't in the workflow'scontext:list, or the workflow lists a context key the trigger doesn'testablish. Add it tocontext:or pick a trigger that establishes it.Required input not connected — wire it, or set a fixed value under
nodes.<id>.inputs.<port>instead (never both on the same port).Graph structural errors — a cycle outside a loop's own return edge, a nested
map, a node claimed by two loop bodies, ormap.itemfeeding a node outside its body. These come from the same loop-body checks the interpreter enforces at runtime; see Loop with map for the model.Invalid trigger filter (
Sélecteur du déclencheur invalide, colonne <n> : …) — the workflow'strigger.selectordoes not compile, is empty, or reads no field ofevent. Fix the expression, or removeselectorto receive every event of the type. See JEXL expressions. The load problems of the loaded plugins, printed under✗ plugins : <n> problème(s) de chargement :, and their fixes:« D » en 0.3.0, hors de la plage « ^0.2.0 », plugin non chargé. …(kinddependency) — the version of the dependency found is outside the plugin'sdependsOnrange. Load a version of the dependency in the range. When the version found is above the range, the message also proposes^<version found>: declare it once the plugin is reviewed against that version, and bump the plugin's version.meridian-plugin depslists each dependency and its state.plage « latest » de « D » invalide, plugin non chargé. …— the range is not a semver range. Declare one, such as^<major>.<minor>.0of the version you test with.« P » se cite dans dependsOn, plugin non chargé. …— the plugin names itself independsOn. Remove that entry.dépendance « D » absente, …— the dependency is found neither innode_modulesnor in a sibling directory; the line under the problem says where the CLI looked. Install it, place its directory next to the plugin's, or pass it with--plugin.dépendance « D » refusée au chargement, …— the dependency, found with a version in the range, is refused for its contract or one of its own ranges; its own problem is listed before. Fix it first.« eventType » exclu avec « event », qui le fournit. Retirer « eventType ».(same forestablishesandexample) — a trigger withevent:also writes a key the event provides. Remove it from the node.sortie « x » : « y » absent du contrat de « E ». …— a declarative trigger's output (or itsfrom) names no contract key. Pointfromat a declared field, a context id oroccurredAt, or declare the field on the event.événement « E » inconnu dans la fermeture dependsOn. …— the trigger references an event that neither its plugin nor itsdependsOnclosure declares, or the declaring plugin was not loaded. Add that plugin todependsOn(and to--pluginwhen it is not resolvable throughnode_modules), or fix the type.plage sdk « … » inférieure à 0.1.1, requise par les événements déclarés. …orplage sdk absente, …— declaresdk: "^0.1.1"in the manifest (sdk: "^0.1.3"when the message names the default filter of an event,sdk: "^0.1.4"when it names the description of the reception of a protocol,sdk: "^0.1.5"when it names conditional port rules,sdk: "^0.1.6"when it names connections,sdk: "^0.1.7"when it names port connections or non-HTTP connection types,sdk: "^0.1.8"when it names the outbound connection of an inbound connection type).transport « http » fourni par l'hôte, ignoré. Retirer « http » de « receive.transports ».(kindtrigger) — a pinned trigger lists a host transport (http,directory) among its protocol's own transports. The host offers both to every protocol: remove the name.filtre par défaut invalide, retiré. Colonne N : … Corriger « defaultSelector ».— an event'sdefaultSelectordoes not compile, is empty, or reads no field ofevent. The event stays declared without its filter. Fix the expression at the column given.« defaultSelector » se déclare sur l'événement (contributes.events), clé ignorée. Le déplacer sur l'événement « E ».— a trigger witheventcarriesdefaultSelector. Move the filter to the event it receives.« defaultSelector » non admis sur un déclencheur épinglé, clé ignorée. …or« defaultSelector » non admis sur un nœud « K », clé ignorée. …— a pinned trigger or a node that is not a trigger carriesdefaultSelector. Remove the key.clé inconnue, ignorée : la valeur de « description » est coupée à une virgule. Mettre cette valeur entre guillemets.(kindkey, named by the path of the key, such ascontributes.nodes[0].outputs[1]["émis si l'événement le porte."]) — in a{ … }mapping, an unquoted comma ends the value of the key the message names and the rest becomes a key. Quote the value.clé inconnue du contrat plugin 0.1.8, ignorée. Corriger ou retirer la clé.(kindkey, named by the path of the key, such ascontributes.nodes[3].config[0].option) — the manifest carries a key its schema does not know, often a typo, or a key of a newer plugin contract. Fix or remove it.« input » exclu avec « inputs », clé ignorée. Retirer « input ».or« input » : nom de type attendu, clé ignorée. Retirer « input ».(kindkey, alsooutput) — an agent writes theinputshorthand next to itsinputslist, or with a value that is not a type name. The agent keeps its list. Remove the shorthand.binding « b » ne produit pas « encounterId », requis par « E ». …— an event binding'smapdoes not cover a required field or required context id. Add the key tomap(and torequire, so records without it start no run).binding « b » : champ « x » absent du contrat de « E ». Déclarer le champ ou retirer « x » de require.— therequireof an event or message binding names a field the event does not declare. Declare the field in the event, or fix or remove the name.déclencheur non épinglé sans « event ». Déclarer l'événement et le référencer par « event ».— a trigger withoutpinnedRoutingnames its type witheventType, or names none. Declare the event and replaceeventType,establishesandexamplewithevent: E; see Declare an event.type « X » identique à l'adresse du déclencheur épinglé « T ». Renommer l'événement.— a declared type is the address of a pinned trigger. Rename the event. The plugin that pins the address getsadresse « X » déclarée comme type d'événement par « P », événement refusé. …: change the address, or have the event renamed.événement « E » déjà déclaré par « P ». Retirer l'une des deux déclarations.— another plugin, or the same manifest earlier, declares the type. Remove one of the declarations.plage sdk « R » incompatible avec le contrat plugin 0.1.8 de l'hôte, plugin non chargé. …(kindcontract) — the plugin'ssdkrange does not accept the host's contract: the host does not load it. Declare a range that accepts it.
Every check and its effect at load is listed under Load checks.
For the full mechanics behind every check (context propagation, split-pin resolution, loop-body inference), see Static validation engine.
Limitations
- Agent nodes aren't projected. The CLI doesn't turn
agents:manifest entries into node metadata, so a workflow using anagent.<slug>node reports it as an unknown node type (libs/plugin-cli/src/commands/validate.ts). See Author an agent and Use agents in a workflow. - It's static only. A green run confirms nothing about your behaviors, adapters, or live ports at runtime — only that the graph is well-typed and complete. Follow up with Simulate a workflow for a run against sample data, or Run and resolve for the real thing.
Add it to CI
Because the command exits non-zero on any error (libs/plugin-cli/src/cli.ts, main),
add it as a plain step in your pipeline — no special integration required:
- run: npm run validate workflows/example.workflow.yaml
Run it once per workflow file you ship, alongside meridian-plugin i18n check (see
Translate a plugin) and before
building and publishing — a workflow that fails to validate has
no reason to reach the registry.
Related
- Plugin manifest — the
plugin.yamlschemavalidatereads its node/type/value-set metadata from. - TypeRef — the type grammar behind connection compatibility.
- CLI reference — every
meridian-plugincommand and flag. - Workflow DSL — the YAML grammar being
checked (
trigger,nodes,connections,context). - Two-level validation — how this static pass relates to runtime port validation.
- Static validation engine
— internals of
validateSpecand the graph/loop checks. - Simulate a workflow — the next check, actually running your behaviors.
- Scaffold a plugin and Generate types — the steps before this one.
- Build and bundle — the step after a green validation.
- Build a plugin, end to end — this step in context.