Core Concepts
Causl has a small vocabulary, but each word carries a contract. This page introduces every concept
you need to read the rest of the documentation and answer the question
"what actually happens when I call graph.commit(...)?" — without dragging
you through the full pipeline mechanics. (Those live in the
Usage Guide and the canonical
SPEC.md.)
If you have used Redux, MobX, Jotai, or RxJS, much of this will feel familiar — but the contracts are different in load-bearing ways. We will flag the differences as we go.
The graph
The central object in causl is the graph: a directed, acyclic dataflow of named
cells. Some hold writable state (inputs), others recompute from upstream cells
(derivations). You construct one with createCausl().
import { createCausl } from '@causl/core';
const graph = createCausl();
Everything on the public surface — read, commit,
subscribe, simulate, snapshot, hydrate — is a
method on this one object. There is no global store, no module-level singleton, no provider to
import. You can hold as many graphs as you like; they share no state.
How is this different from Redux? A Redux store has one state tree updated by reducers. A causl graph is a collection of named nodes with explicit dependencies between them. You do not write reducers; you declare what each derived value is, as a function of the values it reads, and the engine keeps it correct.
Nodes: inputs and derivations
Every cell in the graph is a node. There are exactly two flavours, and they cover everything else by composition.
1. Inputs
An input is a writable cell. You give it a stable id and an initial value; you get back a typed handle.
const count = graph.input('count', 0);
const name = graph.input('user:name', 'Ada');
The id is the durable identity of the cell — it survives snapshot / hydrate and is what devtools,
persistence, and replay see. Namespace ids after your information model
(cell:wb1:Sheet1:A1) rather than after UI state.
2. Derivations
A derivation is a computed cell. You give it a stable id and a pure function that reads other nodes and returns a value.
const doubled = graph.derived('doubled', g => g(count) * 2);
const greeting = graph.derived('greeting',
g => `Hello, ${g(name)}!`);
const summary = graph.derived('summary',
g => `${g(greeting)} You picked ${g(doubled)}.`);
The accessor records a dependency: the engine learns
doubled depends on count, and summary on both
greeting and doubled. You never declare dependencies up front; they
are inferred by execution, so a derivation with an if branch rewires its
dependency set when the branch flips.
Inputs sit at the left edge (no incoming arrows); derivations sit downstream. Every arrow is an
observed get call. The engine refuses cycles at the commit that closes them.
Reading values
Outside a transaction, the graph is read-only. To get a value, call graph.read(node).
console.log(graph.read(count)); // 0
console.log(graph.read(doubled)); // 0
console.log(graph.read(summary)); // "Hello, Ada! You picked 0."
read is synchronous and total — it always returns the committed value at the current
time. There is no loading state, no undefined intermediate. If a derivation has
been registered, you can read it.
Reference identity is not contractually guaranteed. Two calls tograph.read(node)at the same time may or may not return the same JavaScript object reference. The contract is value identity (Object.ison primitives, deep structural equality on aggregates), not reference identity. If you need a stable memoisation key, key oncommit.timeor the per-node version counter, not on the read return. SPEC §15.1 has the full story.
Commits: the one and only way to mutate
Outside of a commit, the graph is read-only. To change anything, hand a
callback to graph.commit and write through the tx handle it gives you.
graph.commit('bump-count', tx => {
tx.set(count, 5);
});
console.log(graph.read(doubled)); // 10
console.log(graph.read(summary)); // "Hello, Ada! You picked 10."
Three things matter about a commit:
-
It is the only mutation API. There is no
graph.setorgraph.update. Anything that changes a value runs inside acommitcallback. -
It carries an intent label. The first argument is a human-readable string
(
'bump-count','user-typed'). It flows into devtools, the commit log, and replay tooling. -
It returns the frozen
Commitrecord —{ time, intent, originatedAt, changedNodes }, the same record subscribers see.
Stage as many writes as you want inside the callback. They are visible together on the next tick, or — if anything throws — none of them are.
graph.commit('rename-and-reset', tx => {
tx.set(name, 'Grace');
tx.set(count, 0);
});
// Subscribers see name='Grace' AND count=0 in the same notification.
// They never see name='Grace' AND count=5.
GraphTime: the discrete clock
Every commit advances a logical clock called GraphTime by exactly one tick. Before any commits, time is zero. After the first commit, time is one. There is no fractional time, no two-ticks-per-commit, no commit that leaves the clock alone.
GraphTime answers "when was this value last true?" Every node carries a
lastWriteTime stamp identifying the commit that produced its current value, and
the Commit record's time field is the GraphTime of the moment it was
sealed. The whole specification is written in terms of GraphTime.
Why a discrete clock? The denotational frame isBehavior a = GraphTime → a: every cell, input or derived, is a function from time to value. Discrete time lets you ask "what wasdoubledat t = 17?" and get exactly one answer. Continuous-time reactive frameworks (classic FRP) leave that question open between events; causl closes it.
Transactions and atomic commits
The callback you pass to commit is a transaction. The
tx handle is its API — currently just tx.set(node, value). Three
properties of transactions are load-bearing:
- All-or-nothing. If the callback throws, every staged write is discarded and no subscriber fires. The graph is byte-identical to its pre-call state. If the callback returns normally, every write lands at the same GraphTime.
-
No escape. The
txhandle is bounded to the callback. Saving it in a closure and using it later raisesStaleTxError. This rule is what makes atomicity a structural property rather than a documentation rule. -
No nesting. Calling
commitfrom inside anothercommitraisesCommitInProgressError. There is exactly one tick advancing at a time.
try {
graph.commit('invalid-update', tx => {
tx.set(count, 100);
throw new Error('change my mind');
});
} catch (e) {
// The throw escapes, but count is still 5.
console.log(graph.read(count)); // 5
}
Glitch-freedom: never see an inconsistent state
A glitch is what happens in naive reactive systems when an upstream change propagates to some — but not all — of its downstream consumers, producing a transient state where derivations disagree about reality. Causl forbids glitches by construction.
Consider a diamond: two derivations both read count, and a third reads both.
In some signal libraries, a write to count propagates to even and
then runs both against the new even but the old
odd — a momentary inconsistent reading. Causl walks the affected sub-graph in
topological order to a fixpoint before any subscriber fires, so the denotational
equation derived(t) = f(upstream₁(t), …, upstreamₙ(t)) holds for every
t you can observe.
Why this is a theorem, not a scheduler trick. Because every cell is a function ofGraphTimeand every commit advances the clock atomically, the only times you can observe are the post-commit ticks. Pre-fixpoint intermediate values exist only as implementation detail inside one phase of the pipeline. Subscribers fire in a later phase, after the fixpoint has settled, with the valuereadwould return at the new tick.
Subscriptions
Observe the graph by subscribing. Two flavours: per-node and per-commit.
const unsubscribe = graph.subscribe(summary, (commit, value) => {
console.log(`[${commit.intent}@${commit.time}] summary = ${value}`);
});
graph.subscribeCommits(commit => {
console.log(`commit ${commit.time}: changed ${commit.changedNodes.length} nodes`);
});
Per-node subscribers fire only when the node's value actually changed (an Object.is
equality cutoff). Per-commit subscribers fire on every commit. Both kinds fire after
the commit has been sealed — by the time your callback runs, graph.read already
returns the new values.
Dependency tracking
Causl never asks you to declare dependencies up front. The compute callback is the
dependency list. Every get(node) call during evaluation is recorded; if a branch
is not taken, its dependencies are not part of this run's set.
const flag = graph.input('flag', true);
const a = graph.input('a', 1);
const b = graph.input('b', 2);
const branch = graph.derived('branch',
g => g(flag) ? g(a) : g(b));
// branch depends on { flag, a } right now.
graph.commit('flip', tx => tx.set(flag, false));
// branch depends on { flag, b } now. A write to `a` will not
// recompute branch; a write to `b` will.
Derivations only recompute when their actually-observed inputs change. Static dependency declarations would either over-recompute or drift out of sync with what executed.
The Phase A–H pipeline, conceptually
Under the hood, every commit call runs a fixed eight-phase pipeline. You do not
need to memorise the names to use causl, but knowing the shape helps explain the contract.
The pipeline is the structural reason causl can promise atomicity and glitch-freedom: every observable side effect happens in Phases G and H, after the recompute has settled. A throw anywhere in A–F lands in a single catch arm that rolls back every byte; G and H run outside the catch arm, so a misbehaving subscriber cannot corrupt the graph.
The detailed pipeline lives in
SPEC §5.1;
the Usage Guide tours each phase and shows the seams
(simulate, hydrate, commitMetadataDerived) that ride on
top.
Putting it together
That is the vocabulary. With it, you can already read every example in the Tutorial.
import { createCausl } from '@causl/core';
const graph = createCausl();
// Inputs
const price = graph.input('price', 9.99);
const quantity = graph.input('quantity', 3);
const taxRate = graph.input('taxRate', 0.08);
// Derivations
const subtotal = graph.derived('subtotal',
g => g(price) * g(quantity));
const tax = graph.derived('tax',
g => g(subtotal) * g(taxRate));
const total = graph.derived('total',
g => g(subtotal) + g(tax));
// Observe
graph.subscribe(total, (commit, value) => {
console.log(`[${commit.intent}] total = $${value.toFixed(2)}`);
});
// Mutate atomically
graph.commit('cart-update', tx => {
tx.set(quantity, 5);
tx.set(taxRate, 0.0875);
});
// → [cart-update] total = $54.32
// (subtotal, tax, total all recomputed; one notification each)
Where to go next
- Tutorial — build something end-to-end, with React, persistence, and async resources wired in.
-
Usage Guide — the mechanics: how the pipeline phases work, how to use
simulatefor previews, how snapshot / hydrate compose, how the static checker catches race classes. -
@causl/coreAPI reference — every method onGraph, every type, every structured error class. - SPEC.md — the canonical specification. Everything on this page is a friendly restatement of §3, §4, and §5.