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 indocs/lifecycle.md, and every illegal transition throwsForbiddenResourceTransitionErrororForbiddenConflictTransitionErrorrather 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:
| From | Event | To |
|---|---|---|
* | fetch-start | loading |
loading | fetch-resolve (clean) | loaded |
loading | fetch-resolve (guard hit) | stale |
loading | fetch-reject | errored |
loaded | invalidate | stale |
loaded | loading | fail(error) | errored |
Any other source state for fail — idle,
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 GraphTimeloadedAt, the post-resolutionResourceState<T>isloadedif and only iforigin === loadedAt, andstaleotherwise. 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 new
fetch-starton the new key allocates a newInputwith a freshorigin; the old key's input is unchanged. - When the old loader settles, its
fetch-resolveevent reaches the reducer withloadingAtfrom the disposed episode. The reducer reads the current state of the old input: if disposal has parked it (or if any commit advanced GraphTime pastloadingAt), the transition routes tostale, notloaded. - The carried Promise on the
loadingarm always resolves — never rejects — so React's renderer canawaitit without producing an unhandled-rejection warning, even after disposal.
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 behindevaluateStatechartmeans the eventual Rust port is a translation rather than a rewrite, and adopters of@causl/syncdo 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:
- Your lifecycle has a small, closed set of states and a finite event vocabulary.
- Off-chart writes (states whose transitions you cannot name) are bugs.
- Race rows between async events and engine commits need a structural — not heuristic — resolution.
Statecharts are not the right tool when:
- You need commutative multi-writer merging (use a CRDT above the registry).
- You need timeouts, exponential backoff, or retry policies on the resource
loader itself (compose those above
resource(); the chart deliberately does not implement them). - Your "lifecycle" is actually a continuous numeric signal — a derived node is the right primitive there, not a five-arm DU.
Related reading
- Documentation index
- Async resources
- Conflict resolution
- API reference —
@causl/sync - SPEC.async.md — the formal specification, including the full race-class catalogue and the property tests that gate it
- SPEC.md §6 (composite chart), §9.1 (race-class catalogue), §17 commitment 7 (chart-by-construction)