Rensei docs

State Machine

metadata.stateMachine substrate.

The state machine substrate embeds a named-state transition graph directly in a workflow definition's metadata.stateMachine field. The generic issue.update_status node reads this substrate to validate transitions at runtime, and the lifecycle config uses it as the vocabulary for stage-to-tracker-state mapping.

When to use a state machine

State machines are optional. Use them when:

  • Your workflow models an SDLC or business process with distinct, enumerable stages (e.g. Icebox → Triage → In Progress → Done)
  • You want the platform to reject illegal transitions rather than silently accepting any string
  • You're publishing a group bundle that carries a well-defined lifecycle model to consumer workspaces

Schema

metadata:
  stateMachine:
    states:
      - Icebox
      - Triage
      - In Progress
      - In Review
      - Done
      - Rejected
    initial: Icebox
    transitions:
      Icebox:
        - Triage
      Triage:
        - In Progress
        - Rejected
      In Progress:
        - In Review
        - Rejected
      In Review:
        - Done
        - In Progress
        - Rejected
    terminal:
      - Done
      - Rejected

Prop

Type

Validation rules

validateStateMachine enforces these cross-field invariants (Zod alone cannot express them):

  1. states is non-empty.
  2. initial is a member of states.
  3. Every key in transitions is a member of states.
  4. Every value in transitions[key] is a member of states.
  5. When terminal is present, every element is a member of states.

The validator returns a discriminated union - callers fold the error into the existing issues array without an exception path:

// Success
{ ok: true }

// Failure
{ ok: false; error: "stateMachine.initial \"Started\" is not a member of stateMachine.states" }

Runtime helpers

Three helpers are exported for node executors and editor tooling:

import { getNextStates, isValidTransition, isTerminal } from '@/lib/workflow/state-machine'

// States reachable in a single step
getNextStates(sm, 'Triage')
// → ['In Progress', 'Rejected']

// Is a specific transition allowed?
isValidTransition(sm, 'In Progress', 'Done')
// → false (not a direct transition; must go through In Review)

// Is the workflow in a terminal state?
isTerminal(sm, 'Done')
// → true

getNextStates returns [] when the current state has no outgoing transitions; callers check isTerminal separately if that distinction matters.

State machine in group bundles

A state machine embedded in metadata.stateMachine travels with the bundle payload when a group bundle is published. Consumer workspaces that fork the bundle receive the state machine definition as part of the group metadata - they can inspect, extend, or override it after forking.

Tracker-native state names (e.g. Linear's "Started", "In Review") are free-form strings in the state machine. The platform does NOT validate whether those names exist in your actual Linear team workflow - matching is exact and case-sensitive. Use the team's canonical state names to avoid silent mismatches (see Lifecycle Config).

Relationship to lifecycle config

The state machine substrate and spec.lifecycle are complementary:

  • State machine - defines WHAT states exist and which transitions are legal. Lives in metadata.stateMachine. Validated at publish time.
  • Lifecycle config - defines WHEN each stage fires (tracker-native transition trigger) and WHAT happens when it exits. Lives in spec.lifecycle. References state names that should match the state machine vocabulary.

Using both together gives you a fully modelled SDLC lifecycle with validated transitions.

Example: software delivery lifecycle

metadata:
  stateMachine:
    states: [Backlog, In Progress, In Review, QA, Accepted, Rejected]
    initial: Backlog
    transitions:
      Backlog:       [In Progress]
      In Progress:   [In Review, Rejected]
      In Review:     [QA, In Progress]
      QA:            [Accepted, In Progress, Rejected]
    terminal:        [Accepted, Rejected]

spec:
  lifecycle:
    development:
      trigger:
        event: issue.transitioned
        when: { to: "In Progress" }
      exit:
        transition_to: "In Review"
        on_fail: "Rejected"
    qa:
      trigger:
        event: issue.transitioned
        when: { to: "QA" }
      exit:
        transition_to: "Accepted"
        on_fail: "Rejected"

On this page