> ## Documentation Index
> Fetch the complete documentation index at: https://smithers.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# XState

> Typed control state for workflows: fold an XState v5 machine over durable output and signal rows with useSmithersMachine

`smithers-orchestrator/xstate` runs [XState v5](https://stately.ai/docs) state
machines as an **optional derived-state layer** over the rows Smithers already
persists: not a backend, not a scheduler, and not a persisted actor.
Each frame, `useSmithersMachine` recomputes the machine's state as a pure fold
over durable output and signal rows using XState's pure
`initialTransition` / `transition` functions. Nothing about the machine is
persisted; resume, fork, and rewind are correct because the fold's inputs are
exactly the rows those features restore.

Use it when a workflow's control flow outgrows plain conditionals (revision
loops, approval ladders, multi-phase pipelines with re-entry) and you want
`state.matches(...)` instead of hand-rolled row checks. For linear pipelines,
`ctx.outputs` conditionals remain simpler.

```tsx theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { setup, assign } from "xstate";
import { useSmithersMachine, taskOutput, approvalDecided, eventReceived, timedOut } from "smithers-orchestrator/xstate";

const releaseMachine = setup({}).createMachine({
  context: { revision: 0 },
  initial: "researching",
  states: {
    researching: { on: { RESEARCH_DONE: "awaitingApproval" } },
    awaitingApproval: { on: { APPROVED: "drafting", REJECTED: "researching" } },
    drafting: {
      // Declares which <Task> this re-enterable state drives, so the lint
      // can verify its id is derived from context, not a bare constant.
      meta: { smithersTaskId: ({ revision }) => `draft-r${revision}` },
      on: {
        REVISE: { actions: assign({ revision: ({ context }) => context.revision + 1 }) },
        TIMEOUT: "done",
      },
    },
    done: { type: "final" },
  },
});

function Release() {
  const ctx = useCtx();
  // Event sources are pure functions of ctx, never of machine state.
  const state = useSmithersMachine(releaseMachine, {
    id: "release",
    input: ctx.input,
    events: [
      taskOutput(researchSchema, { nodeId: "research" }, () => ({ type: "RESEARCH_DONE" })),
      approvalDecided(gateSchema, { nodeId: "gate" }, (d) => (d.approved ? { type: "APPROVED" } : { type: "REJECTED" })),
      eventReceived("REVISE", reviseSchema, {}, (p) => ({ type: "REVISE", feedback: p.feedback })),
      timedOut(reviseWaitSchema, { scope: "revise" }, () => ({ type: "TIMEOUT" })),
    ],
  });
  const rev = state.context.revision;

  return (
    <Workflow name="release">
      {state.matches("researching") && (
        <Task id="research" output={researchSchema} agent={researcher}>Research {ctx.input.topic}</Task>
      )}
      {state.matches("awaitingApproval") && (
        <ApprovalGate id="gate" output={gateSchema} onDeny="continue" request={{ title: "Continue to drafting?" }} />
      )}
      {state.matches("drafting") && (
        <>
          {/* Machine re-entry never re-executes a completed (nodeId, iteration):
              version task identity from machine context instead. The lint
              enforces this: a state the machine can re-enter must declare
              meta.smithersTaskId as a context-derived function, not a bare
              constant, so this pairing stays checked rather than
              convention-only. */}
          <Task id={`draft-r${rev}`} output={draftSchema} agent={writer}>Write the draft.</Task>
          {/* Wake-and-park plumbing: keeps the run alive and wakes a frame on
              delivery. The REVISE events themselves come from the durable
              signal table, not from this node's output. */}
          <WaitForEvent id={`revise-r${rev}`} event="REVISE" output={reviseWaitSchema} timeoutMs={86_400_000} onTimeout="continue" tagged async />
        </>
      )}
    </Workflow>
  );
}
```

External events are ordinary signals: `bunx smithers-orchestrator signal RUN_ID REVISE --data '{...}'`.
There is **no `send()`, no `onDone` prop, and no actor**.

## The fold

Each frame, `useSmithersMachine(machine, { id, input, events })`:

1. Collects events by evaluating each declared source against `ctx`.
2. Orders them by **(provenance seq, declaration index, mapped-event
   subindex)**: causal arrival order with deterministic tiebreaks, a total
   order that is a pure function of rows on every branch.
3. Folds: `initialTransition(machine, input)`, then
   `transition(machine, snapshot, event)` per event. Returned actions are
   discarded; builtin `assign` applies inside `transition`. Events not
   accepted in the current state are discarded, matching live-actor
   semantics. Events after the machine reaches a final state are discarded.
4. Returns the final `MachineSnapshot`; `state.matches()`, `state.can()`,
   `state.context`, `state.hasTag()` work as `@xstate/react` users expect.

Multiple machines per workflow are supported; give each `useSmithersMachine`
call a distinct `id`.

## Event sources

| Source                                                       | Reads                                                 | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ------------------------------------------------------------ | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `taskOutput(output, { nodeId?, scope? }, map)`               | Durable output rows via `ctx.outputRows`              | One `map` call per row; return an event, an array, or `null` to skip. "A durable row exists", not "the task completed": failed attempts write no row, and `(nodeId, iteration)` writes are upserts.                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `approvalDecided(output, { nodeId?, scope? }, map)`          | Approval decision rows                                | Denials persist a row only with `onDeny: "continue"`; a failing gate ends the branch and writes nothing.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `eventReceived(signalName, schema, { correlationId? }, map)` | Durable `_smithers_signals` rows via `ctx.signalRows` | Folds the signal table directly on the shared seq clock, so every delivered signal reaches the machine regardless of listener mount timing, including a signal delivered before its listener has ever mounted. `correlationId` scopes the fold to signals delivered with a matching id, required when the same signal name is used by more than one concurrent wait. Payloads failing `schema.safeParse` are skipped deterministically (same rows ⇒ same skips), so a malformed external delivery can never brick every future render of the run. Rewinding past a delivered signal makes the machine forget it, the same as output rows. |
| `timedOut(output, { nodeId?, scope? }, map)`                 | Tagged `{ kind: "timeout" }` rows                     | Requires the wait to opt into the tagged wait-result envelope (`tagged` + `onTimeout: "continue"`); pointing `timedOut()` at a non-tagged wait is a hard render-time error (`TASK_OUTPUT_SCHEMA_INVALID`), not a silent no-op.                                                                                                                                                                                                                                                                                                                                                                                                            |

All sources are pure functions of `ctx`; deriving a source's `nodeId` from
the fold's own output is circular and unsupported.

## Constraints (enforced at mount)

A mount-time lint runs per machine identity (hot reload re-lints) and rejects
machine features that cannot exist inside a durable fold, each with a typed
error naming the Smithers-native alternative. There is no escape hatch.

| Rejected                                                                  | Use instead                                                                                              |
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `invoke`                                                                  | A `<Task>`/`<Subflow>` writing an output row + `taskOutput()`                                            |
| `spawn` / `spawnChild` (including inside `assign`)                        | `<Task>` execution + `taskOutput()`                                                                      |
| `after`                                                                   | `<WaitForEvent timeoutMs tagged onTimeout="continue">` + `timedOut()`, or `<Timer>`                      |
| `raise`                                                                   | Model the step as machine states, or a real signal + `eventReceived()`                                   |
| `sendTo`                                                                  | Durable channels: outputs, approvals, signals                                                            |
| `emit`                                                                    | Derive observers from the returned snapshot                                                              |
| `enqueueActions`                                                          | Plain `assign`; side effects in tasks                                                                    |
| `stopChild` / `stop`                                                      | Stop rendering the task (unmounting is the durable equivalent)                                           |
| `cancel`                                                                  | `<WaitForEvent timeoutMs>` + `timedOut()`                                                                |
| Custom / `log` actions                                                    | Only `assign` runs in the fold; run side effects in tasks                                                |
| A bare-constant `meta.smithersTaskId` on a state the machine can re-enter | Derive the id from context: `meta: { smithersTaskId: (context) => ... }` minted from an `assign` counter |

Every callback in the fold (event mappers, context initializers, guards,
assigners, machine `output` functions) must be pure, side-effect-free, free
of time/randomness, and must not mutate inputs. A throwing mapper, guard, or
assigner (or a throwing context initializer) fails the render with a typed
`SmithersError` naming the machine and event/phase, rather than an anonymous
crash deep inside the fold.

## Listeners, re-entry, and final states

* **The signal table is the event source; listeners are wake-and-park
  plumbing.** A parked `<WaitForEvent>` matters for liveness: an idle
  machine state with nothing rendered makes the graph quiescent and ends the
  run. States awaiting external events should render a listener to keep
  the run parked and wake a frame on delivery. A missed wake delays the fold
  by one frame but never loses the event.
* **Machine re-entry never re-executes a completed Smithers task.** Any task
  rendered inside a machine state the machine can re-enter needs
  context-versioned identity (`draft-r${rev}` minted from an `assign`
  counter). A bare-constant task id in a state on a machine cycle deadlocks
  the machine: no new row, no new event, and the quiescent graph ends the run.
  The lint catches this for any state that declares `meta.smithersTaskId`: a
  re-enterable state (reachable back to itself through the machine's
  transition graph, whether a direct self-transition or a longer cycle) may only
  declare it as a function of context, never a bare string.
* **Final states are derived-only.** Smithers can finish while the machine is
  non-final (graph quiescent); a machine final state does not cancel
  in-flight tasks (stop rendering them instead); `snapshot.output` is not the
  run output; a failure-flavored final state does not fail the run.

## Durability and time travel

The fold's inputs are exactly the rows Smithers restores, so crash/resume
recomputes an identical machine state; rewinding to an earlier frame yields
the machine state those earlier rows imply; forked runs fold their copied
row history and diverge cleanly with their own signals.

One carve-out: an in-place payload replacement at the same
`(nodeId, iteration)` (manual `retry-task` of a completed node, HumanTask
reopen) keeps its seq but changes its payload, so folded history from that
position **reinterprets**. The in-process cache validates by content hash and
refolds automatically; workflows feeding machines from replaceable tasks
should prefer fork-and-migrate.

The prefix cache key is `(runId, machine id, reducer hash)`, where the
reducer hash is a content hash
of the machine (config + `setup()` implementations) plus every declared
event source (kind, target, options, `map`/`schema` source), so one process
hosting many runs (or a fork, which inherits its parent's row history and
machine `id` verbatim) never serves one run's snapshot to another, and a
mid-run machine or event-source edit can't reuse a fold computed under the
old reducer. The cache is bounded (LRU-evicted) so a long-running process
does not retain every historical snapshot. Mid-run machine edits ride the
`acceptWorkflowChange` gate: the reducer hash changing for an already-folded
`(runId, id)` logs a machine-history reinterpretation warning instead of
silently reinterpreting.

## Performance

Recomputing per frame is cheap in practice: an in-process prefix cache
validates folded history by content hash and folds only the new suffix each
frame. The CI benchmark (`packages/xstate/tests/fold-benchmark.test.js`)
folds 10,000 events on a parallel + nested-compound machine in well under a
second full-refold (\~20µs/event), and proves an incremental frame folds only
the new suffix by counting actual transitions executed (immune to machine
load), not by timing it; a wall-clock threshold is logged but kept
deliberately generous and non-strict, since it isn't the real gate. Practical
limit: tens of thousands of events per machine per run; beyond that, prefer
summarizing history into machine context via coarser events.

## Visualization

Machines are plain XState, so Stately Studio import and `xstate/graph` static
visualization work unmodified on the machine definition.
