Statecharts

Causl models every async lifecycle — resource fetches, conflict resolution, transaction phases, interaction modes — as a statechart: a closed set of named states, a closed event vocabulary, and an explicit edge set drawn between them. The @causl/sync adapter implements two of these regions (ResourceFleet and ConflictRegistry) on top of the engine's Input / Derived primitives. The decision logic lives in pure reducers that the engine exposes through a single seam: BackendEngine.evaluateStatechart.

This page covers what a Causl statechart is, how to drive one from application code, what race rows it closes (S-1 stale-async, S-2 disposed-mid-load), and the two theorems — single-writer resolution and origin-bound resolution — that make this model auditable. See SPEC.async.md for the formal version; this page is the practitioner's tour.

Why a statechart at all?

A naive resource library reaches for a handful of booleans — isLoading, hasError, isStale — and lets the application figure out which combinations are valid. Every such combination is a state the library could be in but never explicitly declared. The first time a fetch resolves after an invalidate() call, the booleans disagree and a bug ships.

Causl takes the opposite stance: every lifecycle is a closed discriminated union, every transition is a named chart edge, and an illegal transition is a typed throw — not a silent write. The ResourceFleet sub-statechart has exactly five arms and a documented edge set; the ConflictRegistry sub-statechart has exactly four. There is no sixth state and there is no implicit edge.

Detailed explanation. The chart-by-construction discipline is SPEC.md §17 commitment 7: every legal transition is an edge on the composite chart in docs/lifecycle.md, and every illegal transition throws ForbiddenResourceTransitionError or ForbiddenConflictTransitionError rather than ship an enum tag whose transition is not specified by the chart. New behaviour finds an edge or it does not ship.

The ResourceFleet sub-statechart

A resource has five states. Four of them carry payloads the chart guarantees are present in that state; the fifth (idle) carries nothing.

// from @causl/sync type ResourceState<T> = | { state: 'idle' } | { state: 'loading'; origin: GraphTime; promise: Promise<unknown> } | { state: 'loaded'; value: T; origin: GraphTime; loadedAt: GraphTime } | { state: 'stale'; value: T; origin: GraphTime; loadedAt: GraphTime } | { state: 'errored'; error: unknown; origin: GraphTime; erroredAt: GraphTime }

The legal edges, taken straight from docs/lifecycle.md §1:

FromEventTo
*fetch-startloading
loadingfetch-resolve (clean)loaded
loadingfetch-resolve (guard hit)stale
loadingfetch-rejecterrored
loadedinvalidatestale
loaded | loadingfail(error)errored

Any other source state for failidle, stale, errored — throws ForbiddenResourceTransitionError. The chart has no Idle → Errored edge, so the adapter refuses to ship one.

1. Register a resource

import { createCausl } from '@causl/core'; import { resource } from '@causl/sync'; const graph = createCausl(); const user = resource(graph, 'user:42', { loader: async (origin) => { const res = await fetch(`/api/users/42`); return (await res.json()) as User; }, }); graph.read(user.node); // { state: 'idle' }

resource() allocates a single Input<ResourceState<T>> initialised to { state: 'idle' }. All later transitions are commits onto that one input — one chart edge per graph.commit, advancing GraphTime by exactly one tick.

2. Drive the chart

await user.fetch(); graph.read(user.node); // { state: 'loaded', value: { id: 42, ... }, origin: 0, loadedAt: 1 } user.invalidate(); graph.read(user.node); // { state: 'stale', value: { id: 42, ... }, origin: 0, loadedAt: 1 } await user.fetch(); graph.read(user.node); // { state: 'loaded', value: { id: 42, ... }, origin: 2, loadedAt: 3 }

The handle is intentionally small: fetch, invalidate, fail, plus the underlying node. Every method maps to one chart edge. Anything that is not a chart edge does not have a method.

Origin-bound resolution

The defining theorem of the resource adapter: a loading resource resolves with respect to the GraphTime at which its loader was invoked.

Theorem 1 — Origin-bound resolution. For every loading episode (node, origin) whose loader resolves at GraphTime loadedAt, the post-resolution ResourceState<T> is loaded if and only if origin === loadedAt, and stale otherwise. The disjunction is exhaustive — there is no third arm, no timeout-tolerance window, no "approximately equal" guard.

This is what makes the stale-async race — SPEC.md §9.1 row 6 — structural rather than heuristic. Concretely, when fetchOnce runs:

function fetchOnce() { const origin = graph.now; // GraphTime at fetch issue const loaderPromise = loader(origin); applyEvent('fetch:start', { kind: 'fetch-start', origin, promise }); const loadingAt = graph.now; // GraphTime immediately after the loading commit return loaderPromise.then(value => { // The reducer compares loadingAt against graph.now at resolve. // If anything advanced GraphTime in between, route to 'stale'. applyEvent('fetch:settle', { kind: 'fetch-resolve', value, loadingAt, ... }); return value; }); }

The guard is the equality check, full stop. There is no debounce, no exponential backoff, no per-key cancellation token. If any commit advanced GraphTime between loadingAt and the resolve, the transition is Loading → Stale. If none did, the transition is Loading → Loaded. The predicate is total over the GraphTime line.

Race rows the chart closes

The adapter contributes three rows to the master race-class catalogue in SPEC.async §9. Two of them — S-1 and S-2 — are race scenarios that compose multiple chart edges with an external event sandwiched between them.

S-1: stale-async (abandon-then-resume)

Scenario: the host issues fetch(), then calls fail() mid-load to abandon the first attempt, then issues a second fetch() before the first loader's Promise has settled. Without a guard, the first loader's late resolution would overwrite a state the host has already abandoned.

const r = resource(graph, 'feed', { loader: slowFetch }); const first = r.fetch(); // GraphTime 0 -> 1, origin = 0 r.fail(new Error('aborted')); // GraphTime 1 -> 2; state: 'errored' const second = r.fetch(); // GraphTime 2 -> 3; new origin = 2 // first finally settles here, but its loadingAt was 1. // graph.now is 3; 3 > 1, so the resolve routes to 'stale'. await first; graph.read(r.node); // { state: 'loaded', ... } from the second fetch, NOT the first.

No new mechanism is required: the row-6 staleness guard already covers this case structurally. Every chart edge in the trajectory — fetch-start, fail, fetch-start, fetch-resolve — is a chart-legal edge, and the GraphTime monotonicity invariant ensures the first loader's late settle lands as a stale write rather than as a silent overwrite. The commit log records both events truthfully.

S-2: disposed-mid-load

Scenario: a component mounts, issues a fetch, and then unmounts (or a hook's dependency keys change) before the loader has settled. React's renderer needs a defined answer for what happens when the next render of the same key starts a fresh loading episode against a graph that still holds the previous episode's loading arm. SPEC.async §10.4 names this row: a disposed loading resource whose Promise settles after disposal must not crash the engine and must not silently commit against a node the host has parked.

// React component using the resource function Profile({ userId }: { userId: string }) { const handle = useCauslResource(graph, `user:${userId}`, { loader: (origin) => fetchUser(userId), }); if (handle.state === 'loading') throw handle.promise; if (handle.state === 'errored') throw handle.error; return <UserView user={handle.value} />; }

When userId changes from '42' to '43' mid-load, the old episode's loader is still in flight. The chart guarantees:

The chart's closed edge set is what makes the disposed-mid-load case answerable rather than implementation-defined. There is no path from "disposal" to an off-chart write because every path runs through the same reducer, and the reducer consults only the current state and the chart edge set.

The ConflictRegistry sub-statechart

Conflict resolution is the second orthogonal region the adapter owns. Each tracked conflict id has four states — open, resolved, ignored, superseded — and three outgoing edges, all from open; the other three are terminal.

import { createConflictRegistry } from '@causl/sync'; const registry = createConflictRegistry(graph, { openSet: openConflictsDerived, // Derived<ConflictBase[]> }); registry.resolve(graph, 'conflict:7', { winner: 'remote' }); registry.ignore(graph, 'conflict:8'); registry.supersede(graph, 'conflict:9', 'conflict:10');

Single-writer resolution

The chart has no edge from Resolved back into Resolved — and no edge between any two terminals. A second resolve() on an already-resolved conflict throws ForbiddenConflictTransitionError rather than silently overwriting the first resolution's payload.

Theorem — Single-writer resolution. The registry tracks one resolution record per conflict id, not a list. A second resolve() call is the chart-named refusal — not a CRDT merge, not a last-writer-wins overwrite. Multi-writer reconciliation belongs above this layer, the same way SPEC.md §13.7 names it for the engine.

This is a deliberate trade-off. If your application needs commutative merging of independent resolution writes from multiple replicas, the conflict registry is the wrong primitive and a CRDT belongs above it. If your application needs one authoritative resolution per conflict id with a typed refusal on the second write, this is the primitive.

evaluateStatechart: the seam

Both sub-statechart reducers are pure: given (state, event, time, id) they return either the next state or a structural ForbiddenTransition record. They do not read the graph, they do not call graph.now, they do not capture closures.

The engine exposes the dispatch through a single seam on its BackendEngine interface:

// From @causl/core interface BackendEngine { // ... evaluateStatechart(input: StatechartInput): StatechartResult; }

The default JsBackend.evaluateStatechart dispatches structurally identical decision logic to the reduceResource / reduceConflict reducers in @causl/sync. The WASM backend (WasmBackend) delegates to the same canonical evaluator today; the post-0.9.0 Rust port replaces it with a Rust-side implementation under the engine-rs-core feature = "future" gate. The two implementations are kept byte-equivalent by a cross-backend determinism property test that runs in CI.

Detailed explanation. Why a seam? The reducers' decision logic is mechanical in the sense that translating it to another language is a direct restatement, not a redesign. Rust's zero-cost enums and exhaustiveness checks are the statechart region's natural home. Keeping the dispatch behind evaluateStatechart means the eventual Rust port is a translation rather than a rewrite, and adopters of @causl/sync do not see the backend swap — the public surface stays identical.

Why a Promise lives on the state

A render that throws a fresh Promise per render breaks SuspenseList's coordination and startTransition's deferral. The { state: 'loading', origin, promise } shape parks the in-flight Promise on the state itself, so every render that reads the node during a loading episode receives the same Promise identity.

The Promise carried on the loading arm always resolves — never rejects — even when the loader rejects. The loader's rejection still drives the resource to errored through the chart's fetch-reject edge; the next render reads the errored tag and the error boundary catches it. The Suspense-safe Promise just lets the renderer await cleanly without producing an unhandled-rejection warning when the chart wants to deliver the error through the state, not through the throw.

Where statecharts are the right tool — and where they aren't

Statecharts are the right tool when:

Statecharts are not the right tool when:

Related reading