ReadonlycommitThe engine's commit log surfaced as a DerivedNode —
realises the engine's promise that "the transaction log is a
Behavior [Commit], queryable by the same API as any other
graph value." Subscribers via the standard subscribe(node, observer) see the array initially and once per successful
commit thereafter; the log is readable, subscribeable, and
appears in explain(node) lineage views — there is no separate
"log API" because the log is just another node.
The value is the bounded ring-buffer history, capped by
commitHistoryCap, which defaults to 0: on a graph
constructed with no retention options this node's value is the
empty array after every commit. Retention is opt-in; pass
createCausl({ commitHistoryCap: 1000 }) to get the log. Failed
commits do not append
entries — atomicity demands a transaction either creates exactly
one new t or none at all, and a failed commit must leave no
trace in the log. Zero retention is what a long-lived process
already has, so it needs to pass nothing.
Coexists with subscribeCommits, which carries the narrower
per-fire notification capability (one Commit per fire, no log
read). Capability-narrow consumers prefer subscribeCommits;
consumers that need the log itself (devtools panels, persistence
replay) consume commitLog.
ReadonlynowCurrent committed time. A getter, not a method — the denotational vocabulary needs an external observer to ask "what time is it?" without firing a commit.
Advance time by one and apply staged writes atomically.
Caller-supplied label retained on the Commit
record and the commit log. Labels surface in devtools and
replay logs; the engine treats 'hydrate' specially so SSR-
restore events are distinguishable from user-initiated
commits.
Callback receiving a Tx; all tx.set calls
land at the same GraphTime. There is no fractional time and
no nested-commit story — commit is the only operation that
advances time, by exactly one.
The Commit record describing the moment.
CommitInProgressError on nested commits. The re-entrancy guard fires the moment a commit is invoked while another is already in flight on the same graph, defeating the concurrent-engine-mutation row of the SPEC §9.1 race catalogue by absence-of-API.
CycleError if the commit closes a derivation cycle. Cycles are detected at the first commit that closes them, with a structured error naming the cycle path; static cycle detection is reserved for the bounded model checker.
StaleTxError if the captured Tx handle is
used after run returned. The handle is bounded to its
commit; a write through an escaped reference would land
outside the staging window and break the "exactly one new
GraphTime per commit" invariant.
NotAnInputNodeError if tx.set targets a
DerivedNode. Permitting the write would tear the
denotational equation derived(t) = f(b₁(t), …, bₙ(t)), so
the engine rejects the call at the API boundary.
NodeDisposedError if tx.set targets a node
that has been released through the adapter-layer dispose
hook. Disposal records a tombstone keyed by node id; the
typed error lets adapters distinguish "released" from "never
registered" — the use-after-dispose row of SPEC §9.1.
UnknownNodeError if tx.set targets a
fabricated handle whose id is not registered on this graph.
The same getEntry gate that protects every read-side
primitive in SPEC §12.1's canonical seven catches the write
side here.
Register a derived Behavior whose compute reads commit metadata —
graph.commitLog, the just-completed commit's stamp, or values
produced by other commit-metadata-tagged deriveds — and whose
value is expected to reflect the just-completed commit, not the
previous one.
Internally a commitMetadataDerived is a DerivedNode like
any other; it participates in read, subscribe, explain, and
readAt uniformly with plain Graph.derived. The seam
this factory adds is scheduling: the engine recomputes commit-
metadata deriveds in Phase F.5, after Phase D's regular
fixpoint has settled and Phase F.4 has refreshed
commitLogEntry.value, but before Phase G fires per-node
subscribers. A derivation registered through this factory
therefore sees the new commit log entry on the same commit that
produced it; subscribers fire once, with the post-commit value.
A handle to the derived node.
DuplicateNodeError if id is already registered.
CycleError if the initial compute closes a cycle.
Realises §11's "first-class derived for inspection" framing for
the commit-metadata-reading case (#452). iasbuilt/causl#383
attempted to turn whyUpdated / whyNotUpdated / commitLog into live
derived nodes through plain derived(...); the attempt failed
because Phase D's recompute saw the previous commit's log
array. This factory adds the typed seam #452 picks up: tagged
nodes are recomputed in Phase F.5 against the just-refreshed
commitLogEntry.value, so devtools surfaces can be derived
nodes rather than one-shot snapshots. Ordinary deriveds are NOT
affected by Phase F.5 — they settle exactly once per commit in
Phase D, preserving the §3 atomicity contract for code that did
not opt in.
Direct (depth-1) dependencies of node at the current committed
time — every upstream node id node reads on its most recent
evaluation, in lexicographic order for stable iteration.
A frozen readonly NodeId[] snapshot of the dep set as of
now. The array is a one-shot projection of the engine's
internal entries.get(id).deps set; topology changes after the
call (a derivation re-evaluating onto a different conditional
branch, a fresh derived registration, an adapter dispose)
are NOT reflected. Callers that need a live view should re-query
on the Graph.subscribeCommits fire of interest.
UnknownNodeError if node.id is not registered on
this graph. Same getEntry gate as read/subscribe/explain,
so the read-side error surface stays uniform across the §12.1
canonical seven and the §11 inspection primitives layered on
top.
NodeDisposedError if node.id has been released
through the adapter-layer dispose hook
(@causl/causl-wasm-ts/internal). Adapter code branches on this typed
error to distinguish "released" from "never registered" — the
same discriminator read/subscribe/explain already produce.
Realises the third bullet of SPEC §11's liveness commitment: "a
node's current dependents and current dependency are themselves
derived nodes." This implementation ships the one-shot snapshot
shape rather than a DerivedNode-valued handle. The reason
is structural: a live derived dependencies(node) would have to
be invalidated by the same commit pipeline that mutates the
dep-set, and the pipeline cannot host derived nodes whose value
is metadata about the commit pipeline itself without a
recursive-fire path the §5 "exactly one new GraphTime per commit"
invariant cannot absorb (iasbuilt/causl#383). The snapshot shape preserves the
§11 semantics — the engine is its own observer — without
re-entering the commit pipeline; a future PR may layer a derived
handle on top once the recursive-fire question is settled.
Direct (depth-1) dependents of node at the current committed
time — every derived node id whose most recent evaluation read
node, in lexicographic order for stable iteration.
A frozen readonly NodeId[] snapshot of the reverse-dep
set as of now. Same one-shot semantics as
Graph.dependencies: topology changes after the call are
NOT reflected. The array is sourced from the engine's internal
reverse-dep adjacency map (dependents: Map<NodeId, Set<NodeId>>) which the commit pipeline already maintains for
invalidation; this method publishes the same view as a
read-only projection.
UnknownNodeError if node.id is not registered on
this graph.
NodeDisposedError if node.id has been released
through the adapter-layer dispose hook.
Pairs with Graph.dependencies as the §11 third-bullet
inspection primitive. The spreadsheet question — what depends on
this cell? — is answerable through engine primitives instead of
by walking every other node's explain. See
Graph.dependencies for the detailed rationale on why this
ships as a snapshot rather than a DerivedNode-valued
handle (iasbuilt/causl#383).
Register a composed Behavior.
Stable, graph-unique identifier.
Closure invoked with a tracked get accessor;
dependencies are inferred from observed get calls rather
than declared up front, so a derivation that switches inputs
on an if branch naturally rewires its dependency set on the
next evaluation.
Optionaloptions: DerivedOptions<T>Optional DerivedOptions bag.
key — a marker-expressible projection of the derivation's
value that becomes the subject of the §5.1 cutoff relation:
the node counts as changed iff key(value)'s marker moved,
not iff the whole value's did. Governs propagation only; the
value is still retained and served verbatim. See
DerivedOptions.key for the opaque-projection rule.tag — registration tag; tag: 'live' is used by
liveDerived (devtools) so graph.explain reports
via: 'live' for hot-swappable nodes — the affordance that
makes "edit a derivation while it's running" demoable.A handle to the derived node.
DuplicateNodeError if id is already registered.
CycleError if the eager first evaluation closes a dependency cycle visible at the registration moment.
InvalidNodeHandleError if the compute calls get
with a value that is not a live node handle
(causl/causl-wasm-ts#498). A self-referential compute takes this
shape by construction: the handle for the node being registered
does not exist until derived() returns, so a closure over its
own binding reads undefined during the eager first evaluation.
DerivedComputeError wrapping any error the compute
body itself throws during the eager first evaluation, with the
original as cause.
Derived view of a node's lineage, itself subscribable.
A DerivedNode carrying an Explanation. Returning a node rather than a one-shot JSON dump is deliberate — it makes the engine inspectable through its own primitives, which is the only way devtools earn the comparison to spreadsheets the engine commits to.
UnknownNodeError if node.id is not registered
on this graph. explain validates the entry up front (the same
getEntry gate as read and subscribe) so the error surface
is uniform across the read-side primitives in SPEC §12.1's
canonical seven — no read-side primitive silently fabricates
lineage for an id the graph has never seen.
NodeDisposedError if node.id has been released
through the adapter-layer dispose hook
(@causl/causl-wasm-ts/internal). The typed disposal error mirrors
the contract on read and subscribe so adapter code can
branch on "released" vs. "never registered" no matter which
read-side primitive surfaced the post-disposal access.
Export a CauslModel IR snapshot — the bridge to
causl-check, the bounded model checker that lifts runtime
race-detection into a CI gate.
Optionaloptions: ExportModelOptionsOptional caller tuning (e.g. commit-log cap).
A CauslModel document suitable for the Rust
model checker. The document describes the registered nodes,
the dependency edges (static and conditional), the registered
resources and their statechart, the registered constraints,
and the application's Msg union. The checker enumerates
bounded interleavings over this IR and asserts glitch-
freedom, dynamic-dep correctness, statechart conformance,
cycle reachability, and replay determinism at every reachable
state.
Bulk-apply a GraphSnapshot to this graph by routing the
snapshot's input set through the same Phase A–H commit pipeline
that Graph.commit drives: stages the writes, advances
Graph.now by exactly one tick (the §3 monotonicity
invariant), recomputes the affected derivations, publishes a single
Commit with intent: 'hydrate' and originatedAt: snap.time, fires per-node subscribers whose value changed, and
notifies subscribeCommits observers — uniformly with any other
commit. The §5 "one mutation pipeline" contract holds: hydrate is
a privileged caller of commit's pipeline, not a parallel one.
The snapshot to apply.
CommitInProgressError when invoked mid-commit (re-entrant hydrate is forbidden, identical to nested commits).
HydrationSchemaError when the snapshot's
schema version is unsupported, or when its schemaHash does
not match the live graph's registered node id-set. The
capability check closes the hydration-mismatch race class
structurally rather than by hope: a mismatched server snapshot
is rejected at the door, not silently absorbed. The schema gates
run BEFORE the commit pipeline is entered so a rejected hydrate
never appears in commitLog and never fires subscribers.
The snapshot's recorded time is preserved on the published
Commit as originatedAt so persistence and devtools can
answer "this commit replays a server snapshot from t=N" without
inspecting intent strings. The engine clock advances by exactly
one tick regardless of snap.time — graph.now after a
successful hydrate is prev.now + 1, not snap.time. This is
what closes iasbuilt/causl#366 (monotonic GraphTime ordering)
and iasbuilt/causl#378 (single mutation pipeline) in one stroke.
Register a writable Behavior.
Stable, graph-unique identifier from the user's
information-model namespace (e.g. cell:wb1:Sheet1:A1), kept
strictly separate from editor-controller identifiers.
Value at GraphTime zero, satisfying
input(t₀) = initial.
Optionaloptions: { invariant?: (value: T) => void }Optional registration options.
invariant — caller-supplied value guard invoked in commit's
staging phase (Phase A.7) for every staged write to this
input. A throw aborts the commit and surfaces as
InvariantViolationError carrying the offending value and
the original throw as cause. The full commit is rolled back
atomically — same shape as CycleError / NotAnInputNodeError.
Sync only; returning a Promise has no effect. Initial value
is NOT validated at registration time.A handle suitable for tx.set, read, subscribe,
and explain.
DuplicateNodeError if id is already registered.
Read a node's value at the engine's current logical time.
The staged value if this read is inside a transaction that
has already written node; the committed value at
Graph.now otherwise.
Outside a transaction that is the committed value at
Graph.now. INSIDE the run callback of a Graph.commit
or Graph.simulate, a tx.set earlier in the same callback
SHADOWS the committed cell: read returns the staged value, so the
SPEC §10 read-modify-write idiom composes.
There is still no way to read into another transaction's staging
window — that race class is caught by the absence of the construct.
There is exactly one mutation pipeline, it does not nest, and a
captured tx cannot escape its callback, so the only staging window
a read can see is the one it is lexically inside.
Inside simulate the same rule holds, on every engine — which is
what makes a dry run predict the commit it models: if the body
branches on a read-back, simulate takes the branch commit would.
Nothing staged by a simulate survives it (§5, the dry-run promise).
This entry documented the OPPOSITE ("@returns The committed value at
now") through the release that made the shadow authoritative on the
shipping engine, so an adopter following it would have written a
before/after audit log that records the same value twice.
UnknownNodeError if node is not registered.
// SPEC §10 — read-modify-write inside one commit body.
graph.commit('inc', (tx) => {
tx.set(n, graph.read(n) + 1)
tx.set(n, graph.read(n) + 1) // reads the value staged above
})
// n is now +2, not +1.
// A before/after log needs the value captured BEFORE the write:
graph.commit('audit', (tx) => {
const before = graph.read(n) // committed value
tx.set(n, 10)
audit(before, graph.read(n)) // staged value
})
Read the value of node at a past committed time t.
A RetentionResult carrying either the retained
value (and the snapshot's actual time) or an evicted marker
with the oldest still-retained time so callers can clamp future
requests into the bounded window.
Bounded by snapshotRetentionCap, which defaults to 0, as does
the commitHistoryCap that gates it: on a graph constructed with
no retention options this method answers evicted at every t.
Retention is opt-in; pass
{ commitHistoryCap: 1000, snapshotRetentionCap: 1000 } (both, see
CreateCauslOptions.commitHistoryCap) to get history. At a
positive cap the engine keeps
the most recent N committed input snapshots and drops older
ones. Derived nodes are recomputed against the retained input
snapshot; the recompute is wavefront-memoised so a diamond DAG
resolves each join exactly once. Time-travel devtools and
replay-determinism tests both consume this primitive — and
because JUMP_TO_* is observed as a read via this method
(returning a discriminated Retained | Evicted rather than
mutating the graph), inconsistent-snapshot-history races are
caught by the type narrow plus the engine's branch-fork record.
InvalidNodeHandleError if a derived's compute,
re-run definitionally for a node the window holds no row for,
calls get with a value that is not a live node handle
(causl/causl-wasm-ts#498).
DerivedComputeError wrapping any error such a
definitional recompute's compute body itself throws, with the
original as cause.
Predict what commit(intent, run) would do without committing.
Caller-supplied label that would have been recorded on the predicted Commit. Treated as opaque metadata; not appended to the commit log because no commit is published.
Callback receiving a transient Tx; staged
writes drive the same recompute pipeline commit runs, then
the entire effect is discarded before this method returns.
A SimulateResult: 'clean' carrying the would-be
Commit, the staged-input diff, and the derived diff;
'failed' carrying the typed error the simulated transaction
would have thrown. Re-entrancy is the only failure mode that
does throw — see CommitInProgressError below.
§5 names exactly three commit-mode shapes — strict,
with-conflicts, and a separate graph.simulate(...) API for
dry-run. simulate runs the staging + recompute phases of commit
against a transient view and discards the result, so application
code can answer "what would happen if I ran this transaction?"
without an out-of-band rollback. The §5 contract is the strong
one: after simulate returns,
lastTime) — simulate reuses commit's atomicity
rollback to restore byte-identical post-recompute state.The dry-run is therefore observer-invisible. Graph.exportModel
called immediately after simulate returns produces the same IR
document that an exportModel call immediately before would
have produced.
CommitInProgressError on re-entry from inside an
in-flight commit callback or another simulate. Same contract
as nested commit: there is exactly one mutation pipeline and
it does not nest, and simulate borrows that pipeline. Every
other engine-emitted error — CycleError,
NotAnInputNodeError, UnknownNodeError, NodeDisposedError,
StaleTxError — is surfaced on the 'failed' arm of the result
rather than thrown, so callers can predict the failure mode
without try/catch.
Capture the current input set + GraphTime as a serialisable GraphSnapshot suitable for SSR transfer, persistence, or DevTools time-travel. Derived nodes are intentionally omitted — they are pure functions of inputs and recompute on first read, so transmitting them is redundant at best and a determinism risk at worst.
A GraphSnapshot capturing every input whose
current value is JSON-serialisable, plus a schemaHash derived
from the registered node id-set for Graph.hydrate-side
capability validation. Without a single-call snapshot every
adapter would rebuild the equivalent and they would drift.
Project a whole-graph GraphSnapshot at a historical
GraphTime t, sourced from the engine's bounded retention
buffer. Returns { status: 'evicted', oldestRetainedTime } when
t falls outside the retention window.
The bridge consumes this on JUMP / IMPORT_STATE / ROLLBACK so
time travel is observed as a read on a Behavior, not as a
mutation — preserving the denotational contract that the only
way time advances is through commit. Cheaper than enumerating
inputs and calling readAt per node, and yields a value of the
same shape as snapshot().
Snapshot the engine's retained-state telemetry counters as a single, frozen-shape EngineTelemetry record.
The §11 inspection-primitives bullet promised "the engine surfaces
its own retained shape so a long-running host can audit drift." This
method realises that promise as a pure read: it iterates no
collection, allocates exactly one object, and never mutates engine
state. Callers query before/after a workload boundary (fixture
teardown, bench scenario end, devtools tab refresh) and compare
counters; a non-zero residual on subscribersTotal after every
subscribe/unsubscribe round-trip has been performed is a
subscriber-leak proof.
Driven by #757 (the JS-side first cut of the audit-required
telemetry surface flagged by #695 wasm cluster) and consumed by the
subscriber-churn-1k bench scenario (#733/#738) as its end-of-run
leak gate. Future refinements may extend the shape (additional
counters land at the tail of the object; never reorder or remove);
the stable contract is the field names listed on
EngineTelemetry.
This snapshot carries no engine label, and deliberately so (#295).
Every field here is a COUNTER that moves as the graph is used, and
the point of the cross-backend contract is that the two backends
report the same counters for the same workload — a discriminator
sitting among them would invite exactly the branch-on-backend
consumers the contract exists to prevent. Which engine executed a
graph is a fixed property of its construction, not a counter, so it
is answered separately by causlEngineOf(graph) from
@causl/causl-wasm-ts/wasm, which is total (it names the TS floor
for a graph the wasm subpath never built) and graph-scoped (unlike
isCauslWasmPreloaded(), which reads a process-wide slot).
Observe value changes on a single node.
Target node.
Callback invoked once per commit during which
the node's value changed. A multi-write commit produces
exactly one notification per affected subscriber, never one
per tx.set — the worked example graph.commit('bump-both', tx => { tx.set(a, 100); tx.set(b, 200) }) fires a single
301 and not two.
Optionaloptions: SubscribeOptionsOptional SubscribeOptions: pass
{ transient: true } to register the observer as a one-shot
that auto-disposes after its first Phase G fire (#766). The
synchronous initial fire does NOT consume the transient slot —
the auto-dispose trigger is the next commit-time fire.
A disposer that detaches the observer.
UnknownNodeError if node.id is not registered
on this graph. subscribe validates the entry up front (the
same getEntry gate as read) so a fabricated handle is
rejected before any subscription bookkeeping is allocated —
the symmetric guard against the "fabricated id" race that
protects every read-side primitive in SPEC §12.1's canonical
seven.
NodeDisposedError if node.id has been released
through the adapter-layer dispose hook
(@causl/causl-wasm-ts/internal). Disposal records a tombstone keyed
by node id; subsequent subscribe calls surface this typed error
rather than the generic UnknownNodeError so adapter authors
can branch on "released" vs. "never registered" — the
discriminated tag the React useCauslFamily hook depends on
to clean up after a component's mount window closes.
Subscribe to every commit on this graph. The commit log is
itself a Behavior [Commit]; this is the narrow per-fire
notification capability — one Commit object per fire, no log
read — that adapters (React, devtools, persistence, SSR
hydrate) use to listen for "any change at all" without being
handed access to the full log. Callers that need the log itself
consume Graph.commitLog instead.
A disposer that detaches the observer.
Register a single observer against a tuple of nodes. The observer is invoked once synchronously with each node's current value, and afterwards once per commit during which any of the registered nodes' values changed — the engine fires the group exactly once per commit even when several of the group's members move in the same transaction (#766).
Tuple of nodes whose value changes should fire the
observer. Order is preserved in the value tuple passed to the
observer. Empty tuples are accepted: the observer fires once
synchronously with [] and then never again.
Callback receiving the freshly-read value tuple on each notification.
Optionaloptions: SubscribeOptionsOptional SubscribeOptions: pass
{ transient: true } to register the group as a one-shot that
auto-disposes after its first commit-time fire.
An Unsubscribe that drops the entire group as a single operation. Idempotent — repeated invocations are harmless.
UnknownNodeError if any nodes[i].id is not
registered on this graph. The error is raised before any
subscription bookkeeping is allocated, so a partial group never
exists from the engine's perspective.
NodeDisposedError if any nodes[i].id has been
released through the adapter-layer dispose hook.
The engine maintains the per-node subscriber index introduced in
#671 to keep Phase G's changed → bucket walk cheap; #738 shipped
the index but not the multi-node convenience surface. This method
registers one SubscriptionEntry per node sharing a single
observer reference, so:
subscribeReads uses for its
re-entered-via-multiple-deps case), so the observer still fires
exactly once.changedNodes, the engine
never visits the group's buckets at all — same O(1)-per-commit
firehose acceptance the per-node subscribe enjoys.Cheaper than N independent Graph.subscribe calls because the per-node index is built once at registration and the dedupe marker amortises the multi-write fan-in.
Subscribe to a projection's value, with the engine tracking the projection's read-set automatically (SPEC §11.1 amended, #701).
The engine runs projection() once at registration under the same
tracking get accessor it uses for derived computes, captures the
set of node ids the projection reads, and fires
observer(commit, value) on every commit whose
Commit.changedNodes intersects the captured set. The
projection is re-run on every fire — both to refresh the value
passed to the observer and to refresh the recorded read-set so
conditional reads "follow the live branch" without the adopter
managing dep arrays by hand. The initial registration also fires
once synchronously with the projection's initial value, mirroring
Graph.subscribe's initial-fire contract.
Conditional reads are handled automatically: a projection like
() => flag.read() ? get(b) : get(a) initially records
{flag, a}; after flag flips and the projection re-runs the
recorded set becomes {flag, b} and writes to a no longer
fire the observer. This is the contract-layer surface for
"subscribe to a derived projection without registering a
derived node," and is the dispatch shape that useCauslNode
and similar React adapters can build on without round-tripping
through subscribeCommits's every-commit fan-in.
Callback invoked with the just-published commit and the projection's freshly-evaluated value.
Pure read closure executed under the engine's
tracking accessor. The set of nodes the projection touches via
the supplied tracking get becomes the registration's recorded
read-set; subsequent commits only fire the observer when their
changedNodes intersect this set.
A disposer that idempotently removes the registration.
Public surface of a causl engine instance.
Remarks
Realises the canonical seven primitives —
input,derived,commit,read,subscribe,explain, pluscreateCauslitself — together with the second-tier extensions (subscribeCommits,commitLog,exportModel,snapshot,hydrate,readAt,snapshotAt,simulate,dependencies,dependents,now). Each of those rows justifies itself individually: each names an unavoidable concept the engine cannot express without it. The seven canonical methods are the load- bearing surface defended on every PR review; the second tier is acknowledged drift, reviewed quarterly, and any row whose justification fades gets demoted to internals or removed. Keeping this surface small is the design discipline the engine commits to — the alternative is the previous draft's nine kinds of hooks and fifty-seven enum tags before a developer could write a counter.