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.
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 — 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 interopOne 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.
// 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 invocationsSurface
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.