Guide

Persistence & Recovery

Understand durable commits, storage, retention, and recovery.

Durability begins at one precise boundary: a handled macrostep becomes committed state. Everything before that boundary is an attempt. Everything after it is a recoverable fact or a recoverable intent.

One commit owns the durable boundary

A durable engine folds an admitted event into a deterministic next snapshot and serializable effect plan. One storage transaction then commits the facts that must agree:

Current headSnapshotThe configuration and data recovery resumes from.
Accepted stepDiary rowThe event, result, and provenance of this macrostep.
Planned workOutbox rowsEmits, child sends, Resource operations, and resolved timer sends.

Atomic commit All three become visible, or none of them do.

Only after that transaction commits may the engine enact the plan. The walkthrough below follows one Approve event across that line, then lets you place a crash on either side of it.

Attempt → commit → recovery

Put the crash on the correct side of commit.

Follow one handled event from proposed fold, through atomic commit, into recovery. The chart meaning stays fixed while the authority moves from process memory to durable storage.

1 · Plan one deterministic macrostep

Before commit, every result is proposed.

Approve selects Approved and plans ReceiptRequested. The snapshot, diary row, and outward intent agree in memory, but none is durable until storage accepts their transaction.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const Approve = Schema.TaggedStruct("Approve", { orderId: Schema.String });
const ReceiptRequested = Schema.TaggedStruct("ReceiptRequested", {
  orderId: Schema.String,
});

export class Order extends State.Compound<Order>()("Order") {
  static states = States.make(() => [AwaitingApproval, Approved]);
}

class AwaitingApproval extends State.Atomic<AwaitingApproval>()("AwaitingApproval") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Approve, Approved).emit(ReceiptRequested, ({ event }) => ({
      orderId: event.orderId,
    })),
  ]);
}

class Approved extends State.Atomic<Approved>()("Approved") {}

export const OrderChart = Statechart.make(Order);

2 · Cross one atomic storage boundary

State and intent commit together.

The cluster engine commits the next head, successful step, and outbox intent as one operation. Move the crash across COMMIT: before it, nothing advances; after it, both state and pending work recover.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { ClusterStatechartEngine } from "@motive/motive-cluster";
import { Effect, Layer, Schema } from "effect";

const Approve = Schema.TaggedStruct("Approve", { orderId: Schema.String });
const ReceiptRequested = Schema.TaggedStruct("ReceiptRequested", {
  orderId: Schema.String,
});

export class Order extends State.Compound<Order>()("Order") {
  static states = States.make(() => [AwaitingApproval, Approved]);
}

class AwaitingApproval extends State.Atomic<AwaitingApproval>()("AwaitingApproval") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Approve, Approved).emit(ReceiptRequested, ({ event }) => ({
      orderId: event.orderId,
    })),
  ]);
}

class Approved extends State.Atomic<Approved>()("Approved") {}

export const OrderChart = Statechart.make(Order);

export const OrderLive = OrderChart.toLayer({
  emits: { ReceiptRequested: () => Effect.void },
}).pipe(Layer.provideMerge(ClusterStatechartEngine.layer));

3 · Resume from committed authority

Recovery redrives intent, then settles it.

A replacement owner reads the committed head and unfinished outbox, then invokes the ordinary handler again. RecoveryReadiness opens only after a clean scan covers the current registration token.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { ClusterStatechartEngine, RecoveryReadiness } from "@motive/motive-cluster";
import { Effect, Layer, Schema } from "effect";

const Approve = Schema.TaggedStruct("Approve", { orderId: Schema.String });
const ReceiptRequested = Schema.TaggedStruct("ReceiptRequested", {
  orderId: Schema.String,
});

export class Order extends State.Compound<Order>()("Order") {
  static states = States.make(() => [AwaitingApproval, Approved]);
}

class AwaitingApproval extends State.Atomic<AwaitingApproval>()("AwaitingApproval") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Approve, Approved).emit(ReceiptRequested, ({ event }) => ({
      orderId: event.orderId,
    })),
  ]);
}

class Approved extends State.Atomic<Approved>()("Approved") {}

export const OrderChart = Statechart.make(Order);

export const OrderLive = OrderChart.toLayer({
  emits: { ReceiptRequested: () => Effect.void },
}).pipe(Layer.provideMerge(ClusterStatechartEngine.layer));

export const awaitOrderRecovery = Effect.gen(function* () {
  const recovery = yield* RecoveryReadiness.RecoveryReadiness;
  yield* recovery.awaitReady;
}).pipe(Effect.provide(OrderLive));

Before commit, there is only an attempt

The chart may decode the event, select an edge, compute Approved, and plan ReceiptRequested without making any of those results durable. They are a proposed macrostep until storage accepts the complete transaction.

A crash or storage failure before commit leaves no partially advanced head, successful diary row, or orphaned outbox intent. Malformed events, invalid folds, stale deliveries, and other refusals also do not become successful committed steps merely because the runtime attempted them.

Operational metadata may still explain why an attempt was retried, quarantined, or parked. That is a different authority from the chart diary: failure evidence does not rewrite an unsuccessful attempt as domain history.

After commit, fact and intent recover together

A crash immediately after commit may prevent the external handler from running. It cannot erase the agreement between the Approved head and its pending ReceiptRequested outbox row. Recovery can therefore resume from committed truth instead of guessing whether state advanced before the process disappeared.

The transaction stops at the system boundary. A committed outbox row means the intent survived; it does not mean the receiver completed it exactly once. Give external work stable identity and an idempotent receiver so redelivery can repeat one intent without creating another outcome.

Recovery reads committed facts

The cluster recovery scanner pages instances with unfinished outbox work and wakes their current owners. Each owner starts from the committed head, submits recoverable work through the ordinary handler protocol, and settles the durable outbox row when that attempt completes.

Recovery does not replay arbitrary process memory, reconstruct work that failed before commit, or restore data the storage platform deleted. It preserves the boundary that storage actually accepted.

Readiness is a fence, not settlement

RecoveryReadiness.awaitReady observes the current registration token and completes only after a clean recovery pass covers it. A degraded pass keeps readiness unavailable while the singleton scanner remains the retry authority.

That fence says the registered durable population has been surveyed. It does not promise every external effect has already settled. In the browser engine, the generation waits for this recovery fence and then runs beforeReady, where an application can add its own committed-state checks before clients receive the new generation.

Keep Recovering, Faulted, and Parked out of the Order topology unless they are genuinely domain states. Engine status explains whether the runtime can safely continue committed history; domain states explain the order.

Choose retention deliberately

The current head is the recovery authority and remains separate from optional history. Snapshot history is off by default. The step diary keeps all rows by default; a numeric stepRetention asks storage to retain the newest N rows per instance.

That bound is not always a hard maximum. Storage protects evidence still needed for message acknowledgement inside the redelivery lease and rows at or after the oldest live Activity's arming position. A bound of zero may remove ordinary old diary rows while preserving the current head and rows still required for correct recovery.

Retention changes what historical inspection can answer. An absent diary row may be outside the retained range or may never have committed; absence alone does not prove that a message was never accepted. Unfinished outbox work has its own lifecycle and is not discarded by a history bound.

Storage is part of the guarantee

Restart survival is only as strong as every storage service beneath the engine. A durable cluster needs durable statechart and Effect Cluster message storage. The browser topology additionally owns runner storage and one database authority for the generation.

SQL-backed storage services sharing one database connection must also share one transaction authority. Independent clients over the same connection create independent serialization contexts and break the premise behind one atomic per-step boundary.

Browser commits can survive generation replacement while remaining subject to quota, persistence grants, origin clearing, and device loss. Stronger preservation needs an explicit export, replication, or backup policy at the application's system boundary.

Change the world

  1. Crash before commit and list which proposed facts must remain invisible.
  2. Crash after commit but before ReceiptRequested settles and identify the recovery authority.
  3. Make the receipt endpoint idempotent using the emit handler's stable intent identity.