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.

count ─────────────┐ ▼ doubled ──────┐ ▼ name ─► greeting ──────► summary

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 to graph.read(node) at the same time may or may not return the same JavaScript object reference. The contract is value identity (Object.is on primitives, deep structural equality on aggregates), not reference identity. If you need a stable memoisation key, key on commit.time or 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:

  1. It is the only mutation API. There is no graph.set or graph.update. Anything that changes a value runs inside a commit callback.
  2. 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.
  3. It returns the frozen Commit record{ 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.

t = 0 ─────► t = 1 ─────► t = 2 ─────► t = 3 genesis commit(bump) commit(rename) commit(undo)

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 is Behavior a = GraphTime → a: every cell, input or derived, is a function from time to value. Discrete time lets you ask "what was doubled at 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:

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.

count / \ ▼ ▼ even odd \ / ▼ ▼ 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 of GraphTime and 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 value read would 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.

commit(intent, run) called │ ▼ ┌─────────────────────────────────────────────────────┐ │ A. Run the user callback. Stage writes. │ │ B. Publish staged writes to input cells. │ │ C. Advance GraphTime by exactly one tick. │ │ D. Recompute affected derivations to fixpoint. │ │ E. Freeze and assemble the Commit record. │ │ F. Append to the commit history (optional). │ │ G. Notify per-node subscribers. │ │ H. Notify per-commit subscribers. │ └─────────────────────────────────────────────────────┘ │ ▼ return Commit

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