Non-deterministic state machine specification for defining workflows in YAML.
In software systems, workflows are state machines. Development cycles, review processes, deployment pipelines, content moderation. All are sequences of states with transitions governed by conditions. Yet no declarative, validatable format exists for defining these state machines independently of their execution.
This specification proposes a YAML format for non-deterministic state machine workflows. A flow definition declares states, transitions, and exit contracts. Transitions may be gated by evidence-based conditions. States may invoke subflows with call-stack semantics. Within-flow cycles are permitted for iterative workflows. The validator checks structural integrity. Tools query, track, and visualise. No execution engine. No side effects. Pure structure.
This is not a new or revolutionary idea. State machines are well-understood. The gap is not in the concept but in the format: no declarative, validatable YAML standard exists for non-deterministic state machine workflows that treats validation as a first-class concern independent of execution. By giving a precise definition to this format, it becomes possible to build shared tooling (validators, editors, visualisers, session trackers) that work across any project that adopts the specification. The format is the foundation.
A complete flow definition with all structural elements highlighted using the colour guide above.
flow: deploy # unique name version: 1.0.0 # semver params: # optional parameters - environment exits: [deployed, failed] # declared outcomes attrs: # opaque metadata owner: SE # extension field git: feature # extension field states: - id: build # unique within flow next: ok: test fail: failed - id: test attrs: timeout: 300 conditions: # named condition groups quality: coverage: ">=80" next: pass: to: staging when: quality # named condition ref fail: failed - id: staging flow: smoke-test # subflow invocation flow-version: "^1" next: pass: deployed fail: review - id: review next: retry: staging # cycle back abort: failed
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.
A flow definition MUST contain flow (unique name string), version (semver), exits (non-empty list of exit names), and states (ordered list of state objects).
flow: deploy version: 1.0.0 exits: [deployed, failed] states: [...]
The first state in states is the initial state. A flow MUST contain at least one state.
exits declares the outcomes this flow offers to callers. Every exit name MUST be referenced by at least one state's next mapping. exits MUST be a non-empty list.
Each state MUST have a unique id within the flow. State ids MUST NOT match any exit name (ambiguous reference).
Each state MUST have a next mapping (trigger → target), unless the state only references exits as terminal targets.
A state MAY declare attrs: an opaque dict. State-level attrs replace (not merge) flow-level attrs. The specification does not interpret attrs.
states: - id: build attrs: timeout: 600 docker: true next: ok: test
Every next target MUST resolve to either a state id or an exit name. A target that matches both is a validation error. A target that matches neither is a validation error.
A transition MAY include when: a dict of guard conditions. Evidence keys MUST match when keys exactly (closed schema). No extra keys, no missing keys.
next: approve: to: deployed when: { score: ">=80" }
Guard conditions use expression operators: == (equality), != (inequality), >= <= > < (numeric comparison). A plain value without an operator prefix is an implicit == (e.g., status: approved is equivalent to status: ==approved). Numeric extraction is applied to both sides: >=80% vs evidence 75% compares 80 vs 75. Multiple conditions in a when dict are AND-combined. No inheritance. Every condition is explicit on the transition where it applies.
A state MAY declare conditions: a mapping of named condition groups. Each group is a condition-map (key-value pairs of expressions). Named groups are referenced by transitions via when.
states: - id: review conditions: quality_gate: score: ">=80" coverage: ">=90" next: approve: to: published when: quality_gate # named ref
The when field on a transition accepts three forms: a dict (inline condition-map), a string (reference to a named group), or a list (mix of named refs and inline dicts). All conditions are AND-combined. A named ref that does not match a group defined on the same state MUST cause a validation error.
next: deploy: to: production when: - quality_gate # named ref - { override: "==yes" } # inline reject: failed
A state with a flow field becomes a subflow invocation. The flow value is a relative file path. Parent next keys MUST match child exits exactly.
Subflows use a call-stack: push on entry, pop on exit. Context is isolated to the current flow. A flow-version field MAY constrain compatible child versions using semver ranges.
A conforming validator MUST check all of the following at load time:
next target resolves to a state id or exit namenext target is ambiguous (matches both state id and exit name)next keys match child exits exactlyexits are referenced by at least one statewhen resolve to a group defined on the same stateA session tracks flow, state, name, created_at, updated_at, stack (subflow call stack), and params. Session writes MUST be atomic (temp-file-then-rename). Filesystem is the source of truth.
flow: deploy state: review name: default created_at: "2026-05-01T10:00:00" updated_at: "2026-05-01T14:25:00" stack: [] params: {}
Within-flow cycles are allowed (a state MAY transition to an earlier state in the same flow). Cross-flow cycles are forbidden.
Flows SHOULD use semver for versioning. Adding a new exit is a minor bump. Adding states is a patch. Removing or renaming exits is a major (breaking) change.
A flow definition MAY contain fields not specified in this document. Such extension fields are not interpreted by a conforming validator. The keys defined in this specification (flow, version, params, exits, attrs, states, id, next, to, when, conditions, flow, flow-version) are reserved. Implementations MUST NOT assign semantics to reserved keys beyond what this specification defines.
The attrs field is the designated extension point. Implementations SHOULD place implementation-specific data (agent assignments, tool configuration, environment variables) inside attrs rather than as top-level keys. This keeps the structural contract separate from tool-specific configuration.
The following grammar defines valid flow definition structure:
The following diagrams illustrate valid structural patterns. They are normative: they depict what the specification permits.
The smallest valid flow: one state, one exit, one transition.
A state with multiple outgoing transitions. The actor chooses which path to take, not the machine. This is what makes flowr non-deterministic.
A transition that requires evidence. The actor sends a trigger with evidence, and the condition engine validates it. The guarded path is visually distinct.
A parent state invokes a child flow. The call-stack pushes on entry, pops on exit. Parent next keys match child exits.
A state transitions back to an earlier state in the same flow. Permitted. Cross-flow cycles are forbidden.
Complete, valid flow definitions that serve as reference implementations.
A linear flow with exits. The first state is initial. States reference exits in next.
flow: deploy version: 1.0.0 exits: [deployed, failed] states: - id: prepare next: ready: execute - id: execute next: success: deployed error: failed
Guarded transitions requiring evidence. Both approve and reject are gated. The actor provides evidence and the engine validates it.
flow: review version: 1.0.0 exits: [approved, rejected] states: - id: pending next: submit: under-review - id: under-review next: approve: to: approved when: { score: ">=80" } reject: to: rejected when: { score: "<40" }
States loop back to earlier states. The red state is revisited for each new test example.
flow: tdd-cycle version: 1.0.0 exits: [all_green, blocked] states: - id: red next: test_written: green blocked: blocked - id: green next: test_passes: refactor - id: refactor next: next_example: red all_pass: all_green
A parent flow invokes a child flow via flow:. Parent next keys match child exits exactly.
# Parent flow: feature-flow version: 1.0.0 exits: [completed, cancelled] states: - id: scope flow: scope-cycle next: complete: build blocked: cancelled - id: build next: done: completed
A conforming implementation MUST satisfy all rules marked MUST in this specification. A conforming implementation SHOULD satisfy all rules marked SHOULD unless there is a documented reason not to.
Conformance levels:
| Level | Meaning | Requirement |
|---|---|---|
| MUST | Required for all conforming implementations | Immutable loaded flows, closed evidence schema, validation rules |
| SHOULD | Recommended but optional | Filesystem wins over session cache on conflict, semver for flows |
| MAY | Optional extension | Per-state attrs, flow params, Mermaid export |
Existing solutions target execution engines or are framework-specific. They define what the workflow does: side effects, retries, timeouts, error handling. flowr defines what the workflow is: its structure, states, transitions, and guard conditions. By staying agnostic to execution, any tool (editors, visualizers, CI systems, AI agents) can build on the same structural foundation without coupling to a runtime.
YAML is the most human-readable format for nested structures with string keys. It supports comments (needed for specification examples), requires less punctuation than JSON, and is more concise than XML. The format prioritises authoring experience.
flowr defines what a workflow is, not what it does. Execution engines are opinionated. They prescribe side effects, error handling, retry logic. flowr stays agnostic so any tool (editors, visualizers, CI systems, AI agents) can build on the same structural foundation without coupling to a runtime.
Parallel (fork-join) states are out of scope for v1. A flow is a sequence of states with transitions. If your workflow needs parallelism, model each branch as a separate subflow and coordinate through a parent flow.
next?A state MUST have next unless it only references exits as terminal targets. Every state must declare where it can go, even if the only option is an exit.
The transition is blocked. The actor receives a warning indicating which conditions failed and what evidence is required. No state change occurs.
flow-version accepts a semver range (e.g., "^1"). The validator checks that the referenced flow's version satisfies the constraint. Breaking changes (renamed exits) require a major version bump.
Merge semantics require defining deep-merge rules, conflict resolution, and precedence, adding complexity without universal benefit. Replace is unambiguous: if a state declares attrs, those are its attrs. If it doesn't, it inherits flow-level attrs.
Yes. flowr is a specification first. The CLI is a reference implementation. Any tool can parse the YAML format and implement validation, querying, or visualisation independently. The specification is the contract, not the tool.
Yes. Fields not defined in this specification are extension fields and are not interpreted by a conforming validator. The reserved keys (flow, version, params, exits, attrs, states, id, next, to, when, conditions, flow, flow-version) belong to the spec. Place implementation-specific data inside attrs to keep the structural contract clean.