<!-- source: openspec/current/state.md -->

---
title: State
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: XState v5 state-machine library — owns workflow modeling, Realtime integration, Server Action lifecycle, and persistence helpers.
---
# State — capability spec

> XState v5 state-machine library for the studio. Owns workflow modeling,
> Realtime integration, Server Action lifecycle, and persistence helpers.
> Consumed by `apps/studio` (canvas at v1; templates pilot in flight per
> RAP-128 PR-3); positioned for `apps/web` and future apps.

---

## Where state lives

- Source: `packages/state/`
- Package name: `@rapoport-studio/state`
- Version: `0.0.0`, `private: true`
- Entry surfaces: `./` (barrel), `./actors`, `./testing`
- Build: none. Consumers compile `.ts` directly via Next.js / vitest.
- Scripts: `lint`, `typecheck`, `test`
- Test runtime: vitest with `environment: "node"` for pure machine /
  helper tests; `"jsdom"` for `@xstate/react` hook tests when needed
  (consumers configure their own jsdom env — the package itself ships
  no jsdom setup since its primitives are environment-agnostic).

---

## Architecture

```
                ┌─────────────────────────────────┐
                │     @rapoport-studio/state       │
                │                                  │
  setup()  ◄────┤  src/setup.ts (re-exports)       │
                │                                  │
  actors:  ◄────┤  src/actors/                     │
                │    server-action.ts              │
                │    supabase-realtime-bridge.ts   │
                │                                  │
  helpers: ◄────┤  src/persistence.ts              │
                │  src/dev-inspector.ts            │
                │                                  │
  testing: ◄────┤  src/testing/                    │
                │    mock-actors.ts                │
                └─────────────────────────────────┘
                         │
                         ▼ consumed by (route-colocated)
                ┌─────────────────────────────────┐
                │  apps/studio/src/app/...         │
                │    (canvas)/spec/[id]/         │
                │      canvas-machine.ts           │
                │      machines/                   │
                │        chat.machine.ts (Wave 2)  │
                │        files.machine.ts          │
                │        instructions.machine.ts   │
                │        participants.machine.ts   │
                │        costBudget.machine.ts     │
                │    (default)/templates/[id]/     │
                │      prompt.machine.ts (PR-3)    │
                └─────────────────────────────────┘
```

The package is a **library of primitives**, not a registry of machines.
Domain machines live next to their consumer route (RAP-128 § Decision 14);
only cross-cutting actor sources live here.

---

## Authoring conventions

### Required `setup()` entry point

Machines must be defined via `setup({ types, actors, guards, actions })`
followed by `.createMachine(...)`. Direct calls to `createMachine` with
untyped objects are not used.

```ts
import { setup, assign } from "@rapoport-studio/state";
import { serverActionActor } from "@rapoport-studio/state/actors";

export const myMachine = setup({
  types: {} as { context: MyContext; events: MyEvent; input: MyInput },
  actors: {
    save: serverActionActor(saveAction),
  },
  guards: {
    isValid: ({ context }) => context.errors.length === 0,
  },
}).createMachine({
  id: "my-domain",
  initial: "idle",
  context: ({ input }) => ({ /* ... derived from input ... */ }),
  states: { /* ... */ },
});
```

The package re-exports `setup`, `assign`, `createActor`, `fromCallback`,
`fromPromise`, `sendTo`, `sendParent`, `raise`, `spawnChild`, `stopChild`,
plus the standard type aliases (`AnyEventObject`, `SnapshotFrom`,
`ContextFrom`, etc.) from `xstate`. **Consumers import xstate primitives
from `@rapoport-studio/state`, never from `xstate` directly** — this gives
a single seam for future cross-cutting wrappers (telemetry, logging) and
decouples consumers from the upstream version pin.

### File and naming

| Concern | Convention |
|---|---|
| File | `<domain>.machine.ts` (route-colocated; historical exception: existing `canvas-machine.ts` without dot) |
| Export | `<domain>Machine` |
| Context type | `<Domain>Context` |
| Event type | `<Domain>Event` discriminated union |
| Event `type` strings | `"DOMAIN.VERB"` (e.g. `"PROMPT.SAVE"`) |
| Actor source | `<verb><Subject>Actor` |
| State id | nominal lowercase |
| Test file | `<domain>.machine.test.ts` co-located |

### Initial state from RSC

Machine-backed client components receive initial state as a prop and feed
it to `useMachine` via `input`:

```tsx
"use client";
import { useMachine } from "@xstate/react";
import { canvasMachine } from "./canvas-machine";

export function CanvasView({ canvasId, initial, viewer }) {
  const [state, send] = useMachine(canvasMachine, {
    input: { canvasId, initial, viewer },
  });
  /* ... */
}
```

The machine consumes `input` in its initial context:

```ts
context: ({ input }) => ({
  canvasId: input.canvasId,
  files: input.initial.files,
  /* ... */
}),
```

This preserves RSC as the authoritative initial-state source. Machines
never refetch first paint.

---

## Actor sources

### `serverActionActor(action)` — Server Action wrapper

Wraps a typed Next.js Server Action as a `fromPromise` actor source.
Mutations and on-demand fetches both go through this helper.

```ts
import { serverActionActor } from "@rapoport-studio/state/actors";

const saveTemplate = serverActionActor(saveTemplateAction);

// in setup()
actors: { saveTemplate }

// in machine
states: {
  saving: {
    invoke: {
      src: "saveTemplate",
      input: ({ context }) => ({ content: context.content }),
      onDone: { target: "saved", actions: assign({ /* ... */ }) },
      onError: { target: "ready", actions: assign({ /* surface error */ }) },
    },
  },
}
```

Why a wrapper rather than calling `fromPromise` inline: keeps the
machine-side import surface uniform, and lets us add cross-cutting
behavior later (logging, telemetry, retry) in one place.

### `supabaseRealtimeBridge` — Realtime channel actor

Wraps a Supabase Realtime channel as a `fromCallback` actor. Configured
declaratively via `subscriptions[]`. Sends typed events back to the
parent machine; never owns state itself.

```ts
import { supabaseRealtimeBridge } from "@rapoport-studio/state/actors";

invoke: {
  id: "realtime",
  src: supabaseRealtimeBridge,
  input: ({ context }) => ({
    supabase: context.supabase,
    channelName: `canvas:${context.canvasId}`,
    subscriptions: [
      { table: "canvas_files",   filter: `canvas_id=eq.${context.canvasId}`, eventType: "REALTIME.FILE_CHANGED" },
      { table: "canvas_messages", filter: `canvas_id=eq.${context.canvasId}`, eventType: "REALTIME.MSG_INSERTED", event: "INSERT" },
    ],
  }),
},
```

Two synthetic events are always sent:

- `REALTIME.CONNECTED` — channel `subscribe()` reports `SUBSCRIBED`
- `REALTIME.DISCONNECTED` — channel reports `CLOSED` or `CHANNEL_ERROR`

Parent machines react to `DISCONNECTED` by entering a `reconnecting`
state that re-fetches authoritative initial data from the server, then
returns to `connected`. This is the studio-canonical resilience pattern
for Supabase Realtime's no-guaranteed-delivery semantics. Wave 2 of
RAP-128 wires this up at the canvas orchestrator level.

---

## Persistence

`makePersistedActor(machine, storageKey, options)` wraps `createActor`
with localStorage hydrate-on-create + persist-on-snapshot.

```ts
import { makePersistedActor } from "@rapoport-studio/state";

const actor = makePersistedActor(promptMachine, `prompt-draft:${id}`, {
  input: { templateId: id, initialContent: "" },
  shouldPersist: (snapshot) =>
    snapshot.matches("draft") || snapshot.matches("ready"),
});
```

Persistence is **opt-in per machine**, never default. The two v1
adopters are:

- `promptMachine` — survives refresh during template authoring (PR-3 of
  RAP-128)
- (future) Domain Modeling Wizard — survives refresh between steps

Live machines (anything backed by Realtime or DB) are **not** persisted —
the server is source of truth, and a stale snapshot would lie to the
user. `canvasMachine` falls into this category.

---

## Inspector

`attachInspector()` returns a `@statelyai/inspect` browser inspector when
`process.env.NODE_ENV === "development"`, otherwise `null`. Attached
once at app boot; machines created after attach are auto-tracked.

```ts
// apps/studio/src/instrumentation-client.ts
import { attachInspector } from "@rapoport-studio/state";
void attachInspector();
```

Production builds tree-shake the inspector via the dynamic-import
behind the env gate. Verified in RAP-128 PR-2 acceptance:

```bash
pnpm --filter @rapoport-studio/studio build
grep -rn "createBrowserInspector\|@statelyai/inspect" \
  apps/studio/.next/static/chunks apps/studio/.next/server 2>/dev/null \
  | grep -v "\.map$"
# Expected: zero matches.
```

Stately Studio (cloud) is **not** adopted. Local inspector covers
authoring + debugging needs without vendor lock.

---

## Relationship to other capabilities

| Capability | Relationship |
|---|---|
| `db` | Machines never import `@rapoport-studio/db` — Server Actions own DB access |
| `auth` | Machines never check auth — Server Actions enforce via `getUser()` |
| `muse` | `runMuseTurn` is invoked from inside `chat.machine.ts` (Wave 2) via `serverActionActor`; muse signature unchanged |
| `mirror` | Mirror authoring tool calls happen inside Muse runtime; machine layer doesn't see them at v1 |
| `studio` | Primary consumer. Templates surface (`promptMachine`) live since Wave 1 (2026-05-07). Canvas orchestrator (`canvasMachine` + `chat` / `instructions` / `participants` children + `supabaseRealtimeBridge`) live as of Wave 2A → 2B-ii (2026-05-09). See `studio-workspace/spec.md § Canvas state orchestration` for the shipped topology. |
| `web` | No consumer today; positioned for `/contact` form state machine if value emerges |

---

## Testing

### Pure machine tests — vitest, node environment

Test pattern: drive the machine via `createActor(machine).start()`, send
events, assert state and context.

```ts
import { createActor } from "@rapoport-studio/state";
import { promptMachine } from "../prompt.machine";

it("transitions draft → validating → ready on EDIT", async () => {
  const actor = createActor(promptMachine).start();
  actor.send({ type: "PROMPT.EDIT", content: "Hello {name}" });
  // ... await idle
  expect(actor.getSnapshot().value).toBe("ready");
  expect(actor.getSnapshot().context.variables).toEqual(["name"]);
});
```

`fromPromise` actors are mocked via `setup({ actors: { save: mockServerActionActor(...) } })`
in tests; `fromCallback` actors use `mockCallbackActor(...)`. Both helpers
live in `@rapoport-studio/state/testing`.

### Component tests — vitest, jsdom environment

Use `@xstate/react`'s `useMachine` directly in test components. Assert
on rendered DOM driven by machine state. The consuming app configures
the jsdom environment in its own `vitest.config.ts` (the package itself
stays node-only).

### Out of scope at v1

- `@xstate/test` model-based test generation — see
  `add-xstate-state-orchestration` archive § Open 1

---

## Out of scope

- Server-side machines (Cloudflare Worker runtime). Strong candidate is
  `acceptLeadAction`'s three-step non-atomic flow; CPU cost on Workers
  is unmeasured. Tracked as future change.
- Generated TypeScript from machine inspection (no `@xstate/cli`
  typegen — `setup()` provides full inference).
- Studio-wide event bus (cross-machine broadcast). v1 routes via parent
  machines. Revisit if noise emerges.
- Auto-generated Mermaid diagrams for OpenSpec proposals from machine
  source. Possible future ergonomic; not a v1 deliverable.

---

## Open questions

- **Server-side XState performance on Workers** — see
  `add-xstate-state-orchestration/design.md § Open 2`
- **`@xstate/test` adoption** — see same § Open 1
- **Cross-machine event broadcasting pattern** — see same § Open 3
- **Production debug overlay** — see same § Open 4

---

## Dependencies

### Runtime

| Package | Version | Used by |
|---|---|---|
| `xstate` | `^5.31.0` | core |
| `@xstate/react` | `^5.0.5` | hooks for React consumers |

### Peer (optional)

| Package | Used by |
|---|---|
| `react` (`^19`) | `@xstate/react` consumers |
| `@supabase/supabase-js` (`^2`) | `supabaseRealtimeBridge` |

Both peers are declared optional via `peerDependenciesMeta` so consumers
that only need `serverActionActor` / `makePersistedActor` / `attachInspector`
don't have to install them.

### Dev

| Package | Used by |
|---|---|
| `@statelyai/inspect` | dev inspector (dynamic import only) |
| `vitest`, `@testing-library/react` | tests |
| `eslint`, `typescript` | lint, typecheck |

### Internal

None. Leaf in the package graph (does not import any other
`@rapoport-studio/*`).

---

## Boundaries

Enforced by `eslint.config.mjs`. `state` is a leaf — may not import any
`@rapoport-studio/*` package. Apps and other packages may import from
`@rapoport-studio/state` freely.

---

## Realtime contract conformance (applied 2026-05-11 from change `add-frontend-runtime-standards`)

`supabaseRealtimeBridge` is the XState-side adapter for [`frontend-runtime.md § Layer 2 — Realtime auto-sync contract`](frontend-runtime.md). The bridge satisfies the observable obligations declared there:

| Obligation | Bridge binding | Status |
|---|---|---|
| Subscribe `(channel, scope)` | `subscriptions[]` config (`channel`, `event`, `filter`) | ✅ |
| Cache patch on event | parent machine assigns typed events to context | ✅ |
| Emit `CONNECTED` / `DISCONNECTED` | synthetic `REALTIME.CONNECTED` / `REALTIME.DISCONNECTED` events | ✅ |
| Reconnect-refetch | parent machine `reconnecting` state invokes loader actor, returns to `connected` | ✅ |
| Burst coalescing (~250 ms) | window in the bridge (RAP-270) | ✅ |
| Visibility-change refetch | (follow-up) | add `VISIBILITY.VISIBLE` handler that triggers same reconnect path |
| RLS-only authorisation | `filter` is a Postgres `WHERE`; no channel-side ACL | ✅ |

Tier constants (`STALE_REFERENCE` / `STALE_OPERATIONAL` / `STALE_HOT`) live in `packages/state/src/tiers.ts` (RAP-269) and are exported from the package barrel. Cross-stack name parity with the engagement-side TanStack Query adapter is intentional; see `frontend-runtime.md § Staleness tiers`.

When `realtime.broadcast_changes` triggers ship for HOT-tier domains (per `frontend-runtime.md § Layer 2 — Producer-side wiring`), the bridge's `subscriptions[].channel` field switches from `postgres_changes` to `broadcast` for those domains. The consumer-side observable contract is identical; only the transport changes.

**Subscribe boundary rule:** Realtime subscriptions are opened only from `'use client'` boundaries. Server Components MAY initial-fetch but MAY NOT call `.subscribe()`. Rationale: `supabase-js#1559` documented WebSocket initialisation race in Node-like runtimes (Workers SSR) — see `frontend-runtime.md § Layer 2 — Subscriber must live in a client boundary`.

---

## Action-workflow actors (applied 2026-05-20 from change `extract-actions-machine`)

Server-action workflows owned by a state machine MAY co-locate their `serverActionActor(...)` wrappers and action-domain types in a sibling module loaded by the consuming machine.

The shipped pattern lives at `apps/studio/src/app/(auth-walled)/(canvas)/spec/[id]/machines/actions.machine.ts`: the six non-turn canvas action actors (`promoteAction`, `convertAction`, `saveAllAction`, `openPRAction`, `saveArtifactActor`, `saveFileActor`) and their action-domain types (`ActionKind`, `SuccessAction`, `ActionPending`, `LastPromoteResult`, `ErrorKind`) are exported from this module and consumed by `chat.machine.ts`, which spreads the `actionActors` registry into `setup({ actors: { turnSseStream, ...actionActors } })` and keeps the six invoking state definitions inline.

This is the productive intermediate between (a) inline definition inside the machine file and (b) full sibling-machine extraction with `sendTo` plumbing. Use this pattern when actor wrappers and types belong to a coherent sub-domain (here: non-turn actions) but their state nodes still cross-mutate the parent machine's context fields, so `sendTo`-routed sibling machines are not yet justified.

**Promotion trigger** to a full sibling machine: a second consumer of the actor surface, or a refactor that cleanly separates the shared context fields (most likely as part of the planned RAP-1098 broadcast migration which already touches the canvas table set). Until then this is the canonical co-location.

Source: `openspec/archive/2026-05-20-extract-actions-machine/`.
