This walk gets a beginner from zero to a converged simulation receipt in five steps. Every example is lifted from the test suite — nothing here is invented.
- Node.js 24 or newer (
node -v) - pnpm 10 (
pnpm -v)
git clone <repo> causal-lab
cd causal-lab
pnpm install
pnpm buildpnpm start
# causal-lab listening on http://127.0.0.1:8787The server binds to 127.0.0.1 only. To change the port set CAUSAL_LAB_PORT; the host must remain 127.0.0.1 or ::1.
A scenario has two parts: a config (the network) and a list of steps (what the replicas do over virtual time). This scenario partitions a from b, lets a write while isolated, then heals:
curl -X POST http://127.0.0.1:8787/v1/run \
-H 'content-type: application/json' \
-d '{
"config": {
"replicas": ["a", "b"],
"seed": 19,
"minLatency": 1,
"maxLatency": 3,
"dropRate": 0.25,
"duplicateRate": 0.5
},
"steps": [
{ "at": 0, "action": "partition", "left": "a", "right": "b" },
{ "at": 1, "action": "put", "replica": "a", "key": "mode", "value": "safe" },
{ "at": 5, "action": "heal" }
]
}'The response is a SimulationReport. Two fields to check first:
converged: true— every replica reached the same canonical state after the heal.trace— an ordered list of every local, scheduled, dropped, held, delivered, partition, and heal event.
Run it again with the same body: the response is byte-identical. That is the central guarantee of the simulator.
Try a scenario that exercises the observed-remove map. Partition two replicas, have each write the same key concurrently, then heal and watch them converge:
{
"config": { "replicas": ["a", "b"], "seed": 7, "minLatency": 1, "maxLatency": 2, "dropRate": 0, "duplicateRate": 0 },
"steps": [
{ "at": 0, "action": "partition", "left": "a", "right": "b" },
{ "at": 1, "action": "put", "replica": "a", "key": "mode", "value": "safe" },
{ "at": 2, "action": "put", "replica": "b", "key": "mode", "value": "fast" },
{ "at": 5, "action": "heal" }
]
}After the heal, states.a.mode and states.b.mode both list ["fast", "safe"] (sorted) — the concurrent writes survived and the replicas converged. A remove that observed an earlier value suppresses that value's late delivery, but never a concurrent put.
To keep a scenario and its run receipt across restarts, enable the catalog with CAUSAL_LAB_DB:
CAUSAL_LAB_DB=./catalog.sqlite pnpm startStore a scenario:
curl -X POST http://127.0.0.1:8787/v1/scenarios \
-H 'content-type: application/json' \
-d '{ ...same scenario... }'
# -> { "id": "<64 hex chars>", "canonicalization": "causal-lab-json-v1" }The id is a SHA-256 receipt over the canonical JSON — re-posting the same scenario returns the same id (idempotent). Execute and store the run:
curl -X POST http://127.0.0.1:8787/v1/scenarios/<id>/runs
# -> { "id": "...", "scenarioId": "...", "traceSha256": "...", "converged": true, ... }Fetch it later:
curl http://127.0.0.1:8787/v1/runs/<run-id>Scenarios and runs are immutable — the database rejects updates and deletes. Re-opening the catalog runs integrity and foreign-key checks before serving.
- API reference — every route, status code, and schema.
- Error codes — what each 422/404/500 message means and how to fix it.
- Glossary — CRDT, dot, version vector, tombstone, anti-entropy.
- Test contract — the 11 invariants the suite enforces.