Validation System
Validation System
Moira performs comprehensive validation at multiple levels to ensure workflow integrity and correct agent responses.
Validation Levels
1. JSON Schema Validation
Workflows are validated against a JSON Schema definition:
- Structure validation against workflow schema
- Required field checking (id, metadata, nodes)
- Node ids are kebab-case (
^[a-z0-9][a-z0-9-]*$); an id with a dot, an upper-case letter or an underscore is a schema error on that node - Type validation for all properties
- Built-in node schema branches and the namespaced extension-node form
2. Structural Validation
Graph structure is analyzed for correctness:
- Node connectivity - All connections point to valid nodes
- Required nodes - Start node must exist
- Circular dependencies - Loops are detected and flagged
- Unreachable nodes - Nodes no entry point leads to. Entry points are the start node and every
teleport: a teleport has no ordinary incoming connections, so whatever it routes to is reachable throughstep({ teleportTo })and is not reported - Registry entries - Each
variableRegistryentry must be a valid JSON Schema (a malformeditems/pattern/etc. is a blocking error) with a non-empty description - Materialize declarations - Every entry has exactly one source (
fromor emptycontent), registry sources are string defaults, declared paths are safe and unique, and all connection targets exist - Process blocks - When
progressis present, every node declaresprogressNodeIdfor an existing block, every block hascontent.summary, every connection leaving a block or returning carries aconnectionLabelsentry (returns withcycle.causeandcycle.exit), and each{{progress_*_outcome}}template sits on one block that owns a writer; each violation is an error with a stable code (unowned-node,unknown-block,empty-block,empty-description,unlabeled-edge,unexplained-cycle,outcome-duplicate,outcome-unowned,unconnected-block) - Block list bindings - A block’s
listbinding names at least one ofitems,currentandtotal, and only the fieldsitems,title,current,done,totalandindexBase(0or1). Each ofitems,current,doneandtotalis a variable path whose root is a declared global or a node id — an unknown root is an error naming the block, the field and the path — andtitleis a path inside one item, so it is an error withoutitems - Routing cases - Each case of a
conditionoragent-directivenode is checked as described under Routing Diagnostics below - Human gates - An
agent-directive’shumanGateis a closed object:labelis 1–120 characters,notifyisautooroff, andremindAfteris a positive number followed bym,hord; an unknown field is an error. Itswhengets the same structure check and the same declared-variable check as a routing case’swhen - Extension node types - Types found in the live extension registry or a published registry snapshot validate their declared configuration schema. A live registry that does not contain a type reports an error; a type absent from a snapshot, or validation without usable registry data, produces an unresolved warning instead
3. Input Validation
Agent responses are validated against inputSchema:
- AJV-based JSON Schema validation
- Type checking for response fields
- Required field validation
- Pattern and format validation
Schema Version and Migration
metadata.schemaVersion is an integer that describes the shape of the definition itself, beside the
semver metadata.version that describes its content. The current value is 1; a definition without
the field is version 0.
Every definition is upgraded to the current version before it is checked, so validation never sees an older shape. The upgrade runs wherever a definition enters the system — validation, upload through the API, MCP or the CLI, the bundled catalog, and stored definitions read back — and once at startup for stored definitions, reconciliation baselines and recorded conflicts. It is pure and idempotent: a definition already at the current version comes back unchanged.
Authors never have to migrate by hand. To rewrite a file on disk into the current shape, run:
moira-workflow <file> migrateLike every write, it creates a backup first. Run a second time it reports «Already at schema version 1; nothing to migrate».
Routing Diagnostics
A condition or agent-directive node routes through ordered cases, each naming a key of the
node’s connections. The validator checks them as follows. The identifier in parentheses is the
code that appears in the message.
| Severity | Condition |
|---|---|
| error | A case names an output that is not a key of connections (unknown-case-output) |
| error | A case names a reserved control output (error or timeout) |
| error | A condition node has no cases, or no connections.default |
| warning | A case selects the node’s own default output, which makes the case redundant |
| warning | An authored output no case names can never be taken (unreachable-output) |
unreachable-output is a warning rather than an error because a definition is edited one step at a
time and a connection is often added before the case that selects it; the definition stays valid.
The default output (default on a condition node, success on an agent-directive node) and the
control outputs error and timeout need no case of their own.
Each case’s when gets the same operator and structure checks as any structured condition, and the
declared-variable check below applies to the paths it reads. expressions on a routing node get the
same syntax and registry checks as a standalone expression node.
Validation Results
Validation returns structured results:
{ valid: boolean; errors: ValidationError[]; warnings: ValidationWarning[];}Error Types
| Type | Description | Example |
|---|---|---|
schema | JSON structure invalid | Missing required field |
structure | Graph structure invalid | Orphan node |
connection | Connection invalid | Points to non-existent node |
reference | Reference invalid | Invalid subgraph ID |
Warning Types
| Type | Description | Threshold |
|---|---|---|
performance | Large workflow | >20 agent-directive nodes |
complexity | Complex conditions | Deeply nested conditions |
context | Large context | >100KB context size |
node | Notification shows an internal value | A path, a bare counter or a raw step output in message |
node | Two messages about one wait | A notification node leading straight into a humanGate step with notify: "auto" |
Validation Examples
Valid Workflow
{ "id": "valid-workflow", "metadata": { "name": "Valid Workflow", "version": "1.0.0", "description": "A valid workflow" }, "nodes": [ { "id": "start", "type": "start", "connections": { "default": "task" } }, { "id": "task", "type": "agent-directive", "directive": "...", "completionCondition": "...", "connections": { "success": "end" } }, { "id": "end", "type": "end" } ]}Result:
{ "valid": true, "errors": [], "warnings": [] }Invalid Workflow - Missing Connection
{ "nodes": [ { "id": "start", "type": "start", "connections": { "default": "missing" } }, { "id": "end", "type": "end" } ]}Result:
{ "valid": false, "errors": [ { "type": "connection", "message": "Node 'start' references non-existent node 'missing'", "nodeId": "start" } ]}Workflow with Warning
Large workflow triggers performance warning:
{ "valid": true, "errors": [], "warnings": [ { "type": "performance", "message": "Workflow has 25 agent-directive nodes. Consider breaking into subgraphs.", "count": 25 } ]}Input Schema Validation
Agent responses are validated against inputSchema defined on agent-directive nodes.
Nodes Without inputSchema
Nodes without inputSchema require empty input from agent. Non-empty responses are rejected:
// Node without inputSchema{ "id": "task", "type": "agent-directive", "directive": "..." }
// Valid: empty response{}
// Invalid: non-empty response{ "result": "done" } // Rejected with validation errorSchema Definition
{ "type": "agent-directive", "inputSchema": { "type": "object", "properties": { "result": { "type": "string" }, "confidence": { "type": "number", "minimum": 0, "maximum": 10 } }, "required": ["result"] }}Valid Response
{ "result": "completed", "confidence": 8 }Invalid Response
{ "confidence": "high" }Error:
{ "valid": false, "errors": [ { "field": "result", "message": "Required field missing" }, { "field": "confidence", "message": "Expected number, got string" } ]}Declared-But-No-Default Variable Warning
The validator emits a warning (severity warning, not an error — the workflow is still valid) when a variableRegistry variable is referenced in a directive, completionCondition, message, or a routing case’s when, but the variable has no default and is never written by any upstream node’s globalInputs (and is not present in the start node’s initialData).
At runtime, such a reference renders the literal placeholder [[UNDEFINED_VARIABLE]] instead of a value.
{ "valid": true, "errors": [], "warnings": [ { "type": "structure", "severity": "warning", "nodeId": "do-work", "message": "Variable 'iteration' is referenced in node 'do-work' but has no default and is never written by an upstream node. It will render [[UNDEFINED_VARIABLE]] at runtime." } ]}Fix it one of two ways:
- Add a
defaultto the variable invariableRegistry. - Have an upstream node write the variable via its
globalInputsbefore the node that references it.
Notification Content Warning
A notification is read by a person, often on a phone, who can open neither a file on the agent’s
machine nor a step’s raw output. The message of a user-notification or
telegram-notification node gets a warning — never an error — for each value it would show them
that they cannot use:
- a file or path: a variable named
*_path,*_file,*_diror*_directory, a declared global whose description speaks of a file path, directory or folder, or a path written in the message itself (./report.md,~/notes/today.txt,docs/final/report.md). Say what the file contains in words, or link a URL. - a bare counter:
*_index,*_revision,max_*orcurrent_stepwhen nothing in the message names what it counts (a value called…title,…name,…labelor…summary). Put the title next to it, or rely on the plan list the notification already carries. - a step’s raw output:
{{node-id.field}}whose field is neither a URL nor a title. Show a title, a sentence or a URL instead.
{{this.…}} inside loops and the system values ({{runUrl}}, {{executionId}}, …) are never
flagged. The check is a heuristic over names and descriptions; moira-workflow validate, a
workflow upload and the web editor all show the warning, and none of them refuses to save.
⚠ 1 warning(s): • Notification notify-completion shows {{delivery_file}}, a file or path: the reader cannot open it from a message. Say what it contains in words, or link a URL.Notification Before a Gate Warning
A step marked humanGate with notify: "auto" (the default) makes Moira notify the person as the run
starts waiting there. A user-notification or telegram-notification node whose connection leads
straight into such a step sends a second message about the same wait, so it gets a warning — never an
error — naming the gate: keep the workflow’s message and set humanGate.notify to "off" on the gate
(the usual choice when the message carries what the person decides on), or remove the node.
⚠ 1 warning(s): • Node notify-plan-approval: leads straight into "approve-plan", whose humanGate notifies the person itself (notify: auto); the person gets two messages about one wait (gate-notified-twice). Set humanGate.notify to "off" on "approve-plan" to keep this message, or remove this nodePlaybook Reference Validation
A definition that names a playbook with {{playbook:name}} is accepted only when the author can
read that playbook: their own, or another account’s published one. An unreadable reference is a
blocking error wherever a definition is written — the manage tool refuses to create or edit, and a
definition saved through the API is stored as invalid — and the message names the playbook and the
three ways out: create it, publish it, or remove the reference.
The same rule runs once more when a run is started, because a playbook can disappear between the
last edit and the start. start refuses before the execution exists rather than presenting a step
whose behaviour text is missing.
A reference written with a leading backslash (\{{playbook:name}}) is literal text and is not
checked — that is how a document can explain the syntax without naming a playbook.
Injection Safety
Substituted variable and data VALUES are never re-executed as templates. When the engine interpolates a value into a directive or message, that value is treated as a literal string — brace syntax originating from substituted data is neutralized and not re-parsed.
This means templates only ever execute in author-controlled static node fields, not in values that arrive from agent input or external data.
Playbook content follows the same boundary. Your own playbook is author-controlled text and is processed like the rest of your definition; a playbook belonging to another account is neutralized before substitution, so published text cannot execute templates inside your run.
Do not echo untrusted input containing {{...}} into a directive. Declare the variables you need
and reference them explicitly. Prefer explicit named variables over {{context.variables}}
full-dumps, which can expose the entire variable bag.
Materialize Validation
A materialize node is accepted only when basePath is non-empty, files contains at least one entry,
and connections.success is present. Every declared files[].path must be a normalized relative
path without NUL, absolute roots, backslash roots, empty segments, ., or ..; declared paths must
be unique. Each entry declares exactly one of:
from: the name of avariableRegistryentry withtype: "string"and a stringdefault;content: "": an empty skeleton file. Non-empty inline content is rejected.
Because paths can contain templates, the same safety and collision checks run again after
rendering against the current execution context. Archive delivery has no generic count or byte ceiling;
the selected controller validates paths, encodes text and retains the immutable tar publication. A failure while preparing the step routes through the
optional connections.error; otherwise it is raised. Every HTTP download revalidates the
five-minute grant and its user/execution/node/context binding, including after asynchronous work.
The first submission pins the verified controller scope and original request ID; repeats only collect
that publication and cannot render or submit it again. A changed pairing cannot replace its scope. Context delivery revalidates the same bindings and adds one
further bound: a set over 256 KiB in total is refused rather than truncated.
Best Practices
- Always include inputSchema - Validate agent responses for consistent data
- Keep workflows focused - Split large workflows into subgraphs
- Test validation - Use
managewithincludeValidation: true - Handle errors gracefully - Define error connections for validation failures