Async Operations and Sync

The Causl engine is deliberately synchronous: time only advances through graph.commit(), and outside a commit the graph is read-only. That property is what makes Phase A–H deterministic and what lets the static checker prove things about recompute order. But real applications fetch data, and a fetch is the prototypical thing the engine cannot directly express — it spans wall-clock time, can interleave with other commits, and can fail.

@causl/sync is the adapter package that bridges that gap. It models an async fetch as an external Event source whose lifecycle is observed by an InputNode, and surfaces the result as a tagged discriminated union the type system can narrow over. The engine stays synchronous; the package translates loader callbacks into commits.

This page covers what you need to use it correctly: the resource lifecycle state machine, the staleness guard, Promise identity for Suspense, the conflict registry, the two adapter race rows the property suite guards, and the whyUpdated / whyNotUpdated decoders.

Why async needs its own primitive

You could write graph.set(node, await fetch(...)) from inside a handler and call it a day. The reason not to is that the world can move between the moment the fetch starts and the moment it resolves — a second fetch can fire, an input that the loader depended on can be edited, the resource can be disposed. Without a structured lifecycle, each of those cases turns into an ad-hoc bug at the call site.

@causl/sync names the relevant states explicitly, attaches the originating GraphTime to each fetch, and routes the “late resolve” case to a distinct stale tag rather than silently overwriting newer data. That moves the entire family of fetch races from runtime surprises to compile-time tag checks.

The resource lifecycle

A resource is registered with resource(graph, key, options). It allocates an InputNode<ResourceState<T>> initialised to { state: 'idle' } and returns a handle that drives transitions through one-shot commits.

import { createCausl } from '@causl/core'; import { resource } from '@causl/sync'; const graph = createCausl(); const items = resource<Item[]>(graph, 'items', { loader: async () => fetch('/api/items').then((r) => r.json()), }); const data = await items.fetch(); const snapshot = graph.read(items.node); // snapshot.state is one of: 'idle' | 'loading' | 'loaded' | 'stale' | 'errored'

The five state tags correspond to the ResourceFleet sub-statechart inside Causl's composite lifecycle (SPEC.async §6). Every transition lands as exactly one engine commit, so each fetch advances GraphTime by a known number of ticks: one for loading, one for the resolution (loaded or stale) or rejection (errored).

1. Reading state — narrow before you touch .value

ResourceState<T> is a discriminated union, not an object with optional fields. Reading .value requires narrowing on .state first — the type system rejects code that tries to read the value before the loader resolved.

const s = graph.read(items.node); switch (s.state) { case 'idle': return ui.placeholder(); case 'loading': return ui.spinner(s.promise); // s.promise typed only on this arm case 'loaded': return ui.list(s.value); // s.value typed only on this arm case 'stale': return ui.list(s.value, { dim: true }); case 'errored': return ui.error(s.error); }
Detailed explanation: why tags instead of optional fields. Every “X may or may not have Y” field is a state machine in disguise. ResourceState surfaces the machine as the type so tsc catches the “read value before loaded” bug before it ships. The same principle drives the Conflict<T> shape further down this page.

2. The transitions and their commits

Each handle method produces exactly one transition per outcome. The intent label on the commit is set per chart edge so the whyUpdated decoder can name the reason later without consulting prev and next:

EdgeTriggerIntent label
Idle → Loadingfetch()fetch:<key>:start
Loading → Loadedloader resolves, no driftfetch:<key>:loaded
Loading → Staleloader resolves, drift detectedfetch:<key>:stale
Loading → Erroredloader rejectsfetch:<key>:error
Loaded → Staleinvalidate()invalidate:<key>
Loading | Loaded → Erroredfail(error)fail:<key>

Transitions not in this table are not in the chart. Calling fail() from idle, stale, or errored throws ForbiddenResourceTransitionError rather than silently writing the errored tag. Calling invalidate() outside of loaded is a chart-named no-op — the reducer returns the input state unchanged and the adapter skips the commit, so GraphTime does not advance.

3. The staleness guard

The classic async-resource bug is the stale-async race: fetchA starts, fetchB starts later, fetchA resolves last — and overwrites the fresher data. The engine cannot prevent the race (the loaders run outside its control), but it can make the response defined. When the loader resolves, the adapter compares the GraphTime captured at fetch() start (origin) against graph.now. If anything has committed in the meantime, the transition is Loading → Stale, not Loading → Loaded:

const a = items.fetch(); // origin = T0, state: 'loading' graph.commit('unrelated', tx => tx.set(otherInput, 7)); await a; graph.read(items.node); // { state: 'stale', value: ..., origin: T0, loadedAt: T2 }

The same shape covers a fetch that resolved after a later fetch already finished — the older origin is no longer authoritative. Opt out per-resource for last-writer-wins semantics:

resource(graph, 'items', { loader, stalenessGuard: false });

This is race row S-1 from SPEC.async §9.1.1: stale-async resolution. The witness is at packages/sync/test/properties/race-row-S-1.property.test.ts and runs at least 1000 random (commit | fetch-start | fetch-resolve | fetch-reject) sequences per CI build; failing seeds are committed as regressions.

4. Promise identity stability

The loading arm carries the in-flight promise on the state itself: { state: 'loading', origin, promise }. This is what React's Suspense renderer throws when a component reads a not-yet-loaded resource, and identity matters: a fresh Promise per render breaks SuspenseList and startTransition's ability to deduplicate awaits.

SPEC.async §3.1 Theorem 3 commits to Promise identity stability: every read of the same loading episode returns the same promise reference under Object.is. The witness is theorem-3-promise-identity-stability.test.ts.

Detailed explanation: settle-only-resolve. The promise the adapter carries on the loading arm is constructed as loaderPromise.then(() => undefined, () => undefined). It always resolves, never rejects, so React throwing it never produces an unhandled-rejection warning. The original loader rejection still drives the resource to errored and re-throws to the caller of fetch(); the next render reads the errored tag and an error boundary catches it. The Suspense-safe promise is a wrapper, not a replacement.

The conflict registry

Not every async operation succeeds. When a resource lands in errored, when two writers disagree about the same key, or when an offline sync surfaces a server-side rejection, you need a UI surface to display the conflict and a typed protocol for resolving it.

The conflict registry is a derived view over engine primitives. The runtime universe contains only InputNode and DerivedNode; “conflict” is a role, not a permanent node kind. Two pieces compose:

  1. An application-supplied derived that emits the currently-open conflict set.
  2. An Input<ReadonlyMap<...>> mapping conflict id to a resolution record.

The exported registry is itself a derived node that overlays the resolution map onto the open set:

import { createConflictRegistry, singleConflictWhen } from '@causl/sync'; const conflicts = createConflictRegistry<Item[]>(graph, { id: 'conflicts', compute: singleConflictWhen( items.node, (s) => s.state === 'errored', () => ({ id: 'items-errored', target: items.key }), ), }); for (const c of conflicts.read(graph)) { switch (c.kind) { case 'open': ui.show(c.target, c.value); break; case 'resolved': ui.audit(c.id, c.resolution, c.resolvedAt); break; case 'ignored': ui.dim(c.id, c.ignoredAt); break; case 'superseded': ui.link(c.id, c.supersededBy, c.supersededAt); break; } }

The discriminator is kind — the open arm carries value and raisedAt, the resolved arm carries resolution and resolvedAt, and so on. Each variant carries exactly the fields the ConflictRegistry sub-statechart guarantees in that state. The same “optional-fields-are-state- machines” principle as ResourceState: there is no c.resolution !== undefined runtime check left for callers.

Three legal transitions out of Open

The states are Open | Resolved | Ignored | Superseded; the latter three are terminal. The mutators each commit one tick:

conflicts.resolve(graph, 'items-errored', { choice: 'use-cache' }); conflicts.ignore(graph, 'items-errored'); conflicts.supersede(graph, oldId, newId);

Targeting a non-Open conflict throws ForbiddenConflictTransitionError. Shipping a transition that is not in the chart is the SPEC.md §17.7 anti-pattern this package was written against — a silent no-op there would let adopters reason inconsistently about the state space.

Race row S-2 — open-set drift mid-resolution

The second adapter race the property suite guards is open-set drift: while a resolve() mutator is running, the application's open-set compute can change (an upstream input edits, a derived recomputes), and the seam between the requireOpen guard and the resolution-Input commit must remain atomic. The witness at packages/sync/test/properties/conflict-registry-drift.property.test.ts fuzzes randomized open-set-source mutations across that seam and asserts the §5 atomicity contract holds: the resolution record stamps the same GraphTime the guard read, and the post-resolve now is exactly one tick past it. The drift seam is closed structurally.

Disposal and post-dispose loader resolution

A resource can be disposed (released by the host) while a fetch is in flight. The adapter must not write the resolved value back to a disposed node — that would resurrect state the host believed gone. The witness at packages/sync/test/spec-async-10-4-disposed-mid-load.test.ts asserts the §10.4 invariant: a loader that resolves after dispose is a no-op against the disposed node, and subsequent reads surface a dispose tombstone rather than the resolved value. The adjacent property test at disposed-mid-load.property.test.ts extends the resource state with a terminal disposed tag in the model and verifies that no event sequence after dispose mutates the state. Note that disposed is engine-level node disposal — the ResourceState<T> union itself has five arms; disposal lives one level below it.

Decoding updates: whyUpdated and whyNotUpdated

Subscribers see commits flow past. To classify why a particular commit updated a resource — for devtools, telemetry, or higher-level sync orchestration — SPEC.async §11.1 ships two pure decoders.

whyUpdated(commit, prev, next): ResourceUpdateReason

Returns one of seven closed reasons. The decoder reads the commit's intent label (which the resource adapter set per chart edge) and maps it to the matching tag; prev and next are accepted in the signature so a future schema bump that needs cross-arm disambiguation does not break adopter call sites.

import { whyUpdated } from '@causl/sync'; graph.subscribe(items.node, (next, prev, commit) => { const reason = whyUpdated(commit, prev, next); // 'fetch-begin' | 'fetch-resolved' | 'fetch-stale' | 'fetch-rejected' // | 'invalidated' | 'failed' | 'dep-changed' telemetry.record({ key: items.key, reason }); });

The runtime tuple RESOURCE_UPDATE_REASONS is the value-level dual: TypeScript erases types at runtime, so a devtools panel that wants to render every possible reason reaches for the frozen tuple rather than reconstructing the enum from string literals.

whyNotUpdated(prev, next): WhyNotUpdatedReason | null

The dual: when a subscriber didn't see an update, this decoder names why. There are two named reasons; null is returned only when the resource actually did transition (so the “why not” question has no answer):

import { whyNotUpdated } from '@causl/sync'; const before = graph.read(items.node); graph.commit('unrelated', tx => tx.set(otherInput, 7)); const after = graph.read(items.node); whyNotUpdated(before, after); // 'no-dep-overlap' — the commit didn't touch items.node

Both decoders are pure: same inputs produce same outputs, no graph reads, no side effects. They compose with subscribeCommits or commitMetadataDerived at the application layer. A future iteration lifts them into derived nodes via the Phase F.5 seam; this module ships the synchronous helper API first.

Bundle budget

@causl/sync ships with per-primitive sub-imports so adopters pay only for what they use. Three CI-gated size-limit ceilings:

ImportCeiling
@causl/sync (full barrel)12 KB
@causl/sync/resource8 KB
@causl/sync/conflict8 KB
import { resource } from '@causl/sync/resource'; import { createConflictRegistry } from '@causl/sync/conflict';

A PR that crosses a ceiling fails the bundle-size gate. The §14.2 narrative pins the trade-off: a smaller bundle buys a smaller install footprint; the ceiling rules out unilateral growth.

When async/sync is the right tool — and when it isn't

@causl/sync is the right fit when you have multiple async resources whose results can interleave, when staleness matters (an old result must not overwrite a new one), and when you want compile-time narrowing instead of optional-field discipline. Pair it with @causl/react's useCauslNode for Suspense integration and the Promise-identity guarantee buys you stable SuspenseList ordering.

It is not a replacement for a fetch library: there is no caching, no deduplication of identical requests across keys, no retry policy. Those live in your loader. @causl/sync's contribution is the lifecycle and the race-row coverage; the network stays your problem.

Next: see the API reference for the full ResourceHandle and ConflictRegistry surfaces, or move on to the persistence guide for snapshotting graphs that contain live resources.