RoadmapForward direction

FlowState — a flow-focused DSL over xState

xState is the canonical statechart language and we want to keep it as the substrate for xFlow's IR. But xState's general-purpose surface — mutable context, inline action functions, multiple ways to express the same shape — creates ambiguity and foot-guns when flows are the only goal. FlowState is the simplified subset: same statechart semantics, tighter authoring shape, optimizer-friendly, registry-native. It compiles bidirectionally with xState v5; SCXML round-trips through xState.

This page is a roadmap, not a current product. The companion spec lives at specs/flowstate--dsl.md. xState remains a fully supported authoring surface throughout — FlowState is additive, not replacement.

Why a subset

xState is broad. Flows are narrow. The subset is the IR.

xState v5 is a comprehensive statechart implementation — beautiful and broad. But for flow definitions specifically (workflows, agent loops, durable orchestration), most of xState's surface is unused, and some of it is foot-gun-shaped: mutable context, inline action closures, multiple ways to express the same transition, time-as-numeric. FlowState is the minimum xState subset that retains the semantic richness flows need (parallel, hierarchical, history, guards) plus the xFlow-specific additions that make the IR optimization-friendly.

FlowState — sample
// FlowState — flow-focused DSL.
//   Statechart semantics; symbolic actions; schemas at boundaries;
//   no mutable JS context, no inline functions, no foot-guns.

defineFlowState({
  id: "shop.checkout",
  version: "1.0.0",
  inputSchema:  z.object({ userId: z.string(), cartId: z.string() }),
  outputSchema: z.object({ orderId: z.string() }),
  initial: "loading",
  states: {
    loading: {
      invoke: { action: "action:cart.load@^1", input: "$.cartId" },
      on: { DONE: "pricing", ERROR: "failed" },
    },
    pricing: {
      invoke: { action: "action:cart.price@^2", input: "$.cart" },
      on: { DONE: "review" },
    },
    review: {
      type: "parallel",
      states: {
        humanApproval: { invoke: { action: "action:human.approve@^1" } },
        fraudCheck:    { invoke: { action: "action:fraud.check@^3" } },
      },
      onDone: "charging",
    },
    charging: {
      invoke: {
        action: "action:payment.charge@^1",
        input:  "$.total",
        idempotencyKey: "$.cartId",
      },
      on: { DONE: "done", ERROR: "refunding" },
    },
    refunding: {
      invoke: { action: "action:payment.refund@^1" },
      on: { DONE: "failed" },
    },
    done:   { type: "final", output: { orderId: "$.orderId" } },
    failed: { type: "final" },
  },
})

// Compiles to:
//   • Canonical JSON (RFC 8785) — registry-publishable
//   • xState v5 machine config — Stately Studio + devtools
//   • SCXML — W3C interop

One way per pattern

Each flow shape has a canonical FlowState representation. Lint warns on alternative formulations that compile to the same shape.

No mutable JS context

Run state lives on the substrate (signed log, Postgres row, etc.). I/O at boundaries is schema-typed. No context object to drift.

Symbolic actions only

Every action is an action:id@version registry ref. No inline closures, no assign(), no raise().

Schemas at boundaries

Zod or JSON Schema on inputs and outputs. Action refs declare interface contracts; the resolver verifies match before running.

Optimizer-friendly

Every state, transition, guard, and action is structural and addressable. Passes like parallel-region inference and dead-state elimination operate on FlowState directly.

xState-compatible

FlowState compiles to xState v5 machine config; existing tooling (Stately Studio, devtools, simulator) works unchanged. Migration is mechanical.

Side by side

The same flow, two authoring surfaces.

xState owns the statechart language. FlowState is a thin authoring layer that drops xState's general-purpose JS-flavored bits and adds xFlow's registry-native bits. The compiled artifact is the same shape; FlowState just refuses to let you author the shape in foot-gun-prone ways.

xState v5 vs FlowState
// Same flow shape, three authoring surfaces.

// xState v5 (general purpose statechart):
createMachine({
  id: "shop.checkout",
  context: { userId: "", cartId: "", total: 0 },        // ← mutable
  initial: "loading",
  states: {
    loading: {
      invoke: {
        src: fromPromise(async ({ input }) => loadCart(input.id)),
        input: ({ context }) => ({ id: context.cartId }),
        onDone: { target: "pricing", actions: assign({...}) },
      },
    },
    // ... lots of mixing of definition + runtime concerns
  },
})

// FlowState (flow-focused subset + xFlow extensions):
defineFlowState({
  id: "shop.checkout",
  inputSchema:  z.object({...}),                        // ← schema-typed
  initial: "loading",
  states: {
    loading: {
      invoke: { action: "action:cart.load@^1" },        // ← symbolic ref
      on: { DONE: "pricing" },
    },
    // ... structural and addressable
  },
})

// What FlowState removes:
//   • context (mutable JS object)
//   • inline action functions (assign, raise, send, sendTo)
//   • after / always (delayed / eventless transitions) — express explicitly
//   • src as inline factory — only id@version refs

// What FlowState adds (xFlow-specific):
//   • action: id@version (registry-resolved + signed)
//   • inputSchema / outputSchema per state (zod or JSON Schema)
//   • placement / claim / exposure declarations
//   • idempotencyKey on action invocations

Surface

What's kept, what's dropped, what's added.

Kept from xState

The semantic core

atomic / compound / parallel / final states

Same names, same semantics as xState v5.

history states (shallow + deep)

Same semantics; required for resume-where-you-were patterns.

transitions with target + guard + actions

Actions list refers to action: refs, not inline JS.

onDone / onError on invoke

Same semantics; required for compound + parallel-region join.

guards

As pure expressions over input + run state. Standard guard library: equals, gt, lt, matches, oneOf, and, or, not.

invoke

But invoke.action: id@version (registry-resolved) instead of invoke.src: factory.

Dropped from xState

What's foot-gun-shaped for flows

context (mutable shared JS object)

Replaced by: Run state lives on the substrate (signed event log via xSync, or row state in Postgres, etc.). I/O at boundaries is schema-typed via inputSchema / outputSchema.

Inline action functions (entry/exit/transition actions)

Replaced by: All actions are action:id@version refs in the registry. No closures, no inline JS, no mixing definition with runtime logic.

assign()

Replaced by: Output bindings on action invocations declare what part of the action result becomes part of the run state. Bindings are expressions, not closures.

raise() / send() / sendTo()

Replaced by: Typed event-driven transitions. The substrate emits events; transitions react to event types. No JS-side event raising.

after (delayed transitions)

Replaced by: Explicit `service:timer@^1` invocations or substrate-emitted timer events. Time is data; not an inline numeric.

always (eventless transitions)

Replaced by: Automatic-transition states (transition fires on entry, no event). One specific named pattern; not a general feature.

Multiple ways to do the same thing

Replaced by: One canonical shape per pattern. Lint warns on alternative formulations that compile to the same shape.

Added by xFlow

Registry-native primitives

action: id@version refs

Symbolic action references resolved by the registry. Required field; no inline JS.

inputSchema / outputSchema

Zod or JSON Schema at every boundary. Action refs declare interface contracts; the resolver verifies match.

placement

Per-state or per-flow declaration of where work can run (`server`, `browser`, `gpu`, capabilities). Substrate routes accordingly.

claim

Per-state claim mode (`authority` / `lease` / `deterministic-election` / `optimistic-idempotent`).

exposure

`private` / `internal` / `published` per definition. Registries enforce the allowed set.

idempotencyKey

Per-invocation idempotency expression. Substrate uses it to deduplicate retries.

Roadmap

Five phases. Each is its own milestone.

Spec → codec → authoring → optimizer integration → migration tooling. Each phase has its own deliverables and can land independently. Phase A is the unblocking step; Phase D depends on the optimizer work tracked at /docs/optimization.

Phase A — Spec

  • Draft the FlowState type (TS) and canonical JSON schema
  • Decision log: every xState feature is documented as `kept` / `dropped` / `replaced`, with rationale
  • Error model + warning levels for the xState→FlowState codec
  • specs/flowstate--dsl.md as the source of truth (companion to this page)

Phase B — Codec

Depends on: Phase A
  • @decoperations/xflow-flowstate package (types, validators, FlowState→xState codec)
  • xState→FlowState codec (best-effort, with warnings on unsupported features)
  • Conformance test corpus — sample flows that exercise every FlowState feature; round-trip equality required
  • SCXML preserved through the xState codec — FlowState ↔ xState ↔ SCXML

Phase C — Authoring

Depends on: Phase B
  • defineFlowState() — TS-native authoring with full type inference from schemas
  • Stately Studio compat (export/import) via the xState codec
  • Examples + recipes: agent loop, durable transaction, parallel review, suspend/resume HITL
  • DX: friendly error messages on common authoring mistakes (e.g. closures, mutable context, ambiguous transitions)

Phase D — Optimization integration

Depends on: Phase C
  • FlowState becomes the canonical IR for `xflow.optimize`, `xflow.compile`, `xflow.prove`
  • Optimizer passes target FlowState shapes (parallel-region inference, dead-state elimination, action substitution)
  • Compile targets emit from FlowState (clean) instead of xState (lossy)

Phase E — Migration tooling

Depends on: Phase B
  • `xflow flowstate from-xstate <path>` — codec CLI
  • `xflow flowstate lint` — warns on flow defs that use unsafe xState features
  • `xflow flowstate diff <a> <b>` — semantic diff over the IR
  • Auto-fix codemods for common xState→FlowState migrations

Open questions

Decisions still pending.

These are the design questions the spec phase will resolve. Listed here so the roadmap is honest about what's still being worked through, not pretending the answers exist.

1. Should inputSchema / outputSchema be per-state or per-action ref? Probably per-action; states inherit from the action interface.

2. Should we adopt SCXML extension syntax for FlowState extensions, or keep them in xstate-style `meta.xflow`? Leaning meta.xflow for v1.

3. Do we need a textual DSL (FlowState YAML / TOML) or only JSON + TS? Probably only JSON + TS; YAML if community demand emerges.

4. How strict should the lint be by default? Probably warning on dropped xState features for migration ergonomics; error in CI mode.

5. Should FlowState's standard guard library be extensible with custom guards, or fixed for compile-friendliness?

Recap

One sentence.

FlowState is the flow-only subset of xState — same statechart semantics, no mutable context, no inline closures, every action is a registry ref, schemas at every boundary. It compiles bidirectionally with xState v5 and is the canonical IR for xFlow's optimizer, codegen, and proof system.

xState stays. FlowState is additive — authors who prefer xState can keep using it, and the compiled output is the same shape. FlowState is for teams that want a tighter authoring surface that's optimization-friendly and IR-clean from day one.