Skip to content

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 through step({ teleportTo }) and is not reported
  • Registry entries - Each variableRegistry entry must be a valid JSON Schema (a malformed items/pattern/etc. is a blocking error) with a non-empty description
  • Materialize declarations - Every entry has exactly one source (from or empty content), registry sources are string defaults, declared paths are safe and unique, and all connection targets exist
  • Process blocks - When progress is present, every node declares progressNodeId for an existing block, every block has content.summary, every connection leaving a block or returning carries a connectionLabels entry (returns with cycle.cause and cycle.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 list binding names at least one of items, current and total, and only the fields items, title, current, done, total and indexBase (0 or 1). Each of items, current, done and total is 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 — and title is a path inside one item, so it is an error without items
  • Routing cases - Each case of a condition or agent-directive node is checked as described under Routing Diagnostics below
  • Human gates - An agent-directive’s humanGate is a closed object: label is 1–120 characters, notify is auto or off, and remindAfter is a positive number followed by m, h or d; an unknown field is an error. Its when gets the same structure check and the same declared-variable check as a routing case’s when
  • 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:

Terminal window
moira-workflow <file> migrate

Like 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.

SeverityCondition
errorA case names an output that is not a key of connections (unknown-case-output)
errorA case names a reserved control output (error or timeout)
errorA condition node has no cases, or no connections.default
warningA case selects the node’s own default output, which makes the case redundant
warningAn 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

TypeDescriptionExample
schemaJSON structure invalidMissing required field
structureGraph structure invalidOrphan node
connectionConnection invalidPoints to non-existent node
referenceReference invalidInvalid subgraph ID

Warning Types

TypeDescriptionThreshold
performanceLarge workflow>20 agent-directive nodes
complexityComplex conditionsDeeply nested conditions
contextLarge context>100KB context size
nodeNotification shows an internal valueA path, a bare counter or a raw step output in message
nodeTwo messages about one waitA 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 error

Schema 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 default to the variable in variableRegistry.
  • Have an upstream node write the variable via its globalInputs before 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, *_dir or *_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_* or current_step when nothing in the message names what it counts (a value called …title, …name, …label or …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 node

Playbook 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 a variableRegistry entry with type: "string" and a string default;
  • 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

  1. Always include inputSchema - Validate agent responses for consistent data
  2. Keep workflows focused - Split large workflows into subgraphs
  3. Test validation - Use manage with includeValidation: true
  4. Handle errors gracefully - Define error connections for validation failures