Integrating causl-wasm-ts into a TypeScript / Node.js app Enterprise
This page kept its old URL on purpose. The package it describes was renamed from
causl-clienttocausl-wasm-tson 2026-07-27, but the directory staysintegrating-causl-client/so that existing links keep resolving. The prose is current; the path is legacy.
The Node-integration counterpart to the engine repo's producer-side
causl-core-rsscripts/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 through the@causl/causl-wasm-tsthin TypeScript client. The engine and client repositories are private, so I cite repositories, files and issues by name rather than by link; reaching them needs a Gitea account ongit.opsite.ca.
This guide is the consumer-side concretion of SPEC §18A.4 (the thin-TS-client definition) and §18A.2 (the Node-target requirement). It covers, in order:
- What
causl-wasm-tsis: the thin TS client over the Rust core, and what it is not. - Installing and using it in a TS/Node app, including the preload plus synchronous construction boot (§18A.12).
- Where the wasm artefact comes from:
the producer/consumer split and the
causl-core-rsPython scripts. - The
node:fsloader and the host floor. - The
read()-identity migration: the one breaking change you must audit for before a real-Rust build. - What the wasm path costs, and which of the older cost claims the measurements refute.
Every load-bearing claim cites a SPEC § anchor. Where the shipped code and the SPEC contract differ, the gap is called out as Shipped today versus Dated record, never blurred.
1. What causl-wasm-ts 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). Since the 2026-07-27 rename the two engines live in two different repositories:
| Engine | What it is | Where it lives |
|---|---|---|
| The TypeScript engine | The reference engine, the value-of-record running natively on the JS event loop. | The unconditional floor (§13.8).
causl-core-ts keeps it as the dual-engine default
(js-ssot) and the differential oracle;
causl-ts is its open-source distribution.
It is not in this package at all. |
| The Rust engine | The Rust core (engine-rs-core + engine-rs-bridge) compiled to WebAssembly, reached over FFI. |
The unconditional production engine in
causl-wasm-ts (rust-ssot is the default);
the §18A.3 FFI lift landed
(causl-core-rs#170)
so every adopter operation resolves from Rust. Lives in
causl-core-rs. |
causl-wasm-ts is the thin TypeScript client over the
causl-core-rs core (§18A.4). Stated plainly so the
boundary stays honest, it 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 (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, no build
tooling, and no TypeScript engine. The produce-and-place tooling
is the engine repo's (causl-core-rs, §18A.11); the
consume-and-bind surface is this package's.
Repository topology (§18A.10)
causl-core-rs Rust engine, FFI externs, Python build and package tooling.
The source of truth for the Rust to WebAssembly engine.
Formerly causljs/causl-wasm.
causl-wasm-ts THIS PACKAGE. The thin TS client, the Node loader, the
adoption docs. Consumes the placed .wasm; ships no Rust
and no TypeScript engine. Formerly causljs/causl-client.
causl-core-ts The TypeScript engine (the floor), the differential oracle,
and the cross-backend bench and conformance harness. Stays
dual-engine. Formerly causljs/causl-ts-wasm-engine.
All three now live under
git.opsite.ca/causl;
the old GitHub names are 301 redirects. 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). The engine this package loads is a real Rust engine (
engine-rs-core), not a TS wrapper: the §18A.3 FFI structural lift has landed (causl-core-rs#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.
Single-engine, and the TypeScript fallback is withdrawn (§18A.13; §18A.13.1 is now a dated record). By a recorded governance decision,
causl-wasm-tsships the wasm engine as its sole engine. The driver is complexity-elimination, one engine and 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) briefly reversed part of that: it retained
createCauslTsinternally and wired it as the implicitcreateCausl()path's capability fallback. That section was withdrawn at 0.5.0 and its own heading now tells you to read it "as a dated record, not as a live obligation." Two findings killed it. The fallback engine and the primary disagreed on in-place mutation of a committed value (causl-wasm-ts#272), which §18A.1.1 requires to be identical; and the implicit path was the accident path, the one you reached by calling the ordinary factory. 0.4.0 warned on it and 0.5.0 throws.So today
createCausl()is construct-or-throw, andcreateCauslTsis deleted from source: agit grep createCauslTsoverpackages/*/src/**returns zero hits. Nothing in this package degrades to TypeScript at runtime. Scope: this iscausl-wasm-tsonly.causl-core-tshosts the §18A.1.1 differential oracle and the benchmark suite, deliberately keeps the dual-engine TypeScript floor as its default (DEFAULT_WASM_ENGINE_MODE = 'js-ssot'), and adds a real-Rust differential leg as the byte-identity oracle.
2. Installing and using it
2.1 Install
The @causl scope is served by the private Gitea
package registry, and every read needs the token issued during
Enterprise onboarding. Put both lines in
.npmrc before the first install:
@causl:registry=https://git.opsite.ca/api/packages/causl/npm/
//git.opsite.ca/api/packages/causl/npm/:_authToken=${CAUSL_NPM_TOKEN}
pnpm add @causl/causl-wasm-ts
# npm install @causl/causl-wasm-ts
# yarn add @causl/causl-wasm-ts
The
@causl/corehazard: get this wrong and the install still succeeds.@causl/coreis not this package and has zero versions on the Gitea registry. It does resolve on the public npm registry, at 0.3.0 through 0.3.3, to a different package (the retired TypeScript engine). So an.npmrcmissing the@causl:registry=line, or a lockfile still pinning@causl/core, installs cleanly and gives you the wrong engine instead of an error. If you are migrating from an older integration, grep your lockfile for@causl/coreand for the dead Verdaccio hostiasbuilt-npm.opsite.ca, and replace both.
Node 22 or newer is required (engines.node: ">=22").
The wasm engine lives on the /wasm subpath of the package,
but do not read that as optional: the engine is
unconditional in this client, and a build that never
imports the subpath throws CAUSL_WASM_ENGINE_UNAVAILABLE at
construction. Import it from your boot module, as below.
2.2 The smallest worked example
Identical to SPEC §10. Two inputs, one derived, one diamond, one subscriber, two commits, three observed propagations:
import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm'
import { createCausl } from '@causl/causl-wasm-ts'
// Once, at app init. On the Node target the glue is already synchronous and
// this is a no-op; on the browser bundler target it is required. See §2.3.
await preloadCauslWasm()
const graph = createCausl() // the wasm engine, or a throw. No third outcome.
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 rather than 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 Booting the engine: preload once, construct synchronously (§18A.12)
There is exactly one adopter-facing boot pattern, and it has one
await in it. Compiling the WebAssembly.Module is
unavoidably async, and the §12 commit / read
spine is and stays synchronous (§18A.6). A synchronous
consumer (a React hook or render body, an SSR pass,
xldatagrid's imperative grid API) cannot put an
await at the construction site.
SPEC §18A.12 splits the compile out of construction so
the construction site stays synchronous. This is shipped.
import { preloadCauslWasm } from '@causl/causl-wasm-ts/wasm'
import { createCausl, createCauslWasmSync } from '@causl/causl-wasm-ts'
// ONCE, at app init: the only async seam. Compiles and 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 so a retry recompiles.
await preloadCauslWasm()
// Thereafter, at every render / hook / SSR site: FULLY synchronous.
// `new WebAssembly.Instance` from the cached Module, zero await.
const graph = createCausl() // the default factory
const another = createCauslWasmSync() // the explicit one, same engine
await preloadCauslWasm(opts?): the one async seam, called once per process at app init. It paysWebAssembly.compileand the sidecar and snippet imports up front. The companionsisCauslWasmPreloaded(bridge?)andgetPreloadedCauslWasm(bridge?)are synchronous peeks at the resolved state (set on resolution, not merely while the compile is in flight), which is what you want in a render guard.createCausl(): Graph: the default public factory, synchronous and construct-or-throw. Two statements, no branch, no heuristic, no runtime engine choice.createCauslWasmSync(handle?, create?): Graph: the explicit form of the same thing. Fully synchronous; mints a freshWebAssembly.Instancefrom the cached Module with zeroawait.createCauslWasm(opts?): Promise<Graph>: the async one-call factory is retained, re-expressed aspreloadCauslWasm ∘ createCauslWasmSync(one instantiate codepath, zero drift). Reach for it when anawaitat the construction site is fine; reach for the preload plus sync split when it is not.
Node versus browser (cross-ref §18A.2). On Node and SSR
the --target nodejs glue is already synchronous end to end
(readFileSync + new WebAssembly.Module +
new WebAssembly.Instance at require or
import time), so construction works without a
prior preload and 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 graph. That 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.
2.4 When construction fails: two codes, and no fallback
Construction has two outcomes: a wasm Graph, or a throw.
There is no third outcome and no silent degrade, because the TypeScript
engine is not in this package to degrade to (§1 above). Branch on
error.code and never on
instanceof: the code is the contract, the class is not.
| Code | Means | What to do |
|---|---|---|
CAUSL_WASM_ENGINE_UNAVAILABLE |
The wasm subpath was never imported, or the host cannot instantiate the engine. | Check that your boot module imports the /wasm subpath
and that the host clears the
floor. If the
host genuinely cannot run the engine, that host cannot run this
package; use the open-source TypeScript engine there instead. |
CAUSL_WASM_NOT_PRELOADED |
The subpath was imported, but preloadCauslWasm() never
resolved before construction. |
An ordering bug in your boot, not a capability problem.
await the preload before the first render that
constructs a graph. |
There is no
backend: 'auto'here. Older versions of this guide described acreateCausl({ backend: 'auto' })heuristic that started a graph on the TypeScript engine and migrated the live graph onto the wasm backend once graph size, derivation depth or subscriber count tripped a threshold. That heuristic, and thebackend: 'js' | 'auto'string option generally, exist only on the dual-enginecausl-core-tsdistribution. Incausl-wasm-tsthere is nothing to migrate from and nothing to migrate to, so there is no heuristic, no live-graph migration, and no per-callbackendstring.
2.5 Engine choice lives 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. Where a choice exists at all (that is, in
causl-core-ts) it 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 is 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.
3. The wasm artefact placement (the producer/consumer split)
causl-wasm-ts 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 causl-core-rs, the producer side, and a
webapp's CI/CD pipeline drives them. The split:
| Side | Repo | Artefact | Tool |
|---|---|---|---|
| Producer | causl-core-rs |
builds and places the .wasm |
scripts/build_wasm.py, scripts/package_wasm.py |
| Consumer | causl-wasm-ts (you) |
binds the placed .wasm over FFI |
the @causl/causl-wasm-ts/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-core-rs's
scripts/README.md.
# Build and 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 # to 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 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 cannot 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 the §18A.12 invariants, so a
wasm-pack or wasm-bindgen upgrade that breaks the
synchronous-construction seam (§2.3) 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. This check used to also flaggc-builtinsas bundler-target only, because it emittedrequire("wasm:js-string")which stock Node cannot resolve.gc-builtinshas since been deleted (causl-core-rs#355), sogc-classicis the only artefact the check has to consider.
3.2 Consumer side: vendor and 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. Vendoring the artefact this way is also the answer to a CDN or strict-CSP layout: the engine resolves from your own deploy, offline, with no fetch at runtime.
4. The loader and the host floor
4.1 The host floor and the one bridge that ships
Two exported functions are called detectBridge() and they do
opposite things, so check which one you imported. The version on the
@causl/causl-wasm-ts/wasm subpath is a real probe: #426
wired it to a 56-byte module built from the artefact's measured minimum, and it
throws WasmEngineUnavailableError naming the failing feature. The
version re-exported from the main barrel is still the placeholder
the older caveat described: every marshalling primitive on it throws "pending
#692", and it reports { gc: false, jsStringBuiltins: false }
with abiVersion: 0 even on hosts where the engine instantiates without
complaint.
So do not call detectBridge() from the default import to ask whether
this host supports the engine. It will tell you no on a host that works. Either
import it from the /wasm subpath, or do the thing you actually want,
which is to call preloadCauslWasm() and branch on
error.code if it throws.
There is currently one bridge. gc-classic is
it. The second variant, gc-builtins, was
deleted
(causl-core-rs#355).
The ids are gc-classic and gc-builtins, never
wasmgc-classic or wasmgc-builtins; if you find
the wasmgc- spelling in a config, it is stale. With one
bridge shipping there is nothing to pin, so let
detectBridge() do its job.
The host floor
(packages/core/src/wasm-registry.ts):
| Host | Minimum |
|---|---|
| Safari | 18.2 |
| Chromium | 119 |
| Firefox | 120 |
| Node | 22 |
The governing requirement is typed function references,
established by feature-bisecting the shipped artefact in
causl-wasm-ts#426. It is not WasmGC heap
types: the artefact declares zero WasmGC struct, array or
rec-group types, so any prose that gates the floor on "WasmGC support" is
describing a requirement the artefact does not have. The floor this site
used to publish (Safari 18, paired with macOS 15, and a
Node ceiling of 20) was wrong on three separate counts.
Treat these versions as engine release-note claims, not as our measurement. Nobody has run the shipped artefact on a real boundary host. If you deploy to a fleet sitting near the floor, test it yourself before you commit to it.
4.2 The loader resolution shape
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.
Node target (§18A.2). The --target nodejs
glue instantiates the wasm synchronously at require or
import time, so the Node loader hook resolves the placed
artefact via node:fs with no async load
shape, and works identically in CJS and ESM. This is the loader that
causl-core-rs's scripts/README.md "Consuming the
packaged artefact in Node" section pairs with, and it satisfies the §18A.2
requirement. For a CDN or strict-CSP layout, prefer vendoring the artefact
into your own deploy with package_wasm.py (§3.2) over
rewriting a base URL at runtime: the §18A.11 contract is explicitly
offline-resolvable.
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 engine
(await preloadCauslWasm()), not committing through
it. A commit is one atomic tick (§3 Theorem 2 / §5 Phase A to 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.
Atomicity is untouched by the preload (cross-ref §4.3 / §18A.6 / §3 Theorem 2). The compile
awaitis hoisted to bootstrap, outside any commit envelope, andcreateCauslWasmSyncperforms noawaitbetween the §5 Phase E and Phase G boundaries. Moving the one async to app init does not put anawaitanywhere near a commit.
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).
Why it bites, and why it is worse than it sounds. The
obvious mental model, "the wasm engine deserialises across the boundary so
every read() is a fresh object", is wrong, and the
truth is harder to test for. The engine is not uniformly
fresh: a ValueHandleCache retains the original
adopter reference, so identity is stable on a cache hit
and fresh only on the decode paths. The break is therefore
intermittent. A test that reads the same node twice in a
row will very likely pass. Any adopter who keys memoisation on the
read() return reference gets a bug that
fires on some commits and not others, with no error.
The fix: key on commit.time or a per-node version,
not on read() identity.
// WRONG: keys the memo on a reference the engine does not promise to keep.
// It invalidates on some commits and not others, which is why it survives
// a naive test.
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 do not 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).
6. What the wasm path costs
Honesty about cost is a contract, not a footnote. State it in front of every adoption decision.
The §18A.7 Criterion-3 ceiling, and why it is the wrong thing to watch. Criterion 3 named a marshal-overhead bar: single commit ≤ 250 µs p95, batch or large mutation ≤ 5 ms p95. That criterion is GO, and it always would have been, because marshalling is not the binding constraint. On the benchmark host a raw boundary crossing measures 1.6 ns, and value marshalling costs about 77 ns per read, roughly 4× the TypeScript engine's read. Real, but third-order.
Two claims this page used to make are refuted. The "78× wire tax" and the "~85× per-commit execution cost" attributed to WASM runtime immaturity do not survive measurement. The per-commit wire cost the older text implied is several times larger than the entire measured commit, and it was derived against a serde bridge artefact that no longer ships. What actually dominates is engine-side and client-side work: a residual per-commit clone that scales with the number of live derived nodes, a per-recompute constant, and a per-commit constant. I do not restate that analysis here. Wasm performance carries the numbers, the confidence caveats, and the open issues.
What this means for you, concretely:
- The Rust engine is the production engine (§18A.3 lift
landed,
causl-core-rs#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 not an opt-in tier, it is the only path this package has. - Perf is within RAIL, and it is not a beats-TS claim. Complexity-elimination (one engine, one codepath) was the driver per §18A.13, and median parity was never the goal. Do not adopt this package expecting a wall-time win; adopt it for the single-codepath Rust core. Check the measurements against your own graph shape before you commit.
- The dual-engine floor is a different repository. The
TypeScript reference engine and the §18A.1.1 differential oracle live in
causl-core-ts, which keeps the TypeScript floor as its default (DEFAULT_WASM_ENGINE_MODE = 'js-ssot') and adds a real-Rust differential leg proving byte-identity at the §12 boundary. It does not flip to rust-ssot. The §18A.7 perf gate is bypassed by §18A.13 forcausl-wasm-ts, and that bypass is stated plainly rather than buried.
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), the full FFI
surface (2), the marshal ceiling above (3), the Node target plus a
real-Rust production adopter (4), and the TS-engine floor
maintained (5). That last one is the same criterion the
two-engine page lists,
and it is satisfied today by causl-core-ts rather than by
anything inside this package. The dated amendment landed on
2026-06-21
(causl-core-rs#169):
all five GO, rust-ssot unconditional, and the per-flush byte-compare
oracle plus sticky-downgrade fail-safe removed (§18A.8).
See also
- Two-engine architecture: the §18A contract in prose, the byte-identity gate, the repository topology, and the withdrawn TypeScript fallback in full.
- Wasm performance: what the wasm path costs, measured, and which older claims the measurements refute.
- Enterprise overview: the tier, the private Gitea
registry, and the
@causl/coreinstall hazard. SPEC.md§18A: the two-engine contract, covering §18A.1 equivalence, §18A.2 Node target, §18A.4 thin TS client, §18A.5 read-identity, §18A.6 FFI atomicity, §18A.7 GO/NO-GO criteria, §18A.10 topology, §18A.11 Python tooling. This bundled copy is a dated snapshot; the current copy lives incausl-wasm-ts.wasm-adoption-guide.md: preload and SRI posture, dynamic-import vendoring, and the full read-identity migration walkthrough. Also a bundled snapshot.causl-core-rsscripts/README.md: the producer-sidebuild_wasm.py/package_wasm.pycontract this guide consumes.