Options accepted by createCausl.

Intentionally minimal — every additional public option is a teaching cost paid by every future user. The bar for adding a field here is the same as for adding a public method: name the unavoidable concept the engine cannot express without it, or take the cost of growing every README and every consumer's mental model.

interface CreateCauslOptions {
    adaptThresholds?: Partial<AdaptThresholds>;
    batchedFlush?: { afterN?: number; intervalMs?: number };
    commitHistoryCap?: number;
    disposedTombstoneCap?: number;
    enableH1HazardWarning?: boolean;
    experimentalFlags?: Partial<CauslFlags>;
    name?: string;
    onObserverError?: ObserverErrorHandler;
    snapshotRetentionCap?: number;
    strictCycles?: boolean;
}

Properties

adaptThresholds?: Partial<AdaptThresholds>

Per-engine overrides for the auto-adapt heuristic thresholds.

Since 0.5.0 — inert. Its only trigger was backend: 'auto', and #280 removed the backend option along with the TS floor it selected between. Nothing reads this field; it is retained for one release beside import('./auto-adapt.js').AdaptThresholds / shouldMigrate, which stay exported, and is removed in 0.6.0.

Was consumed only when backend === 'auto'. Each field on AdaptThresholds can be flipped here for a single engine instance without mutating the process-wide env. Keys not present fall back to the value parsed once at module load from process.env via CAUSL_WASM_* env vars (the MODULE_THRESHOLD_OVERRIDES snapshot in ./auto-adapt.ts). Construction-time merge only — the wrapper captures the merged snapshot once and never re-reads process.env over its lifetime.

batchedFlush?: { afterN?: number; intervalMs?: number }

C.4 (#1505) — per-graph batched-flush opt-in for the WASM backend (epic #1493, option-c batched-commit boundary scaffolding).

A PURELY EXPLICIT per-graph wire-tempo opt-in (#163). It is consumed by loadWasmBackend({ batchedFlush }) and by the ASYNC factory createCauslWasm(). (It used to name the backend: 'auto' wrapper as a third consumer; #280 removed the backend option and the wrapper module with it.)

…and, since causl/causl-wasm-ts#363 finding 21, by the SYNCHRONOUS factories too. It had been dropped by both of them: perGraphParamsForSync (packages/core/wasm/index.ts) returned { ...handle, retentionCaps } unconditionally, so a per-call window was discarded and the PRELOAD's baked one used in its place. Before #280 the sync path re-threaded it under createOpts.engine !== undefined; removing the engine option removed the one arm that did, and nothing caught it because the NEIGHBOURING per-call option commitHistoryCap kept working (mergePerCallRetentionCaps, per-call wins) and because — see the paragraph below — no observable behaviour depends on the window.

Measured after await preloadCauslWasm({ bridge: 'wasmgc-classic' }), probing __wasmBackendForTests(g).__batchedFlushConfigForTests():

                                     BEFORE          AFTER

createCausl({ batchedFlush:{afterN:7} }) undefined { afterN: 7, … } createCauslWasmSync(u, {…afterN:3}) undefined { afterN: 3, … } await createCauslWasm({…afterN:5}) { afterN:5, … } { afterN: 5, … }

A per-call value wins PER FIELD; a field the call omits inherits the preload's baked window for that field — the same terms as the retention caps, which is what this paragraph claimed before causl/causl-wasm-ts#363 and what the code now does. It had been a whole-object ??, so a PARTIAL per-call window discarded the baked sibling field and picked up the class default in its place. Measured, with preloadCauslWasm({ …, batchedFlush: { afterN: 9, intervalMs: 99 } }):

                                     BEFORE            AFTER

createCausl({ batchedFlush:{afterN:7} }) {7, 16} {7, 99} createCausl({ batchedFlush:{intervalMs:5} }) {1, 5} {9, 5}

16 and 1 are BatchedFlushOptions' own defaults, so the pre-fix answers were indistinguishable from "the preload baked nothing".

createCauslWasm takes the option on EITHER of its two bags (the loader bag or create); create wins per field. Pinned by test/wasm-implicit-pergraph-caps-141.test.ts.

Omitting it installs no queue of its own — but on the wasm factories an omitted option INHERITS the window preloadCauslWasm() baked, so a graph can carry a queue this call never asked for. Pass it explicitly to override a baked window; there is no per-call spelling that clears one. None of this is adopter-observable: the rust-ssot engine — since #280 the only engine this package ships, and no longer selected by an option — crosses the wire PER COMMIT and never consults a batched-flush window; the window is honoured only by the shadow / cross-backend-determinism-gate marshaler path (#163). commit() / read() / subscribe() results are unchanged whether or not it is set. Per-graph, not global (option-c doc §2.3).

The shape is { afterN?: number; intervalMs?: number } — kept as a structural type here so ./types.js does not depend on the @causl/causl-wasm-ts/wasm subpath (which the main barrel must never pull in). The canonical declaration is BatchedFlushOptions in packages/core/wasm/index.ts.

commitHistoryCap?: number

Bound on the internally-retained commit log. Default 0, which is no retention at all: commitLog stays empty and every readAt / snapshotAt answers evicted.

Retention is opt-in, not opt-out. SPEC §5.1 Amendment 2 makes 0 the default and says adopters using readAt / snapshotAt / commitLog must explicitly ask for it; Phase F.6, the phase that fills and evicts the chain, runs iff this value is > 0. So this is the master switch, and snapshotRetentionCap on its own opts you into nothing.

Pass a positive cap at construction to get history. MEASURED, on a graph with one input, one derived and two commits:

passed commitLog readAt / snapshotAt
nothing (the default) empty evicted at every t
{ commitHistoryCap: 1000 } 2 rows evicted at every t
{ snapshotRetentionCap: 1000 } empty evicted at every t
{ commitHistoryCap: 1000, snapshotRetentionCap: 1000 } 2 rows retained

The second row is the one that surprises: a positive commitHistoryCap alone gives you the log and still evicts every historical read, because the snapshot cap it gates is itself 0 by default. For readAt / snapshotAt, set both.

createCausl({ commitHistoryCap: 1000, snapshotRetentionCap: 1000 })

The cap is read from EVERY bag on every factory that takes one, so a call that names it is never silently built without it:

createCausl({ commitHistoryCap: 1000 })                      // the bag
createCauslWasmSync(handle, { commitHistoryCap: 1000 }) // 2nd arg
preloadCauslWasm({ commitHistoryCap: 1000 }) // baked
createCauslWasm({ commitHistoryCap: 1000 }) // loader bag
createCauslWasm({ create: { commitHistoryCap: 1000 } }) // create bag

createCauslWasm is the only factory with TWO bags, and it accepts the cap in EITHER. When both name it, create wins per field: a cap only the loader bag names is inherited, so { commitHistoryCap: 1000, create: { snapshotRetentionCap: 2 } } builds { 1000, 2 }. The same per-field rule governs the neighbouring CreateCauslOptions.batchedFlush. Until causl/causl-wasm-ts#411 the create bag's caps were read by neither half of the graph, so a call that named them only there built a graph with NO retention and no error. That is the shape a reader takes as "retention is broken" rather than as "the option did not take".

preloadCauslWasm BAKES the cap into its handle, and a later createCauslWasmSync that omits it inherits that baked value per field (boot inheritance). The async createCauslWasm does NOT inherit a baked cap: it resolves its window from its own two bags on every call, so the first preload's caps never leak into an unrelated later graph.

disposedTombstoneCap?: number

Bound on retained disposed-node tombstones. Default 1000.

Disposal records a tombstone keyed by node id so that subsequent public-surface access surfaces a typed NodeDisposedError rather than UnknownNodeError. Under churn with fresh ids each lifecycle (timestamped keys, family(uuid()), generated row ids in a virtualized list), an unbounded tombstone map is a monotonic retention root. The cap is the same shape as commitHistoryCap: a FIFO ring on insertion order. Past the cap, very-old tombstones are evicted and their ids fall back to UnknownNodeError — the "log rotated" arm — which is acceptable because the typed disposal error is most useful immediately after disposal, not years later.

enableH1HazardWarning?: boolean

Enable the dev-only H1 hazard warning (#1155, #1241).

Per docs/wasm-backend-adopter-audit.md H1 and SPEC §15.1 (PR #1129), the engine does NOT guarantee reference-identity stability for values returned from Graph.read: a commit may produce a structurally equivalent but distinct object, and adopters who cache a read() return across a commit boundary will silently desynchronise from the graph's current value. The symptom is subtle — no error fires; the cached reference simply stops tracking the live state.

When enabled, every non-null object/function returned by read() outside a tracking projection is recorded as a WeakRef along with the read-time GraphTime. After each commit advances now, the engine walks live WeakRefs and emits one console.warn per survivor whose recorded GraphTime predates the post-commit clock — naming the offending node id and pointing at SPEC §15.1. The warning never throws; the contract is informational only.

Default is false (opt-in) per the panel review of #1241. PR #1238 originally shipped with an auto-detected dev/prod default, but the canonical @causl/react adapter holds the read() return inside useSyncExternalStore's snapshot cache for tearing detection — that single retained reference triggered the warning on every commit for any adapter usage.

The follow-up (#1241) ships three coordinated fixes:

  • A. Default enableH1HazardWarning is now false; adopters who want the dev safety net opt in explicitly with createCausl({ enableH1HazardWarning: true }).
  • B. An internal __causl_* adapter-exemption seam (used by @causl/react's canonical hooks) suppresses H1 tracking for reads inside an adapter's getSnapshot boundary, so opt-in adopters do not see false positives from official adapters.
  • C. The instrumentation is wrapped in process.env.NODE_ENV !== 'production' literal blocks so esbuild / terser can dead-code-eliminate the WeakRef apparatus in production builds.

Pass true explicitly to engage the dev-only WeakRef tracker; pass false (or omit the option) to keep the engine on the production hot path with no per-read() bookkeeping.

Bookkeeping cost when armed: one WeakRef allocation per qualifying read() call (primitives, null, reads inside a tracking projection, and reads inside the adapter-exemption seam are skipped); one O(N) walk per commit over the survivor list with dead-ref pruning. Empirically <1% on linear-chain × 1000 traces in dev mode.

experimentalFlags?: Partial<CauslFlags>

Engine-instance overrides for the CAUSL_* env-var flag protocol (#706). Each field on CauslFlags can be flipped here for a single engine instance without mutating the process-wide env. Keys not present fall back to the value parsed once at module load from process.env (the MODULE_FLAGS snapshot in ./flags.ts).

The experimental prefix mirrors the experimentalFlags naming adopted by other long-lived TypeScript libraries: callers should read each entry as "this knob is opt-in measurement only; the default is the safe behaviour, and the field exists so an adopter or a benchmark can flip it without an env-var dance." Construction-time merge only — the engine captures the merged snapshot in its closures and never re-reads process.env for that flag again over the engine's lifetime.

name?: string

Stable identifier for this graph instance. Surfaced as graphId on every IR node and commit in the schema-3 IR (graph.exportModel()). Optional — the engine assigns a UUID v4 when absent.

Validity rule: must match /^[A-Za-z0-9_.:-]{1,256}$/. The engine throws InvalidGraphNameError at construction if the regex does not match. The character set is the intersection of "safe in JSON", "safe in URL fragments", and "safe in filesystem paths" — the three places adopters have been observed pasting a graphId. The 256-char cap mirrors the §12.2 teaching-cost cap on public surface names.

The precedence rule is application-supplied wins: a name passed here lands on the IR; absence falls back to a UUID v4 the engine mints once at construction. The field is read-only on the graph instance once construction returns — there is no public mutator and no rebrand operation.

onObserverError?: ObserverErrorHandler

Hook fired when an observer (per-node or per-commit) throws. Defaults to console.error. Pass a no-op to silence.

snapshotRetentionCap?: number

Bound on retained per-commit snapshots used by readAt(t) and the DevTools bridge. Default 0, and it does nothing on its own.

The effective window is commitHistoryCap > 0 ? snapshotRetentionCap : 0, so a graph that sets this and leaves CreateCauslOptions.commitHistoryCap at its default still answers evicted everywhere. Set both, or neither. See the measured table on commitHistoryCap.

WHERE to pass it is identical to its sibling's, including createCauslWasm({ create: { … } }) and the per-field merge across that factory's two bags (causl/causl-wasm-ts#411). See the shape list on CreateCauslOptions.commitHistoryCap.

strictCycles?: boolean

As of #670 / #705 this option is a no-op. The pre-#705 strict-cycle gate ran an O(|nodes|) forward DFS at every derived() registration to refuse a back-edge before the entry landed; the cost was load-bearing on linear-chain × 1000 (420 ms median against a 5 ms audit floor) and structurally lethal on linear-chain × 10000 (V8 stack overflow on the registration recursion). Phase D's augmented Kahn pass now catches the same race-class at first-commit-time without paying the registration-time cost — see SPEC §9.1 row 8 (and its Amendment 1) for the contract. The option remains accepted on createCausl({ strictCycles }) for one major version so adopter call sites do not have to be edited in lockstep with the gate removal; both true and false produce identical first-commit-time semantics. A future amendment will remove the surface entirely (semver-major).