# Implicit state machines

> For agents: start with the [agent guide](https://ratstack.sh/llms.txt). Every page is Markdown by default; add `Accept: text/html` for HTML.

New on /news: [tla-ts 0.0.9 adds TLC's -continue](/news).

**Goal:** find the state machines your code already runs, and write them down.

## What David Khourshid said

[David Khourshid](/lore/david-khourshid), who teaches state machines with XState, posted this on 2026-11-09. [X](https://x.com/davidkpiano/status/2119788094434038264)

> Your apps are full of state machines, which is the logic that says "when we're in this state, and this event happens, now we're in this state" (given, when, then).
>
> Sometimes those state machines are clearly mapped out (switch statements, reducers, tables/maps, libraries like XState for the more complex ones, etc.) and the logic is explicitly enumerable.
>
> And sometimes it's not. The ad-hoc logic is spread all over the place. That's how developers typically code, and that's how agents typically vibe-code (they learned from you). Those are implicit state machines.
>
> What's wrong with implicit state machines? It's not clear what state transitions are possible, so you might have "allowed" states that should have been impossible without knowing it. Agents make the same mistakes.
>
> You can get all tests passing and your code review agents (even the well-funded ones) can say "LGTM" and you can *still* have very bad bugs because an implicit state machine allowed unexpected or illegal behavior.

In the same thread, he said what explicit logic buys. [X](https://x.com/davidkpiano/status/2119803034716766340)

> My point is one of clarity not correctness.

He added: [X](https://x.com/davidkpiano/status/2119804116515848262)

> Making implicit state machines explicit doesn’t guarantee correctness. It just makes the full logic visible and enumerable so you can actually enforce correctness (with types, tests, formal methods, etc) instead of shipping bugs that your ad-hoc implicit logic silently allowed.

## Our take

The rest of this page is ours.

rat-stack writes the [machine](/lore/lifecycles-are-machines) down whenever the states matter. It uses named states and events in XState, and side effects in declared [Effect](/lore/effect-basics) actors.

### Flags hide states

Three booleans allow eight combinations.

Before: an upload as flags; lines 1-5; highlighted none.

```typescript
export interface UploadFlags {
  readonly uploading: boolean;
  readonly failed: boolean;
  readonly done: boolean;
}
```

Only four combinations mean anything. Nothing stops `uploading` and `done` from both being true.

A tagged union names the states and makes the rest unrepresentable.

After: an upload as named states; lines 1-5; highlighted none.

```typescript
export type Upload =
  | { readonly state: "idle" }
  | { readonly state: "uploading" }
  | { readonly state: "failed"; readonly cause: string }
  | { readonly state: "done"; readonly url: string };
```

The type lists the states. It does not list the moves between them. A machine lists both.

### Worked example: a deploy poll

Before commit bb50d1a, our deploy checks polled for a new content version in a loop.

packages/deploy/src/checks.ts at caabc4420a2e94f8183be6eb09f37a8e881dd179; lines 364-364,370-382,411-412; highlighted 364,378-381,411. Title: Before: the states live in one condition and a sleep.

```typescript
  for (;;) {
    const verdict = yield* runCheck(
      "content-version",
      work.pipe(Effect.timeout(Math.max(1, remaining)))
    );

    attempts.push(verdict);
    const elapsed = (yield* Clock.currentTimeMillis) - startedAt;

    if (
      gateOutcome(verdict) === "pass" ||
      verdict.reason !== "content-version-mismatch" ||
      elapsed >= options.deadlineMs
    ) {
    yield* Effect.sleep(delay);
  }
```

This loop has four states, but none of them has a name.

- It is attempting while `runCheck` runs.
- It is waiting during `Effect.sleep`.
- It stops for three different reasons in one `if`: a pass, a failure that is not a version mismatch, or the deadline.

A reader has to trace the loop to learn which results retry and which stop.

The commit [bb50d1a](https://github.com/joelhooks/rat-stack/commit/bb50d1a9d3b2e82e2c837c63d0e6efe1b2e9ce16) moved both deploy polls into one machine.

packages/deploy/src/poll-machine.ts at bb50d1a9d3b2e82e2c837c63d0e6efe1b2e9ce16; lines 107-107,113-115,133-133,136-136,140-140,147-147,151-151,157-157,160-160,163-171; highlighted 136,147,157. Title: After: each exit is a named state.

```typescript
    initial: "attempting",
    states: {
      attempting: {
        invoke: {
            if (event.output.settled) {
                target: "settled",
            if (elapsedMs >= context.deadlineMs) {
                target: "deadlineReached",
            return {
              target: "waiting",
          src: "attempt",
      deadlineReached: { type: "final" },
      settled: { type: "final" },
      waiting: {
        invoke: {
          input: ({ context }) => ({ delayMs: context.delayMs }),
          onDone: { target: "attempting" },
          src: "wait",
        },
      },
```

The machine has four named states: `attempting`, `waiting`, `settled` and `deadlineReached`. Each result picks exactly one target. The result records `stoppedBy`, so a caller sees why polling ended.

The check now decides only what "settled" means. The machine owns the retry.

### What the explicit version enables

David's follow-up is the point. The machine is not correct because it is a machine. It is checkable because its moves are listed.

`packages/deploy/test/poll-machine.model.test.ts` runs both polls against a small reference model with generated observation sequences. It checks attempts, delays, the deadline and the verdict. See [model-based testing](/lore/model-based-testing) for the method.

### The fence keeps it explicit

Agents write the loop again because they learned it from us. A lint rule catches it.

`rat-stack-patterns/no-sleep-in-loop` fails any loop that yields `Effect.sleep`. Its message reads:

> This loop yields Effect.sleep. A hand-rolled retry or polling loop hides its states, cancellation, and resumption. Fix: move the loop into an XState lifecycle machine with declared `createEffectLogic` actors.

The rule landed in [47308be](https://github.com/joelhooks/rat-stack/commit/47308bebc9d28890b76bac733f117a9b304d781f), three hours before the polls moved. See [the fence](/lore/the-fence) for how rules like this stay on.

## Next

Build one with [the add-a-lifecycle-machine skill](/skills/add-a-lifecycle-machine). Read [lifecycles are machines](/lore/lifecycles-are-machines) for the inspection machine, the reference shape.

## Sources

1. [David K 🎹 on X: "What is an implicit state machine?" / X](https://x.com/davidkpiano/status/2119788094434038264)
   David Khourshid. Post of 2026-11-09; quoted for the definition of implicit state machines and why they hide impossible states. Accessed 2026-11-09.

2. [David K 🎹 on X: "My point is one of clarity not correctness." / X](https://x.com/davidkpiano/status/2119803034716766340)
   David Khourshid. Reply in the same thread; quoted for clarity versus correctness. Accessed 2026-11-09.

3. [David K 🎹 on X: "Making implicit state machines explicit doesn’t guarantee correctness." / X](https://x.com/davidkpiano/status/2119804116515848262)
   David Khourshid. Follow-up in the same thread; quoted for what explicit logic makes enforceable. Accessed 2026-11-09.

4. [Deploy checks before the poll machine, at caabc44](https://github.com/joelhooks/rat-stack/blob/caabc4420a2e94f8183be6eb09f37a8e881dd179/packages/deploy/src/checks.ts)
   GitHub joelhooks/rat-stack. Pinned source for the hand-rolled polling loop. Accessed 2026-11-09.

5. [refactor(deploy): deploy polling loops run on one lifecycle machine](https://github.com/joelhooks/rat-stack/commit/bb50d1a9d3b2e82e2c837c63d0e6efe1b2e9ce16)
   GitHub joelhooks/rat-stack. The commit that replaced both polling loops with one machine; pinned source for the machine excerpt. Accessed 2026-11-09.

6. [rat-stack/packages/deploy/test/poll-machine.model.test.ts at main · joelhooks/rat-stack · GitHub](https://github.com/joelhooks/rat-stack/blob/main/packages/deploy/test/poll-machine.model.test.ts)
   GitHub joelhooks/rat-stack. Property tests that compare both polls with a reference model. Accessed 2026-11-09.

7. [lint: require machines for sleeping runtime loops](https://github.com/joelhooks/rat-stack/commit/47308bebc9d28890b76bac733f117a9b304d781f)
   GitHub joelhooks/rat-stack. Adds rat-stack-patterns/no-sleep-in-loop; used for the rule and its message. Accessed 2026-11-09.

## Lore on this page

- [Lifecycles are machines](/lore/lifecycles-are-machines)

## Linked from

- Agent guide → An Effect stack so pure (aspirational) Kit Langton will blush. → [Read page](https://ratstack.sh/llms.txt)

- Change log → What changed in the files served here, newest first. → [Read page](https://ratstack.sh/log)

- David Khourshid → David Khourshid teaches state machines with XState and argues for model-based and property-based testing. → [Read page](https://ratstack.sh/lore/david-khourshid)

- Full agent guide → An Effect stack so pure (aspirational) Kit Langton will blush. → [Read page](https://ratstack.sh/llms-full.txt)

- Lifecycles are machines → XState owns finite lifecycle states while declared Effect actors own side effects. → [Read page](https://ratstack.sh/lore/lifecycles-are-machines)

- Rat Stack lore | rat-stack → Short, source-grounded notes on the ideas and decisions behind rat-stack. → [Read page](https://ratstack.sh/lore)

- Source change history (log.md) → The generated change log as Markdown. → [Read page](https://ratstack.sh/log.md)
