Integrating causl-client into a TypeScript / Node.js app Enterprise
The Node-integration counterpart tocausl-wasm's producer-sidescripts/README.md. That file documents how a Python pipeline builds and places the wasm artefact; this file documents how a TS/Node app consumes the placed artefact and binds it through the@causl/core/wasmthin TS API.
This guide is the consumer-side concretion of SPEC §18A.4 (the thin-TS-API definition) and §18A.2 (the Node-target requirement). It covers, in order:
- What
causl-clientis — the thin TS API over the wasm core, and what it is not. - Installing and using it in a TS/Node app — including the preload + synchronous construction pattern (§18A.12).
- Where the wasm artefact comes from
— the producer/consumer split and the
causl-wasmPython scripts. - The
node:fsloader and host-tier matrix. - The
read()-identity migration — the one breaking change you must audit for before a real-Rust build. - The performance ceiling — what the wasm path costs and what it does not buy you today.
Every load-bearing claim cites a SPEC § anchor. Where the shipped code and the SPEC contract differ — they do, in two named places — the gap is called out as Shipped today vs Planned (§…), never blurred.
1. What causl-client is
causl is a reactive dependency-graph engine with denotational,
glitch-free, transactional semantics. Its public contract is the §12
surface: the seven-method spine
input · derived · commit · read · subscribe · snapshot · explain
plus the structural queries dependencies / dependents (and their transitive
closures, the commit log, commit metadata, handle/disposal validation, stats —
the §12.2 second-tier surface).
Per SPEC §18A.1, that contract ships in two conformant engines, held byte-identical at the §12 boundary by the cross-backend determinism gate (§18A.1.1):
| Engine | What it is | Status |
|---|---|---|
causl-ts |
The TypeScript reference engine — the value-of-record running natively on the JS event loop. | The unconditional floor (§13.8). Lives in causljs/causl-ts-wasm-engine. |
causl-wasm |
The Rust core (engine-rs-core + engine-rs-bridge) compiled to WebAssembly, reached over FFI. |
The unconditional production engine in causl-client (rust-ssot is the default); the §18A.3 FFI lift landed (causl-wasm#170) so every adopter op resolves from Rust. Lives in causljs/causl-wasm. |
causl-client is the thin TypeScript API over the causl-wasm core
(§18A.4). Stated plainly so the boundary stays honest, it 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 (1) implements the public Graph interface by
delegating each method to the wasm core over FFI; (2) marshals parameters across
the JS↔WASM boundary (the cost is measured, not hidden — §6 below); and (3)
binds to the placed .wasm artefact. It ships no Rust source and no build
tooling — the produce-and-place tooling is the engine repo's
(causljs/causl-wasm, §18A.11); the consume-and-bind surface is this repo's.
Repository topology (§18A.10)
causljs/causl-wasm Rust engine + FFI externs + Python build/package tooling
(the source of truth for the causl-wasm engine)
causljs/causl-client THIS REPO — the thin TS API + Node loader + adoption docs
(consumes the placed .wasm; ships no Rust)
causljs/causl-ts-wasm-engine causl-ts (the TS floor) + cross-backend bench/conformance harness
Named first-party integration consumers: iasbuilt/xldatagrid and
iasbuilt/webapp (Node.js TS web apps).
Current state of the wasm path (SPEC §18A.3 / §18A.5). TheWasmBackendreturned byloadWasmBackend()is a real Rust engine (engine-rs-core), not a TS wrapper: the §18A.3 FFI structural lift has landed (causl-wasm#170), so every adopter operation resolves from Rust — engine orchestration (commit pipeline, dependency tracking, cutoff, history) runs in Rust, while the user'sderived()compute lambdas run in JS over the bridge callback by design. The FFI seam, the bridge picker, and the cross-backend byte-identity gate are stable and enforced. Importing the wasm entry point is the unconditional production path; the standing JS↔WASM marshal cost is named honestly (§17.6 G.1), within the §14 RAIL budget — not a "beats-TS" claim. This is repeated at the top ofpackages/core/wasm/README.md.
causl-client→ wasm-default with a TS capability-fallback (SPEC §18A.13 + §18A.13.1; shipped). By a recorded governance decision,causl-clientships the wasm engine as its sole default and public engine (rust-ssot is the default) and has un-exported the pure-TypeScript factory (createCauslTs) from the public@causl/corebarrel. The driver is complexity-elimination — one engine, one codepath — with perf explicitly accepted as immaterial here (it deliberately bypasses the §18A.7 GO/NO-GO perf gate and says so plainly). §18A.13.1 (2026-06-23) retainscreateCauslTsinternally and wires it as the implicitcreateCausl()path's WasmGC-unavailable capability fallback: on a host where WasmGC cannot instantiate, the implicitcreateCausl()degrades loudly (console.warn + telemetry, never silent) to the TS floor, while explicitcreateCauslWasm()/engine: 'rust-ssot'still fail loud (CAUSL_WASM_ENGINE_UNAVAILABLE). So this is not strictly "wasm-only," and deletingcreateCauslTsoutright is dropped from near-term scope. (The WasmGC probe itself is still a placeholder, #691.) The cut was executed wire-before-cut: the §18A.12 sync seam (below) landed first socreateCausl()routes to the wasm engine synchronously at every call site (post-preload), and the public TS factory was then un-exported (epiccausl-client#31). The §18A.3 FFI structural lift has since landed (causl-wasm#170), so every adopter operation resolves from Rust; the internal TS engine is retained (not deleted) as the §12 reference and the §18A.13.1 capability fallback. Scope: the cut iscausl-clientonly. The fork that hosts the §18A.1.1 differential oracle and the benchmark suite (causljs/causl-ts-wasm-engine) deliberately keeps the dual-engine TS floor as its DEFAULT (DEFAULT_WASM_ENGINE_MODE = 'js-ssot') and adds a real-Rust differential leg as the byte-identity oracle — it does not flip to rust-ssot.
2. Installing and using it
2.1 Install
pnpm add @causl/core # or: npm i @causl/core / yarn add @causl/core
The wasm path is an opt-in subpath. Importing @causl/core pulls in only the
TS engine and a tiny loader stub (~1 KB); the wasm bundle cost is paid only by
callers who explicitly import('@causl/core/wasm'). Node 22+ is required
(engines.node: ">=22").
2.2 The smallest worked example (TS engine — the floor)
Identical to SPEC §10. Two inputs, one derived, one diamond, one subscriber, two commits, three observed propagations:
import { createCausl } from '@causl/core'
const graph = createCausl() // backend: 'js' is the default
const a = graph.input('a', 1)
const b = graph.input('b', 2)
const sum = graph.derived('sum', (get) => get(a) + get(b))
const sumPlusOne = graph.derived('sumPlusOne', (get) => get(sum) + 1)
graph.subscribe(sumPlusOne, (v) => console.log(v)) // 4
graph.commit('bump-a', (tx) => tx.set(a, 10)) // 13
graph.commit('bump-both', (tx) => {
tx.set(a, 100)
tx.set(b, 200)
}) // 301 — exactly one notification, not two (glitch-free)
All mutation happens inside commit; outside, the graph is read-only
(§12, commitment 2). The single notification on the second commit is
glitch-freedom as a theorem, not a scheduler trick (§3 Theorem 2).
2.3 Opting into the wasm engine
There are two adopter-facing paths. Pick based on whether you want WASM unconditionally or only when a workload heuristic trips.
Path A — drive the wasm backend directly (loadWasmBackend()). This is the
canonical path for "I want the wasm engine on this graph." loadWasmBackend()
returns a BackendEngine — the third actor at the BackendEngine seam
(packages/core/src/backend.ts), alongside JsBackend (the TS engine) and
WasmBackend (the Rust core). Always wrap it in the documented fallback:
import { createCausl } from '@causl/core'
import { loadWasmBackend, WasmBackendUnavailableError } from '@causl/core/wasm'
import type { BackendEngine } from '@causl/core/wasm'
async function makeEngine(): Promise<{ kind: 'wasm' | 'js' }> {
try {
const backend: BackendEngine = await loadWasmBackend()
// … wire `backend` as the graph's engine seam (see §2.4 note) …
return { kind: 'wasm' }
} catch (err) {
if (err instanceof WasmBackendUnavailableError) {
// Host can't run the chosen bridge (CSP block, missing WasmGC,
// pinned-but-unsupported bridge, fetch failure). Fall back to the
// TS floor — it runs on any host that runs JavaScript.
return { kind: 'js' }
}
throw err
}
}
WasmBackendUnavailableError carries a structured code field so you can
dispatch per failure mode (CAUSL_WASM_NOT_BUILT, CAUSL_WASM_CSP_BLOCKED,
…); the five codes and the per-code dispatch are documented in
docs/wasm-adoption-guide.md §3. The loader does
not auto-fall-back — you decide.
Path B — let the engine choose (backend: 'auto'). Start on the TS engine
and migrate to the wasm backend at runtime when the auto-adapt heuristic trips
(graph size, derivation depth, subscriber count). The migration is one-way (a
transient spike cannot ping-pong the selection):
import { createCausl } from '@causl/core'
const graph = createCausl({ backend: 'auto' })
// runs on the TS engine until the heuristic trips, then transparently
// migrates the same graph onto the wasm backend.
Accuracy note — thecreateCauslbackendoption. Incausl-client(wasm-default), the production path is the wasm engine reached throughpreloadCauslWasm()+ the synccreateCausl()/createCauslWasmSync()route (§18A.13) —createCauslresolves to the wasm engine post-preload, no per-callbackendstring needed. The historicalbackend: 'js' | 'auto'string option and the'auto'auto-adapt heuristic live on the fork's dual-engine distribution;'wasm'was never a synchronous constructor string because loading the artefact requires theimport('@causl/core/wasm')+loadWasmBackend()/preload seam. SPEC §18A.4/§18A.1 describe the seam as "backend: await loadWasmBackend()passed tocreateCausl"; on the wasm-default distribution that seam is now the default, reached via the preload + sync split below.
2.4 Switch engines at the seam, never in user code
The whole point of the byte-identity gate is that the two engines are
interchangeable at the public-contract boundary. Your model code —
input / derived / commit / read / subscribe — is identical regardless
of which engine is behind it. The 'js'↔'wasm' choice lives at the
BackendEngine seam (packages/core/src/backend.ts) and nowhere else. If a
piece of application logic has to know which engine it's running on, that is a
bug — the only legitimate exception is the read()-identity migration (§5),
which you fix once, defensively, so it is correct under both engines.
2.5 Synchronous wasm construction — preload once, build sync (§18A.12)
The two paths above reach the wasm engine through an await at the
construction site (await loadWasmBackend(), or the one-call
await createCauslWasm() convenience). For a synchronous consumer — a React
hook or render body, an SSR pass, xldatagrid's imperative grid API — an
await at the build site is exactly where it hurts. SPEC §18A.12
splits the one unavoidable async (compiling the WebAssembly.Module) out of
construction so the construction site stays synchronous. This is
shipped — in the fork (causljs/causl-ts-wasm-engine, the authoritative
loader source) and in causl-client.
The factory trio. Engine choice is explicit at the call site when you want it — there is no silent runtime flip:
| Factory | Engine | Sync? | Subpath |
|---|---|---|---|
createCauslTs() |
The pure-TypeScript reference engine — the floor that runs anywhere JS runs. | sync | @causl/core |
createCauslWasm() / createCauslWasmSync() |
The wasm engine (Rust→WebAssembly core, reached through the consolidated causl-engine-bridge loader). |
async / sync | @causl/core/wasm |
createCausl() |
In causl-client routes to the wasm engine post-preload (§18A.13), degrading loudly to the retained internal createCauslTs() on a WasmGC-unavailable host (§18A.13.1). On the fork's dual-engine distribution it routes to createCauslTs() by default (js-ssot). |
sync | @causl/core |
The pattern: preload once at init, then construct synchronously. The
single await lives at app/init; every render, hook, or SSR site thereafter
builds a wasm graph with no await:
import { preloadCauslWasm, createCauslWasmSync } from '@causl/core/wasm'
// ONCE, at app/init — the only async seam. Compiles + caches the
// WebAssembly.Module (plus the _bg.js sidecar and the compute-imports
// snippet), keyed by bridge. Idempotent: concurrent calls share one
// compile; a transient failure drops the cache entry for retry.
await preloadCauslWasm()
// Thereafter, at every render / hook / SSR site — FULLY synchronous.
// `new WebAssembly.Instance` from the cached Module, zero await.
const graph = createCauslWasmSync()
await preloadCauslWasm(opts?)— the one async seam, called once per process + bridge at app init. PaysWebAssembly.compileand the sidecar / snippet imports up front. CompanionsisCauslWasmPreloaded(bridge?)andgetPreloadedCauslWasm(bridge?)are synchronous peeks at the resolved state (set on resolution, not merely while the compile is in-flight) — useful for a render guard.createCauslWasmSync(handle?, create?): Graph— fully synchronous; mints a freshWebAssembly.Instancefrom the cached Module with zeroawait. If nothing is preloaded for the bridge it throwsCauslWasmNotPreloadedError(namingpreloadCauslWasm()as the remedy), preserving the §18A loud-fail discipline — a silent swap to the TS floor would differ inread()-identity and commit-clock (§5), a glitch-freedom hazard. Pass{ fallbackToTs: true }to synchronously degrade tocreateCauslTs()instead of throwing (safe while both engines ship, because the two are byte-identical at the §12 boundary per §18A.1.1).createCauslWasm(opts?): Promise<Graph>— the async one-call factory is retained, now re-expressed aspreloadCauslWasm ∘ createCauslWasmSync(one instantiate codepath, zero drift). Reach for it when anawaitat the construction site is fine; reach for the preload + sync split when it is not.
Node vs browser (cross-ref §18A.2). On Node / SSR the --target nodejs
glue is already synchronous end-to-end (readFileSync +
new WebAssembly.Module + new WebAssembly.Instance at require/import
time), so createCauslWasmSync() works without a prior preload — the
server render stays synchronous on its own. It is the browser bundler target
that needs the one-time await preloadCauslWasm() to complete before the
first render that constructs a wasm graph; this ordering is what keeps SSR↔CSR
hydration parity (no engine constructed mid-render in an unresolved-promise
state). An app that skips the preload does not silently block — it throws (or, with
fallbackToTs, degrades to the floor).
Atomicity is untouched (cross-ref §4.3 / §18A.6 / §3 Theorem 2). The compileawaitis hoisted to bootstrap, outside any commit envelope;createCauslWasmSyncperforms noawaitbetween the §5 Phase E and Phase G boundaries. The single-tick invariant of §4.3 holds exactly as before — moving the one async to app init does not put anawaitanywhere near a commit.
3. The wasm artefact placement (the producer/consumer split)
causl-client consumes a placed .wasm artefact. It does not build one. Per
§18A.11, the build-and-place tooling is two stdlib-only Python scripts in
causljs/causl-wasm — the producer side — and a webapp's CI/CD pipeline drives
them. The split:
| Side | Repo | Artefact | Tool |
|---|---|---|---|
| Producer | causljs/causl-wasm |
builds + places the .wasm |
scripts/build_wasm.py, scripts/package_wasm.py |
| Consumer | causljs/causl-client (you) |
binds the placed .wasm over FFI |
the @causl/core/wasm loader |
3.1 Producer side — the Python scripts (run by your pipeline)
These need a Rust toolchain (cargo + wasm-pack + wasm-opt); your consuming
pipeline does not — it only needs CPython stdlib to run package_wasm.py. The
full contract is in causl-wasm's scripts/README.md.
# Build + place in one call — the common pipeline entry point.
# --dest states WHERE the artefact goes; the consumer picks the path.
python3 scripts/build_wasm.py --bridge gc-classic # → build/wasm-nodejs
python3 scripts/package_wasm.py --build --dest /path/to/app/src/engine/wasm
build_wasm.py produces a size-optimised, node-target (--target nodejs)
.wasm plus the node glue (causl_engine_bridge.js) and .d.ts typings.
package_wasm.py copies that artefact set into --dest and writes a
deterministic manifest (causl-wasm.manifest.json, schema
causl-wasm/manifest@1) recording the engine version, bridge, target,
wasm-opt flags, and a per-file sha256. It is stdlib-only, makes no network
calls, and fails loud (non-zero exit, clear stderr) if --src is
incomplete, a file can't be hashed, or --dest is unwritable.
The same scripts also place the compute-imports snippet next to the
.wasm / _bg.js and assert fail-loud at packaging time on three
§18A.12 invariants, so a wasm-pack / wasm-bindgen upgrade
that breaks the synchronous-construction seam (§2.5) never ships silently:
- The sync seam — the glue must still instantiate synchronously
(
new WebAssembly.Instanceat module load, not a Promise), which is what letscreateCauslWasmSync()stayawait-free. - Snippet presence — every
./snippets/.../*.jsthe glue references is required; a missing compute-imports snippet wouldMODULE_NOT_FOUNDthe sync factory's dynamic import. - Node-loadability —
gc-classicloads as a stock--target nodejsartefact, whilegc-builtinsemitsrequire("wasm:js-string"), which stock Node cannot resolve, so it is flagged bundler-target only rather than shipped as a broken node artefact.
3.2 Consumer side — vendor + verify the manifest
In your app's CI/CD, after package_wasm.py has placed the artefact, verify it
against the manifest before the build proceeds. This closes the
"producer ships, consumer places" loop honestly:
# verify-wasm-manifest.py — a loud post-place gate for your pipeline.
import hashlib, json, pathlib, sys
dest = pathlib.Path("src/engine/wasm")
manifest = json.loads((dest / "causl-wasm.manifest.json").read_text())
for f in manifest["files"]:
digest = hashlib.sha256((dest / f["name"]).read_bytes()).hexdigest()
if digest != f["sha256"]:
sys.exit(f"checksum mismatch for {f['name']}")
print(f"wasm artefact verified: {manifest['engineVersion']} ({manifest['bridge']})")
Git-track the manifest so rebuilds are deterministic and a drifted artefact fails CI loudly rather than shipping silently.
Build targets. The.wasmartefacts vendored in this repo underpackages/core/wasm-pkg/gc-classic-bundler/andgc-builtins-bundler/are--target bundlerbuilds (webpack 5 / Vite 5 / esbuild via asset-pipeline loaders) for the browser path. The--target nodejsartefact + thenode:fsloader hook is the §18A.2 requirement (Criterion 4) and is the Node production shape this guide and thecausl-wasmscripts target. On the Node path the loader resolves the placed artefact vianode:fswith no async load shape; the browser path uses the bundler-resolvednew URL(..., import.meta.url)shape (§4.2). When you wire a pure-Node service with the--target nodejsartefact, consume it as thecausl-wasmscripts/README.mdshows (synchronousrequire/importof the node glue) and bind it through theBackendEngineseam.
4. The loader and the host-tier matrix
4.1 The host-tier bridges (§17.6)
The bridge picker (detectBridge()) probes the host at module load and selects
the most-capable artefact it actually supports; a host that lacks the higher tier
falls back automatically, and the TS engine is the universal floor below all of
them. The two shipped bridge variants of the consolidated
causl-engine-bridge crate:
| Bridge id | String strategy | When picked |
|---|---|---|
wasmgc-builtins |
js-string-builtins — no-copy wasm:js-string |
Hosts with wasm:js-string host bindings (Node 22.6+, Chrome 131+, Firefox 130+). The fastest tier. |
wasmgc-classic |
classic-strings — UTF-16 fallback |
The universal WasmGC baseline; needs no wasm:js-string host support, so it instantiates on every WasmGC-capable host. The safe default detectBridge() returns. |
Pin a bridge to skip detection cost when you know your target:
const backend = await loadWasmBackend({ bridge: 'wasmgc-classic' })
4.2 The loader resolution shape
Shipped today (bundler target). The loader resolves the artefact via
new URL('./pkg/<segment>/causl_engine_bridge_bg.wasm', import.meta.url) — the
lowest common denominator across webpack 5 (experiments.asyncWebAssembly),
Vite 5 (vite-plugin-wasm), esbuild 0.20+ (--loader:.wasm=file), and Node
22+ ESM. Override the base URL for a CSP connect-src / CDN scenario:
const backend = await loadWasmBackend({
wasmBaseUrl: 'https://cdn.jsdelivr.net/npm/@causl/core@<version>/wasm/pkg/',
})
Node target (§18A.2). The --target nodejs glue instantiates the
wasm synchronously at require/import time, so the Node loader hook resolves
the placed artefact via node:fs with no async load shape — it works
identically in CJS and ESM. This is the loader the causl-wasm scripts/README.md
"Consuming the packaged artefact in Node" section pairs with, and it satisfies the
§18A.2 requirement. The browser bundler path above is the parallel shape for the
Vite / webpack target.
4.3 The FFI single-tick invariant — never await the commit entry (§18A.6)
The FFI commit entry point (apply_commands / the Rust commit_batch extern) is
a synchronous #[wasm_bindgen] call. The async surface you await is
loading the backend (await loadWasmBackend()), not committing through it.
A commit is one atomic tick (§3 Theorem 2 / §5 Phase A–H "no intermediate
time"). Do not await a commit — interleaving an await mid-commit would
break single-tick atomicity silently. graph.commit(intent, tx => …) is and
stays synchronous on both engines.
5. The read()-identity migration (§18A.5 / §15.1 #1124)
This is the one breaking change the wasm path introduces, and the one thing you must audit for when consuming the Rust engine.
The contract. graph.read(node) is not contractually required to return
the same JavaScript reference across calls. Value identity at a fixed
GraphTime is guaranteed; reference identity across commits is not
(§15.1, ratified by the #1124 amendment, shipped with 0.9.0).
Why it bites. With the §18A.3 FFI lift landed (causl-wasm#170), a
wasm-backed read() returns a fresh object per call — the value is
deserialised across the FFI boundary on each read. Any adopter who keys
memoisation on the read() return reference re-renders every commit,
silently. No error fires — memoise on commit.time / node-version instead (§5).
The fix — key on commit.time or a per-node version, not on read()
identity.
// WRONG — breaks at the real-Rust swap. The `read()` reference changes
// every commit, so this memo invalidates every commit (or, worse,
// never updates if the reference is reused trivially today).
import { useMemo } from 'react'
function UserCard({ user }: { user: User /* a read() return */ }) {
const transformed = useMemo(() => transform(user), [user]) // ← reference key
// ...
}
// RIGHT — keys on commit.time (the GraphTime on the published Commit),
// which advances by exactly one per commit and is byte-identical under
// both engines.
import { useMemo } from 'react'
function UserCard({ user }: { user: User }) {
const commit = useCauslCommit() // commit.time: GraphTime
const transformed = useMemo(() => transform(user), [commit.time, user])
// ...
}
For workloads where commit.time is too coarse (it advances on every commit,
even ones that don't touch your node), key on the per-node version counter
instead — the read_derived_version extern surfaced through EngineTelemetry:
const telemetry = useEngineTelemetry()
const version = telemetry.nodeVersion(node) // bumps only when `node` changes
const transformed = useMemo(() => expensiveTransform(value), [version])
Pre-migration checklist (required reading before a real-engine upgrade, §18A.5). Audit, across your codebase:
- every
React.memo(C, (prev, next) => prev.value === next.value)whose equality compares aread()return; - every
useMemo(() => transform(value), [value])whose dependency array holds aread()return; - any cache,
WeakMap, or===check keyed on aread()reference held across a commit boundary.
Migrate each to commit.time (GraphTime on the Commit) or the per-node
version counter. A dev-only hazard warning is available behind
createCausl({ enableH1HazardWarning: true }) — it records each long-held
read() return as a WeakRef and emits one console.warn per survivor whose
read-time GraphTime predates the post-commit clock (opt-in; off by default;
dead-code-eliminated in production builds). The right-vs-wrong example also
lives in docs/wasm-adoption-guide.md §H1.
6. The performance ceiling (§18A.7)
Honesty about cost is a contract, not a footnote. State it in front of every wasm-vs-js decision.
The perf ceiling (SPEC §18A.7 Criterion 3). For the wasm path to be a promotion candidate, the JS↔WASM marshal overhead must hold to:
- single commit ≤ 250 µs p95 marshal overhead;
- batch / large mutation ≤ 5 ms p95.
These are the marshal-overhead bars the cross-backend benchmark gates against — the cost of crossing the boundary, separate from the engine's own work.
The standing wire tax. Before the engine does any work there is a documented
crossing cost: the 78× wire tax — ~156.4 ms for 10k commits across the
boundary vs ~2.017 ms TS median. This is the floor that batching (epic #1493) can
amortise the crossing portion of (down toward the ≤50 ns/op floor at large
afterN), but it does not amortise the engine-exec cost. At current WASM
runtime maturity (no GC GA, limited JIT, no SIMD) the Rust-engine-in-WASM
per-commit execution cost is ~85× the TS engine — a property of today's
runtime, not of the engine design, and one batching provably cannot remove.
What this means for you, concretely:
- The Rust engine is the production engine (§18A.3 lift landed,
causl-wasm#170; rust-ssot is the default). Engine orchestration runs in Rust; the user'sderived()compute lambdas run in JS over the bridge callback. Adopting the wasm path is the rust-ssot default, not an opt-in tier. - Perf is within RAIL, not a beats-TS claim. The standing JS↔WASM marshal cost (the 78× wire tax above) and the current WASM-runtime per-commit exec cost are named honestly; they sit inside the §14 responsiveness budget. The wasm path is acceleration for specific graph shapes — very high derivation depth, large node counts where JS GC and hidden-class pressure dominate — not a universal wall-time win. Median parity was never the goal; complexity-elimination (one engine, one codepath) is the driver per §18A.13.
- The fork keeps the dual-engine floor as its DEFAULT. The TS reference engine and
the §18A.1.1 differential oracle live in
causljs/causl-ts-wasm-engine, which keeps the TS floor as default (DEFAULT_WASM_ENGINE_MODE = 'js-ssot') and adds a real-Rust differential leg that proves byte-identity at the §12 boundary; it does not flip to rust-ssot. The §18A.7 GO/NO-GO perf gate is bypassed by §18A.13 forcausl-client(perf accepted as immaterial), and that bypass is stated plainly.
Promotion was governance, not a CI flip — and it happened. The wasm engine becomes the
WASM-path default only when all five §18A.7 GO/NO-GO criteria pass — byte-identity
(1), full FFI surface (2), the perf ceiling above (3), the Node target + a
real-Rust production adopter (4), and the §18A.7 governance criterion (5) — recorded by a
dated amendment. That amendment landed on 2026-06-21
(causl-wasm#169):
all five are GO, rust-ssot is the unconditional default, and the per-flush byte-compare
oracle + sticky-downgrade fail-safe were removed (§18A.8). The TS engine stays the
unconditional floor — retained in causl-client as the §12 reference and the
§18A.13.1 implicit-path capability fallback — but it is no longer the default.
See also
SPEC.md§18A — the two-engine contract (the governing source for everything above): §18A.1 equivalence, §18A.2 Node target, §18A.4 thin TS API, §18A.5 read-identity, §18A.6 FFI atomicity, §18A.7 GO/NO-GO criteria, §18A.10 topology, §18A.11 Python tooling.SPEC.md§18A.12 / §18A.13 / §18A.13.1 — the dated amendments this guide adds: §18A.12 thepreloadCauslWasm()+createCauslWasmSync()synchronous-construction split (§2.5) and the build-script asserts (§3.1); §18A.13 thecausl-clientwasm-default direction (§1); §18A.13.1 (2026-06-23) the implicit-path loud TS capability fallback. These sections live in thecausl-client/causl-ts-wasm-enginedistributions'SPEC.mdand are newer than the bundled localSPEC.mdcopy above, so linked to this repo's canonicalSPEC.md.packages/core/wasm/README.md— the@causl/core/wasmentry point: cost shape, host requirements, CSP, bundler interop, the H1 callout, theWasmBackendUnavailableErrorcodes.docs/wasm-adoption-guide.md— preload / SRI posture, dynamic-import vendoring, the five structured fallback codes, and the full H1 read-identity migration walkthrough.causljs/causl-wasmscripts/README.md— the producer-sidebuild_wasm.py/package_wasm.pycontract this guide consumes.