Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ Causal Lab is a deterministic network simulator for studying an observed-remove

Each replica records dotted operations and a version vector. Put and remove operations carry the exact dots they observed; tombstones make late and duplicated delivery harmless. Healing performs a reliable anti-entropy pass and reports whether all replicas converged to the same canonical state.

An optional content-addressed SQLite catalog persists validated scenarios and immutable deterministic run receipts. It is deliberately outside the simulation core: no storage call, clock, or generated identifier can affect convergence behavior.

## Boundary

- at most 12 replicas and 10,000 scheduled events
- at most 2 MiB per JSON request
- no wall-clock time, sockets, telemetry, hosted state, or dynamic code execution in the simulation core
- scenario identifiers, keys, values, and replica names are bounded before allocation
- canonical scenario and run IDs are SHA-256 receipts over versioned, sorted-key JSON
- SQLite STRICT tables enforce digest, JSON, byte-count, boolean, and foreign-key contracts

The precise invariants are in [`docs/test-contract.md`](docs/test-contract.md). Security assumptions are in [`docs/threat-model.md`](docs/threat-model.md).

## Development

Node 24 or newer and pnpm 10 are required. The core never reads wall-clock time. `pnpm start` serves `POST /v1/run` on `127.0.0.1:8787`; the host remains restricted to `127.0.0.1` or `::1`.

Set `CAUSAL_LAB_DB` to a trusted SQLite file path to add the scenario catalog routes. Without it the original stateless API and its failure surface remain unchanged. See `docs/data-model.md` for the exact tables, receipts, backup boundary, and recovery contract.

```sh
pnpm test
pnpm typecheck
Expand Down
23 changes: 23 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Scenario catalog data model

The simulator remains a pure deterministic core. Storage is an optional adapter enabled by CAUSAL_LAB_DB, so a database failure cannot alter CRDT or virtual-time semantics and removing the setting restores the original stateless service.

## Identity

Validated JSON is encoded with causal-lab-json-v1: object keys are sorted, arrays retain order, and only finite JSON values are admitted. The scenario ID is SHA-256 over the version label, a separator, and those canonical bytes. Key order and whitespace therefore do not change identity, while any semantic field does.

Run IDs bind a scenario ID to the complete canonical report. trace_sha256 separately makes trace verification and indexing cheap. Repeating one deterministic scenario produces the same immutable receipt rather than another timestamp-based row.

## Tables

- scenarios stores scenario_id, canonicalization, canonical_json, and definition_bytes.
- runs stores run_id, scenario_id, report_json, trace_sha256, processed_events, and converged.
- schema_migrations records the applied schema version and owner.

All tables use STRICT typing. Digest shape, JSON validity, byte and event bounds, booleans, and the scenario foreign key are database constraints. Triggers reject updates and deletes from content-addressed rows. On every open the adapter runs integrity_check and foreign_key_check.

## Operations and recovery

POST /v1/scenarios stores a validated definition. GET /v1/scenarios/:id returns it. POST /v1/scenarios/:id/runs executes and stores the deterministic receipt, and GET /v1/runs/:id reads it. These routes exist only when the catalog is configured; POST /v1/run remains stateless and backward compatible.

For a live backup, use the SQLite backup API or briefly stop the single process and copy the database together with its WAL and shared-memory files. A portable rollback is an export of canonical scenario and report JSON, verified again by their IDs before import.
60 changes: 60 additions & 0 deletions docs/superpowers/plans/2026-07-19-reproducible-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Reproducible Scenario Catalog Implementation Plan

**Goal:** Add a content-addressed SQLite catalog for scenarios and deterministic run receipts while keeping CRDT semantics storage-independent.
**Architecture:** Parsing and execution become pure reusable functions. A catalog adapter stores canonical scenario JSON and immutable reports; the simulator never imports storage.
**Tech Stack:** TypeScript 5.9, Node.js 24 node:sqlite, Hono, Vitest, SQLite STRICT tables
**Verification:** pnpm lint; pnpm typecheck; pnpm test; pnpm build; PRAGMA foreign_key_check and integrity_check.

---

## Three-pass review

1. Repository evidence: validation and execution live inside one HTTP handler, preventing reusable scenario identity.
2. External standard: CRDTs converge from the same update set; SQLite offers strict typing and atomic single-file storage.
3. Adversarial review: timestamps, random IDs, mutable reports, arbitrary invariant code, and user-controlled SQL were rejected.

## Decision

Canonicalize validated scenarios with sorted keys and SHA-256. The digest is the scenario ID. Store one immutable report and trace digest per scenario. Existing POST /v1/run stays compatible; catalog routes exist only when configured.

## SQLite schema

- scenarios(scenario_id, canonical_json, definition_bytes)
- runs(run_id, scenario_id, report_json, trace_sha256, processed_events, converged)
- schema_migrations(version, applied_by)

IDs are 64 lowercase hex characters, JSON is checked, and reports reference scenarios.

## Implementation tasks

### Task 1: Extract pure scenario execution

**Files:** Create src/scenario.ts and test/scenario.test.ts; modify src/app.ts.

- [x] Add failing tests for identity across key order and byte-identical execution.

### Task 2: Implement the catalog

**Files:** Create schema/sqlite/001_catalog.sql, src/catalog.ts, and test/catalog.test.ts.

- [x] Test migration idempotence, immutable replay, hash rejection, foreign keys, reopen, and integrity checks.
- [x] Use prepared statements and disable extensions.

### Task 3: Add catalog routes

**Files:** Modify src/app.ts, src/server.ts, and test/app.test.ts.

- [x] Add create, fetch, and run receipt routes without changing stateless limits.

### Task 4: Document recovery

**Files:** Modify README.md, docs/test-contract.md, and docs/threat-model.md; create docs/data-model.md.

- [x] Document WAL-aware backup and canonical JSON export rollback.

## Risk and rollback

- Medium: isolate the active-development Node SQLite API in one adapter.
- Medium: version canonicalization and pin it with fixtures.
- Low: fail startup on integrity or foreign-key errors.
- Rollback: omit CAUSAL_LAB_DB; stateless execution remains unchanged.
5 changes: 4 additions & 1 deletion docs/test-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The suite must demonstrate:
5. a reliable anti-entropy pass after healing converges every replica;
6. different delivery histories that contain the same operations converge to the same canonical map;
7. replica, event, field, probability, and HTTP body limits are enforced before state mutation.
8. object key order and whitespace do not change a scenario ID, while semantic changes do;
9. repeated deterministic execution produces one immutable run receipt across database reopen;
10. catalog migrations are idempotent and integrity, foreign-key, hash, byte-count, and trace checks pass;
11. catalog routes are absent when storage is not configured and preserve the stateless run contract.

Limits: 12 replicas, 10,000 events, 2 MiB JSON, 128-byte identifiers and keys, and 4 KiB values.

10 changes: 7 additions & 3 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Assets and inputs

The simulator protects deterministic trace order, operation identity, version-vector monotonicity, observed-remove semantics, and bounded resource use. Scenario JSON, replica names, keys, values, topology changes, and network probabilities are untrusted.
The simulator protects deterministic trace order, operation identity, version-vector monotonicity, observed-remove semantics, bounded resource use, scenario identity, and immutable run receipts. Scenario JSON, replica names, keys, values, topology changes, network probabilities, catalog IDs, and database contents are untrusted.

## Defended cases

Expand All @@ -12,8 +12,12 @@ The simulator protects deterministic trace order, operation identity, version-ve
- operations are immutable data; duplicate delivery is idempotent and remove tombstones survive out-of-order delivery
- virtual time is an integer controlled by the simulation, not by timers or the host clock
- public failures report contract errors without stack traces or environment paths
- storage is injected at the HTTP boundary and is never imported by CRDT or simulation modules
- prepared statements bind all catalog values; extension loading and double-quoted string literals are disabled
- catalog creation executes the bounded pure scenario once, so semantically invalid replica references are never persisted
- database files reject symbolic-link targets and are restricted to process-owner permissions
- STRICT tables, immutable triggers, foreign keys, digest recomputation, and open-time health checks detect drift

## Non-goals

This is not a production database, Byzantine protocol, consensus system, authentication service, or performance benchmark. It does not defend against a process-account compromise or attempt to model every transport behavior.

This is not a Byzantine protocol, consensus system, authentication service, or performance benchmark. The optional local catalog is not a multi-writer distributed database. It does not defend against a process-account compromise or attempt to model every transport behavior.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"causal-lab": "./dist/server.js"
},
"files": [
"dist"
"dist",
"schema"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
Expand Down
51 changes: 51 additions & 0 deletions schema/sqlite/001_catalog.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY CHECK (version > 0),
applied_by TEXT NOT NULL CHECK (applied_by = 'causal-lab')
) STRICT;

CREATE TABLE IF NOT EXISTS scenarios (
scenario_id TEXT PRIMARY KEY CHECK (
length(scenario_id) = 64 AND scenario_id NOT GLOB '*[^0-9a-f]*'
),
canonicalization TEXT NOT NULL CHECK (canonicalization = 'causal-lab-json-v1'),
canonical_json TEXT NOT NULL CHECK (json_valid(canonical_json)),
definition_bytes INTEGER NOT NULL CHECK (definition_bytes > 0 AND definition_bytes <= 2097152)
) STRICT, WITHOUT ROWID;

CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY CHECK (
length(run_id) = 64 AND run_id NOT GLOB '*[^0-9a-f]*'
),
scenario_id TEXT NOT NULL,
report_json TEXT NOT NULL CHECK (json_valid(report_json)),
trace_sha256 TEXT NOT NULL CHECK (
length(trace_sha256) = 64 AND trace_sha256 NOT GLOB '*[^0-9a-f]*'
),
processed_events INTEGER NOT NULL CHECK (processed_events >= 0),
converged INTEGER NOT NULL CHECK (converged IN (0, 1)),
FOREIGN KEY (scenario_id) REFERENCES scenarios(scenario_id) ON DELETE RESTRICT
) STRICT, WITHOUT ROWID;

CREATE INDEX IF NOT EXISTS runs_by_scenario ON runs(scenario_id, run_id);

CREATE TRIGGER IF NOT EXISTS scenarios_immutable_update
BEFORE UPDATE ON scenarios BEGIN
SELECT RAISE(ABORT, 'scenarios are immutable');
END;
Comment on lines +31 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block replace inserts for immutable rows

These immutable triggers only cover explicit UPDATE and DELETE statements. With SQLite's default recursive_triggers setting, a maintenance/import path that uses INSERT OR REPLACE on an existing primary key can delete and reinsert the row without firing this trigger, so catalog rows are still mutable at the database layer despite the schema contract; add a duplicate-key BEFORE INSERT guard or enable recursive triggers before relying on these triggers.

Useful? React with 👍 / 👎.


CREATE TRIGGER IF NOT EXISTS scenarios_immutable_delete
BEFORE DELETE ON scenarios BEGIN
SELECT RAISE(ABORT, 'scenarios are immutable');
END;

CREATE TRIGGER IF NOT EXISTS runs_immutable_update
BEFORE UPDATE ON runs BEGIN
SELECT RAISE(ABORT, 'runs are immutable');
END;

CREATE TRIGGER IF NOT EXISTS runs_immutable_delete
BEFORE DELETE ON runs BEGIN
SELECT RAISE(ABORT, 'runs are immutable');
END;

INSERT OR IGNORE INTO schema_migrations(version, applied_by) VALUES (1, 'causal-lab');
Loading