Public surface of a causl engine instance.

Realises the canonical seven primitives — input, derived, commit, read, subscribe, explain, plus createCausl itself — 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.

interface Graph {
    commitLog: DerivedNode<readonly Commit[]>;
    now: number;
    commit(intent: string, run: (tx: Tx) => void): Commit;
    commitMetadataDerived<T>(id: string, compute: Compute<T>): DerivedNode<T>;
    dependencies<T>(node: Node<T>): readonly string[];
    dependents<T>(node: Node<T>): readonly string[];
    derived<T>(
        id: string,
        compute: Compute<T>,
        options?: DerivedOptions<T>,
    ): DerivedNode<T>;
    explain<T>(node: Node<T>): DerivedNode<Explanation>;
    exportModel(options?: ExportModelOptions): CauslModel;
    hydrate(snap: GraphSnapshot): void;
    input<T>(
        id: string,
        initial: T,
        options?: { invariant?: (value: T) => void },
    ): InputNode<T>;
    read<T>(node: Node<T>): T;
    readAt<T>(node: Node<T>, t: number): RetentionResult<T>;
    simulate(intent: string, run: (tx: Tx) => void): SimulateResult;
    snapshot(): GraphSnapshot;
    snapshotAt(t: number): RetentionResult<GraphSnapshot>;
    stats(): EngineTelemetry;
    subscribe<T>(
        node: Node<T>,
        observer: Observer<T>,
        options?: SubscribeOptions,
    ): Unsubscribe;
    subscribeCommits(observer: (commit: Commit) => void): Unsubscribe;
    subscribeMany<Ts extends readonly Node<unknown>[]>(
        nodes: Ts,
        observer: (values: ValueMap<Ts>) => void,
        options?: SubscribeOptions,
    ): Unsubscribe;
    subscribeReads<T>(
        observer: SubscribeReadsObserver<T>,
        projection: () => T,
    ): Unsubscribe;
}

Properties

commitLog: DerivedNode<readonly Commit[]>

The 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.

now: number

Current committed time. A getter, not a method — the denotational vocabulary needs an external observer to ask "what time is it?" without firing a commit.

Methods

  • Advance time by one and apply staged writes atomically.

    Parameters

    • intent: string

      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.

    • run: (tx: Tx) => void

      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.

    Returns Commit

    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.

    Type Parameters

    • T

      Value type produced by the derivation.

    Parameters

    • id: string

      Stable, application-chosen identifier within the graph.

    • compute: Compute<T>

      Pure function expressing the derivation's semantics. Reads through get(graph.commitLog) see the bounded ring of recent commits including the in-flight one.

    Returns DerivedNode<T>

    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.

    Type Parameters

    • T

      Value type of the node being inspected.

    Parameters

    • node: Node<T>

      The node whose dependency set to enumerate. Inputs never have dependencies; the call is well-defined on inputs and returns the empty array.

    Returns readonly string[]

    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.

    Type Parameters

    • T

      Value type of the node being inspected.

    Parameters

    • node: Node<T>

      The node whose consumer set to enumerate. Both inputs and derived nodes can have dependents; the call is well-defined on either, and returns the empty array when no live derivation reads node.

    Returns readonly string[]

    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.

    Type Parameters

    • T

      Value type produced by compute.

    Parameters

    • id: string

      Stable, graph-unique identifier.

    • compute: Compute<T>

      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.

    Returns DerivedNode<T>

    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.

    Type Parameters

    • T

      Value type of the explained node.

    Parameters

    • node: Node<T>

      Target node.

    Returns DerivedNode<Explanation>

    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.

    Parameters

    Returns CauslModel

    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.

    Parameters

    Returns void

    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.timegraph.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.

    Type Parameters

    • T

      Value type carried by the input.

    Parameters

    • id: string

      Stable, graph-unique identifier from the user's information-model namespace (e.g. cell:wb1:Sheet1:A1), kept strictly separate from editor-controller identifiers.

    • initial: T

      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.

    Returns InputNode<T>

    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.

    Type Parameters

    • T

      Value type carried by the node.

    Parameters

    • node: Node<T>

      Input or derived node to read.

    Returns T

    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.

    Type Parameters

    • T

      Value type of the node.

    Parameters

    • node: Node<T>

      The node handle whose past value should be retrieved.

    • t: number

      GraphTime to read at; must be ≤ Graph.now.

    Returns RetentionResult<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.

    Parameters

    • intent: string

      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.

    • run: (tx: Tx) => void

      Callback receiving a transient Tx; staged writes drive the same recompute pipeline commit runs, then the entire effect is discarded before this method returns.

    Returns SimulateResult

    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,

    • Graph.now is unchanged,
    • no entry has been appended to the commit log (Graph.commitLog subscribers do not fire),
    • no per-node Graph.subscribe observer fires,
    • no Graph.subscribeCommits observer fires,
    • every input cell still holds its pre-call value,
    • every derived cell still holds its pre-call value (and dep set, and 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.

    const result = graph.simulate('preview', tx => tx.set(a, 42))
    if (result.status === 'clean') {
    console.log('would change:', result.commit.changedNodes)
    } else {
    console.error('would fail with:', result.error)
    }
    // graph.now and every cell value are unchanged at this line.
  • 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.

    Returns GraphSnapshot

    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.

    Parameters

    • t: number

    Returns RetentionResult<GraphSnapshot>

    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.

    Returns EngineTelemetry

    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.

    Type Parameters

    • T

      Value type of the observed node.

    Parameters

    • node: Node<T>

      Target node.

    • observer: Observer<T>

      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: SubscribeOptions

      Optional 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.

    Returns Unsubscribe

    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.

    Parameters

    • observer: (commit: Commit) => void

      Callback invoked once per commit with the Commit record.

    Returns Unsubscribe

    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).

    Type Parameters

    • Ts extends readonly Node<unknown>[]

      Tuple of Node handles being observed. The observer's values parameter is typed as ValueMap<Ts>, so a registration over [Node<number>, Node<string>] receives [number, string] at fire-time.

    Parameters

    • nodes: Ts

      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.

    • observer: (values: ValueMap<Ts>) => void

      Callback receiving the freshly-read value tuple on each notification.

    • Optionaloptions: SubscribeOptions

      Optional SubscribeOptions: pass { transient: true } to register the group as a one-shot that auto-disposes after its first commit-time fire.

    Returns Unsubscribe

    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:

    • When one node in the group changes, the engine walks the per-node bucket for that id alone, fires the shared observer once, and skips the buckets for the unchanged nodes.
    • When multiple nodes in the group change in the same commit, the engine dedupes via a per-commit "fired this many-group" marker (the same shape subscribeReads uses for its re-entered-via-multiple-deps case), so the observer still fires exactly once.
    • When no node in the group is in 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.

    Type Parameters

    • T

      Value type produced by the projection closure.

    Parameters

    • observer: SubscribeReadsObserver<T>

      Callback invoked with the just-published commit and the projection's freshly-evaluated value.

    • projection: () => T

      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.

    Returns Unsubscribe

    A disposer that idempotently removes the registration.