Add a lifecycle machine

A lifecycle machine20 defines named states and the events that permit movement between them. Effect describes work with typed failures36 and required dependencies. A service is a named interface for one job. Schema defines accepted values and their runtime checks.

Copy packages/core/src/inspect-machine.ts when the work has states that matter. Keep a direct Effect when it does not.

Before changing Effect or XState code, read node_modules/effect/AGENTS.md. Then read the pinned XState and @xstate/effect source listed in AGENTS.md.

1. Write the states first

List the states and final results before writing the machine.

For file inspection, the states are reading, inspected, and unreadable. The final result is either Inspected with file stats or Unreadable with a FileStatsError.

Use an explicit result union when a known error should move the machine into a final state.

2. Put side effects in actors

Define each side effect with fromEffect outside the machine:

const performWork = fromEffect({
  effect: ({ input }) => ThingService.use((service) => service.run(input.id)),
  schemas: { input: Schema.Struct({ id: Schema.String }) },
});

An actor is a running unit that processes events or performs work for a machine. These actors carry typed errors and service dependencies. Declare them through setupEffect. Do not return an Effect from an inline XState callback.

3. Build the machine

Pass the actors and schemas to setupEffect, then call createMachine:

type ThingOutcome =
  | { readonly _tag: "Succeeded"; readonly value: ThingResult }
  | { readonly _tag: "Failed"; readonly error: ThingError };
interface ThingContext {
  readonly id: string;
  readonly outcome: ThingOutcome | undefined;
}

export const thingMachine = setupEffect({
  actors: { performWork },
  schemas: {
    context: types<ThingContext>(),
    input: Schema.Struct({ id: Schema.String }),
  },
}).createMachine({
  context: ({ input }) => ({ id: input.id, outcome: undefined }),
  initial: "working",
  output: ({ context }) => context.outcome,
  states: {
    working: {
      invoke: {
        src: "performWork",
        input: ({ context }) => ({ id: context.id }),
        onDone: {
          target: "succeeded",
          context: ({ context, event }) => ({
            ...context,
            outcome: { _tag: "Succeeded", value: event.output },
          }),
        },
        onError: {
          target: "failed",
          context: ({ context, event }) => ({
            ...context,
            outcome: { _tag: "Failed", error: event.error },
          }),
        },
      },
    },
    succeeded: { type: "final" },
    failed: { type: "final" },
  },
});

XState owns states and moves between them. Effect owns side effects, errors, services, and cleanup.

4. Run it inside Effect

Start the machine with createEffectActor. Do not use XState’s createActor. Hand the actor to watchActor from @rat-stack/capability/actor-watch, then wait for it with join inside Effect.scoped:

export const runThingMachine = Effect.fn("runThingMachine")(function* (
  id: string
) {
  const actor = yield* createEffectActor(thingMachine, { input: { id } });
  yield* watchActor("thingMachine", actor);
  // @effect-diagnostics-next-line anyUnknownInErrorContext:off -- join exposes unknown machine failures; unexpected failures become defects with orDie.
  const outcome = yield* join(actor).pipe(Effect.orDie);
  if (outcome === undefined) {
    return yield* Effect.die(new Error("machine completed without an outcome"));
  }
  if (outcome._tag === "Failed") {
    return yield* outcome.error;
  }
  return outcome.value;
}, Effect.scoped);

watchActor does nothing until devtools runs the capability. Then rat_list_actors, rat_list_transitions, and rat_get_actor show every state and event. rat-stack-patterns/watch-effect-actors fails lint when a runtime module starts an actor without it.

A missing result or machine-level error is a bug in this design. A known product error belongs in the final result. join has an unknown machine-error channel, so the example uses one narrow diagnostic override before Effect.orDie.

Call the runner from the capability handler, the server-side implementation of one named action. Provide its service Layer in apps/cli/src/cli.ts. A Layer builds services and supplies their construction dependencies.

5. Generate inputs and command histories

Add a property and a model test with each lifecycle. Use effect/Arbitrary and @effect/vitest. Do not add a separate generator dependency.

Copy the complete test template at skills/add-a-lifecycle-machine/templates/lifecycle.model.test.md into packages/core/test/<name>.model.test.ts. It runs unchanged for inspectMachine. Replace its schemas, service, outcomes, and commands for your lifecycle. The executable reference is packages/core/test/inspect-machine.model.test.ts.

it.effect.prop supplies virtual time. For delayed work, add a wait command and use TestClock.adjust from effect/testing:

if (command.kind === "wait") {
  yield * TestClock.adjust(command.millis);
}
assertAgainstModel(actor.getSnapshot(), model);

Model the deadline independently. Check before, at, and after it. Never use wall-clock sleeps to wait for retries or deadlines.

Before trusting the property, temporarily break a lifecycle rule in the machine. Run the new tests and record the failing shrunk input and replay token. Restore the machine, then prove the tests pass. Keep mutation evidence in the packet or review report. Keep it out of production code.

The xstate-effect/no-inline-effect rule blocks inline Effect logic. Fix the code instead of disabling the rule.

6. Finish

pnpm turbo run check test build

Last changed 2026-10-07 in f1a00f2. Source on GitHub. Change log.