Two-engine architecture Enterprise

Causl ships two conformant implementations of the same denotational contract: causl-ts, the pure-TypeScript engine that is the open-source floor and reference oracle, and causl-wasm, the Rust→WebAssembly production engine consumed through the thin causl-client TypeScript API. Both expose the same public surface and are held byte-identical by a cross-backend determinism gate. The wasm path is an Enterprise feature: in causl-client it is the unconditional production engine (rust-ssot default), while causl-ts is retained as the §12 conformance reference. This page is the prose companion to SPEC §18A.

Why two engines

For most of its history Causl committed to one engine — the TypeScript value-of-record (SPEC §18 closes the arc on exactly that). The §13.8 reopen trigger fired on 2026-06-19: a governance decision committed the Rust-backed wasm engine as a conformant alternative to the TypeScript engine, with a named promotion path to make it the WASM-path default once the §18A.7 GO/NO-GO criteria were met. Those criteria are now all GO (2026-06-21), so the full Rust engine port has shipped — the wasm core is the production engine in causl-client.

The reopen fired now — not earlier and not as a slogan — because enough scaffolding had landed (the EPIC #680 Phase-1 bridges, the #1493 batched-boundary infrastructure, the #1071/#1146 cross-backend byte-identity gate, the §18A.3 FFI surface plan, the #1333/#1124 FFI-boundary invariants) that the engineered shape was no longer speculative. The decision to commit is recorded in §17.6 Commitment 15 (two-engine byte-identity conformance and the thin-TS-API-over-wasm-core architecture); §18A is its full technical contract. Critically, §17.6 retired the earlier "acceleration, not substitution" framing of Commitment 14 in favour of a real second engine promoted under §18A.7 — the thing behind the host-tier bridges is a second engine, not merely an accelerator of the first. That promotion has since landed: in causl-client the wasm core is the production engine (rust-ssot default), and the §18A.3 FFI structural lift means every adopter operation resolves from Rust.

causl-ts: the floor and the reference oracle

causl-ts is the TypeScript engine — the value-of-record cell topology running natively on the JS event loop. Per §13.8 and §18A.7 Criterion 5 it is the unconditional floor: it stays supported and byte-identical to the wasm engine for all adopter code, and any host that runs JavaScript runs causl. It is not the default engine in causl-client — there the production default is the Rust→WASM engine (rust-ssot), and causl-ts is retained internally as the §12 conformance reference and the implicit-path capability fallback (§18A.13.1). It remains the value-of-record and default backend in the causl-ts-wasm-engine fork. It is also the reference oracle — the byte-identity gate (below) measures the wasm engine against the TS engine's output, so the TS engine defines what "correct" means at the public boundary. An adopter who never imports @causl/core/wasm on the OSS pure-TS distribution sees zero behavioural change and pays zero bytes for the wasm path.

causl-wasm: the Rust core, reached over FFI

causl-wasm is the Rust engine (the engine-rs-core pure-algorithm crate plus the engine-rs-bridge JS↔WASM bridge crates) compiled to WebAssembly and reached over FFI. Per §18A.1 it is a conformant implementation of the same §3 semantic equation and the same §12 public surface. The substrates differ — engine-rs-core carries real Rust types: a generational NodeId { slot, gen } (#1151), BTreeMap-keyed JSON values (#1078), and the seven-named-struct cell shape (#1077). The behaviour at the public surface does not differ; that is the whole point of the gate (§18A.1.1).

The Rust crate is a from-scratch engine stood up against the same §0/§12 contract, not a line-by-line transliteration of the TS engine. §18A.4 and §18A.9 are explicit that the canonical way is a from-scratch Rust engine proven byte-identical via the gate — a transliteration "carrying two copies of the same substrate assumptions in two languages" was considered and rejected.

One public surface, two engines

Both engines implement the same public Graph contract. The seven-method spine that adopter code calls is identical regardless of which engine is behind it:

input · derived · commit · read · subscribe · snapshot · explain

Alongside the canonical seven, the engines expose the §12.2 second-tier structural surface — including dependencies and dependents (and their transitive closures), the commit log, commit metadata, handle/disposal/StaleTx validation, and stats. For the thin TS API to satisfy the full surface with no JS-engine fallback for any row, the wasm core must surface these as FFI externs; lifting that structural surface (today internal to engine-rs-core) into the public FFI contract is, per §18A.3, "the spine of the work" (≈12–15 new #[wasm_bindgen] externs, each backing a named §12 row, all routed through the existing wire-format codecs so the byte-identity gate holds).

Because the surface is identical, the engine choice lives at the BackendEngine seam (packages/core/src/backend.ts) and nowhere else. Your model code — input / derived / commit / read / subscribe — is the same on either engine. If application logic has to know which engine it is running on, that is a bug; the one legitimate exception is the read()-identity migration (below), which you fix once, defensively.

Synchronous construction: the preload seam (§18A.12)

Constructing a wasm graph used to be unavoidably async, because compiling the .wasm module is an await. That single asynchrony leaked into every call site — and the §12 commit / read spine is and stays synchronous (§18A.6), so a sync consumer (a React hook in render, an iasbuilt/xldatagrid cell) had no clean way to build a wasm graph without an await at the wrong place. §18A.12 splits the one unavoidable async (the WebAssembly compile) away from synchronous construction, so the seam offers a fully synchronous build path. This is shipped in the engine repo and in causl-client.

The split is a preload step you run once at app init, after which construction is a zero-await synchronous call:

// once, at app/init — the single await lives here
await preloadCauslWasm()              // compiles + caches the WebAssembly.Module
                                      // (+ the _bg.js sidecar + compute-imports
                                      // snippet), keyed by bridge; idempotent —
                                      // concurrent calls share one compile, and a
                                      // transient failure drops the cache

// anywhere after — fully synchronous, zero await, new instance from the cache
const graph = createCauslWasmSync()   // throws CauslWasmNotPreloadedError if not
                                      // preloaded; { fallbackToTs: true } returns
                                      // createCauslTs() instead of throwing

preloadCauslWasm(opts?) compiles and caches the WebAssembly.Module (plus the _bg.js sidecar and the compute-imports snippet), keyed by bridge; it is idempotent — concurrent calls share a single compile, and a transient failure drops the cache so a retry recompiles. Its synchronous peek companions — isCauslWasmPreloaded(bridge?) and getPreloadedCauslWasm(bridge?) — let a caller branch without awaiting. createCauslWasmSync(handle?, create?) then does a new WebAssembly.Instance from the cached Module with zero await; if nothing is preloaded it throws CauslWasmNotPreloadedError, and passing { fallbackToTs: true } returns createCauslTs() rather than throwing.

The retained async factory createCauslWasm(opts?): Promise<Graph> is now re-expressed as preload ∘ createCauslWasmSync — one codepath, not two — so the async and sync constructors cannot drift. On the server the seam is moot: Node's --target nodejs glue is already synchronous end-to-end, so no preload is needed there. It is the browser bundler target that needs the one-time preload, which is exactly where the sync render constraint bites.

Construction therefore resolves into an explicit factory trio, with engine choice made at the call site when you want it:

FactoryEngineShape
createCauslTs() The pure-TS floor. Synchronous. No toolchain, no preload — runs anywhere JS runs.
createCauslWasm() /
createCauslWasmSync()
The wasm engine. Async / synchronous. The sync form needs a prior preloadCauslWasm() on the browser target; both share one codepath.
createCausl() The default public factory. Synchronous. In causl-client it routes to the wasm engine (rust-ssot) once preloaded, and degrades loudly to the retained internal createCauslTs() on a WasmGC-unavailable host (§18A.13.1). In the causl-ts-wasm-engine fork it routes to the TS floor by default. See the causl-client direction below.

The equivalence contract: byte-identity (§18A.1)

"Two interchangeable engines" is a contract, not a slogan, because of the cross-backend byte-identity gate (§18A.1.1). Given the same action sequence, the two engines MUST produce byte-identical Commit records, graph snapshots, structural-query results (dependencies, dependents, commit-log, commit-metadata, stats), and subscriber fire-order. The gate runs at the §15.2 1000-trial property floor — JS-side in cross-backend-determinism.property.test.ts and Rust-side in bridge_byte_identity.rs — per-commit under the default single-commit envelope (afterN=1) and per-flush under the epic-#1493 batched-boundary opt-in. Zero-byte divergence on every replayed commit is the pass condition; any divergence is a CI failure and a §18A.7 Criterion-1 NO-GO.

Byte-identity is reachable on the Rust side only because the container invariants are pinned: §5.1 Amendment 4 (#1333) requires the per-node subscriber index to preserve insertion order (IndexMap<NodeId, SmallVec<[ObserverId; 2]>>); engine-rs-core's prior BTreeMap/BTreeSet shape (which sorts by id) is non-conformant and is promoted to insertion-order semantics concurrent with the FFI surface work.

causl-client: the thin TS API over the wasm core (§18A.4)

causl-client is the TypeScript-side FFI binding for the wasm engine — the third actor at the BackendEngine seam, alongside JsBackend (wrapping the TS engine) and WasmBackend (wrapping the Rust core). The Graph facade delegates every public method to the backend; for the wasm path that backend marshals parameters across the JS↔WASM boundary to the externs of §18A.3.

§18A.4 states plainly, so the boundary stays honest, what causl-client is not: not a copy of the TS engine's graph.ts; not the ~7.5k-LOC TS shell under a new name; not "the same TS engine, just calling into WASM." It is a thin adapter that delegates each public method to the wasm core over FFI, marshals parameters across the boundary (the cost is measured, not hidden), and binds to a placed .wasm artefact. It ships no Rust source and no build tooling — those live in the engine repo (below). The consumer-side framing for TS/Node apps is the Integrating causl-client guide.

causl-client: wasm-default with a TS capability-fallback (§18A.13 + §18A.13.1)

causl-client ships wasm as its sole default and public engine — but it is wasm-default with a TS capability-fallback, not strictly "wasm-only." This is shipped: gate-bypassed (a dated §18A.13 governance amendment, on complexity-elimination grounds), enterprise-only, and scoped to causl-client alone. createCausl() routes to the wasm engine (rust-ssot default), and the §18A.3 FFI structural lift landed (causl-wasm#170), so every adopter operation resolves from Rust. The public direct createCauslTs() factory was un-exported (executed wire-before-cut, epic #31), but createCauslTs is retained internally as the §12 conformance reference.

§18A.13.1 (2026-06-23) re-extended a loud TS capability-fallback to the implicit path. On a host where the WasmGC engine cannot instantiate — Safari < 18 / macOS < 15, policy-pinned pre-119 Chromium/WebView2, Node ≤ 20 — the implicit createCausl() degrades to the retained internal createCauslTs() (via JsFallbackBackend) loudly: a one-time console.warn + onCauslCapabilityFallback telemetry, never silent. The explicit createCauslWasm() / createCauslWasmSync() / engine: 'rust-ssot' factories still fail loud (CAUSL_WASM_ENGINE_UNAVAILABLE) — a consumer that explicitly asked for wasm must never silently run on JS. The literal zero-TS core (deleting createCauslTs outright) is therefore dropped from near-term scope by §18A.13.1. Caveat: the WasmGC capability probe is still a placeholder (#691), so the auto-degrade is wired in design but not yet a fully-runtime-proven probe.

The driver is complexity elimination, not speed: causl-client accepts the wasm engine's runtime characteristics as the single core and sheds the dual-engine branching. Performance is explicitly accepted as immaterial for this decision — the JS↔WASM wire tax (named at honest status) is paid knowingly. The cut is a §18A.13 deviation from the §18A.7 promotion gate that governs the default in the dual-engine repos; it does not move that gate, it bypasses it for this one consumer-facing package.

Repository topology (§18A.10)

The two-engine contract spans three first-party repositories under the causljs org. Each owns exactly one artefact of the §18A surface, and no repo duplicates another's role — the split is recorded so the FFI source-of-truth, the thin TS binding, and the TS value-of-record floor are never confused for one another. The SPEC.md is kept byte-identical across the repositories so each reads the same governing contract.

RepositoryRoleOwns
causljs/causl-wasm The wasm causl-core / engine repo — the source of truth for the causl-wasm engine. The §18A.3 FFI surface, engine-rs-core, engine-rs-bridge, the byte-identity gate's Rust side (bridge_byte_identity.rs), and the Python build & packaging tooling (build_wasm.py + package_wasm.py, §18A.11).
causljs/causl-client The TypeScript API for causl-wasm — the thin TS binding of §18A.4 (a binding over the FFI, not a copy of the TS engine). The §12 Graph contract as a thin binding over the wasm FFI, the --target nodejs loader hook (§18A.2) that resolves the placed artefact via node:fs, and the wasm-adoption guide. Consumes the placed .wasm; ships no Rust.
causljs/causl-ts-wasm-engine The causl-ts reference engine — the TypeScript value-of-record / §13.8 unconditional floor. The causl-ts engine (the floor until all §18A.7 criteria are met), the cross-backend benchmark / conformance harness (the byte-identity gate's JS side), and the historical wasm-cutover R&D.

Named first-party integration consumers are iasbuilt/xldatagrid and iasbuilt/webapp (Node.js TS web apps). This topology is descriptive — it records where the committed artefacts live and does not itself alter the §18A.7 GO/NO-GO criteria.

Build & packaging tooling (Python, §18A.11)

A webapp must be able to take the --target nodejs artefact and place it into its own deployment reproducibly — build-once / place-where-told, not rebuild-per-app and not fetch-late. Per §18A.11 the tooling is two stdlib-only CPython scripts (no pip install, no third-party deps) that live in causljs/causl-wasm — the engine repo, because the engine repo owns producing and placing its own artefact:

ScriptRole
scripts/build_wasm.py The BUILD half. Compiles the Rust core to a size-optimised, node-target .wasm (wasm-pack --target nodejs then external wasm-opt -Oz), staging the optimised .wasm + node glue + .d.ts typings and the §18A.12 compute-imports snippet next to the .wasm / _bg.js. This is the only place a Rust toolchain is needed — the producer side.
scripts/package_wasm.py The PACKAGE half — the script the Python webapp pipeline plugs in. It places the node-consumable artefact set at a consumer-stated destination (--dest, REQUIRED) and writes a deterministic version + per-file-sha256 manifest (causl-wasm.manifest.json). No network. Idempotent. Fails loudly (non-zero exit, clear stderr) on a missing source, an unhashable file, or an unwritable destination.

A Python pipeline states where the engine goes and calls the package script — it never authors a YAML kit or reasons about bundler integration:

import subprocess, sys
subprocess.run(
    [sys.executable, "scripts/package_wasm.py", "--build", "--dest", WASM_TARGET_DIR],
    check=True,
)

The contract the tooling must satisfy: the placement is versioned and checksum-pinned; there is no deploy/runtime network (the engine is vendored into the deploy artefact, offline-resolvable); the placed node-target artefact works across container, serverless, and SSR-Node deploy shapes without per-shape loader rework; a missing source, unhashable file, unwritable destination, or checksum mismatch is a hard, loud failure (never a silent fall-through to a wrong or partial engine); and the consuming pipeline needs only CPython stdlib — no Rust toolchain, mirroring §18A.4's "causl-client ships no Rust source."

Both scripts fail loud on the §18A.12 sync seam, so a build cannot quietly ship an artefact the synchronous constructor can't use. They assert: that the new WebAssembly.Instance seam is present (the sync path of §18A.12); that the compute-imports snippet was placed next to the .wasm / _bg.js; and that the artefact is node-loadable where it claims to be. The node-loadability check encodes a real bridge distinction: the gc-classic bridge loads as --target nodejs, but gc-builtins emits require("wasm:js-string") — which stock Node cannot resolve — so gc-builtins is asserted bundler-target only. Any of these failing is a non-zero exit with a clear message, never a silent pass.

The read()-reference-identity migration (§18A.5)

The one breaking change the wasm path introduces — and the one thing an adopter must audit for before upgrading to a real-Rust build. The TS engine caches values in its cells and returns the same JS reference per read() within a commit window. A real Rust engine deserialises values across the FFI boundary and returns a fresh object per call. Per the §15.1 amendment (#1124), reference identity across commits is not contractually guaranteed; value identity at a fixed GraphTime is preserved by both backends.

The fix is to key memoisation on commit.time (the GraphTime on the published Commit, byte-identical under both engines) or on the per-node version counter from EngineTelemetry (the read_derived_version extern, §18A.3) — never on the read() return reference. §18A.5's pre-migration checklist requires auditing every React.memo(...) and useMemo(() => transform(value), [value]) whose key is the read() return itself. A property guard (read-no-identity-contract.property.test.ts) wraps a backend in a deep-clone-on-read decorator and stays green to prove no engine-internal code relies on identity — the honest pre-disclosure of a silent break, surfaced before the swap rather than after.

FFI single-tick atomicity (§18A.6)

Theorem 2 (glitch-freedom — "no intermediate time") is proven on the TS engine by single-threaded JS event-loop evaluation. That proof does not survive an FFI boundary without additional machinery. The §3 Amendment (#1333) pins the contract: the marshal of a Commit envelope across the host boundary MUST be atomic with respect to JS-observable scheduling — no microtask, requestAnimationFrame, MessageChannel callback, or await-able continuation may run between engine-side Phase E (the Commit sealed in engine memory) and host-side Phase G (subscriber callbacks fired in the TS host).

The wasm bridge enforces this by construction: the FFI commit entry point (apply_commands / commit_batch) is a synchronous #[wasm_bindgen] extern, not async. The async surface you await is loading the backend (await loadWasmBackend()), not committing through it. Calling await apply_commands(...) from the TS host is syntactically permitted, breaks the uninterruptibility contract, and violates Theorem 2 silently — so do not await a commit. graph.commit(intent, tx => …) is and stays synchronous on both engines.

The 5-criterion GO/NO-GO gate — met 2026-06-21 (§18A.7)

The wasm engine became the default for the WASM path (route through WASM first) when, and only when, all five criteria below were met. On 2026-06-21 the named, dated promotion (causl-wasm#169) recorded all five criteria GO — this was a governance decision by the authority that opened §13.8, not a CI auto-flip. The mechanical flip is DEFAULT_WASM_ENGINE_MODE: 'js-ssot' → 'rust-ssot', verified in source at packages/core/wasm/index.ts:423; createCausl() / createCauslWasm() now route commit through the Rust engine by default for the WASM path.

#CriterionStatus
1 Correctness — cross-backend byte-identity. TS and wasm engines produce byte-identical commits, snapshots, structural-query results, and subscriber fire-order. (The §18A.1.1 gate.) GO — 0-byte divergence over 1000+ trials (100k nightly).
2 Completeness — full FFI surface. Every §12.1 canonical-seven and §12.2 second-tier row is reachable over FFI with no JS-engine fallback. (The §18A.3 completeness fixture.) GO — the §18A.3 FFI structural lift landed (causl-wasm#170); every adopter op resolves from Rust.
3 Performance floor — acceptable marshaling tax. Representative workloads stay under the named ceiling: single commit ≤ 250 µs p95 marshal overhead, batch / large mutation ≤ 5 ms p95. Gates "stays responsive" within the §14 RAIL budget, not "beats TS." GO / RESOLVED (2026-06-19). 6/6 RAIL cells PASS; perf is immaterial to the flip. Was one of the five — all now satisfied.
4 Node target + real-world adoption. The --target nodejs artefact + ESM/node:fs loader ships (§18A.2), and at least one adopter has shipped a production workload on the real Rust engine. GO — Node target shipped (epic #680); iasbuilt/xldatagrid is the named adopter.
5 TS-engine floor maintained. engine: 'js-ssot' stays supported and byte-identical: no §12 row removed, no perf regression the TS engine could not also accept. GO — the js-ssot floor is a one-line per-graph opt and the value-of-record in the fork; §18A.13.1 re-extends it to causl-client's implicit fallback path.

Reversibility & the governance revert (§18A.8)

The 2026-06-21 fail-safe-removal amendment (§18A.8, causl-wasm#169) removed the per-flush TS-vs-Rust byte-compare oracle and the V2.5/#1544 K=1 sticky-downgrade fail-safe. Under engine: 'rust-ssot' the Rust commit_batch post-state is applied and the Rust-derived Commit returned unconditionally — no per-flush compare, no rollback, no in-process demotion to js-ssot. The justification was 0-byte divergence over 100,000 trials, far beyond the 1000-trial floor. The CI-blocking cross-backend byte-identity gate (§18A.1.1) is now the sole conformance guarantee and sole detector: a divergence is a HALT-before-merge condition, and the revert path is a governance revert (re-pin the floor), not a runtime downgrade.

Honest status today

The discipline throughout §18A is, in its own words, "brutal honesty: the costs are named with the rationale, not buried in an adoption guide." So, preserving the SPEC's own "shipped" vs "planned" distinctions:

In short: causl-wasm via causl-client is the production engine — the real Rust engine, rust-ssot by default, with the byte-compare oracle and sticky-downgrade removed and the node loader + FFI lift landed. causl-ts is the unconditional floor and the differential oracle, retained in causl-client as the §12 reference and the implicit-path capability fallback (§18A.13.1, loud on a WasmGC-unavailable host), and the value-of-record in the fork. Construction is synchronous behind a one-time preload (§18A.12, shipped).

Related