Error Handling
Causl makes failure modes structural: every error you can encounter on the public surface is a typed class with a discriminator tag, the engine's atomicity contract guarantees the graph stays byte-identical on any throw mid-commit, and observer failures are isolated through a hook that cannot abort the commit pipeline. This page covers what throws where, what gets rolled back, and how to handle each error class.
Reading order. If you have not yet read Transactions, that page is the prerequisite — most of the rollback contract makes sense only once you've internalised the Phase A–H pipeline (SPEC §5.1). This page assumes that vocabulary.
1. The error hierarchy
Every error the engine itself throws extends CauslError. That gives you one instanceof check to distinguish engine-emitted failures from host-thrown errors that surfaced through observers or user computes:
import {
CauslError,
NodeDisposedError,
CycleError,
CommitInProgressError,
StaleTxError,
NotAnInputNodeError,
} from '@causl/core'
try {
graph.commit('user-action', tx => {
tx.set(someInput, nextValue)
})
} catch (err) {
if (err instanceof CauslError) {
// Engine-emitted: branch on err.kind or instanceof for the specific class.
handleEngineError(err)
} else {
// Host-thrown: came from a user compute or a tx.set callback body.
throw err
}
}
Sub-classes that participate in a discriminated union also carry a readonly kind string tag — 'NodeDisposed', 'HydrationSchema', 'NonDeterministicCompute', etc. — so an exhaustive switch (err.kind) narrows correctly.
2. Compute failures during derive
A derived(graph, compute) body is a pure function: it reads upstream values through the tracked get accessor and returns a new value. The engine invokes it during Phase B, Phase D (the Kahn topological recompute fixpoint), and Phase F.5. A throw from any of these calls is a compute failure: the engine catches it, restores the graph to its pre-commit state (see §3), then re-throws the original exception to the graph.commit(...) caller. You see exactly the value your derive threw, not a wrapper:
import { createCausl, input, derived } from '@causl/core'
const graph = createCausl()
const denominator = input(graph, 1)
const reciprocal = derived(graph, g => {
const d = g.read(denominator)
if (d === 0) throw new Error('divide by zero')
return 1 / d
})
try {
graph.commit('zero', tx => {
tx.set(denominator, 0)
})
} catch (err) {
// err.message === 'divide by zero' — the *user* error, unwrapped.
console.log(graph.read(denominator)) // 1 — rolled back
console.log(graph.read(reciprocal)) // 1 — rolled back
}
Common shapes a compute body can throw: domain validation (preconditions that must hold for the derive to be meaningful — denominator non-zero, lookup key present); NonDeterministicComputeError raised by the engine itself when experimentalFlags: { assertDeterministicCompute: true } detects that a compute returned a different value on a second invocation against the same dep snapshot (SPEC §15.1 — a dev gate, production leaves it off); and DerivedRegistrationStackOverflowError, a defensive guard for stack exhaustion that remains as residual coverage alongside the iterative registration driver.
3. Rollback semantics (Phases B–F.4)
A commit is atomic by construction. SPEC §5.2 makes this a structural claim, not an aspirational one: the engine catches any throw from Phases B through F.6 in a single arm at the bottom of the commit body and restores byte-identical pre-commit state across every mutable field — input cells (via the inputRollbackEntries parallel array captured in Phase B), derived state (via the derivedRollback map Phase D and Phase F.5 share), commitHistory (Phase F's push undone), commitLogEntry.value (Phase F.4's refresh reverted), and now (Phase C's increment rolled back).
After the catch arm runs, no subscriber has fired. Phases G and H sit outside the try-catch's mutation envelope, so they never execute on the failure path. From the perspective of every external observer, the failed commit never happened:
const graph = createCausl()
const a = input(graph, 1)
const b = input(graph, 2)
const callsToObserver: number[] = []
graph.subscribe(a, v => callsToObserver.push(v))
const before = graph.now
try {
graph.commit('mixed-write-throw', tx => {
tx.set(a, 10) // staged; would have changed
tx.set(b, 20)
throw new Error('user aborts')
})
} catch {
// Engine state is byte-identical to before.
console.log(graph.read(a)) // 1
console.log(graph.read(b)) // 2
console.log(graph.now) // === before
console.log(callsToObserver) // [1] (only the initial fire, no commit-time fire)
}
What's not in scope. There is no out-of-band rollback API. The team rejectedgraph.discardCommit(handle)in SPEC §5.6 because a fractional time would already be observer-visible by the time the caller asked to discard it — Phases G and H would have fired. The only way to not commit is to throw insiderun. The only way to ask "what would happen if I committed?" isgraph.simulate, which runs Phases A–E only and unconditionally restores the prefix.
3.1 Phase A throws and Phase F.4
A throw before any tx.set lands (Phase A) is even cheaper: nothing observable has been staged, the staged-writes parallel arrays are truncated, and the engine returns to Idle. Phase F.4 is the boundary at which the engine has produced a frozen Commit; a throw after Phase F.4 but before Phase G still rolls back via the same catch arm. SPEC §5.1 Amendment 1 gates Phase F.4 on commitHistoryCap > 0; if you opted out of history retention (the default, post-Amendment 2), Phase F.4 is dead and there is nothing to revert.
4. NodeDisposedError (generational NodeId disposal)
Causl supports family-keyed adapter patterns (family(id) across React hooks, persistence keys, devtools-bridge subscriptions). When a family member is no longer needed, the adapter calls into @causl/core/internal's dispose hook. The engine-rs core uses generational NodeId identifiers — a disposed slot's generation is bumped, so reads through the old handle structurally cannot collide with a future occupant of the same index. The TypeScript engine mirrors this with a tombstone keyed on the NodeId, paired with the GraphTime at which disposal happened.
A subsequent read, subscribe, or tx.set through the disposed handle surfaces NodeDisposedError:
import { NodeDisposedError } from '@causl/core'
import { dispose } from '@causl/core/internal' // adapter-only
const row = input(graph, { id: 1, label: 'first' })
dispose(graph, row.id)
try {
graph.read(row)
} catch (err) {
if (err instanceof NodeDisposedError) {
// err.kind === 'NodeDisposed'
// err.id === row.id
// err.disposedAt ===
console.warn(`Node ${err.id} was disposed at t=${err.disposedAt}`)
}
}
The discriminator matters: NodeDisposedError tells you "this id was registered and then released," distinct from UnknownNodeError which says "this id was never registered on this graph." Adapter authors should branch on the tag rather than collapse both to one message — debugging family-keyed lifecycle leaks depends on knowing which arm fired.
Tombstone retention is bounded by createCausl({ disposedTombstoneCap }) (default 1000). Past the cap, the FIFO ring evicts very-old tombstones and they fall back to UnknownNodeError — the "log rotated" arm. The typed disposal error is most useful immediately after disposal, so the cap is a memory-hygiene knob, not a correctness one.
5. PersistenceError discriminated union
The @causl/persistence adapter surfaces I/O and schema errors as a five-state discriminated union, never as a raw Error. Each state corresponds to a distinct, reachable failure mode in the SPEC §5.2 PersistedInput sub-statechart:
kind | Meaning | Fields beyond key |
|---|---|---|
'parse' | Stored envelope could not be JSON-parsed | cause |
'migrate-threw' | Caller-supplied migrate threw | expectedVersion, storedVersion, cause |
'migrate-missing' | Schema version mismatched, no migrate supplied | expectedVersion, storedVersion |
'serialise' | Write-side JSON serialisation threw | cause |
'quota' | Storage adapter rejected the write (quota / DOMException) | cause |
The split between 'migrate-threw' and 'migrate-missing' is deliberate — earlier drafts encoded both as the presence/absence of an optional cause?, which was the §17.4 "X may or may not have Y" anti-pattern.
import { persistedInput, localStorageAdapter } from '@causl/persistence'
import type { PersistenceError } from '@causl/persistence'
const handle = persistedInput(graph, {
key: 'editor.cursor',
storage: localStorageAdapter(),
version: 3,
initial: { row: 0, col: 0 },
migrate: (stored, storedVersion) => {
if (storedVersion === 2) return { row: (stored as any).line ?? 0, col: 0 }
throw new Error(`unsupported version ${storedVersion}`)
},
onError: (err: PersistenceError) => {
switch (err.kind) {
case 'parse': return reportCorrupt(err.key, err.cause)
case 'migrate-threw': return reportMigrateFailure(err.expectedVersion, err.storedVersion, err.cause)
case 'migrate-missing': return reportMigrateMissing(err.expectedVersion, err.storedVersion)
case 'serialise': return reportSerialise(err.cause)
case 'quota': return reportQuota(err.cause)
}
},
})
The handler is the only error surface. The in-memory input falls back to initial on any failure; with preserveOnError: true (the default) the on-disk envelope is left in place so a future deploy with a correct migrate can still recover the user's data.
6. The onObserverError contract
Subscribers are not part of the commit's atomic envelope. They fire in Phase G (per-node) and Phase H (per-commit), after the catch arm's mutation window has closed — by design, so a subscriber throw cannot tear engine state. The engine wraps every observer call in a defensive try/catch and routes the error to createCausl({ onObserverError }):
import { createCausl } from '@causl/core'
import type { ObserverErrorContext } from '@causl/core'
const graph = createCausl({
onObserverError: (err: unknown, ctx: ObserverErrorContext) => {
logger.error('subscriber threw', {
source: ctx.source, // 'node-subscriber' | 'commit-subscriber' | 'subscribe-initial'
// | 'subscribe-reads-initial' | 'subscribe-reads'
// | 'subscribe-reads-projection'
nodeId: ctx.nodeId, // present when source is per-node
time: ctx.time, // GraphTime at which the dispatch fired
err,
})
},
})
The source tag is the call-site label on the edge leaving Engine Publishing (SPEC §5.3) — not a state the engine occupies, but enough information to attribute the failure to a specific subscription path. 'subscribe-initial' covers the synchronous initial fire at subscription time; 'subscribe-reads-projection' covers projection-closure throws during a subscribeReads re-run, and the registration is preserved so the next commit retries. Default behaviour is console.error; passing a no-op silences it. If your handler itself throws, the engine catches that and falls back to a one-line console.error so dispatch cannot livelock.
7. Capability errors (narrowCapability)
Adapter packages — @causl/react, @causl/persistence, @causl/devtools-bridge — wrap their selector and listener boundaries with the internal narrowCapability(graph) primitive so application code receives only a read-side capability: read, subscribe, subscribeCommits, and now. Mark Miller's principle of least authority, applied at the adapter seam.
The narrowing has two enforcement layers. At compile time, the return type is ReadOnlyGraph = Pick<Graph, 'read' | 'subscribe' | 'subscribeCommits' | 'now'>, so a selector that tries to spell narrowed.commit(...) fails tsc before the code runs. At runtime, a Proxy traps every other property access and throws CapabilityViolation:
// Inside a misbehaving selector:
useCauslNode(graph, someNode, (cap) => {
// TypeScript already rejects this; if the caller silenced the type error
// with `as any` or `as Graph`, the runtime Proxy fires:
;(cap as any).commit('sneaky', () => {})
// ^^^^^^ throws CapabilityViolation: tried to invoke 'commit'
// on a narrowed ReadOnlyGraph.
})
CapabilityViolation is an internal class — adapter test suites assert on the class name string, but application code should not import it directly. If your selector or listener needs authority over the engine, accept a full Graph parameter at the call site explicitly rather than reach for ambient capability through a coerced cast. SPEC §12.3 explains why the primitive is internal-only: applications should not be in the business of inventing their own capability slices.
8. Quick reference
| Error | Where thrown | Recovers commit? |
|---|---|---|
User throw in run body or derive compute | Phase A–F.6 | Yes — byte-identical rollback |
NotAnInputNodeError / UnknownNodeError | tx.set | Yes (commit aborts) |
StaleTxError | tx.set after run returned | n/a (commit already done) |
CommitInProgressError | Nested graph.commit | n/a (outer commit unaffected) |
CycleError | Phase D fixpoint | Yes (rollback restores) |
NodeDisposedError | read/subscribe/tx.set on tombstoned id | Yes if inside run |
HydrationSchemaError | graph.hydrate | Yes (pre-validates before Phase B) |
| Observer throw | Phase G / H | n/a — routes to onObserverError |
PersistenceError | @causl/persistence I/O | n/a — routes to onError, falls back to initial |
CapabilityViolation | Narrowed proxy in adapter selectors | Propagates to the selector caller |
Further reading
- Transactions and commit lifecycle — the Phase A–H pipeline this page assumes.
- Dry-run with
simulate— preview a commit without publishing. - Persistence adapters — schema versioning and the storage abstraction.
- FAQ: Is causl really atomic? — the structural claim behind §5.2.
- SPEC §5.1 (phase table), §5.2 (atomicity), §5.3 (ObserverError), §9.1 (race-class catalogue), §12.3 (capability seams).