Two-engine architecture Enterprise

Causl ships two conformant implementations of the same denotational contract: a pure-TypeScript engine that is the open-source floor and reference oracle, and causl-core-rs, the Rust→WebAssembly production engine consumed through the thin causl-wasm-ts TypeScript client. Both expose the same public surface and are held byte-identical by a cross-backend determinism gate. The two engines no longer live in one repository. Since the 2026-07-27 rename, causl-wasm-ts ships only the Rust engine, and causl-core-ts is the repository that stays dual-engine and remains the differential oracle. 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-wasm-ts.

The reopen fired then, 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-client-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-wasm-ts the wasm core is the production engine (rust-ssot default), and the §18A.3 FFI structural lift means every adopter operation resolves from Rust.

The TypeScript floor and the reference oracle

The TypeScript engine is 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 present in causl-wasm-ts at all. There the production engine is the Rust→WASM engine (rust-ssot) and nothing else. The TypeScript engine is the value-of-record and default backend in causl-core-ts, which still defaults to js-ssot, and it ships as open source as causl-ts.

It is also the reference oracle. The byte-identity gate (below) measures the wasm engine against the TypeScript engine's output, so the TypeScript engine defines what "correct" means at the public boundary. causl-wasm-ts pins causl-core-ts by commit SHA (9c3d947e…) and runs 5,000 differential trials per pull request against it. An adopter on the open-source pure-TypeScript distribution sees zero behavioural change from any of this and pays zero bytes for the wasm path.

causl-core-rs: the Rust core, reached over FFI

causl-core-rs 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 TypeScript 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.

The engine repo is called causl-core-rs, and that is its only name. The old name causl-wasm survives purely as a redirect on Gitea, so a link to github.com/causljs/causl-wasm is stale. The engine repository is git.opsite.ca/causl/causl-core-rs, and it is private, so throughout this page I name repositories, files and issues without linking to them; reading the source needs a Gitea account on that host.

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: dependencies and dependents (and their transitive closures), the commit log, commit metadata, handle/disposal/StaleTx validation, and stats. For the thin TS client to satisfy the full surface with no JS-engine fallback for any row, the wasm core has to surface these as FFI externs. Per §18A.3 that structural lift was the spine of the work, and it has landed (causl-core-rs#170): every §12 row is now a named #[wasm_bindgen] extern routed through the existing wire-format codecs, so the byte-identity gate still holds over it, and every adopter operation resolves from Rust.

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-wasm-ts.

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 CAUSL_WASM_NOT_PRELOADED if the // preload never resolved

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 with code CAUSL_WASM_NOT_PRELOADED. There is no fallbackToTs escape hatch any more; see the withdrawn fallback.

The retained async factory createCauslWasm(opts?): Promise<Graph> is now re-expressed as preload ∘ createCauslWasmSync, one codepath rather than 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 two live factories, with engine choice made at the call site when you want it:

FactoryEngineShape
createCauslWasm() /
createCauslWasmSync()
The wasm engine, explicitly. Async / synchronous. The sync form needs a prior preloadCauslWasm() on the browser target; both share one codepath.
createCausl() The default public factory. Synchronous, and construct-or-throw. In causl-wasm-ts it routes to the wasm engine (rust-ssot) once preloaded and throws otherwise; two statements, no branch. In causl-core-ts it routes to the TypeScript floor by default (js-ssot).

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. Since the split, causl-wasm-ts runs the gate against a SHA-pinned causl-core-ts at 5,000 trials per pull request.

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 was promoted to insertion-order semantics alongside the FFI surface work.

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

causl-wasm-ts is the TypeScript-side FFI binding for the wasm engine, the actor at the BackendEngine seam that wraps the Rust core. The Graph facade delegates every public method to the backend, and 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-wasm-ts is not: not a copy of the TypeScript 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-wasm-ts guide.

The withdrawn TypeScript fallback (§18A.13, and §18A.13.1 as a dated record)

causl-wasm-ts ships wasm as its sole engine. This is shipped: gate-bypassed (a dated §18A.13 governance amendment, on complexity-elimination grounds), enterprise-only, and scoped to this one package. createCausl() routes to the wasm engine (rust-ssot default), and the §18A.3 FFI structural lift landed (causl-core-rs#170), so every adopter operation resolves from Rust.

For a period in 2026 that was not the whole story. §18A.13.1 (2026-06-23) re-extended a loud TypeScript capability-fallback to the implicit path: on a host where the wasm engine could not instantiate, createCausl() would degrade to a retained internal createCauslTs() with a one-time console.warn plus telemetry. That section was withdrawn at 0.5.0. Its own heading now says so, and it adds that it should be read "as a dated record, not as a live obligation."

Two things killed it, and both are more interesting than the withdrawal itself:

So today createCausl() is construct-or-throw, two statements in packages/core/src/graph.ts, and createCauslTs is deleted from source. A git grep createCauslTs over packages/*/src/** returns zero hits; it survives only in tests that assert its absence and in dated records like the paragraph above. Branch on error.code and never on instanceof: CAUSL_WASM_ENGINE_UNAVAILABLE (the wasm subpath was never imported, or the host cannot instantiate the engine) and CAUSL_WASM_NOT_PRELOADED (imported, but preloadCauslWasm() never resolved).

The driver for the original cut was complexity elimination, not speed. causl-wasm-ts accepts the wasm engine's runtime characteristics as the single core and sheds the dual-engine branching. Performance was explicitly accepted as immaterial to that decision. The cut is a §18A.13 deviation from the §18A.7 promotion gate that governs the default in the dual-engine repository; 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, all now hosted on Gitea at git.opsite.ca/causl/. 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 client, and the TypeScript value-of-record floor are never confused for one another. All three were renamed on 2026-07-27; the old names are 301 redirects.

RepositoryWasRoleOwns
causl-core-rs causl-wasm The Rust engine repo, the source of truth for the Rust→WebAssembly 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 and packaging tooling (build_wasm.py + package_wasm.py, §18A.11). Publishes @causl/core-rs.
causl-wasm-ts causl-client The TypeScript client for the Rust engine, the thin TS binding of §18A.4 (a binding over the FFI, not a copy of the TS engine). Single-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 adoption docs. Consumes the placed .wasm; ships no Rust and no TypeScript engine. Publishes @causl/causl-wasm-ts.
causl-core-ts causl-ts-wasm-engine The TypeScript reference engine, the value-of-record and §13.8 unconditional floor. Stays dual-engine. The TypeScript engine, the differential oracle for the byte-identity gate's JS side, the benchmark and conformance harness, and the historical wasm-cutover R&D. Still defaults to js-ssot. causl-wasm-ts pins it by commit SHA and runs 5,000 differential trials per pull request against it.

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.

SPEC.md is no longer byte-identical across the repositories. It used to be, and §18A.10 used to promise it would stay that way. As measured on 2026-08-02 the two copies are 3,333 and 3,280 lines: the causl-core-ts copy is missing §5.1 Amendments 5 to 7 and §5.7. Treat the copy in causl-wasm-ts as the current one, and treat the copy bundled with this site as a dated 3,280-line snapshot rather than the live contract.

Build and 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 and 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 causl-core-rs, 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 and .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 plus per-file-sha256 manifest. 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 or runtime network (the engine is vendored into the deploy artefact and is 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 and 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-wasm-ts 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 cannot 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. Any of these failing is a non-zero exit with a clear message, never a silent pass.

Only one bridge ships now. The node-loadability check used to encode a distinction between two bridges: gc-classic loads as --target nodejs, while gc-builtins emitted require("wasm:js-string"), which stock Node cannot resolve, so gc-builtins was asserted bundler-target only. gc-builtins was subsequently deleted (causl-core-rs#355). gc-classic is the only bridge that ships. The ids are gc-classic and gc-builtins, never wasmgc-classic or wasmgc-builtins.

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

This is 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 TypeScript engine caches values in its cells and returns the same JS reference per read() within a commit window. 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.

Be precise about what the wasm engine does, because the obvious mental model is wrong in a way that makes the bug harder to find. It is not uniformly fresh. A ValueHandleCache retains the original adopter reference, so read() identity is stable on a cache hit and fresh only on the decode paths. The break is therefore intermittent, and an intermittent identity break is considerably harder to catch than a uniform one: a test that reads twice in a row may well pass.

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 TypeScript 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 engine (await preloadCauslWasm()), 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-core-rs#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', and createCausl() / createCauslWasm() now route commit through the Rust engine by default for the WASM path.

#CriterionStatus
1 Correctness, cross-backend byte-identity. The TypeScript 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), and 5,000 differential trials per pull request today.
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-core-rs#170); every adopter operation resolves from Rust.
3 Performance floor, acceptable marshalling tax. Representative workloads stay under the named ceiling: single commit ≤ 250 µs p95 marshal overhead, batch or large mutation ≤ 5 ms p95. Gates "stays responsive" within the §14 RAIL budget, not "beats TS." GO / RESOLVED (2026-06-19), and now known to have been the wrong bar to worry about. Marshalling is not the binding constraint: see wasm performance.
4 Node target plus real-world adoption. The --target nodejs artefact and ESM/node:fs loader ship (§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 TypeScript engine could not also accept. GO, with the floor now located in causl-core-ts, which still defaults to js-ssot and hosts the differential oracle. The floor is no longer reachable from inside causl-wasm-ts.

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

The 2026-06-21 fail-safe-removal amendment (§18A.8, causl-core-rs#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" versus "planned" distinctions:

In short: the Rust engine reached through causl-wasm-ts is the production engine, rust-ssot by default, with the byte-compare oracle and sticky-downgrade removed and the node loader plus FFI lift landed. The TypeScript engine is still the unconditional floor and the differential oracle, but it lives in causl-core-ts and no longer inside the client. Construction is synchronous behind a one-time preload (§18A.12, shipped).

Related reading