diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..14e1352
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+target/
+**/target/
+**/node_modules/
+**/.next/
+.git/
+.claude/
+**/*.node
diff --git a/.gitignore b/.gitignore
index b1c2892..3806c3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
/target
target/
-.DS_Store
\ No newline at end of file
+.DS_Store
+*.node
+node_modules/
+.next/
+.sod-*/
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index f152980..b1d81a0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -159,6 +159,18 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
[[package]]
name = "arrow-array"
version = "58.3.0"
@@ -345,6 +357,20 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+[[package]]
+name = "blake3"
+version = "1.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -624,6 +650,21 @@ dependencies = [
"tiny-keccak",
]
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -659,6 +700,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -760,6 +810,16 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "daachorse"
version = "1.0.1"
@@ -1728,6 +1788,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libm"
version = "0.2.16"
@@ -1946,6 +2016,63 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "napi"
+version = "2.16.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
+dependencies = [
+ "bitflags 2.13.0",
+ "ctor",
+ "napi-derive",
+ "napi-sys",
+ "once_cell",
+]
+
+[[package]]
+name = "napi-build"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab"
+
+[[package]]
+name = "napi-derive"
+version = "2.16.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
+dependencies = [
+ "cfg-if",
+ "convert_case",
+ "napi-derive-backend",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "napi-derive-backend"
+version = "1.0.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
+dependencies = [
+ "convert_case",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "semver",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "napi-sys"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
+dependencies = [
+ "libloading",
+]
+
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -2765,7 +2892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -2836,6 +2963,52 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "sod"
+version = "0.0.1"
+dependencies = [
+ "blake3",
+ "fjall",
+ "fold",
+ "getrandom 0.2.17",
+ "postcard",
+ "serde",
+ "tungstenite 0.24.0",
+]
+
+[[package]]
+name = "sod-demo"
+version = "0.0.1"
+dependencies = [
+ "fold",
+ "postcard",
+ "sod",
+]
+
+[[package]]
+name = "sod-demo-node"
+version = "0.0.1"
+dependencies = [
+ "fold",
+ "napi",
+ "napi-build",
+ "napi-derive",
+ "postcard",
+ "sod",
+]
+
+[[package]]
+name = "sod-web-addon"
+version = "0.0.1"
+dependencies = [
+ "fold",
+ "napi",
+ "napi-build",
+ "napi-derive",
+ "postcard",
+ "sod",
+]
+
[[package]]
name = "spin"
version = "0.9.8"
@@ -3164,7 +3337,7 @@ dependencies = [
"futures-util",
"log",
"tokio",
- "tungstenite",
+ "tungstenite 0.29.0",
]
[[package]]
@@ -3215,6 +3388,24 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.8.7",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -3334,6 +3525,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
diff --git a/Cargo.toml b/Cargo.toml
index 6415c3f..23ee67c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,17 @@
[workspace]
resolver = "3"
-members = ["fold", "anny", "ese", "examples/*"]
+members = [
+ "fold",
+ "anny",
+ "ese",
+ "sod",
+ "examples/*",
+ "examples/sod-demo/node",
+ "examples/sod-web/addon",
+]
+# examples/sod-web is a Next.js app (no Cargo.toml at its root); its Rust
+# crate is the explicit member above
+exclude = ["examples/sod-web"]
# hoisted from fold; applies workspace-wide
[profile.release]
diff --git a/README.md b/README.md
index 951a620..2b6a19b 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,13 @@ ESE, our first take on a compiler oriented approach to static embedding. It’s
### Approximate Nearest Neighbors... yeah (ANNy)
This is a very fast crate for creating HNSWs.
+### Sod
+Symmetric replication for fold apps: local-first replicas ("sods") that
+always accept writes and converge by exchanging hash-chained delta logs —
+client↔server and p2p are the same protocol. Portable core (compiles to
+wasm32) with fold as the default engine. See `sod/README.md` and the
+`sod-demo` example.
+
### Examples
In this directory you'll find a few examples that show bog style databases in various use cases.
@@ -52,6 +59,8 @@ In this directory you'll find a few examples that show bog style databases in va
- `timeseries` — weather readings bucketed into hourly and daily aggregates, updated incrementally. `cargo run -p timeseries`
- `chat` — a chat backend where fold is the source of truth and every update is broadcast to clients over a websocket. `cargo run -p chat`, then open http://localhost:3000
- `search` — text search three ways over one document stream: BM25 keyword search, HNSW semantic search over ese embeddings, and hybrid rank fusion. A good base for agent memory or document search projects. `cargo run -p search`
+- `sod-demo` — a replicated notes bag: two or more local sod replicas (native CLI and a Node.js addon) converging over websocket sync. `cargo run -p sod-demo --
add hello`
+- `sod-web` — the three-bog demo: a Next.js emoji reaction board where each instance embeds a sod replica; a Fly-deployed hub (live at https://sod-web-demo.fly.dev) plus two local instances survive a real wifi partition and converge on heal. See `examples/sod-web/README.md`
## More about Bog
Bog is a database runtime that makes every attempt to do as much work as possible as early as possible, to make reads incredibly fast. This means compiling queries into functions that eagerly update their output as mutations occur.
diff --git a/docs/superpowers/plans/2026-08-15-sod-implementation.md b/docs/superpowers/plans/2026-08-15-sod-implementation.md
new file mode 100644
index 0000000..961db51
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-sod-implementation.md
@@ -0,0 +1,312 @@
+# Sod Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build `sod/`, the symmetric replication crate for fold apps: portable core (frames, version vectors, log, replica, engine port, sans-io sync), file log, websocket transport, fold engine, demo app, and the test suite that enforces SOD-1..9.
+
+**Architecture:** Portable core with four ports (Engine, LogStore, Transport, entropy). Per-origin hash-chained feeds, version-vector anti-entropy, log-first write path. Fold is one engine behind a default-on feature; `MemEngine` is the always-compiled oracle and the wasm-viable engine.
+
+**Tech Stack:** Rust, postcard, blake3, tungstenite (feature `ws`), fold (feature `fold-engine`), getrandom (feature `os-rng`), napi-rs (demo addon only).
+
+**Spec:** `docs/superpowers/specs/2026-08-15-sod-design.md`
+
+## Global Constraints
+
+- Feature flags: `default = ["fold-engine", "ws", "os-rng"]`. `cargo check -p sod --no-default-features` must always pass, including for `--target wasm32-unknown-unknown`.
+- No wall-clock reads anywhere in `sod` (SOD-7): `event_time` is a caller-supplied argument. `grep -rn "SystemTime\|Instant::now" sod/src/` must return nothing.
+- No HashMap iteration at any output or serialization boundary; use `BTreeMap`/`BTreeSet`.
+- All persisted/wire encoding is postcard; frame hash is BLAKE3 of the encoded frame bytes.
+- `PROTOCOL_VERSION: u16 = 1`. On-disk record: `u32 LE len | frame_bytes | [u8;32] blake3(frame_bytes)`.
+- Every public item gets rustdoc; `sod/README.md` and the workspace `README.md` updated in the same change as the public API (repo standing rule, applied to sod).
+- Commit after every green test cycle. Run `cargo test -p sod` per task; full `cargo test --workspace` before claiming done.
+
+---
+
+### Task 1: Crate scaffold, `Frame`, record encoding, golden fixture
+
+**Files:**
+- Create: `sod/Cargo.toml`, `sod/src/lib.rs`, `sod/src/frame.rs`, `sod/tests/golden_log.rs`, `sod/tests/fixtures/golden.sodlog`
+- Modify: `Cargo.toml` (workspace members — `examples/*` already globs; add `"sod"`)
+
+**Interfaces (produces):**
+```rust
+pub struct ReplicaId(pub [u8; 16]); // Ord, Copy, Serialize, Display=hex
+impl ReplicaId { pub fn generate() -> Self; /* feature os-rng */ }
+pub struct FrameHash(pub [u8; 32]); // Ord, Copy, Serialize
+pub const ZERO_HASH: FrameHash;
+pub struct Frame {
+ pub prev_hash: FrameHash,
+ pub origin: ReplicaId,
+ pub seq: u64, // 1-based
+ pub event_time: u64, // ms epoch, origin-stamped
+ pub payload: Vec<(Vec, i64)>, // (postcard datum, multiplicity)
+}
+impl Frame {
+ pub fn encode(&self) -> Vec; // postcard body
+ pub fn hash(&self) -> FrameHash; // blake3(encode())
+ pub fn encode_record(&self, out: &mut Vec); // len|body|hash
+}
+pub fn decode_record(buf: &[u8]) -> Result, SodError>;
+// Ok(None) = clean partial tail (needs more bytes); Err = corrupt (hash mismatch)
+pub enum SodError { Corrupt(&'static str), Gap { .. }, Equivocation { .. },
+ Poisoned(ReplicaId), VersionMismatch { .. }, Io(String) }
+```
+
+- [ ] Step 1: Write `sod/Cargo.toml` (deps: serde+derive, postcard alloc+use-std, blake3; optional: getrandom, fold path dep, tungstenite; features as in Global Constraints) and empty module skeleton; add to workspace members. `cargo check -p sod` passes.
+- [ ] Step 2: Failing tests in `frame.rs` `#[cfg(test)]`: `frame_roundtrip` (encode_record → decode_record → equal frame + hash), `decode_partial_tail_is_none` (truncated record → Ok(None)), `decode_flipped_byte_is_corrupt` (flip a body byte → Err(Corrupt)), `hash_chains` (frame2.prev_hash = frame1.hash()).
+- [ ] Step 3: Implement; run `cargo test -p sod` to green.
+- [ ] Step 4: Golden fixture: a test-generated two-frame log written once to `sod/tests/fixtures/golden.sodlog` (deterministic content: fixed ReplicaId bytes, fixed event times); `golden_log.rs` asserts byte-exact parse forever. Regenerating requires deliberately re-running the ignored generator test — document in the test.
+- [ ] Step 5: Commit.
+
+### Task 2: Version vectors
+
+**Files:** Create `sod/src/vector.rs`
+
+**Interfaces (produces):**
+```rust
+#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
+pub struct VersionVector(BTreeMap); // seq held through, contiguous
+impl VersionVector {
+ pub fn get(&self, id: &ReplicaId) -> u64; // 0 if absent
+ pub fn advance(&mut self, id: ReplicaId, seq: u64); // panics unless seq == get+1
+ pub fn set(&mut self, id: ReplicaId, seq: u64); // for engine cursors
+ pub fn iter(&self) -> impl Iterator- ;
+ /// origins+ranges self holds beyond `other`: (origin, other_have, self_have]
+ pub fn ahead_of(&self, other: &Self) -> Vec<(ReplicaId, u64, u64)>;
+}
+```
+
+- [ ] Step 1: Failing tests: `advance_contiguous`, `advance_gap_panics`, `ahead_of_disjoint_and_overlap` (covers: origin unknown to other, partially known, fully known).
+- [ ] Step 2: Implement; green; commit.
+
+### Task 3: LogStore port, `MemLog`, `FileLog` with torn-tail recovery
+
+**Files:** Create `sod/src/store.rs` (trait + MemLog), `sod/src/log_file.rs` (FileLog), `sod/tests/log_recovery.rs`
+
+**Interfaces (produces):**
+```rust
+pub trait LogStore {
+ fn append(&mut self, frame: &Frame) -> Result<(), SodError>;
+ fn sync(&mut self) -> Result<(), SodError>;
+ /// decoded + hash-verified frames, in file order; recovery already done
+ fn frames(&self) -> Result
, SodError>;
+}
+pub struct MemLog { .. } // Vec ; Default
+pub struct FileLog { .. }
+impl FileLog { pub fn open(path: &Path) -> Result<(Self, Vec ), SodError>; }
+// open scans; first short/бad record truncates the file there (torn tail);
+// a corrupt record *followed by further valid data* is Err(Corrupt) — that is
+// not a torn tail, it is corruption, and we refuse rather than drop data.
+```
+Note: `frames()` on FileLog returns the frames captured at `open` plus appends since — FileLog keeps them in memory (v1 keeps the whole log in memory by design; documented).
+
+- [ ] Step 1: Failing tests in `log_recovery.rs`: `roundtrip_reopen`, `torn_tail_truncated` (append 3, truncate file by k bytes for k in 1..last record len, reopen → 2 frames, file length restored to end of frame 2, appends still work), `mid_file_corruption_refuses` (flip byte in frame 1 of 3 → Err).
+- [ ] Step 2: Implement MemLog + FileLog (open with recovery scan, append = encode_record + write, sync = File::sync_data); green; commit.
+
+### Task 4: Engine port and `MemEngine`
+
+**Files:** Create `sod/src/engine.rs`
+
+**Interfaces (produces):**
+```rust
+pub trait Engine {
+ /// Commit deltas + cursor advance for (frame.origin, frame.seq) atomically.
+ fn apply(&mut self, frame: &Frame, watermark: u64) -> Result<(), SodError>;
+ /// Cursor durably applied through, per origin (read at open).
+ fn applied(&self) -> VersionVector;
+ /// Seed the watermark at open before any apply (default no-op).
+ fn seed_watermark(&mut self, _wm: u64) {}
+}
+pub struct MemEngine { multiset: BTreeMap, i64>, applied: VersionVector, watermark: u64 }
+impl MemEngine {
+ pub fn new() -> Self;
+ pub fn view_bytes(&self) -> Vec; // postcard of multiset — the convergence probe
+ pub fn count(&self, datum: &[u8]) -> i64;
+ pub fn watermark(&self) -> u64;
+}
+```
+MemEngine removes zero entries (so transient negatives are representable and visible, per spec).
+
+- [ ] Step 1: Failing tests: `apply_advances_cursor`, `retraction_before_insert_goes_negative_then_zero_entry_removed`, `view_bytes_order_independent` (two MemEngines, same frames in different origin-interleavings → equal view_bytes).
+- [ ] Step 2: Implement; green; commit.
+
+### Task 5: `Replica` — open/replay, commit, ingest, poisoning
+
+**Files:** Create `sod/src/replica.rs`; Modify `sod/src/lib.rs` (exports)
+
+**Interfaces (produces):**
+```rust
+pub struct Replica { .. }
+impl Replica {
+ /// Rebuild feeds/vector/watermark from log, verify chains, then replay
+ /// into the engine every frame beyond engine.applied() (SOD-1, SOD-5).
+ pub fn open(id: ReplicaId, log: L, engine: E) -> Result;
+ /// Local write: build frame (chain to local head, seq = local+1), append
+ /// log, sync, engine.apply. Returns the frame's hash.
+ pub fn commit(&mut self, payload: Vec<(Vec, i64)>, event_time: u64)
+ -> Result;
+ /// Remote frame from sync. Dedup: seq <= have and same hash → Ok(false).
+ /// seq <= have, different hash → poison origin, Err(Equivocation).
+ /// seq > have+1 → Err(Gap). Poisoned origin → Err(Poisoned).
+ /// Else verify prev_hash == feed head (else poison), append, apply.
+ pub fn ingest(&mut self, frame: Frame) -> Result;
+ pub fn id(&self) -> ReplicaId;
+ pub fn vector(&self) -> &VersionVector;
+ pub fn watermark(&self) -> u64; // max event_time applied
+ pub fn frames_after(&self, origin: ReplicaId, after: u64) -> &[Frame];
+ pub fn poisoned(&self) -> impl Iterator- ;
+ pub fn engine(&self) -> &E; pub fn engine_mut(&mut self) -> &mut E;
+}
+```
+Internal: `feeds: BTreeMap
>` (in-memory copy of the log per origin, chain-verified at open), `poisoned: BTreeSet`.
+
+- [ ] Step 1: Failing unit tests (MemEngine + MemLog): `commit_chains_and_applies`, `reopen_replays_only_unapplied` (engine pre-seeded with partial cursor → only suffix re-applied — the SOD-5 crash-heal path), `ingest_dedups`, `ingest_gap_rejected`, `equivocation_poisons` (second frame, same (origin,seq), different payload → Err + subsequent ingest for that origin → Err(Poisoned), other origins unaffected), `watermark_is_max_event_time` (out-of-order event times).
+- [ ] Step 2: Implement; green; commit.
+- [ ] Step 3: Crash-healing test with FileLog in `sod/tests/log_recovery.rs`: commit twice, simulate crash-between-append-and-apply by reopening with a *fresh* MemEngine (cursor empty) → replay restores counts; then simulate torn tail on a third commit → reopen heals to two frames.
+- [ ] Step 4: Green; commit.
+
+### Task 6: Sans-io sync session + convergence property tests
+
+**Files:** Create `sod/src/sync.rs`, `sod/tests/convergence.rs`
+
+**Interfaces (produces):**
+```rust
+pub const PROTOCOL_VERSION: u16 = 1;
+#[derive(Serialize, Deserialize)]
+pub enum Msg {
+ Hello { protocol: u16, schema: u32, vector: VersionVector },
+ Frames(Vec ),
+ Done,
+}
+pub struct Session { .. }
+impl Session {
+ pub fn new(schema: u32) -> Self;
+ pub fn hello(&self, r: &Replica) -> Msg;
+ /// Feed one inbound message; returns outbound messages (empty ok).
+ /// Hello → version check (SOD-9) then batched Frames (≤256/msg,
+ /// per-origin contiguous, skipping locally-poisoned origins) + Done.
+ /// Frames → ingest each (Gap/Equivocation from a peer = SyncError;
+ /// dedup Ok(false) is fine). Done → mark peer done.
+ pub fn on_msg(&mut self, r: &mut Replica, m: Msg)
+ -> Result, SodError>;
+ pub fn finished(&self) -> bool; // we sent Done and received Done
+}
+/// Drive a full in-memory session between two replicas (test + local sync).
+pub fn sync_pair<..>(a: &mut Replica<..>, b: &mut Replica<..>, schema: u32)
+ -> Result<(), SodError>;
+```
+
+- [ ] Step 1: Failing unit tests: `two_replica_session_converges` (message-pump loop), `version_mismatch_refuses` (schema differs → Err(VersionMismatch), no frames exchanged), `relay_carries_third_party_frames` (A→B, then B→C; C holds A's frames).
+- [ ] Step 2: Implement; green; commit.
+- [ ] Step 3: Property test `convergence.rs` (no proptest dep — hand-rolled deterministic xorshift PRNG seeded per case, 100 cases): N∈2..=5 replicas over MemEngine/MemLog; 200 steps of random {local commit of random datum/±mult, sync_pair of random pair, interrupted sync (deliver only first k outbound messages, then drop the session)}; after each sync compare: any two replicas with equal vectors must have equal `view_bytes()` and equal `watermark()` (SOD-4, SOD-6, SOD-8). End every case with full pairwise rounds until all vectors equal → assert all views identical.
+- [ ] Step 4: Green; commit.
+
+### Task 7: fold additions + `FoldEngine` + differential test
+
+**Files:**
+- Modify: `fold/src/stream/mod.rs` (add `Tx::meta`), `fold/src/stream/unkeyed.rs` (add `Stream::meta_keyspace`, `Stream::meta_snapshot`)
+- Create: `sod/src/engine_fold.rs`, `sod/src/time.rs`, `sod/tests/fold_engine.rs`
+
+**fold additions (additive, documented, no behavior change):**
+```rust
+// unkeyed.rs
+/// Open (or create) a metadata keyspace `meta_{name}`, outside the
+/// pipeline's `sink_*` namespace. For infrastructure layered over Stream
+/// (e.g. replication cursors) that must commit atomically with pipeline
+/// writes via [`Tx::meta`].
+pub fn meta_keyspace(&self, name: &str) -> fjall::SingleWriterTxKeyspace { .. }
+/// A read snapshot of committed state, for reading metadata keyspaces.
+pub fn meta_snapshot(&self) -> fjall::Snapshot { self.store.read_tx() }
+// mod.rs, impl Tx
+/// The raw store transaction, for writing metadata keyspaces atomically
+/// with this transaction's pipeline pushes.
+pub fn meta(&mut self) -> &mut WriteTx<'tx> { self.tx }
+```
+
+**Interfaces (produces):**
+```rust
+pub struct Watermark(Arc);
+impl Watermark { pub fn new() -> Self; pub fn get(&self) -> u64;
+ pub fn clock(&self) -> impl Fn() -> u64 + Clone; }
+pub struct FoldEngine> { stream: fold::stream::Stream,
+ cursor_ks: .., applied: VersionVector, watermark: Watermark }
+impl> FoldEngine {
+ /// Opens the Stream at `path`, loads the cursor from meta keyspace "sod_cursor".
+ pub fn open(path: impl AsRef, pipeline: P, watermark: Watermark) -> Self;
+ pub fn stream(&self) -> &fold::stream::Stream; // for rtx reads
+}
+impl<..> Engine for FoldEngine {
+ // apply: watermark.store(max(cur, wm)); stream.wtx(|tx| { decode+push each
+ // delta; tx.meta().insert(&cursor_ks, origin bytes, seq BE bytes) });
+ // then applied.set(origin, seq). Cursor write is INSIDE the wtx → atomic.
+}
+```
+Cursor layout: key = 16-byte origin, value = 8-byte BE seq. Loaded at open by iterating the keyspace from `meta_snapshot()`.
+
+- [ ] Step 1: fold additions + rustdoc; `cargo test -p fold` still green; commit (separate commit: `feat(fold): metadata keyspaces for infrastructure layered over Stream`).
+- [ ] Step 2: Failing tests in `fold_engine.rs` (feature fold-engine): `fold_replica_counts` (Replica, FileLog>: commit inserts/retracts, read Bag through `engine().stream().rtx`), `crash_between_log_and_apply_heals` (commit via replica; then append a frame directly to the FileLog *without* applying — simulating the crash window; reopen replica with reopened FoldEngine → cursor causes exactly the orphan frame to replay; counts correct), `differential_vs_mem` (same random frame sequence into FoldEngine(Bag) and MemEngine → Bag contents == MemEngine multiset).
+- [ ] Step 3: Implement `time.rs` + `engine_fold.rs`; green; commit.
+- [ ] Step 4: Update spec Time section: record the Retain divergence analysis (arrival-order stamping makes processing-time windows non-convergent under any injected clock; event-time retain in fold is the fix, future work; sod v1 excludes time-windowed operators from SOD-4 and the watermark is maintained for app reads and that future operator). Also scope SOD-4 wording to "views observed through sink readers" (Retain-style internal keyspaces may differ in tie-break bytes). Commit spec edit.
+
+### Task 8: Websocket transport
+
+**Files:** Create `sod/src/transport/mod.rs`, `sod/src/transport/ws.rs`, `sod/tests/ws_sync.rs`
+
+**Interfaces (produces):**
+```rust
+/// Wire = binary websocket messages, each one postcard-encoded `Msg`.
+/// Ordering (deadlock-free over blocking sockets):
+/// initiator: send Hello → recv Hello → send our Frames+Done → recv theirs
+/// responder: recv Hello → send Hello → recv Frames+Done → send ours
+pub fn sync_with(url: &str, r: &mut Replica, schema: u32) -> Result<(), SodError>;
+/// Blocking accept loop, one session at a time (v1; documented).
+/// Returns after `max_sessions` if Some (tests), else loops forever.
+pub fn serve(addr: &str, r: &mut Replica, schema: u32,
+ max_sessions: Option) -> Result<(), SodError>;
+```
+
+- [ ] Step 1: Failing test `ws_sync.rs::two_processes_converge`: thread A serves `127.0.0.1:0`-style fixed test port with `max_sessions=Some(1)` on a replica with data; main thread `sync_with`; both sides converge (compare view_bytes) — MemEngine so the test runs with `--no-default-features --features ws`.
+- [ ] Step 2: Implement with tungstenite (`accept`/`connect`); green; commit.
+
+### Task 9: Portability gate
+
+**Files:** Create `sod/tests/wasm_check.rs`
+
+- [ ] Step 1: Test `core_builds_for_wasm32` (`#[ignore]`-free, std process spawn): run `cargo check -p sod --no-default-features --target wasm32-unknown-unknown`; if the target isn't installed (probe `rustc --print target-libdir --target wasm32-unknown-unknown` failure / check output contains "may not be installed"), print a skip notice and pass. Also assert `cargo check -p sod --no-default-features` (host) succeeds.
+- [ ] Step 2: Fix whatever it flushes out (feature-gate leaks: fold/tungstenite/getrandom imports must all sit behind their cfg-features). Green; commit.
+
+### Task 10: `examples/sod-demo` (native binary)
+
+**Files:** Create `examples/sod-demo/Cargo.toml`, `examples/sod-demo/src/main.rs`, `examples/sod-demo/README.md`
+
+App: datum `String`, pipeline `(terminal::Bag::::new("notes"), terminal::Count::new("count"))`, `FoldEngine` + `FileLog` in `` (log at `/sod.log`, fold db at `/db`, replica id persisted at `/replica_id` — created with `ReplicaId::generate()` on first run, never regenerated while the log exists, deleted with it: SOD-3). `SCHEMA: u32 = 1`. Event time from `SystemTime` **in the demo binary** (apps stamp; sod never does).
+
+Commands: `sod-demo add ` | `remove ` | `list` (bag + count) | `serve ` | `sync `.
+
+- [ ] Step 1: Scaffold crate (deps: sod, fold; picked up by the `examples/*` workspace glob), implement, plus its README (usage transcript showing two dirs converging).
+- [ ] Step 2: Manual verification run: `add` twice in dir A, once in dir B, `serve`+`sync`, `list` both → identical output. Paste transcript into README. Commit.
+
+### Task 11: Node packaging (napi-rs addon for the demo app)
+
+**Files:** Create `examples/sod-demo/node/Cargo.toml` (crate `sod-demo-node`, `crate-type = ["cdylib"]`, deps napi/napi-derive), `examples/sod-demo/node/src/lib.rs`, `examples/sod-demo/node/demo.mjs`, extend `examples/sod-demo/README.md`
+
+Exposed JS surface (thin, app-specific — the template pattern): `open(dir)`, `add(text)`, `remove(text)`, `list(): string[]`, `count(): number`, `syncWith(url)`, `serveOnce(addr)`, `close()`. Implementation holds `Replica, FileLog>` behind a `Mutex>` (fold Stream is !Send-safe here: use `napi` sync functions, single-threaded access documented).
+Build/run: `cargo build -p sod-demo-node`, copy `target/debug/libsod_demo_node.{dylib,so}` → `sod_demo_node.node`, `node demo.mjs`.
+
+- [ ] Step 1: Implement addon + `demo.mjs` (opens two dirs, adds notes, syncs via in-process serve on a thread? No — two Node processes in the README transcript; demo.mjs does add/list/sync against a `sod-demo serve` peer).
+- [ ] Step 2: Verify `node demo.mjs` end-to-end against the native binary serving. Paste transcript into README. Commit.
+- [ ] Fallback: if napi cannot build in this environment, drop the crate from the workspace, keep the directory with README documenting the pattern, and record the blocker in the PR description. Do not fake the transcript.
+
+### Task 12: Docs + workspace integration
+
+**Files:** Create `sod/README.md`; Modify `README.md` (add sod to "In this workspace"), `sod/src/lib.rs` (crate-level rustdoc: model, invariants table, port map, target matrix)
+
+- [ ] Step 1: Write `sod/README.md`: what sod is, the convergence argument, port map, feature flags, target matrix, how to run the demo + tests, link to spec.
+- [ ] Step 2: `cargo doc -p sod --no-deps` builds without warnings. `cargo test --workspace` (full features) green. Commit.
+
+---
+
+## Self-review notes
+
+- Spec coverage: SOD-1 (T5 open/replay), SOD-2 (T1 hashing, T5 poisoning), SOD-3 (T1 generate + T10 id lifecycle), SOD-4 (T6 property + T7 differential), SOD-5 (T3+T5 crash tests), SOD-6 (T6 interrupted syncs), SOD-7 (grep gate + event_time-as-argument), SOD-8 (T4/T6 order independence), SOD-9 (T6 version refusal). Engine port T4/T7; sans-io sync T6; ws T8; wasm gate T9; Node packaging T11; watermark T7; spec Time correction T7.4.
+- Known deliberate deviation from spec text: `Replica::commit` takes `event_time` as an argument (stronger than "sod's only wall-clock read"); spec updated in T7.4 alongside the Retain analysis.
diff --git a/docs/superpowers/plans/2026-08-16-sod-web-implementation.md b/docs/superpowers/plans/2026-08-16-sod-web-implementation.md
new file mode 100644
index 0000000..abdea59
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-sod-web-implementation.md
@@ -0,0 +1,145 @@
+# sod-web Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** The three-bog emoji-reaction demo: additive sod transport/wire changes, a per-app napi addon, a Next.js app with sync loop + connected badge, a local three-instance rehearsal, and Fly deploy config.
+
+**Architecture:** Each Next.js instance embeds a sod replica server-side via `sod-web-addon`. Sod gains `SyncListener`/`IncomingSession` (per-session replica locking), `Hello.id`, and `SyncReport`. Peers are a uniform reachability list; the UI's connected badge counts peers with a completed session in a 10 s window.
+
+**Tech Stack:** Rust (sod, napi-rs), Next.js (App Router, standalone output), Fly.io (Docker, raw TCP sync port, volume).
+
+**Spec:** `docs/superpowers/specs/2026-08-16-sod-web-design.md`
+
+## Global Constraints
+
+- Fold is consumed, never modified (`git diff main -- fold` stays empty).
+- Sod changes are additive API / pre-release wire-field only; `PROTOCOL_VERSION` stays 1; golden log fixture untouched (record format unchanged).
+- Workspace: add `"examples/sod-web/addon"` to `members`, `"examples/sod-web"` to `exclude`. `.gitignore` grows `node_modules/`, `.next/`.
+- Addon never blocks the Node event loop on I/O (Promise/thread for sync; serve loop on a Rust thread).
+- `SCHEMA: u32 = 1` in the addon. Liveness window 10_000 ms; sync loop tick 3_000 ms.
+- Run `cargo test -p sod` per sod task; full `cargo test --workspace` + wasm gate before claiming done.
+
+---
+
+### Task 1: `Hello.id` + `SyncReport`
+
+**Files:** Modify `sod/src/sync.rs`, `sod/src/transport/ws.rs`, `sod/src/lib.rs` (export `SyncReport`), tests in `sod/src/sync.rs`, `sod/tests/ws_sync.rs`, `sod/tests/convergence.rs`.
+
+**Interfaces (produces):**
+```rust
+pub enum Msg { Hello { protocol: u16, schema: u32, id: ReplicaId, vector: VersionVector }, .. }
+pub struct SyncReport { pub peer: ReplicaId, pub peer_vector: VersionVector, pub skipped: Vec }
+impl Session { pub fn report(self) -> SyncReport; } // replaces into_skipped; peer fields captured from the peer's Hello
+// sync_pair -> Result<(SyncReport, SyncReport), SodError> (a's report, b's report)
+// ws::sync_with -> Result
+```
+`Session` stores `peer: Option<(ReplicaId, VersionVector)>` set in the Hello handler; `report()` panics with "session saw no Hello" if called before one (transports always see one).
+
+- [ ] Step 1: Failing tests: `hello_carries_id` (Session::hello of replica 1 → matches `Msg::Hello { id, .. }` with id 1), `sync_pair_reports_peers` (a↔b: a's report.peer == b.id(), vector snapshot equals b's pre-session vector; b's mirror), update `version_mismatch_refuses` construction.
+- [ ] Step 2: Implement; fix all callers (`ws.rs` run_session returns `Ok(session.report())`; convergence tests destructure or ignore reports).
+- [ ] Step 3: `cargo test -p sod` green; commit `feat(sod): Hello carries replica id; SyncReport from sessions`.
+
+### Task 2: Transport accept/session split
+
+**Files:** Modify `sod/src/transport/ws.rs`; Test `sod/tests/ws_sync.rs`.
+
+**Interfaces (produces):**
+```rust
+pub struct SyncListener { listener: TcpListener }
+impl SyncListener {
+ pub fn bind(addr: &str) -> Result;
+ pub fn local_addr(&self) -> Result;
+ pub fn accept(&self) -> Result; // blocks; ws handshake done here
+}
+pub struct IncomingSession { sock: WebSocket }
+impl IncomingSession {
+ pub fn run(mut self, r: &mut Replica, schema: u32)
+ -> Result; // responder role; closes socket on exit
+}
+```
+`serve()` becomes: bind once, loop `{ accept; run; count only Ok }` — behavior identical (stray-probe test must stay green; a failed *accept handshake* returns `Err` from `accept()`, so serve loops on accept errors too, logging them).
+
+- [ ] Step 1: Failing test `listener_idles_without_replica_lock`: bind `SyncListener` on port 47165, hold it idle on a thread that does NOT own the replica; meanwhile commit 100 writes to the replica on the main thread (proving no lock interaction); then dial with `sync_with` from a second replica after passing the replica into the accept thread via channel; assert convergence.
+- [ ] Step 2: Implement; rewrite `serve()` over the new API.
+- [ ] Step 3: `cargo test -p sod` green (including existing `stray_connection_does_not_consume_one_shot_serve`); commit `feat(sod): SyncListener/IncomingSession — per-session replica locking`.
+
+### Task 3: Workspace scaffold for `examples/sod-web`
+
+**Files:** Modify root `Cargo.toml`, `.gitignore`; Create `examples/sod-web/package.json`, `next.config.mjs`, `tsconfig.json`, `app/layout.tsx`, `app/page.tsx` (placeholder), `examples/sod-web/addon/Cargo.toml`, `addon/build.rs`, `addon/src/lib.rs` (stub: napi `ping()` returning "pong").
+
+Next.js scaffolding is manual (no create-next-app): `package.json` with `next@15`, `react`, `react-dom`, `typescript`, `@types/*`; `output: "standalone"` in next.config. Addon Cargo.toml mirrors `examples/sod-demo/node/` (napi/napi-derive/napi-build, `crate-type = ["cdylib"]`, deps sod/fold/postcard).
+
+- [ ] Step 1: Scaffold files; `members += "examples/sod-web/addon"`, `exclude = ["examples/sod-web"]`.
+- [ ] Step 2: Verify: `cargo check -p sod-web-addon` passes; `npm install && npm run build` inside `examples/sod-web` passes; `cargo test --workspace` unaffected.
+- [ ] Step 3: Commit `feat(sod-web): workspace + app scaffold`.
+
+### Task 4: The addon
+
+**Files:** Rewrite `examples/sod-web/addon/src/lib.rs`; Create `examples/sod-web/scripts/smoke.mjs`.
+
+**Interfaces (produces — JS surface):**
+```
+open(dir: string): string // replica id hex
+close(): void
+react(emoji: string): void
+unreact(emoji: string): void // throws "nothing to unreact" at zero
+board(): { reactions: Array<{emoji: string, count: number}>, total: number }
+status(): { id: string, vector: Record, watermark: number,
+ peers: Array<{key: string, lastSyncMs: number|null, lastError: string|null}>,
+ connectedIds: string[] } // distinct peer replica-ids seen ≤10s ago
+syncWithPeer(url: string): Promise // refusals; worker thread (napi AsyncTask)
+startServeLoop(addr: string): string // bound addr; Rust thread; per-session lock
+```
+Internals: statics `REPLICA: Mutex>`, `PEERS_SEEN: Mutex>` (updated by both sync directions), `PEER_STATUS: Mutex>`. Pipeline `(sod::sinks::Bag, Count)`; dir handling copied from sod-demo `open()` (SOD-3 id lifecycle). `connectedIds` computed from `PEERS_SEEN` at call time. Serve loop thread: `SyncListener::bind`, loop `{ accept → lock replica → run → record report.peer }`; on accept error, log and continue.
+
+- [ ] Step 1: Implement addon.
+- [ ] Step 2: `smoke.mjs`: build, copy `.node`, then open tmp dir → react ×3 two emoji → board matches → unreact → guarded-unreact throws → status has id + empty connectedIds → close. Two-replica leg: replica B `startServeLoop("127.0.0.1:0")` in process B? (single replica per process → spawn `node smoke-peer.mjs` child process with its own dir + serve; parent `syncWithPeer` to it; assert boards converge and `connectedIds` includes B's id.)
+- [ ] Step 3: `node examples/sod-web/scripts/smoke.mjs` passes; commit `feat(sod-web): addon with async sync + serve loop + peer tracking`.
+
+### Task 5: Next.js app — API, sync loop, UI
+
+**Files:** Create `examples/sod-web/lib/sod.ts` (addon loader + init-once from env), `lib/sync-loop.ts`, `app/api/{react,unreact,board,status,peer-toggle}/route.ts`, rewrite `app/page.tsx`, `app/layout.tsx`, `app/ui/*` components (Nav, Board, NerdPanel).
+
+Behavior:
+- `lib/sod.ts`: loads `.node`, `open(SOD_DATA_DIR)`, `startServeLoop(SOD_SERVE_ADDR)` if set, starts sync loop with `SOD_PEERS.split(",")` if set; module-level singleton (Next dev double-init guarded by a global).
+- `lib/sync-loop.ts`: `setInterval(3000)`; per peer `{url, paused}`; on tick, unpaused peers → `await syncWithPeer(url)` → record ok/err into a status map merged into `/api/status`; `kick()` exported, called after react/unreact.
+- `/api/status` merges addon `status()` + loop state: `{...addonStatus, peers: [{url, paused, lastOkMs, lastError}], connected: connectedIds.length, online: anyRemotePeerOkWithin10s}`.
+- UI: palette `["👍","❤️","😂","🎉","🚀","👀","🔥","🥲"]`; tiles show count, click → POST react; long-press/right-click → unreact. Nav: color dot + short id, `◉ N bogs connected`, online pill, last-sync age. NerdPanel: per-peer rows with pause toggles, connected-now vs heard-from-ever, raw vector. Poll board+status each 1 s via `useEffect`.
+
+- [ ] Step 1: Implement; `npm run build` clean.
+- [ ] Step 2: Manual verify single instance: `SOD_DATA_DIR=/tmp/sw-a npm run dev` — react, reload survives, status shows id.
+- [ ] Step 3: Commit `feat(sod-web): reaction board app with sync loop and connected badge`.
+
+### Task 6: Three-instance local rehearsal
+
+**Files:** Create `examples/sod-web/scripts/demo-local.mjs`, `examples/sod-web/README.md` (local section).
+
+`demo-local.mjs`: builds app once, then spawns three `next start` instances (hub :3002 serve-only, :3000, :3001 configured per the spec topology but pointing at the local hub), waits for `/api/status` on each, then drives the checks via HTTP: react on each → poll until all three `/api/board` equal; POST peer-toggle to pause both locals' hub peers (the partition) → react on :3000 → assert :3001 converges but hub doesn't → unpause → assert hub converges; print PASS/FAIL summary and kill children.
+
+- [ ] Step 1: Implement script; run it; iterate until PASS.
+- [ ] Step 2: README local-demo section with the exact commands and expected badge behavior.
+- [ ] Step 3: Commit `feat(sod-web): three-instance local rehearsal script`.
+
+### Task 7: Fly deploy config + runbook
+
+**Files:** Create `examples/sod-web/Dockerfile`, `examples/sod-web/fly.toml`, `.dockerignore`; extend `examples/sod-web/README.md`.
+
+- Dockerfile: stage 1 `rust:1` — build `sod-web-addon` release (workspace copy, `cargo build -p sod-web-addon --release`); stage 2 `node:22` — `npm ci && npm run build` (standalone); stage 3 `node:22-slim` — standalone output + `.node` artifact + `server.js` entry; `ENV SOD_DATA_DIR=/data/sod`.
+- `fly.toml`: app name `sod-web-demo`; `[http_service]` internal_port 3000, force_https, min_machines_running 1, auto_stop disabled; `[[services]]` raw TCP: internal_port 7300, `[[services.ports]]` port 10700 handlers [] (plain TCP first; note the TLS flip); `[mounts]` source `sod_data`, destination `/data`; env `SOD_SERVE_ADDR=0.0.0.0:7300`.
+- README: deploy runbook (`fly launch --no-deploy`, `fly volumes create sod_data`, `fly deploy`), local-vs-hub env for the wifi-kill demo, and the stage script (kill wifi → locals keep syncing → reconnect → hub converges) with expected badge states at each step.
+
+- [ ] Step 1: Write config; `docker build` locally if docker available, else validate Dockerfile by review and note in README.
+- [ ] Step 2: If `flyctl` is authenticated in this environment, deploy and run the real runbook; otherwise deliver config + runbook and state plainly it hasn't been deployed yet.
+- [ ] Step 3: Commit `feat(sod-web): Fly.io deploy config and demo runbook`.
+
+### Task 8: Docs + final gates
+
+**Files:** Modify `sod/README.md` (SyncReport/SyncListener API notes), root `README.md` (sod-web bullet under examples), `examples/sod-web/README.md` (polish).
+
+- [ ] Step 1: Doc updates; `cargo doc -p sod --no-deps` no warnings.
+- [ ] Step 2: `cargo test --workspace` + wasm gate green; `node examples/sod-web/scripts/smoke.mjs` and `demo-local.mjs` PASS.
+- [ ] Step 3: Commit `docs(sod-web): README + sod API docs`.
+
+## Self-review notes
+
+Spec coverage: transport split (T2), Hello id + SyncReport (T1), addon (T4), app/UI/badge/nerd-panel (T5), uniform peers + partition rehearsal (T6), Fly (T7), docs (T8), scaffold/workspace (T3). Deferred per spec: TLS feature, SSE, hub scale-out. Type consistency: `SyncReport` produced in T1, consumed T2/T4; addon surface of T4 consumed verbatim in T5.
diff --git a/docs/superpowers/specs/2026-08-15-sod-design.md b/docs/superpowers/specs/2026-08-15-sod-design.md
new file mode 100644
index 0000000..808c2cd
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-15-sod-design.md
@@ -0,0 +1,407 @@
+# Sod: symmetric replication for fold apps
+
+Date: 2026-08-15
+Status: draft for review
+
+## What sod is
+
+Sod is a replication layer for fold applications. A **sod** is any replica
+embedding it: a small one inside a Node.js process, a big always-on one on a
+server — same crate, same protocol, different deployment. Sod turns a
+single-process fold `Stream` into an offline-first replica that converges with
+its peers, PouchDB-style: local writes always succeed, and replicas exchange
+what the other is missing whenever connectivity allows.
+
+Sod lives in this workspace as a crate (`sod/`) with a path dependency on
+`fold`. This is deliberate: fold's API is alpha and fast-moving, and sod is
+compiled against it, so fold changes break sod at `cargo build` time and get
+fixed in-tree — never a lagging external binding chasing a moving API.
+
+**Sod consumes fold; it never modifies it.** Everything sod needs from fold
+comes through fold's public API — the `Push` trait, sink keyspaces, the
+startup snapshot, transactional writes. Where fold's behavior doesn't fit
+replication (see the `Bag` clamp finding below), sod ships its own sink
+rather than patching the core. This keeps fold pristine and
+upstream-mergeable, and keeps sod a pure consumer with nothing to rebase.
+
+The goal is that both **client↔server patterns and decentralized p2p
+patterns** can be enabled against **different bog machinery** — fold today,
+other engines tomorrow — with replicas running in browsers, native apps,
+React Native, and on servers. The protocol and core never assume a topology,
+an engine, or a platform; those are all ports. The first shipped
+implementations of the ports are native (std filesystem log, websocket
+transport, fold engine), but `sod`'s core compiles for `wasm32-unknown-unknown`
+from day one and this is enforced in the test suite.
+
+Sod has nothing to do with clog.
+
+### Why symmetric replication is correct for fold
+
+Fold's write primitive is a Z-set delta: a datum plus a signed multiplicity.
+Deltas commute — the multiset state is the sum of applied deltas, and sums are
+order-independent. Retraction of a not-yet-seen datum is algebraically fine
+(the multiplicity goes negative until the matching insert arrives). Fold views
+are deterministic functions of the multiset. Therefore: replicas that hold the
+same set of deltas hold the same views, regardless of the order or topology by
+which the deltas arrived. Convergence comes from algebra, not coordination.
+
+### Authority is policy, not protocol
+
+The protocol is symmetric-only, permanently. There is no client role and no
+server role on the wire. An application that needs order-sensitive operations
+(uniqueness constraints, claims, invariant-preserving read-modify-write)
+designates one replica as the sequencer *for those operations* and routes such
+requests to it as ordinary application RPC, outside the sync protocol. The
+sequencer's decisions come back as normal deltas that replicate like anything
+else. Nothing in the log or protocol special-cases this, and nothing precludes
+it.
+
+## Prior art, and what each contributes
+
+| System | Lesson | Where it lands in sod |
+|---|---|---|
+| Secure Scuttlebutt | Per-origin append-only feeds, hash-chained, gossiped by version vector | The core model: per-replica logs, chained frames, vector exchange |
+| git | Content-addressed identity; integrity is structural, not bolted on | Frame identity is its BLAKE3 hash |
+| Blockchains | Hash chains make equivocation detectable | Two frames claiming one `(origin, seq)` = poisoned feed, refused |
+| WebTorrent | Piece verification enables trustless relay and swarming | Hash-verified frames can be relayed by any peer; mesh is retrofittable |
+| IPFS | Content-address big payloads; separate data from replication metadata | Future work: blob store for large values (e.g. embedding vectors) |
+| CouchDB/PouchDB | Resumable idempotent replication; version-metadata handshake; the re-initialized-replica bug; compaction pressure | Vector-based resume; schema-version handshake; replica-id freshness rule; compaction constraints recorded |
+| Streaming systems | Time must be data (watermarks), never local wall clock | The watermark clock is the only clock a pipeline may observe |
+
+## Invariants
+
+Each invariant is enforced by a named test.
+
+- **SOD-1 (log is truth).** The sod log is the source of truth. The fold db is
+ a rebuildable cache: deleting it and replaying the log yields an equivalent
+ replica.
+- **SOD-2 (chained identity).** A frame's identity is the BLAKE3 hash of its
+ encoded bytes. Every frame carries the hash of its predecessor from the same
+ origin (zero hash for `seq == 1`). A frame that fails hash verification, or
+ a second distinct frame claiming an already-seen `(origin, seq)`, marks that
+ origin's feed as poisoned: sod stops accepting frames for that origin and
+ surfaces the error. Already-applied frames are not rolled back.
+ **Exception: a replica never poisons its own feed** — it is the feed's
+ authority, a conflicting claim about it is the peer's forgery (or a reused
+ id), and self-poisoning would let one hostile message halt local commits.
+ Note the exception covers *conflicting* forgeries only: frames are
+ unsigned in v1, so a forged frame that cleanly *extends* our feed
+ (correct next seq, correct prev-hash) is accepted like any other — that
+ is the pre-existing unsigned-frame limitation the signatures item in
+ Future work owns, not something poisoning can address. Refusals recorded
+ mid-session are returned to sync callers (at most one per origin per
+ session, so a flooding peer cannot grow them unboundedly), never
+ swallowed — a silently-poisoned feed is a feed that silently stopped
+ replicating.
+- **SOD-3 (fresh replica id).** `replica_id` is 128 random bits generated when
+ the local log is created, and never outlives the log: deleting or resetting
+ the log requires generating a new id. Ids are never reused, configured, or
+ derived from hardware.
+- **SOD-4 (convergence).** Two replicas running the same schema version, the
+ same engine, and the same pipeline, whose version vectors are equal,
+ observe byte-identical exact views **through sink readers**. (Internal
+ operator state may differ in tie-break bytes — e.g. arrival sequence
+ numbers — as long as no reader can observe the difference.) Approximate
+ indexes (HNSW) converge on the vector *set*; their query results are
+ order-sensitive and may differ until rebuilt from the store (see Known
+ deviations).
+- **SOD-5 (crash healing).** After a crash at any point — mid log append, or
+ between log fsync and fold commit — reopening yields a replica equivalent to
+ replaying the durable log prefix. Torn tail frames are truncated; the
+ applied-cursor replays exactly the un-applied suffix.
+- **SOD-6 (resumable sync).** A sync session killed at any byte leaves both
+ replicas correct; the next session resumes from the current version vectors
+ with no duplicated application (dedup on `(origin, seq)`).
+- **SOD-7 (watermark time).** The only clock observable by a sod-compatible
+ pipeline is the watermark: the maximum event-time across frames applied so
+ far. No wall-clock reads anywhere in the apply path.
+- **SOD-8 (deterministic apply).** Applying the same set of frames yields the
+ same view bytes regardless of arrival interleaving across origins. Frames
+ from a single origin apply in contiguous seq order.
+- **SOD-9 (versioned handshake).** Sync sessions begin by exchanging a schema
+ version (app-declared) and a sod protocol version. Any mismatch refuses the
+ session with a clear error. No partial or best-effort cross-version sync.
+
+## Architecture
+
+The crate splits into a **portable core** (pure logic, no I/O, no fold, no
+std-only dependencies) and **port implementations** behind feature flags.
+Everything platform- or engine-specific enters through a port.
+
+```
+sod/ workspace crate
+├── frame.rs core frame encoding, BLAKE3 hashing, chain verification
+├── vector.rs core version vectors: compare, diff, merge
+├── store.rs core LogStore port (append/scan/truncate) + MemLog impl
+├── replica.rs core Replica: write path, open/recovery, apply, dedup,
+│ poisoning; generic over the Engine port
+├── engine.rs core Engine port ("the bog machinery") + MemEngine, a
+│ minimal deterministic multiset engine for tests,
+│ wasm builds, and non-fold deployments
+├── sync.rs core sans-io session state machine: consumes/produces
+│ protocol messages, owns no sockets
+├── time.rs core watermark clock
+├── log_file.rs [std] filesystem LogStore with torn-tail recovery
+├── transport/ws.rs [ws] blocking websocket peer + listener
+├── engine_fold.rs [fold] fold-backed Engine: wraps the app pipeline in
+│ an AppliedCursor node (fold public API only)
+└── sinks.rs [fold] replication-safe sinks (Bag) for sod pipelines
+
+examples/sod-demo/ two-replica convergence demo over websocket;
+ doubles as the app template
+```
+
+### The Engine port
+
+An engine is whatever materializes deltas into readable state:
+
+```rust
+pub trait Engine {
+ /// Deterministic applicability check (e.g. datums decode as the
+ /// pipeline type), run by the replica BEFORE a frame is logged: a
+ /// logged frame that deterministically fails apply would fail replay
+ /// on every open — a bricked replica.
+ fn validate(&self, frame: &Frame) -> Result<...>;
+ /// Apply one frame's deltas plus the new watermark, atomically,
+ /// together with the applied-cursor update for `(origin, seq)`.
+ fn apply(&mut self, frame: &Frame, watermark: u64) -> Result<...>;
+ /// The cursor the engine has durably applied through, per origin —
+ /// read at open to replay exactly the un-applied log suffix.
+ fn applied(&self) -> VersionVector;
+}
+```
+
+`engine::MemEngine` (always compiled) is a deterministic in-memory multiset —
+the differential oracle for property tests and the engine available on targets
+fold cannot reach yet. `engine_fold::FoldEngine` (feature `fold-engine`,
+default on) wraps a fold `Stream` with the app's pipeline; `T: Serialize +
+DeserializeOwned` is the same bound fold's sinks already require. Because
+engines differ in what views they materialize, cross-replica convergence
+claims (SOD-4) apply between replicas running the *same* engine and pipeline.
+
+### Targets
+
+- **Server / native apps / Node.js**: full stack — fold engine, file log,
+ websocket transport. Node packaging is a per-app napi-rs addon (below).
+- **Browser**: `sod` core + `MemEngine` compile to `wasm32-unknown-unknown`
+ today (`cargo check --target wasm32-unknown-unknown --no-default-features`
+ is part of the test suite). A persistent browser LogStore (OPFS/IndexedDB)
+ and a WebSocket/WebRTC transport are follow-on port implementations, not
+ core changes. The fold engine reaches the browser only when fold grows a
+ storage port to replace fjall — recorded as fold future work, not sod's.
+- **React Native**: native Rust via a UniFFI/JSI binding — same full stack as
+ native apps (phones have real filesystems and threads, so fjall works).
+ Packaging follow-on; no core changes.
+- **Topologies**: client↔server is a star of pairwise symmetric sessions;
+ p2p is any other graph of the same sessions. The protocol cannot tell the
+ difference — that is the point.
+
+## The log
+
+Each replica keeps a single append-only file holding every frame it knows —
+its own and those received from other origins — in arrival order. Ordering is
+a per-origin property (the hash chain and contiguous seqs), not a property of
+the file. On-disk record:
+
+```
+u32 LE len | [u8; 4] len-check | frame_bytes | [u8; 32] blake3(frame_bytes)
+
+len-check = first 4 bytes of blake3(len bytes) — makes the length prefix
+tamper-evident, so corruption there is detected as interior corruption
+instead of being misread as a clean torn tail (which would silently
+truncate every valid record after it)
+
+frame (postcard) = {
+ prev_hash: [u8; 32], // hash of this origin's previous frame; zero at seq 1
+ origin: [u8; 16], // replica_id
+ seq: u64, // 1-based, contiguous per origin
+ event_time: u64, // origin-stamped, milliseconds since epoch
+ payload: Vec<(Vec, i64)>, // (postcard-encoded T, multiplicity)
+}
+```
+
+- **Durability.** `fsync` policy is configurable; the default fsyncs on every
+ local commit before fold apply (matching Pouch's durable default). Received
+ frames during sync may batch fsyncs.
+- **Recovery scan.** On open, the log is scanned. A *short* record at the
+ end (including a short header) is a torn tail and is truncated; a record
+ whose length-check fails, or a hash-invalid record with bytes beyond its
+ declared end, is interior corruption and open **refuses** — recovery must
+ never silently drop interior data (a regressed vector would make the
+ replica re-issue already-distributed seqs and be poisoned by every peer
+ as an equivocator). Everything before a truncated tail is trusted
+ (SOD-5). Crash model: appends are sequential and recovery only ever
+ truncates, so torn writes produce short records, not garbled ones.
+- **Ordering rule.** Log append (and its fsync, per policy) strictly precedes
+ fold apply. The reverse is impossible by construction.
+
+## The write path
+
+Local commit of a batch of deltas:
+
+1. Assign `seq` (local counter + 1), stamp `event_time` from the system clock
+ (the only wall-clock read in sod — it produces *data*, it is never
+ *observed* by the pipeline).
+2. Encode the frame, chain it to the previous local frame, append, fsync per
+ policy.
+3. Hand the frame to the engine's `apply`, which must commit the deltas and
+ the applied-cursor advance for `(origin, seq)` atomically. In the fold
+ engine this is one fold write transaction: sod wraps the app pipeline in
+ an `AppliedCursor` node — an ordinary `Push` node claiming the sink name
+ `sod_cursor` — which persists the cursor at commit, inside the same
+ transaction as the deltas (fold public API only, no fold changes). In
+ `MemEngine` it is a plain in-memory update.
+
+Remote frames (from sync) follow the same steps 2–3 after chain verification
+and dedup. On open, sod compares the log against the applied-cursor and
+replays exactly the un-applied suffix, making step 2→3 crashes self-healing
+and apply exactly-once (SOD-5, SOD-6).
+
+## Sync protocol
+
+A session between any two peers, over a `Transport` trait (first
+implementation: websocket; the transport carries ordered reliable frames and
+nothing else).
+
+1. **Handshake.** Exchange `(sod_protocol_version, app_schema_version,
+ version_vector)`. Version mismatch → refuse (SOD-9).
+2. **Diff.** Each side computes what the other lacks: for every origin, the
+ suffix above the peer's vector entry. Relayed origins are included — a peer
+ syncs *everything it holds*, not just its own feed (this is what makes
+ hub-and-spoke work with a dumb hub, and mesh work later).
+3. **Stream.** Both directions concurrently, per-origin in contiguous seq
+ order, in bounded batches. Receiver verifies chain + hash per frame,
+ appends, applies, advances its vector. Non-contiguous or chain-breaking
+ frames are protocol errors.
+4. **Completion or interruption.** There is no session-completion state to
+ persist: the version vector *is* the resume point (SOD-6). Couch-style
+ per-peer checkpoints are unnecessary.
+
+Equivocation discovered mid-session (SOD-2) poisons the offending origin's
+feed locally and is reported to the application; the session continues for
+other origins.
+
+## Time
+
+`sod::time::Watermark` is the only "now" a sod replica has: the max
+`event_time` over all frames applied so far. Max is commutative and
+associative over the replicated frame set, so the watermark converges exactly
+as the data does (SOD-7). It is exposed for application reads and advances
+only when writes arrive.
+
+**Time-windowed operators are excluded from v1 sod compatibility.** The
+skeptical finding, recorded so nobody re-attempts the shortcut: fold's
+`Retain` is a processing-time window that stamps each record with the clock
+value at the transaction that *inserts* it. Under replication, insertion
+order differs per replica, so the stamps differ — no injected clock fixes
+this:
+
+- clock = watermark (max event-time so far): a record's stamp is the
+ watermark *at its arrival*, which is arrival-order-dependent → replicas
+ expire it at different horizons → divergence.
+- clock = current frame's event-time: a record stamped `t=10` applied
+ *after* a frame at `t=15` was already applied never sees a cutoff pass
+ above `10` on this replica until the next write, while a replica that
+ applied them in the other order already expired it → divergence.
+
+Convergent windowing needs an **event-time retain** in fold: stamp records
+with their frame's event time and expire against the watermark — two
+different time reads per commit, which `Retain`'s single-clock design cannot
+express. That operator is future work in fold (in-tree); until it exists,
+sod-compatible pipelines must not use `Retain` or any other wall-clock- or
+arrival-order-dependent operator.
+
+## Node.js packaging
+
+Per-app compiled addon. An app is a small Rust crate that:
+
+1. defines its datum type `T` and its fold pipeline,
+2. wraps them in `sod::Replica`,
+3. exposes a thin napi-rs surface: `commit(deltas)`, typed view read methods,
+ `sync(peer_url)` / `serve(addr)`, `open`/`close`.
+
+The JS surface is app-specific and small; all fold-facing code is Rust,
+compiled in-tree. The workspace example is the copyable template. Offline
+behavior needs no special mode: writes land in the local log unconditionally,
+and sync catches up when a peer is reachable.
+
+## Testing
+
+- **Convergence property tests** (the heart): N in-memory replicas, random
+ interleaved writes, random pairwise syncs, partitions, and session kills →
+ whenever two replicas' vectors are equal, their exact-view bytes are equal
+ (SOD-4, SOD-6, SOD-8). Run against `MemEngine` and the fold engine, with
+ `MemEngine` doubling as the differential oracle for fold-engine multiset
+ state. Sink coverage across fold's terminals; any order-sensitivity found
+ in a fold sink is a fold bug, filed and fixed in-tree.
+- **Portability gate.** `cargo check --target wasm32-unknown-unknown
+ --no-default-features` for the `sod` crate must pass (skipped with a notice
+ if the target isn't installed).
+- **Crash tests.** Kill between every pair of write-path steps (torn append,
+ post-append pre-apply, mid-apply), reopen, assert equivalence with clean
+ replay (SOD-5).
+- **Adversarial frames.** Corrupted bytes, broken chains, equivocating
+ origins, seq gaps, version mismatches → correct refusal, poisoning, and
+ reporting (SOD-2, SOD-9).
+- **Golden log format test.** A checked-in log fixture must parse
+ byte-identically forever; format changes require a deliberate fixture and
+ version bump.
+- **Watermark determinism.** Retain-bearing pipeline under shuffled delivery
+ orders → identical views (SOD-7).
+
+## Known deviations and consequences
+
+- **HNSW is order-sensitive.** Graph construction depends on insertion order,
+ so approximate search results may differ across replicas holding identical
+ vector sets; they re-align after a rebuild from the store (which iterates in
+ key order). SOD-4 therefore covers exact views only. Full determinism for
+ ANN would require canonical-order rebuilds and is future work.
+- **Negative multiplicities are visible.** A retraction arriving before its
+ insert leaves a transient negative count. This is correct Z-set behavior;
+ apps that surface raw counts should expect it.
+- **Sinks must not clamp.** The differential oracle caught fold's `Bag`
+ dropping negative running sums, making its state arrival-order-dependent
+ ('-1 then +2' and '+2 then -1' converge differently). Per the
+ fold-unmodified policy, sod ships `sod::sinks::Bag` with
+ order-independent semantics (nonzero sums persist; readers surface
+ positives) instead of patching fold; the finding stands as an upstream
+ report. Every sink in a sod pipeline must be a pure function of the net
+ multiset — use `sod::sinks` or audited fold sinks (`Count` is a plain
+ commutative sum) — and the differential test is the enforcement.
+
+## Future work (recorded now, built later)
+
+- **Compaction.** Logs grow without bound. Snapshot-plus-truncate is only
+ safe for a *closed* peer set whose vectors all cover the truncated prefix —
+ the design constraint is recorded so nothing in v1 assumes infinite
+ retention is acceptable, but v1 does not compact.
+- **Blob store.** Large payload values (embedding vectors, media) should be
+ content-addressed and deduplicated out of frames, IPFS-style.
+- **Batched replay and streaming sync.** `FoldEngine::apply` currently runs
+ one fold transaction per frame (replay of N frames = N storage commits;
+ batching must preserve the per-frame cursor contract of SOD-5), and a
+ sync session materializes each origin-suffix batch eagerly rather than
+ streaming it. Correct today, worth optimizing when logs grow.
+- **Async transports/engines.** The Node addon's `serveOnce`/`syncWithPeer`
+ are synchronous (they block the JS event loop for the session's
+ duration) — fine for the demo, but a production Node binding wants
+ napi async tasks around the same sans-io session.
+- **Signatures.** Per-origin signing keys (SSB-style) upgrade hash chains
+ from tamper-evidence to authorship proof, enabling sync among mutually
+ untrusting peers. The chain format is already compatible.
+- **Swarming.** Hash-verified frames + relay already permit mesh topologies;
+ a gossip/peer-discovery layer would exploit them.
+- **Browser persistence and transports.** An OPFS/IndexedDB LogStore and a
+ browser WebSocket/WebRTC transport, implementing the existing ports. The
+ core already compiles for wasm32; these are additive.
+- **React Native packaging.** A UniFFI/JSI binding of the same native stack.
+- **Fold on wasm.** Requires fold to grow a storage port replacing fjall;
+ tracked as fold future work. Until then, browser replicas run `MemEngine`.
+
+## Non-goals
+
+- Any asymmetric or authoritative wire protocol.
+- Cross-version sync or migration (refuse, don't translate — v1).
+- Multi-writer concurrency within one replica (fold is single-writer; so is
+ sod).
+- Anything to do with clog.
diff --git a/docs/superpowers/specs/2026-08-16-sod-web-design.md b/docs/superpowers/specs/2026-08-16-sod-web-design.md
new file mode 100644
index 0000000..524e099
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-sod-web-design.md
@@ -0,0 +1,190 @@
+# sod-web: the three-bog convergence demo
+
+Date: 2026-08-16
+Status: approved in brainstorming (see conversation); implements on top of
+`docs/superpowers/specs/2026-08-15-sod-design.md`
+
+## What this is
+
+A Next.js **emoji reaction board** where every app instance embeds its own
+sod replica — a per-app compiled napi addon, fold engine and all — plus the
+small additive sod changes required to embed a replica inside a live web
+server. The demo topology is three bogs:
+
+- a **remote hub** deployed on Fly.io,
+- two **local instances** at `localhost:3000` and `localhost:3001`.
+
+The demo script: react on all three boards; kill the wifi; the two local
+bogs keep syncing with each other over loopback (a *partial partition* —
+real, not simulated) while the hub goes dark; keep reacting everywhere;
+reconnect; the hub absorbs both locals' writes and everything converges.
+A top-nav badge on every instance shows how many other bogs it is
+currently connected to, so the partition and the heal are visible.
+
+## Design rules carried forward
+
+- Fold is consumed, never modified.
+- Sod core protocol semantics are unchanged; every sod change below is
+ additive API or a pre-release wire-field addition.
+- Symmetry: dial-vs-serve is about *reachability*, never protocol role.
+ Every instance may serve and dial; `SOD_PEERS` lists "everyone you can
+ reach", and empty is valid (serve-only — e.g. a NAT'd-from hub).
+
+## Sod changes
+
+### 1. Transport accept/session split (`sod::transport::ws`)
+
+Today `serve()` holds `&mut Replica` for the entire accept loop, including
+idle time — unusable inside a server that also takes writes. Additive API:
+
+```rust
+pub struct SyncListener { .. } // owns no replica
+impl SyncListener {
+ pub fn bind(addr: &str) -> Result;
+ pub fn local_addr(&self) -> Result;
+ /// Block until a peer connects and completes the ws handshake.
+ pub fn accept(&self) -> Result;
+}
+pub struct IncomingSession { .. } // handshaken socket
+impl IncomingSession {
+ /// Run the whole session; the replica is borrowed only for this call
+ /// (sessions are milliseconds).
+ pub fn run(
+ self, r: &mut Replica, schema: u32,
+ ) -> Result;
+}
+```
+
+`serve()` is reimplemented as a thin bind/accept/run loop; its behavior
+(one at a time, count only completed sessions, log refusals) is unchanged.
+Host apps accept on a dedicated thread and lock the replica per session.
+
+### 2. `Hello` carries the sender's replica id
+
+`Msg::Hello` gains `id: ReplicaId`. Without it a receiver knows what
+frames a peer lacks but not who the peer *is*, and the connected-badge
+needs peer identity. Pre-release wire change: nothing has shipped, and
+the protocol/schema handshake already refuses mismatched builds, so
+`PROTOCOL_VERSION` stays 1.
+
+### 3. `SyncReport` replaces bare refusal lists
+
+```rust
+pub struct SyncReport {
+ pub peer: ReplicaId,
+ pub peer_vector: VersionVector, // as of the peer's Hello
+ pub skipped: Vec, // ≤ 1 per origin per session
+}
+```
+
+Returned by `sync_with`, `IncomingSession::run`, and `sync_pair` (tests
+updated). Strictly more information; refusal semantics unchanged.
+
+Deferred, recorded: tungstenite TLS client feature (`wss://`) — not
+needed while the Fly sync port uses a plain TCP handler; flipped on
+(one Cargo feature + Fly handler change) before any public showing.
+
+## The app (`examples/sod-web/`)
+
+Layout — the JS app owns the directory root; the addon nests inside:
+
+```
+examples/sod-web/ Next.js app (App Router, package.json here)
+├── addon/ Rust crate `sod-web-addon` (napi cdylib)
+├── Dockerfile addon build → Next standalone → slim runtime
+└── fly.toml
+```
+
+Workspace mechanics: add `examples/sod-web/addon` to `members`,
+`examples/sod-web` to `exclude` (the `examples/*` glob requires a
+Cargo.toml at each match). `.gitignore` grows `node_modules/`, `.next/`.
+
+### Addon (`sod-web-addon`)
+
+Datum `String` (emoji slug); pipeline `(sod::sinks::Bag,
+fold Count)`; `SCHEMA = 1`. Replica dir handling identical to sod-demo
+(fresh `replica_id` whenever the log is created; id dies with the log).
+Surface (async where it does I/O — never blocks the event loop):
+
+- `open(dir) -> id` / `close()`
+- `react(emoji)` — commit +1; `unreact(emoji)` — guarded −1 (refuses when
+ the board shows zero, same hidden-debt guard as sod-demo)
+- `board() -> { reactions: [{emoji, count}], total }`
+- `status() -> { id, vector: {originHex: seq}, watermark,
+ peers: [{key, lastSyncMs, lastError?}], connected }` — `connected` =
+ peers (dialed *or* accepted) with a completed session in the last 10 s
+- `syncWithPeer(url) -> refusals[]` — Promise, worker thread
+- `startServeLoop(addr) -> boundAddr` — Rust thread owning a
+ `SyncListener`; locks the replica per session; updates peer last-seen
+ from each `SyncReport`
+
+### Next.js app
+
+One page: a fixed palette of 8 emoji tiles with live counts and a total.
+Clicking reacts; a small "−" affordance unreacts (guarded). **Top nav**:
+replica identity (short hex + stable color dot), **`◉ N bogs connected`**,
+online/offline pill, last-sync age. A nerd panel lists: each configured
+peer with status and a per-peer pause toggle; *connected now* vs *heard
+from ever* (distinct origins in the version vector); the raw vector.
+
+API routes wrap the addon: `/api/react`, `/api/unreact`, `/api/board`,
+`/api/status`, `/api/peer-toggle`. The UI polls board+status every 1 s
+(polling over SSE deliberately: simplest thing that survives proxies).
+
+Config (uniform across roles):
+
+- `SOD_DATA_DIR` — replica directory (Fly: the mounted volume)
+- `SOD_SERVE_ADDR` — optional; start the serve loop
+- `SOD_PEERS` — optional; comma-separated ws URLs of everyone reachable
+
+Demo topology:
+
+```
+Fly hub: SOD_SERVE_ADDR=0.0.0.0: SOD_PEERS= (NAT: can reach no one)
+:3000 SOD_SERVE_ADDR=127.0.0.1:7300 SOD_PEERS=ws://127.0.0.1:7301,ws://:
+:3001 SOD_SERVE_ADDR=127.0.0.1:7301 SOD_PEERS=ws://127.0.0.1:7300,ws://:
+```
+
+### Sync loop
+
+`sync-loop.ts` in the Next server process: every 3 s, for each unpaused
+peer, `await syncWithPeer(url)`; success stamps last-seen, failure stamps
+last-error (offline *is* just this failing — no special mode). Each local
+`react`/`unreact` kicks an immediate pass so propagation feels instant.
+The nav "go offline" button pauses all non-localhost peers (deterministic
+rehearsal of the wifi kill); the real wifi kill is the stage version, and
+the loopback edge genuinely survives it.
+
+## Fly deployment
+
+- Multi-stage Dockerfile: Rust stage builds `sod-web-addon`; Node stage
+ builds Next standalone; runtime stage carries the `.node` artifact.
+- `fly.toml`: HTTP service (Next, port 3000) behind Fly's edge; a second
+ **raw TCP service port** for the sod listener (plain `tcp` handler
+ first; `tls` handler + sod `wss` feature before public showing);
+ `min_machines_running = 1`; a small volume mounted at `SOD_DATA_DIR`.
+- **Exactly one machine.** The replica is single-writer; hub scale-out is
+ out of scope (a multi-machine hub would be multiple bogs, which the
+ protocol supports but this demo does not exercise).
+
+## Testing
+
+- Sod: accept/session split (listener idles while writes proceed;
+ sessions serialize per-replica), Hello-id + SyncReport coverage, all
+ existing suites updated for the new return types; wasm gate unaffected
+ (transport is feature-gated).
+- Addon: Node smoke script — open, react, board, status, guarded unreact.
+- Demo rehearsal: `npm run demo:local` boots :3000/:3001 plus a local
+ stand-in hub; a script asserts all three boards converge and the
+ connected counts move correctly through a simulated partition
+ (pause/unpause the hub peers).
+- Fly: manual runbook in the README (deploy, wifi kill, reconnect,
+ expected badge behavior at each step).
+
+## Non-goals
+
+- Browser-wasm replicas (hub + locals are all server-embedded; the
+ browser is UI only).
+- Hub scale-out, auth, or TLS-by-default (TLS is a pre-showing flip).
+- Any fold change; any sod protocol-semantics change.
+- SSE/websocket UI push; free-form emoji; per-user identity.
diff --git a/examples/sod-demo/Cargo.toml b/examples/sod-demo/Cargo.toml
new file mode 100644
index 0000000..b56910a
--- /dev/null
+++ b/examples/sod-demo/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "sod-demo"
+version = "0.0.1"
+edition = "2024"
+
+[dependencies]
+sod = { path = "../../sod" }
+fold = { path = "../../fold" }
+postcard = { version = "1", features = ["use-std"] }
diff --git a/examples/sod-demo/README.md b/examples/sod-demo/README.md
new file mode 100644
index 0000000..092be60
--- /dev/null
+++ b/examples/sod-demo/README.md
@@ -0,0 +1,110 @@
+# sod-demo
+
+A replicated notes bag: the smallest complete [sod](../../sod) app, and the
+template for building your own.
+
+Each data directory is one replica (a **sod**):
+
+```
+/
+├── sod.log append-only frame log — the source of truth
+├── db/ fold database — rebuildable cache of the log
+└── replica_id 16 random bytes, generated with the log, dies with it
+```
+
+The pipeline is `(sod::sinks::Bag, fold Count)`; the datum is a
+`String` note. Bump `SCHEMA` in `main.rs` whenever you change either —
+replicas with different schemas refuse to sync instead of corrupting.
+(`sod::sinks::Bag` rather than fold's: replication-safe sinks must be pure
+functions of the net multiset — see the sod README.)
+
+## Commands
+
+```console
+$ cargo run -p sod-demo -- add # insert a note
+$ cargo run -p sod-demo -- remove # retract a note
+$ cargo run -p sod-demo -- list # notes + total count
+$ cargo run -p sod-demo -- serve # accept sync sessions
+$ cargo run -p sod-demo -- sync # sync once with a peer
+```
+
+## Two replicas converging (verified transcript)
+
+```console
+$ sod-demo ./a add hello from a
+$ sod-demo ./a add hello from a
+$ sod-demo ./a add unique to a
+$ sod-demo ./b add greetings from b
+
+$ sod-demo ./a list
+1× unique to a
+2× hello from a
+-- 3 note(s), replica d4df4436dda922715e0a0506a4c69edf
+
+$ sod-demo ./b list
+1× greetings from b
+-- 1 note(s), replica 49b3315ec43a13239266fa5957b78e48
+
+$ sod-demo ./b serve 127.0.0.1:7171 # terminal 1
+$ sod-demo ./a sync ws://127.0.0.1:7171 # terminal 2
+synced with ws://127.0.0.1:7171
+
+$ sod-demo ./a list
+1× unique to a
+2× hello from a
+1× greetings from b
+-- 4 note(s), replica d4df4436dda922715e0a0506a4c69edf
+
+$ sod-demo ./b list # after stopping the server
+1× unique to a
+2× hello from a
+1× greetings from b
+-- 4 note(s), replica 49b3315ec43a13239266fa5957b78e48
+```
+
+One session syncs both directions — there are no client/server roles in the
+protocol, only in who dialed. Notes list in postcard-key order (length
+first), identically on every replica.
+
+Offline is the default posture: `add`/`remove` always succeed locally, and
+the next `sync` (through any chain of peers — sessions relay third-party
+feeds) converges. Deleting a directory's `sod.log` resets that replica: the
+next command generates a fresh `replica_id`, because a reused id with a
+restarted feed would silently diverge at peers that remember the old one.
+
+## The same app from Node.js (verified transcript)
+
+`node/` packages this app as a napi-rs native addon — the per-app compiled
+addon pattern: your datum type + pipeline + sod compiled into one `.node`
+module, with a thin app-specific JS surface.
+
+```console
+$ cargo build -p sod-demo-node
+$ cp target/debug/libsod_demo_node.dylib examples/sod-demo/node/sod_demo_node.node # .so on linux
+
+$ sod-demo ./a serve 127.0.0.1:7172 # terminal 1: a native peer
+$ node examples/sod-demo/node/demo.mjs ./n ws://127.0.0.1:7172 # terminal 2
+replica 02a243b228c194356507a7b85fed502f
+local: [ '2x note from node', '1x only node has this' ] total=3
+after sync: [
+ '1x unique to a',
+ '2x hello from a',
+ '2x note from node',
+ '1x greetings from b',
+ '1x only node has this'
+] total=7
+```
+
+Note `greetings from b`: the Node replica has never met replica b — its
+feed arrived relayed through a. Node, the native binary, and any future
+browser replica speak the same log format and protocol.
+
+## Making it your app
+
+Copy this crate and change three things:
+
+1. the datum type (any `Serialize + DeserializeOwned` type),
+2. the fold pipeline handed to `FoldEngine::open`,
+3. `SCHEMA`.
+
+Everything else — log, recovery, identity, sync, relay — is sod.
diff --git a/examples/sod-demo/node/Cargo.toml b/examples/sod-demo/node/Cargo.toml
new file mode 100644
index 0000000..375fb2e
--- /dev/null
+++ b/examples/sod-demo/node/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "sod-demo-node"
+version = "0.0.1"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+sod = { path = "../../../sod" }
+fold = { path = "../../../fold" }
+postcard = { version = "1", features = ["use-std"] }
+napi = { version = "2", default-features = false, features = ["napi8"] }
+napi-derive = "2"
+
+[build-dependencies]
+napi-build = "2"
diff --git a/examples/sod-demo/node/build.rs b/examples/sod-demo/node/build.rs
new file mode 100644
index 0000000..0f1b010
--- /dev/null
+++ b/examples/sod-demo/node/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ napi_build::setup();
+}
diff --git a/examples/sod-demo/node/demo.mjs b/examples/sod-demo/node/demo.mjs
new file mode 100644
index 0000000..203daf4
--- /dev/null
+++ b/examples/sod-demo/node/demo.mjs
@@ -0,0 +1,33 @@
+// sod from Node.js: offline-first local writes + one sync to converge.
+//
+// Build & run (from the workspace root):
+// cargo build -p sod-demo-node
+// cp target/debug/libsod_demo_node.dylib examples/sod-demo/node/sod_demo_node.node # .so on linux
+// node examples/sod-demo/node/demo.mjs
+//
+// With a native peer serving (terminal 1):
+// cargo run -p sod-demo -- ./b serve 127.0.0.1:7171
+import { createRequire } from "node:module";
+const require = createRequire(import.meta.url);
+const sod = require("./sod_demo_node.node");
+
+const [dir, peer] = process.argv.slice(2);
+if (!dir) {
+ console.error("usage: node demo.mjs [peer-ws-url]");
+ process.exit(2);
+}
+
+const id = sod.open(dir);
+console.log(`replica ${id}`);
+
+sod.add("note from node");
+sod.add("note from node");
+sod.add("only node has this");
+console.log("local:", sod.list(), `total=${sod.count()}`);
+
+if (peer) {
+ const refused = sod.syncWithPeer(peer);
+ for (const r of refused) console.warn("refused during sync:", r);
+ console.log("after sync:", sod.list(), `total=${sod.count()}`);
+}
+sod.close();
diff --git a/examples/sod-demo/node/src/lib.rs b/examples/sod-demo/node/src/lib.rs
new file mode 100644
index 0000000..734f1bd
--- /dev/null
+++ b/examples/sod-demo/node/src/lib.rs
@@ -0,0 +1,155 @@
+//! The sod-demo app packaged as a Node.js native addon (napi-rs).
+//!
+//! This is the "per-app compiled addon" pattern: the app's datum type and
+//! fold pipeline are Rust, compiled together with sod into one `.node`
+//! module; the JS surface is thin and app-specific. Compiling against
+//! in-tree fold/sod means fold API changes break this crate at build time,
+//! not at runtime.
+//!
+//! Access is single-replica-per-process behind a mutex; calls are
+//! synchronous (fold is single-writer, and a notes app doesn't need an
+//! async bridge). See `demo.mjs` for the workflow.
+
+use std::path::Path;
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use fold::pipeline::terminal::Count;
+use napi::bindgen_prelude::*;
+use sod::sinks::Bag;
+use napi_derive::napi;
+use sod::engine_fold::FoldEngine;
+use sod::log_file::FileLog;
+use sod::time::Watermark;
+use sod::transport::ws::{serve, sync_with};
+use sod::{Replica, ReplicaId};
+
+type Pipeline = (Bag, Count);
+type DemoReplica = Replica, FileLog>;
+
+const SCHEMA: u32 = 1;
+
+static REPLICA: Mutex> = Mutex::new(None);
+
+fn err(e: impl std::fmt::Display) -> Error {
+ Error::from_reason(e.to_string())
+}
+
+fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_millis() as u64
+}
+
+fn open_replica(dir: &Path) -> std::result::Result {
+ std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+ let log_path = dir.join("sod.log");
+ let id_path = dir.join("replica_id");
+ if !log_path.exists() {
+ let _ = std::fs::remove_file(&id_path);
+ let _ = std::fs::remove_dir_all(dir.join("db"));
+ }
+ let id = match std::fs::read(&id_path) {
+ Ok(bytes) => ReplicaId(
+ bytes
+ .as_slice()
+ .try_into()
+ .map_err(|_| "replica_id must be 16 bytes".to_string())?,
+ ),
+ Err(_) => {
+ let id = ReplicaId::generate();
+ std::fs::write(&id_path, id.0).map_err(|e| e.to_string())?;
+ id
+ }
+ };
+ let log = FileLog::open(&log_path).map_err(|e| e.to_string())?;
+ let engine = FoldEngine::open(
+ dir.join("db"),
+ (Bag::new("notes"), Count::new("count")),
+ Watermark::new(),
+ );
+ Replica::open(id, log, engine).map_err(|e| e.to_string())
+}
+
+fn with_replica(f: impl FnOnce(&mut DemoReplica) -> Result) -> Result {
+ let mut guard = REPLICA.lock().map_err(|_| err("replica mutex poisoned"))?;
+ let replica = guard.as_mut().ok_or_else(|| err("call open(dir) first"))?;
+ f(replica)
+}
+
+fn commit(note: String, mult: i64) -> Result<()> {
+ let datum = postcard::to_stdvec(¬e).map_err(err)?;
+ with_replica(|r| {
+ r.commit(vec![(datum, mult)], now_ms()).map_err(err)?;
+ Ok(())
+ })
+}
+
+/// Open (or create) the replica at `dir`. One replica per process.
+#[napi]
+pub fn open(dir: String) -> Result {
+ let replica = open_replica(Path::new(&dir)).map_err(err)?;
+ let id = replica.id().to_string();
+ *REPLICA.lock().map_err(|_| err("replica mutex poisoned"))? = Some(replica);
+ Ok(id)
+}
+
+/// Insert one copy of `note`.
+#[napi]
+pub fn add(note: String) -> Result<()> {
+ commit(note, 1)
+}
+
+/// Retract one copy of `note`. Errors if the note is not present — an
+/// unmatched retraction would store a hidden negative multiplicity that
+/// swallows a future add.
+#[napi]
+pub fn remove(note: String) -> Result<()> {
+ let present =
+ with_replica(|r| Ok(r.engine().stream().rtx(|(bag, _count)| bag.contains(¬e))))?;
+ if !present {
+ return Err(err(format!("no such note: {note}")));
+ }
+ commit(note, -1)
+}
+
+/// All notes as `"x "`, in canonical (postcard-key) order.
+#[napi]
+pub fn list() -> Result> {
+ with_replica(|r| {
+ Ok(r.engine().stream().rtx(|(bag, _count)| {
+ bag.iter().map(|(note, n)| format!("{n}x {note}")).collect()
+ }))
+ })
+}
+
+/// Total note count.
+#[napi]
+pub fn count() -> Result {
+ with_replica(|r| Ok(r.engine().stream().rtx(|(_bag, count)| count.get())))
+}
+
+/// Run one full sync session with a peer (e.g. `ws://127.0.0.1:7171`).
+/// Returns any per-origin refusals recorded while the session continued
+/// (equivocating or poisoned feeds) — surface these to the user.
+#[napi]
+pub fn sync_with_peer(url: String) -> Result> {
+ with_replica(|r| {
+ let report = sync_with(&url, r, SCHEMA).map_err(err)?;
+ Ok(report.skipped.iter().map(|s| s.to_string()).collect())
+ })
+}
+
+/// Accept exactly one sync session on `addr`, then return.
+#[napi]
+pub fn serve_once(addr: String) -> Result<()> {
+ with_replica(|r| serve(&addr, r, SCHEMA, Some(1)).map_err(err))
+}
+
+/// Close the replica (drops the store handles).
+#[napi]
+pub fn close() -> Result<()> {
+ *REPLICA.lock().map_err(|_| err("replica mutex poisoned"))? = None;
+ Ok(())
+}
diff --git a/examples/sod-demo/src/main.rs b/examples/sod-demo/src/main.rs
new file mode 100644
index 0000000..f48cae3
--- /dev/null
+++ b/examples/sod-demo/src/main.rs
@@ -0,0 +1,150 @@
+//! sod-demo: a replicated notes bag — the smallest complete sod app, and
+//! the template for building your own (see the README).
+//!
+//! Each data directory is one replica ("a sod"): an append-only frame log
+//! (`sod.log`), a fold database (`db/`, rebuildable cache), and the
+//! replica's identity (`replica_id`, freshly generated whenever the log is
+//! created — SOD-3). Any two directories converge by syncing, in either
+//! direction, through any chain of peers.
+//!
+//! ```console
+//! $ cargo run -p sod-demo -- ./a add hello from a
+//! $ cargo run -p sod-demo -- ./b serve 127.0.0.1:7171 # terminal 1
+//! $ cargo run -p sod-demo -- ./a sync ws://127.0.0.1:7171 # terminal 2
+//! $ cargo run -p sod-demo -- ./b list
+//! ```
+
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use fold::pipeline::terminal::Count;
+use sod::engine_fold::FoldEngine;
+use sod::sinks::Bag;
+use sod::log_file::FileLog;
+use sod::time::Watermark;
+use sod::transport::ws::{serve, sync_with};
+use sod::{Replica, ReplicaId};
+
+type Pipeline = (Bag, Count);
+type DemoReplica = Replica, FileLog>;
+
+/// Bump when the pipeline or datum type changes shape: replicas with
+/// different schemas refuse to sync instead of corrupting (SOD-9).
+const SCHEMA: u32 = 1;
+
+fn open(dir: &Path) -> DemoReplica {
+ std::fs::create_dir_all(dir).expect("cannot create data dir");
+ let log_path = dir.join("sod.log");
+ let id_path = dir.join("replica_id");
+
+ // SOD-3: the replica id never outlives the log. A missing log with a
+ // leftover id (or db) means the replica was reset — start identity and
+ // cache from scratch.
+ if !log_path.exists() {
+ let _ = std::fs::remove_file(&id_path);
+ let _ = std::fs::remove_dir_all(dir.join("db"));
+ }
+ let id = match std::fs::read(&id_path) {
+ Ok(bytes) => ReplicaId(bytes.as_slice().try_into().expect("replica_id is 16 bytes")),
+ Err(_) => {
+ let id = ReplicaId::generate();
+ std::fs::write(&id_path, id.0).expect("cannot persist replica_id");
+ id
+ }
+ };
+
+ let log = FileLog::open(&log_path).unwrap_or_else(|e| panic!("log open failed: {e}"));
+ let engine = FoldEngine::open(
+ dir.join("db"),
+ (Bag::new("notes"), Count::new("count")),
+ Watermark::new(),
+ );
+ Replica::open(id, log, engine).unwrap_or_else(|e| panic!("replica open failed: {e}"))
+}
+
+/// Applications stamp event time; sod itself never reads a clock (SOD-7).
+fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_millis() as u64
+}
+
+/// The datum wire format is the pipeline type's postcard encoding.
+fn encode(note: &str) -> Vec {
+ postcard::to_stdvec(¬e.to_string()).unwrap()
+}
+
+fn usage() -> ! {
+ eprintln!(
+ "usage: sod-demo \n\
+ commands:\n \
+ add insert a note\n \
+ remove retract a note\n \
+ list print notes and total count\n \
+ serve accept sync sessions (e.g. 127.0.0.1:7171)\n \
+ sync sync once with a peer (e.g. ws://127.0.0.1:7171)"
+ );
+ std::process::exit(2)
+}
+
+fn main() {
+ let args: Vec = std::env::args().skip(1).collect();
+ let (dir, cmd, rest) = match args.split_first() {
+ Some((dir, rest)) => match rest.split_first() {
+ Some((cmd, rest)) => (PathBuf::from(dir), cmd.as_str(), rest),
+ None => usage(),
+ },
+ None => usage(),
+ };
+ let mut replica = open(&dir);
+
+ match cmd {
+ "add" | "remove" if !rest.is_empty() => {
+ let note = rest.join(" ");
+ if cmd == "remove" {
+ // Guard retractions: an unmatched -1 would be stored as a
+ // hidden negative multiplicity that swallows a future add.
+ let present = replica
+ .engine()
+ .stream()
+ .rtx(|(bag, _count)| bag.contains(¬e));
+ if !present {
+ eprintln!("no such note: {note}");
+ std::process::exit(1);
+ }
+ }
+ let mult = if cmd == "add" { 1 } else { -1 };
+ replica
+ .commit(vec![(encode(¬e), mult)], now_ms())
+ .unwrap_or_else(|e| panic!("commit failed: {e}"));
+ println!("{cmd}ed: {note}");
+ }
+ "list" => {
+ let (notes, total) = replica
+ .engine()
+ .stream()
+ .rtx(|(bag, count)| (bag.iter().collect::>(), count.get()));
+ for (note, n) in notes {
+ println!("{n}\u{d7} {note}");
+ }
+ println!("-- {total} note(s), replica {}", replica.id());
+ }
+ "serve" => {
+ let addr = rest.first().map(String::as_str).unwrap_or_else(|| usage());
+ println!("replica {} serving on {addr} (ctrl-c to stop)", replica.id());
+ serve(addr, &mut replica, SCHEMA, None)
+ .unwrap_or_else(|e| panic!("serve failed: {e}"));
+ }
+ "sync" => {
+ let url = rest.first().map(String::as_str).unwrap_or_else(|| usage());
+ let report = sync_with(url, &mut replica, SCHEMA)
+ .unwrap_or_else(|e| panic!("sync failed: {e}"));
+ for s in &report.skipped {
+ eprintln!("warning: refused during sync: {s}");
+ }
+ println!("synced with {url} (peer {})", report.peer);
+ }
+ _ => usage(),
+ }
+}
diff --git a/examples/sod-web/Dockerfile b/examples/sod-web/Dockerfile
new file mode 100644
index 0000000..06cf22e
--- /dev/null
+++ b/examples/sod-web/Dockerfile
@@ -0,0 +1,41 @@
+# sod-web: three-stage build. Context is the REPO ROOT (the addon
+# path-depends on sod/ and fold/):
+# docker build -f examples/sod-web/Dockerfile .
+# fly deploy . -c examples/sod-web/fly.toml
+
+# ── stage 1: the Rust addon ─────────────────────────────────────────
+FROM rust:1 AS addon
+WORKDIR /src
+COPY Cargo.toml Cargo.lock ./
+COPY fold fold
+COPY anny anny
+COPY ese ese
+COPY sod sod
+COPY examples examples
+RUN cargo build -p sod-web-addon --release
+
+# ── stage 2: the Next.js build ──────────────────────────────────────
+FROM node:22 AS web
+WORKDIR /app
+COPY examples/sod-web/package.json examples/sod-web/package-lock.json ./
+COPY examples/sod-web/addon/package.json examples/sod-web/addon/index.js ./addon/
+RUN npm ci
+COPY examples/sod-web/ .
+COPY --from=addon /src/target/release/libsod_web_addon.so ./addon/sod_web_addon.node
+RUN npm run build
+
+# ── stage 3: runtime ────────────────────────────────────────────────
+FROM node:22-slim
+WORKDIR /app
+ENV NODE_ENV=production
+ENV SOD_DATA_DIR=/data/sod
+# Next's standalone server binds process.env.HOSTNAME when present, and
+# Fly machines inject HOSTNAME= — pin it or the proxy can't
+# reach the app
+ENV HOSTNAME="0.0.0.0"
+COPY --from=web /app/.next/standalone ./
+COPY --from=web /app/.next/static ./.next/static
+# the addon must resolve at runtime as a real package next to server.js
+COPY --from=web /app/addon ./node_modules/sod-web-addon
+EXPOSE 3000 7300
+CMD ["node", "server.js"]
diff --git a/examples/sod-web/README.md b/examples/sod-web/README.md
new file mode 100644
index 0000000..2b6ad90
--- /dev/null
+++ b/examples/sod-web/README.md
@@ -0,0 +1,135 @@
+# sod-web
+
+Three bogs, one board. An emoji reaction wall where every app instance
+embeds its own [sod](../../sod) replica — writes always land locally, and
+instances converge by syncing whoever they can reach. The top nav shows it
+happening: your replica's identity tints the whole page, and every
+currently-connected peer appears as a dot *in its own color*. Kill the
+network and you watch your neighbors' colors drain out of your header.
+
+The wifi-kill demo, in three acts:
+
+1. **Converge.** A Fly-deployed hub and two local instances
+ (`localhost:3000`, `localhost:3001`) all show the same board.
+2. **Partition.** Kill the wifi. The two local bogs keep syncing with each
+ other over loopback — that's a *real* partial partition, not a
+ simulation — while the hub goes dark and lags. Keep reacting everywhere.
+3. **Heal.** Wifi back on. The hub absorbs both locals' writes (relay
+ included) and all three boards converge byte-identically.
+
+The scripted rehearsal of exactly this (`npm run demo:local`) runs in CI
+distance: three instances, partition, heal, byte-identical assertion.
+
+## Build & run one instance
+
+```console
+$ cd examples/sod-web
+$ npm install
+$ npm run build:addon # cargo build + copy the .node binary
+$ npm run build
+$ SOD_DATA_DIR=./.sod-a npm run dev
+```
+
+Config is uniform — dial-vs-serve is about *reachability*, never role:
+
+| env | meaning |
+|---|---|
+| `SOD_DATA_DIR` | replica directory (log + fold db + replica id) |
+| `SOD_SERVE_ADDR` | optional: accept sync sessions here |
+| `SOD_PEERS` | optional: comma-separated ws URLs of everyone you can reach |
+
+## The three-bog topology
+
+```console
+# hub (stands in for Fly when rehearsing locally; serve-only — NAT'd
+# peers can reach it, it can reach no one)
+$ SOD_DATA_DIR=./.sod-hub SOD_SERVE_ADDR=127.0.0.1:7302 PORT=3002 npm run dev
+
+# local a — serves and dials b + hub
+$ SOD_DATA_DIR=./.sod-a SOD_SERVE_ADDR=127.0.0.1:7300 \
+ SOD_PEERS=ws://127.0.0.1:7301,ws://127.0.0.1:7302 PORT=3000 npm run dev
+
+# local b — serves and dials a + hub
+$ SOD_DATA_DIR=./.sod-b SOD_SERVE_ADDR=127.0.0.1:7301 \
+ SOD_PEERS=ws://127.0.0.1:7300,ws://127.0.0.1:7302 PORT=3001 npm run dev
+```
+
+Or scripted end-to-end (also the regression test):
+
+```console
+$ npm run build:addon && npm run build && npm run demo:local
+...
+act 1 PASS: three bogs converged; locals connected to 2
+act 2 PASS: partition held — locals kept syncing, hub lagged
+act 3 PASS: heal converged all three boards byte-identically
+DEMO-LOCAL PASS
+```
+
+"Pause remote sync" in the nav is the deterministic stand-in for the wifi
+kill (it pauses every non-localhost peer); the per-peer Pause buttons in
+the nerd panel partition selectively.
+
+## Deploying the hub to Fly.io
+
+> Status: **live** — `https://sod-web-demo.fly.dev`
+> (sanctuary-computer org), sync on `ws://sod-web-demo.fly.dev:10700`.
+> The full three-bog demo (Fly hub + two laptop replicas) has run
+> end-to-end over the public internet.
+>
+> Two deploy gotchas learned the hard way:
+> - The raw-TCP sync port needs a **dedicated IPv4** (~$2/mo,
+> `fly ips allocate-v4`) — Fly's free shared IPv4 only routes
+> HTTP/TLS-handler services. The TLS flip (below) would lift that.
+> - Allocate IPs **immediately** after the first deploy: until an IP
+> exists, resolvers cache "no such domain" for the app hostname, and
+> some home routers hold that stale answer for up to an hour (use the
+> sync port's IP directly, or an `/etc/hosts` line, while it clears).
+
+From the **repo root** (the Docker context needs `sod/` and `fold/`):
+
+```console
+$ fly launch --no-deploy -c examples/sod-web/fly.toml # first time
+$ fly volumes create sod_data -c examples/sod-web/fly.toml --size 1
+$ fly deploy . -c examples/sod-web/fly.toml
+```
+
+The hub serves the UI at `https://sod-web-demo.fly.dev` and sync on raw
+TCP port `10700`. Point the locals at it:
+
+```console
+$ SOD_DATA_DIR=./.sod-a SOD_SERVE_ADDR=127.0.0.1:7300 \
+ SOD_PEERS=ws://127.0.0.1:7301,ws://sod-web-demo.fly.dev:10700 PORT=3000 npm run dev
+$ SOD_DATA_DIR=./.sod-b SOD_SERVE_ADDR=127.0.0.1:7301 \
+ SOD_PEERS=ws://127.0.0.1:7300,ws://sod-web-demo.fly.dev:10700 PORT=3001 npm run dev
+```
+
+Plain TCP first; before showing outside the room, add `handlers = ["tls"]`
+to the sync port in `fly.toml` and enable sod's wss client support.
+
+### Stage runbook
+
+1. Open the Fly URL, `localhost:3000`, and `localhost:3001` side by side.
+ Each window wears its replica's color; locals show `2 bogs connected`.
+2. React everywhere; boards agree within a tick or two.
+3. Kill the wifi. Locals drop to `1 bog connected` (each other, over
+ loopback) and the Fly peer row goes `unreachable`; the hub's own
+ badge drops to `0`. Keep reacting on all three.
+4. Reconnect. Within a sync tick the hub jumps to `2 bogs connected` and
+ every board converges — including writes it never saw directly,
+ relayed through whichever local reached it first.
+
+## Anatomy
+
+- `addon/` — the per-app compiled sod: datum `String` (emoji slug),
+ pipeline `(sod::sinks::Bag, fold Count)`, `SCHEMA = 1`. Async
+ `syncWithPeer` (libuv worker), serve loop on a Rust thread that borrows
+ the replica only per session, peer liveness from `SyncReport`s.
+- `lib/sod.ts` / `lib/sync-loop.ts` — module-singleton replica + the 3 s
+ dial loop ("offline" is just these attempts failing; local writes kick
+ an immediate pass).
+- `app/` — the board, the nav badge, and the nerd panel (per-peer status
+ and pause, connected-now vs heard-from-ever, the raw version vector).
+- `scripts/smoke.mjs` — addon smoke test (includes a two-process sync).
+- `scripts/demo-local.mjs` — the scripted three-act rehearsal.
+
+Design spec: `docs/superpowers/specs/2026-08-16-sod-web-design.md`.
diff --git a/examples/sod-web/addon/Cargo.toml b/examples/sod-web/addon/Cargo.toml
new file mode 100644
index 0000000..81bcbf0
--- /dev/null
+++ b/examples/sod-web/addon/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "sod-web-addon"
+version = "0.0.1"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+sod = { path = "../../../sod" }
+fold = { path = "../../../fold" }
+postcard = { version = "1", features = ["use-std"] }
+napi = { version = "2", default-features = false, features = ["napi8"] }
+napi-derive = "2"
+
+[build-dependencies]
+napi-build = "2"
diff --git a/examples/sod-web/addon/build.rs b/examples/sod-web/addon/build.rs
new file mode 100644
index 0000000..0f1b010
--- /dev/null
+++ b/examples/sod-web/addon/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ napi_build::setup();
+}
diff --git a/examples/sod-web/addon/index.js b/examples/sod-web/addon/index.js
new file mode 100644
index 0000000..f5bda4b
--- /dev/null
+++ b/examples/sod-web/addon/index.js
@@ -0,0 +1,3 @@
+// Loads the compiled napi binary. Build it with `npm run build:addon`
+// (cargo build -p sod-web-addon --release + copy into this directory).
+module.exports = require("./sod_web_addon.node");
diff --git a/examples/sod-web/addon/package.json b/examples/sod-web/addon/package.json
new file mode 100644
index 0000000..179ba0d
--- /dev/null
+++ b/examples/sod-web/addon/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "sod-web-addon",
+ "version": "0.0.1",
+ "private": true,
+ "main": "index.js"
+}
diff --git a/examples/sod-web/addon/src/lib.rs b/examples/sod-web/addon/src/lib.rs
new file mode 100644
index 0000000..f0171cb
--- /dev/null
+++ b/examples/sod-web/addon/src/lib.rs
@@ -0,0 +1,325 @@
+//! The sod-web app's replica, packaged as a Node.js native addon.
+//!
+//! Per-app compiled sod: datum `String` (emoji slug), pipeline
+//! `(sod::sinks::Bag, fold Count)`, `SCHEMA = 1`. One replica per
+//! process behind a mutex. Sync I/O never blocks the JS event loop:
+//! `syncWithPeer` runs on a libuv worker thread ([`AsyncTask`]), and the
+//! serve loop owns a Rust thread that borrows the replica only for the
+//! milliseconds of each session (via sod's `SyncListener`).
+//!
+//! Peer identity comes from sync itself: every completed session (either
+//! direction) records the peer's replica id, and `status().connectedIds`
+//! lists the distinct peers seen within the last 10 s — the top-nav
+//! "N bogs connected" badge.
+
+use std::collections::BTreeMap;
+use std::path::Path;
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use fold::pipeline::terminal::Count;
+use napi::bindgen_prelude::*;
+use napi::{Env, Task};
+use napi_derive::napi;
+use sod::engine_fold::FoldEngine;
+use sod::log_file::FileLog;
+use sod::sinks::Bag;
+use sod::time::Watermark;
+use sod::transport::ws::{SyncListener, connect};
+use sod::{Replica, ReplicaId};
+
+type Pipeline = (Bag, Count);
+type AppReplica = Replica, FileLog>;
+
+/// Bump when the datum type or pipeline changes shape (SOD-9).
+const SCHEMA: u32 = 1;
+/// A peer counts as "connected" if a session completed within this window.
+const LIVENESS: Duration = Duration::from_secs(10);
+
+static REPLICA: Mutex> = Mutex::new(None);
+static PEERS_SEEN: Mutex> = Mutex::new(BTreeMap::new());
+static SERVE_STARTED: OnceLock = OnceLock::new();
+
+fn err(e: impl std::fmt::Display) -> Error {
+ Error::from_reason(e.to_string())
+}
+
+fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_millis() as u64
+}
+
+fn seen(peer: ReplicaId) {
+ PEERS_SEEN.lock().unwrap().insert(peer, Instant::now());
+}
+
+fn open_replica(dir: &Path) -> std::result::Result {
+ std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+ let log_path = dir.join("sod.log");
+ let id_path = dir.join("replica_id");
+ // SOD-3: the id never outlives the log
+ if !log_path.exists() {
+ let _ = std::fs::remove_file(&id_path);
+ let _ = std::fs::remove_dir_all(dir.join("db"));
+ }
+ let id = match std::fs::read(&id_path) {
+ Ok(bytes) => ReplicaId(
+ bytes
+ .as_slice()
+ .try_into()
+ .map_err(|_| "replica_id must be 16 bytes".to_string())?,
+ ),
+ Err(_) => {
+ let id = ReplicaId::generate();
+ std::fs::write(&id_path, id.0).map_err(|e| e.to_string())?;
+ id
+ }
+ };
+ let log = FileLog::open(&log_path).map_err(|e| e.to_string())?;
+ let engine = FoldEngine::open(
+ dir.join("db"),
+ (Bag::new("reactions"), Count::new("total")),
+ Watermark::new(),
+ );
+ Replica::open(id, log, engine).map_err(|e| e.to_string())
+}
+
+fn with_replica(f: impl FnOnce(&mut AppReplica) -> Result) -> Result {
+ let mut guard = REPLICA.lock().map_err(|_| err("replica mutex poisoned"))?;
+ let replica = guard.as_mut().ok_or_else(|| err("call open(dir) first"))?;
+ f(replica)
+}
+
+/// Open (or create) the replica at `dir`. One replica per process.
+#[napi]
+pub fn open(dir: String) -> Result {
+ let replica = open_replica(Path::new(&dir)).map_err(err)?;
+ let id = replica.id().to_string();
+ *REPLICA.lock().map_err(|_| err("replica mutex poisoned"))? = Some(replica);
+ Ok(id)
+}
+
+/// Close the replica (drops the store handles).
+#[napi]
+pub fn close() -> Result<()> {
+ *REPLICA.lock().map_err(|_| err("replica mutex poisoned"))? = None;
+ Ok(())
+}
+
+fn commit(emoji: &str, mult: i64) -> Result<()> {
+ let datum = postcard::to_stdvec(&emoji.to_string()).map_err(err)?;
+ with_replica(|r| {
+ r.commit(vec![(datum, mult)], now_ms()).map_err(err)?;
+ Ok(())
+ })
+}
+
+/// Add one reaction.
+#[napi]
+pub fn react(emoji: String) -> Result<()> {
+ commit(&emoji, 1)
+}
+
+/// Remove one reaction. Errors at zero — an unmatched retraction would
+/// store hidden negative debt that swallows a future reaction. The check
+/// and the commit run under ONE replica borrow, so a concurrent remote
+/// retraction (sync worker thread) cannot slip between them.
+#[napi]
+pub fn unreact(emoji: String) -> Result<()> {
+ let datum = postcard::to_stdvec(&emoji).map_err(err)?;
+ with_replica(|r| {
+ let present = r.engine().stream().rtx(|(bag, _)| bag.contains(&emoji));
+ if !present {
+ return Err(err(format!("nothing to unreact: {emoji}")));
+ }
+ r.commit(vec![(datum, -1)], now_ms()).map_err(err)?;
+ Ok(())
+ })
+}
+
+#[napi(object)]
+pub struct Reaction {
+ pub emoji: String,
+ pub count: i64,
+}
+
+#[napi(object)]
+pub struct Board {
+ pub reactions: Vec,
+ pub total: i64,
+}
+
+/// The whole board, in canonical (postcard-key) order.
+#[napi]
+pub fn board() -> Result {
+ with_replica(|r| {
+ let (reactions, total) = r.engine().stream().rtx(|(bag, count)| {
+ (
+ bag.iter()
+ .map(|(emoji, count)| Reaction { emoji, count })
+ .collect::>(),
+ count.get(),
+ )
+ });
+ Ok(Board { reactions, total })
+ })
+}
+
+#[napi(object)]
+pub struct VectorEntry {
+ pub origin: String,
+ pub seq: i64,
+}
+
+#[napi(object)]
+pub struct Status {
+ pub id: String,
+ /// (origin id hex, seq held through), in origin order — an array so
+ /// the order is deterministic at the output boundary
+ pub vector: Vec,
+ pub watermark: i64,
+ /// distinct peer replica ids (hex) with a completed session ≤ 10 s ago
+ pub connected_ids: Vec,
+ /// distinct origins whose writes we hold (excluding ourselves)
+ pub heard_from: i64,
+}
+
+/// Replica identity + convergence state for the UI.
+#[napi]
+pub fn status() -> Result {
+ with_replica(|r| {
+ let id = r.id();
+ let vector: Vec = r
+ .vector()
+ .iter()
+ .map(|(origin, seq)| VectorEntry {
+ origin: origin.to_string(),
+ seq: *seq as i64,
+ })
+ .collect();
+ let heard_from = r.vector().iter().filter(|(o, _)| **o != id).count() as i64;
+ let connected_ids = PEERS_SEEN
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|(_, at)| at.elapsed() <= LIVENESS)
+ .map(|(peer, _)| peer.to_string())
+ .collect();
+ Ok(Status {
+ id: id.to_string(),
+ vector,
+ watermark: r.watermark() as i64,
+ connected_ids,
+ heard_from,
+ })
+ })
+}
+
+pub struct SyncTask {
+ url: String,
+}
+
+impl Task for SyncTask {
+ type Output = Vec;
+ type JsValue = Vec;
+
+ fn compute(&mut self) -> Result {
+ // Worker thread: blocking here never blocks the JS event loop.
+ // CONNECT BEFORE LOCKING: dialing an unreachable peer (the whole
+ // point of the wifi-kill demo) must never stall reads/writes —
+ // the replica is borrowed only once the socket is live.
+ let outgoing = connect(&self.url).map_err(err)?;
+ with_replica(|r| {
+ let report = outgoing.run(r, SCHEMA).map_err(err)?;
+ seen(report.peer);
+ Ok(report.skipped.iter().map(|s| s.to_string()).collect())
+ })
+ }
+
+ fn resolve(&mut self, _env: Env, output: Self::Output) -> Result {
+ Ok(output)
+ }
+}
+
+/// Run one full sync session with a peer. Resolves to the session's
+/// per-origin refusals (surface these — empty on a clean sync).
+#[napi(ts_return_type = "Promise>")]
+pub fn sync_with_peer(url: String) -> AsyncTask {
+ AsyncTask::new(SyncTask { url })
+}
+
+/// Start the accept loop on `addr` (once per process); returns the bound
+/// address (port 0 resolves). The loop owns a Rust thread and borrows the
+/// replica only per session, so writes proceed while it idles.
+#[napi]
+pub fn start_serve_loop(addr: String) -> Result {
+ if let Some(bound) = SERVE_STARTED.get() {
+ return Ok(bound.clone());
+ }
+ let listener = SyncListener::bind(&addr).map_err(err)?;
+ let bound = listener.local_addr().map_err(err)?.to_string();
+ let bound_ret = bound.clone();
+ SERVE_STARTED.set(bound).ok();
+ std::thread::spawn(move || {
+ let mut consecutive_accept_errors = 0usize;
+ loop {
+ match listener.accept() {
+ Ok(incoming) => {
+ consecutive_accept_errors = 0;
+ // Bounded try-lock: in a mutual-dial topology our own
+ // dialer may hold the lock while waiting on the peer,
+ // whose dialer waits on us — shed the session (the
+ // peer retries next tick) instead of deadlocking
+ // until the socket timeouts fire.
+ let mut guard = None;
+ for _ in 0..20 {
+ match REPLICA.try_lock() {
+ Ok(g) => {
+ guard = Some(g);
+ break;
+ }
+ Err(std::sync::TryLockError::WouldBlock) => {
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ Err(std::sync::TryLockError::Poisoned(_)) => return,
+ }
+ }
+ let Some(mut guard) = guard else {
+ eprintln!("sod-web: replica busy; shedding inbound session");
+ drop(incoming);
+ continue;
+ };
+ match guard.as_mut() {
+ Some(replica) => match incoming.run(replica, SCHEMA) {
+ Ok(report) => {
+ drop(guard);
+ for s in &report.skipped {
+ eprintln!("sod-web: refused during sync: {s}");
+ }
+ seen(report.peer);
+ }
+ Err(e) => eprintln!("sod-web: sync session failed: {e}"),
+ },
+ // replica closed: drop the connection; the peer retries
+ None => drop(incoming),
+ }
+ }
+ Err(e) => {
+ // post-d21cc50, accept() Err means the LISTENER failed;
+ // back off, and give up if it never recovers (the same
+ // hot-spin guard sod's own serve() carries)
+ eprintln!("sod-web: accept failed: {e}");
+ consecutive_accept_errors += 1;
+ if consecutive_accept_errors >= 32 {
+ eprintln!("sod-web: listener unrecoverable; serve loop exiting");
+ return;
+ }
+ std::thread::sleep(Duration::from_millis(500));
+ }
+ }
+ }
+ });
+ Ok(bound_ret)
+}
diff --git a/examples/sod-web/app/api/board/route.ts b/examples/sod-web/app/api/board/route.ts
new file mode 100644
index 0000000..f7e84c0
--- /dev/null
+++ b/examples/sod-web/app/api/board/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server";
+import { getSod } from "@/lib/sod";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ return NextResponse.json(getSod().addon.board());
+}
diff --git a/examples/sod-web/app/api/peer-toggle/route.ts b/examples/sod-web/app/api/peer-toggle/route.ts
new file mode 100644
index 0000000..768923a
--- /dev/null
+++ b/examples/sod-web/app/api/peer-toggle/route.ts
@@ -0,0 +1,14 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSod } from "@/lib/sod";
+
+export async function POST(req: NextRequest) {
+ const { url, paused } = await req.json();
+ if (typeof url !== "string" || typeof paused !== "boolean") {
+ return NextResponse.json({ error: "url and paused required" }, { status: 400 });
+ }
+ const found = getSod().loop.setPaused(url, paused);
+ if (!found) {
+ return NextResponse.json({ error: "unknown peer" }, { status: 404 });
+ }
+ return NextResponse.json({ ok: true });
+}
diff --git a/examples/sod-web/app/api/react/route.ts b/examples/sod-web/app/api/react/route.ts
new file mode 100644
index 0000000..779daff
--- /dev/null
+++ b/examples/sod-web/app/api/react/route.ts
@@ -0,0 +1,13 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSod } from "@/lib/sod";
+
+export async function POST(req: NextRequest) {
+ const { emoji } = await req.json();
+ if (typeof emoji !== "string" || !emoji) {
+ return NextResponse.json({ error: "emoji required" }, { status: 400 });
+ }
+ const sod = getSod();
+ sod.addon.react(emoji);
+ sod.loop.kick();
+ return NextResponse.json({ ok: true });
+}
diff --git a/examples/sod-web/app/api/status/route.ts b/examples/sod-web/app/api/status/route.ts
new file mode 100644
index 0000000..125e72c
--- /dev/null
+++ b/examples/sod-web/app/api/status/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { getSod } from "@/lib/sod";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const sod = getSod();
+ const s = sod.addon.status();
+ return NextResponse.json({
+ ...s,
+ serveAddr: sod.serveAddr,
+ peers: sod.loop.peers,
+ online: sod.loop.online(),
+ hasPeers: sod.loop.peers.length > 0,
+ });
+}
diff --git a/examples/sod-web/app/api/unreact/route.ts b/examples/sod-web/app/api/unreact/route.ts
new file mode 100644
index 0000000..0eb6e2f
--- /dev/null
+++ b/examples/sod-web/app/api/unreact/route.ts
@@ -0,0 +1,17 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSod } from "@/lib/sod";
+
+export async function POST(req: NextRequest) {
+ const { emoji } = await req.json();
+ if (typeof emoji !== "string" || !emoji) {
+ return NextResponse.json({ error: "emoji required" }, { status: 400 });
+ }
+ const sod = getSod();
+ try {
+ sod.addon.unreact(emoji);
+ } catch {
+ return NextResponse.json({ error: "nothing to unreact" }, { status: 409 });
+ }
+ sod.loop.kick();
+ return NextResponse.json({ ok: true });
+}
diff --git a/examples/sod-web/app/globals.css b/examples/sod-web/app/globals.css
new file mode 100644
index 0000000..dbea1fc
--- /dev/null
+++ b/examples/sod-web/app/globals.css
@@ -0,0 +1,277 @@
+/* sod-web: instrument panel for watching replicas converge.
+ The page's ground is tinted by the replica's own hue (set as --hue from
+ the replica id at runtime) — identity as atmosphere, so side-by-side
+ demo windows are instantly tellable-apart. System fonts only: this demo
+ exists to work offline. */
+
+:root {
+ --hue: 150; /* replaced at runtime from the replica id */
+ --ink: #221d16;
+ --ink-soft: #221d16b3;
+ --ember: #b3442e;
+ --ground: hsl(var(--hue) 32% 95%);
+ --panel: hsl(var(--hue) 36% 90%);
+ --line: hsl(var(--hue) 25% 80%);
+ --accent: hsl(var(--hue) 52% 32%);
+ --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
+ --sans: ui-sans-serif, system-ui, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--ground);
+ color: var(--ink);
+ font-family: var(--sans);
+ transition: background 600ms ease;
+}
+
+main {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 0 20px 48px;
+}
+
+/* ── top nav ─────────────────────────────────────────────── */
+.nav {
+ position: sticky;
+ top: 0;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 20px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+ font-family: var(--mono);
+ font-size: 13px;
+}
+
+.nav .self {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+}
+
+.dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ flex: none;
+}
+
+.peer-dots {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+}
+
+.peer-dots .dot {
+ animation: appear 400ms ease;
+}
+
+.badge {
+ color: var(--ink-soft);
+}
+
+.pill {
+ font-family: var(--sans);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 4px 10px;
+ border-radius: 999px;
+ border: 1px solid;
+}
+
+.pill.online {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.pill.offline {
+ color: var(--ember);
+ border-color: var(--ember);
+}
+
+.pill.serving {
+ color: var(--ink-soft);
+ border-color: var(--line);
+}
+
+/* ── board ───────────────────────────────────────────────── */
+.board {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+ margin-top: 32px;
+}
+
+@media (max-width: 560px) {
+ .board {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.tile {
+ appearance: none;
+ border: 1px solid var(--line);
+ background: hsl(var(--hue) 40% 97%);
+ border-radius: 14px;
+ padding: 18px 0 12px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ transition: transform 80ms ease, border-color 120ms ease;
+}
+
+.tile:hover {
+ border-color: var(--accent);
+}
+
+.tile:active {
+ transform: scale(0.94);
+}
+
+.tile:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.tile .emoji {
+ font-size: 34px;
+ line-height: 1;
+}
+
+.tile .count {
+ font-family: var(--mono);
+ font-size: 18px;
+ font-weight: 600;
+ animation: pop 240ms ease;
+}
+
+.total {
+ margin-top: 20px;
+ text-align: right;
+ font-family: var(--mono);
+ font-size: 14px;
+ color: var(--ink-soft);
+}
+
+.offline-btn {
+ appearance: none;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ border: 1px solid var(--line);
+ background: transparent;
+ color: var(--ink);
+ border-radius: 8px;
+ padding: 6px 12px;
+ cursor: pointer;
+}
+
+.offline-btn.engaged {
+ border-color: var(--ember);
+ color: var(--ember);
+}
+
+/* ── nerd panel ──────────────────────────────────────────── */
+.nerd {
+ margin-top: 40px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: hsl(var(--hue) 40% 97%);
+ padding: 16px 18px;
+ font-family: var(--mono);
+ font-size: 12.5px;
+}
+
+.nerd h2 {
+ margin: 0 0 10px;
+ font-family: var(--sans);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.nerd .row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 5px 0;
+ border-top: 1px dashed var(--line);
+}
+
+.nerd .row:first-of-type {
+ border-top: none;
+}
+
+.nerd .url {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.nerd .state-ok {
+ color: var(--accent);
+}
+
+.nerd .state-bad {
+ color: var(--ember);
+}
+
+.nerd .spacer {
+ margin-left: auto;
+}
+
+.nerd button {
+ appearance: none;
+ font: inherit;
+ font-size: 11px;
+ border: 1px solid var(--line);
+ background: transparent;
+ border-radius: 6px;
+ padding: 2px 8px;
+ cursor: pointer;
+ color: var(--ink);
+}
+
+.nerd .facts {
+ margin-top: 12px;
+ color: var(--ink-soft);
+ line-height: 1.7;
+ word-break: break-all;
+}
+
+@keyframes pop {
+ 40% {
+ transform: scale(1.35);
+ }
+}
+
+@keyframes appear {
+ from {
+ opacity: 0;
+ transform: scale(0.4);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .tile .count,
+ .peer-dots .dot {
+ animation: none;
+ }
+ body {
+ transition: none;
+ }
+}
diff --git a/examples/sod-web/app/layout.tsx b/examples/sod-web/app/layout.tsx
new file mode 100644
index 0000000..653aa2e
--- /dev/null
+++ b/examples/sod-web/app/layout.tsx
@@ -0,0 +1,15 @@
+import type { ReactNode } from "react";
+import "./globals.css";
+
+export const metadata = {
+ title: "sod-web",
+ description: "Three bogs, one board: offline-first emoji reactions on sod",
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/examples/sod-web/app/page.tsx b/examples/sod-web/app/page.tsx
new file mode 100644
index 0000000..9333894
--- /dev/null
+++ b/examples/sod-web/app/page.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+const PALETTE = ["👍", "❤️", "😂", "🎉", "🚀", "👀", "🔥", "🥲"];
+
+type Board = { reactions: Array<{ emoji: string; count: number }>; total: number };
+type Peer = { url: string; paused: boolean; lastOkMs: number | null; lastError: string | null };
+type Status = {
+ id: string;
+ vector: Array<{ origin: string; seq: number }>;
+ watermark: number;
+ connectedIds: string[];
+ heardFrom: number;
+ serveAddr: string | null;
+ peers: Peer[];
+ online: boolean;
+ hasPeers: boolean;
+};
+
+/** Deterministic hue from a replica id — the same id gets the same color
+ * in every window, so a peer's dot here matches its own page's tint. */
+function hueOf(id: string): number {
+ return parseInt(id.slice(0, 4), 16) % 360;
+}
+
+function isLocalUrl(url: string): boolean {
+ return url.includes("127.0.0.1") || url.includes("localhost");
+}
+
+export default function Page() {
+ const [board, setBoard] = useState(null);
+ const [status, setStatus] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const [b, s] = await Promise.all([
+ fetch("/api/board").then((r) => r.json()),
+ fetch("/api/status").then((r) => r.json()),
+ ]);
+ setBoard(b);
+ setStatus(s);
+ } catch {
+ // server restarting; next poll will catch up
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ const t = setInterval(() => void refresh(), 1000);
+ return () => clearInterval(t);
+ }, [refresh]);
+
+ useEffect(() => {
+ if (status) {
+ document.documentElement.style.setProperty("--hue", String(hueOf(status.id)));
+ }
+ }, [status]);
+
+ const counts = new Map(board?.reactions.map((r) => [r.emoji, r.count]) ?? []);
+
+ const act = async (path: string, body: unknown) => {
+ await fetch(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ void refresh();
+ };
+
+ const remotePeers = status?.peers.filter((p) => !isLocalUrl(p.url)) ?? [];
+ const wifiKilled = remotePeers.length > 0 && remotePeers.every((p) => p.paused);
+
+ return (
+ <>
+
+ {status && (
+ <>
+
+
+ sod {status.id.slice(0, 8)}
+
+
+ {status.connectedIds.map((peer) => (
+
+ ))}
+
+ {status.connectedIds.length} bog{status.connectedIds.length === 1 ? "" : "s"} connected
+
+
+ {status.hasPeers ? (
+
+ {status.online ? "online" : "unreachable"}
+
+ ) : (
+ serving
+ )}
+ {remotePeers.length > 0 && (
+ {
+ for (const p of remotePeers) {
+ void act("/api/peer-toggle", { url: p.url, paused: !wifiKilled });
+ }
+ }}
+ >
+ {wifiKilled ? "Resume remote sync" : "Pause remote sync"}
+
+ )}
+ >
+ )}
+
+
+
+
+ {PALETTE.map((emoji) => {
+ const count = counts.get(emoji) ?? 0;
+ return (
+ void act("/api/react", { emoji })}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ if (count > 0) void act("/api/unreact", { emoji });
+ }}
+ title="click to react · right-click to unreact"
+ >
+ {emoji}
+
+ {count}
+
+
+ );
+ })}
+
+ {board && {board.total} reaction{board.total === 1 ? "" : "s"} total
}
+
+ {status && (
+
+ Under the bog
+ {status.peers.map((peer) => {
+ const fresh =
+ peer.lastOkMs !== null && Date.now() - peer.lastOkMs <= 10_000 && !peer.paused;
+ return (
+
+ {fresh ? "●" : "○"}
+ {peer.url}
+
+ {peer.paused
+ ? "paused"
+ : fresh
+ ? `ok ${Math.round((Date.now() - (peer.lastOkMs ?? 0)) / 1000)}s ago`
+ : "unreachable"}
+
+
+ void act("/api/peer-toggle", { url: peer.url, paused: !peer.paused })}
+ >
+ {peer.paused ? "Resume" : "Pause"}
+
+
+ );
+ })}
+ {status.peers.length === 0 && (
+ no peers configured — this bog serves; others dial it
+ )}
+
+ connected now: {status.connectedIds.length} · heard from ever: {status.heardFrom}
+ {status.serveAddr ? ` · serving on ${status.serveAddr}` : ""}
+
+ vector:{" "}
+ {status.vector
+ .map(({ origin, seq }) => `${origin.slice(0, 8)}·${seq}`)
+ .join(" ") || "empty"}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/examples/sod-web/fly.toml b/examples/sod-web/fly.toml
new file mode 100644
index 0000000..6b408f4
--- /dev/null
+++ b/examples/sod-web/fly.toml
@@ -0,0 +1,43 @@
+# Deploy from the REPO ROOT (build context needs sod/ and fold/):
+# fly deploy . -c examples/sod-web/fly.toml
+app = "sod-web-demo"
+primary_region = "iad"
+
+[build]
+ # relative to this fly.toml's directory (NOT the deploy context)
+ dockerfile = "Dockerfile"
+
+[env]
+ SOD_SERVE_ADDR = "0.0.0.0:7300"
+ SOD_DATA_DIR = "/data/sod"
+
+# the Next.js app, behind Fly's HTTPS edge
+[http_service]
+ internal_port = 3000
+ force_https = true
+ auto_stop_machines = false
+ auto_start_machines = true
+ # the hub must not sleep mid-demo
+ min_machines_running = 1
+
+# the sod sync port: raw TCP, exposed directly.
+# Plain TCP first (clients dial ws://.fly.dev:10700). Before any
+# public showing: add handlers = ["tls"] here and enable sod's wss
+# client support on the dialing side.
+[[services]]
+ protocol = "tcp"
+ internal_port = 7300
+
+ [[services.ports]]
+ port = 10700
+
+# the log + fold db live here; exactly ONE machine — the replica is
+# single-writer (a scaled-out hub would be several bogs, which the
+# protocol supports but this demo does not exercise)
+[mounts]
+ source = "sod_data"
+ destination = "/data"
+
+[[vm]]
+ size = "shared-cpu-1x"
+ memory = "512mb"
diff --git a/examples/sod-web/instrumentation.ts b/examples/sod-web/instrumentation.ts
new file mode 100644
index 0000000..c0c5442
--- /dev/null
+++ b/examples/sod-web/instrumentation.ts
@@ -0,0 +1,10 @@
+// Runs once at server boot (Next instrumentation hook). Without this the
+// replica — and crucially the sync serve loop — would initialize lazily
+// on the first page/API hit, so a freshly restarted hub would be deaf to
+// peers until a browser happened to visit it.
+export async function register() {
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ const { getSod } = await import("./lib/sod");
+ getSod();
+ }
+}
diff --git a/examples/sod-web/lib/sod.ts b/examples/sod-web/lib/sod.ts
new file mode 100644
index 0000000..b3a5bac
--- /dev/null
+++ b/examples/sod-web/lib/sod.ts
@@ -0,0 +1,70 @@
+// The app's replica: addon loading, env-driven init, module singleton.
+//
+// Config (uniform across roles — dial-vs-serve is reachability, not role):
+// SOD_DATA_DIR replica directory (default: .sod-data)
+// SOD_SERVE_ADDR start the accept loop (optional)
+// SOD_PEERS comma-separated ws URLs (optional; everyone reachable)
+import { createRequire } from "node:module";
+import { SyncLoop } from "./sync-loop";
+
+export type Addon = {
+ open(dir: string): string;
+ close(): void;
+ react(emoji: string): void;
+ unreact(emoji: string): void;
+ board(): { reactions: Array<{ emoji: string; count: number }>; total: number };
+ status(): {
+ id: string;
+ vector: Array<{ origin: string; seq: number }>;
+ watermark: number;
+ connectedIds: string[];
+ heardFrom: number;
+ };
+ syncWithPeer(url: string): Promise;
+ startServeLoop(addr: string): string;
+};
+
+export type Sod = {
+ addon: Addon;
+ id: string;
+ serveAddr: string | null;
+ loop: SyncLoop;
+};
+
+declare global {
+ // survives Next dev hot reloads; one replica per process
+ var __sod: Sod | undefined;
+}
+
+function init(): Sod {
+ const require = createRequire(import.meta.url);
+ const addon = require("sod-web-addon") as Addon;
+
+ const dir = process.env.SOD_DATA_DIR ?? ".sod-data";
+ const id = addon.open(dir);
+
+ let serveAddr: string | null = null;
+ if (process.env.SOD_SERVE_ADDR) {
+ serveAddr = addon.startServeLoop(process.env.SOD_SERVE_ADDR);
+ console.log(`sod-web: replica ${id} serving sync on ${serveAddr}`);
+ }
+
+ const peers = (process.env.SOD_PEERS ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const loop = new SyncLoop(addon, peers);
+ loop.start();
+
+ console.log(
+ `sod-web: replica ${id} (dir ${dir}, peers: ${peers.length ? peers.join(", ") : "none"})`,
+ );
+ return { addon, id, serveAddr, loop };
+}
+
+export function getSod(): Sod {
+ if (!globalThis.__sod) {
+ globalThis.__sod = init();
+ }
+ return globalThis.__sod;
+}
diff --git a/examples/sod-web/lib/sync-loop.ts b/examples/sod-web/lib/sync-loop.ts
new file mode 100644
index 0000000..c37f20e
--- /dev/null
+++ b/examples/sod-web/lib/sync-loop.ts
@@ -0,0 +1,78 @@
+// The background sync loop: every 3 s, one session per unpaused peer.
+// "Offline" is nothing more than these attempts failing.
+import type { Addon } from "./sod";
+
+const TICK_MS = 3_000;
+export const LIVENESS_MS = 10_000;
+
+export type PeerState = {
+ url: string;
+ paused: boolean;
+ lastOkMs: number | null;
+ lastError: string | null;
+};
+
+export class SyncLoop {
+ private addon: Addon;
+ readonly peers: PeerState[];
+ private timer: ReturnType | null = null;
+ private inFlight = false;
+
+ constructor(addon: Addon, urls: string[]) {
+ this.addon = addon;
+ this.peers = urls.map((url) => ({
+ url,
+ paused: false,
+ lastOkMs: null,
+ lastError: null,
+ }));
+ }
+
+ start() {
+ if (this.timer) return;
+ this.timer = setInterval(() => void this.tick(), TICK_MS);
+ void this.tick();
+ }
+
+ /** Immediate pass — called after local writes so propagation feels instant. */
+ kick() {
+ void this.tick();
+ }
+
+ async tick() {
+ if (this.inFlight) return;
+ this.inFlight = true;
+ try {
+ for (const peer of this.peers) {
+ if (peer.paused) continue;
+ try {
+ const refusals = await this.addon.syncWithPeer(peer.url);
+ peer.lastOkMs = Date.now();
+ peer.lastError = null;
+ for (const r of refusals) {
+ console.warn(`sod-web: refused during sync with ${peer.url}: ${r}`);
+ }
+ } catch (e) {
+ peer.lastError = e instanceof Error ? e.message : String(e);
+ }
+ }
+ } finally {
+ this.inFlight = false;
+ }
+ }
+
+ setPaused(url: string, paused: boolean): boolean {
+ const peer = this.peers.find((p) => p.url === url);
+ if (!peer) return false;
+ peer.paused = paused;
+ return true;
+ }
+
+ /** Reachable = an unpaused peer succeeded within the liveness window. */
+ online(): boolean {
+ const now = Date.now();
+ return this.peers.some(
+ (p) => !p.paused && p.lastOkMs !== null && now - p.lastOkMs <= LIVENESS_MS,
+ );
+ }
+}
diff --git a/examples/sod-web/next-env.d.ts b/examples/sod-web/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/examples/sod-web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/sod-web/next.config.mjs b/examples/sod-web/next.config.mjs
new file mode 100644
index 0000000..bd255a8
--- /dev/null
+++ b/examples/sod-web/next.config.mjs
@@ -0,0 +1,17 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: "standalone",
+ // the addon package wraps a native .node binary — never bundle it.
+ // serverExternalPackages alone doesn't survive the file: symlink
+ // (webpack resolves to the real path outside node_modules), so mark it
+ // external at the webpack layer too.
+ serverExternalPackages: ["sod-web-addon"],
+ webpack: (config, { isServer }) => {
+ if (isServer) {
+ config.externals.push({ "sod-web-addon": "commonjs sod-web-addon" });
+ }
+ return config;
+ },
+};
+
+export default nextConfig;
diff --git a/examples/sod-web/package-lock.json b/examples/sod-web/package-lock.json
new file mode 100644
index 0000000..def39c2
--- /dev/null
+++ b/examples/sod-web/package-lock.json
@@ -0,0 +1,1030 @@
+{
+ "name": "sod-web",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "sod-web",
+ "dependencies": {
+ "next": "^15",
+ "react": "^19",
+ "react-dom": "^19",
+ "sod-web-addon": "file:./addon"
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5"
+ }
+ },
+ "addon": {
+ "name": "sod-web-addon",
+ "version": "0.0.1"
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.23.tgz",
+ "integrity": "sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.23.tgz",
+ "integrity": "sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.23.tgz",
+ "integrity": "sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.23.tgz",
+ "integrity": "sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.23.tgz",
+ "integrity": "sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.23.tgz",
+ "integrity": "sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.23.tgz",
+ "integrity": "sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.23.tgz",
+ "integrity": "sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.23.tgz",
+ "integrity": "sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001809",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "15.5.23",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.23.tgz",
+ "integrity": "sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.23",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.23",
+ "@next/swc-darwin-x64": "15.5.23",
+ "@next/swc-linux-arm64-gnu": "15.5.23",
+ "@next/swc-linux-arm64-musl": "15.5.23",
+ "@next/swc-linux-x64-gnu": "15.5.23",
+ "@next/swc-linux-x64-musl": "15.5.23",
+ "@next/swc-win32-arm64-msvc": "15.5.23",
+ "@next/swc-win32-x64-msvc": "15.5.23",
+ "sharp": "^0.34.3"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sod-web-addon": {
+ "resolved": "addon",
+ "link": true
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/examples/sod-web/package.json b/examples/sod-web/package.json
new file mode 100644
index 0000000..9003639
--- /dev/null
+++ b/examples/sod-web/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "sod-web",
+ "private": true,
+ "scripts": {
+ "build:addon": "cargo build -p sod-web-addon --release && node scripts/copy-addon.mjs",
+ "dev": "next dev",
+ "build": "next build && node scripts/prepare-standalone.mjs",
+ "start": "node .next/standalone/server.js",
+ "smoke": "node scripts/smoke.mjs",
+ "demo:local": "node scripts/demo-local.mjs"
+ },
+ "dependencies": {
+ "next": "^15",
+ "react": "^19",
+ "react-dom": "^19",
+ "sod-web-addon": "file:./addon"
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5"
+ }
+}
diff --git a/examples/sod-web/scripts/copy-addon.mjs b/examples/sod-web/scripts/copy-addon.mjs
new file mode 100644
index 0000000..3c17fdb
--- /dev/null
+++ b/examples/sod-web/scripts/copy-addon.mjs
@@ -0,0 +1,22 @@
+// Copies the built cdylib into the addon package as sod_web_addon.node.
+import { copyFileSync, existsSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const workspaceTarget = join(here, "..", "..", "..", "target", "release");
+const candidates = [
+ "libsod_web_addon.dylib",
+ "libsod_web_addon.so",
+ "sod_web_addon.dll",
+];
+const src = candidates
+ .map((name) => join(workspaceTarget, name))
+ .find(existsSync);
+if (!src) {
+ console.error(`no built addon found in ${workspaceTarget} — run: cargo build -p sod-web-addon --release`);
+ process.exit(1);
+}
+const dest = join(here, "..", "addon", "sod_web_addon.node");
+copyFileSync(src, dest);
+console.log(`copied ${src} -> ${dest}`);
diff --git a/examples/sod-web/scripts/demo-local.mjs b/examples/sod-web/scripts/demo-local.mjs
new file mode 100644
index 0000000..f05f375
--- /dev/null
+++ b/examples/sod-web/scripts/demo-local.mjs
@@ -0,0 +1,144 @@
+// Three-instance local rehearsal of the wifi-kill demo, fully scripted:
+// hub :3002 serve-only (stands in for the Fly deployment)
+// a :3000 serves :7300, dials b + hub
+// b :3001 serves :7301, dials a + hub
+// Drives reactions over HTTP, asserts convergence, then partitions the hub
+// (pause its peer entries on a and b), asserts a↔b still converge while
+// the hub lags, heals the partition, and asserts global convergence.
+//
+// Prereqs: npm run build:addon && npm run build. Run: npm run demo:local
+import { spawn } from "node:child_process";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const appDir = join(here, "..");
+const children = [];
+const tmpDirs = [];
+
+// cleanup must run on EVERY exit path — a mid-act crash must not leak
+// three servers holding the demo ports
+function cleanup() {
+ for (const c of children) c.kill();
+ for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
+}
+process.on("exit", cleanup);
+
+function fail(msg) {
+ throw new Error(msg);
+}
+
+function boot(port, env) {
+ // spawn the standalone server directly — no npm wrapper, so kill()
+ // reaches the actual server process and nothing leaks
+ const child = spawn(process.execPath, [join(appDir, ".next", "standalone", "server.js")], {
+ cwd: appDir,
+ env: { ...process.env, PORT: String(port), HOSTNAME: "127.0.0.1", ...env },
+ stdio: ["ignore", "ignore", "inherit"],
+ });
+ children.push(child);
+ return child;
+}
+
+async function until(desc, timeoutMs, f) {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try {
+ if (await f()) return;
+ } catch {
+ // not up yet
+ }
+ if (Date.now() > deadline) fail(`timeout waiting for: ${desc}`);
+ await new Promise((r) => setTimeout(r, 500));
+ }
+}
+
+const get = (port, path) => fetch(`http://127.0.0.1:${port}${path}`).then((r) => r.json());
+const post = (port, path, body) =>
+ fetch(`http://127.0.0.1:${port}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+const HUB_SYNC = "ws://127.0.0.1:7302";
+const A_SYNC = "ws://127.0.0.1:7300";
+const B_SYNC = "ws://127.0.0.1:7301";
+
+const dirs = ["hub", "a", "b"].map((n) => mkdtempSync(join(tmpdir(), `sod-web-demo-${n}-`)));
+tmpDirs.push(...dirs);
+
+try {
+boot(3002, { SOD_DATA_DIR: dirs[0], SOD_SERVE_ADDR: "127.0.0.1:7302" });
+boot(3000, {
+ SOD_DATA_DIR: dirs[1],
+ SOD_SERVE_ADDR: "127.0.0.1:7300",
+ SOD_PEERS: `${B_SYNC},${HUB_SYNC}`,
+});
+boot(3001, {
+ SOD_DATA_DIR: dirs[2],
+ SOD_SERVE_ADDR: "127.0.0.1:7301",
+ SOD_PEERS: `${A_SYNC},${HUB_SYNC}`,
+});
+
+const PORTS = [3002, 3000, 3001];
+for (const p of PORTS) {
+ await until(`:${p} up`, 30_000, async () => (await get(p, "/api/status")).id?.length === 32);
+}
+console.log("all three instances up");
+
+// act 1: everyone reacts; everyone converges
+await post(3002, "/api/react", { emoji: "👍" });
+await post(3000, "/api/react", { emoji: "❤️" });
+await post(3001, "/api/react", { emoji: "🔥" });
+await until("act 1 convergence (total 3 everywhere)", 30_000, async () => {
+ const totals = await Promise.all(PORTS.map(async (p) => (await get(p, "/api/board")).total));
+ return totals.every((t) => t === 3);
+});
+await until("locals see 2 connected bogs", 20_000, async () => {
+ const [a, b] = await Promise.all([get(3000, "/api/status"), get(3001, "/api/status")]);
+ return a.connectedIds.length === 2 && b.connectedIds.length === 2;
+});
+console.log("act 1 PASS: three bogs converged; locals connected to 2");
+
+// act 2: partition the hub (the deterministic stand-in for killing wifi)
+for (const p of [3000, 3001]) {
+ await post(p, "/api/peer-toggle", { url: HUB_SYNC, paused: true });
+}
+await post(3000, "/api/react", { emoji: "🎉" });
+await post(3001, "/api/react", { emoji: "🎉" });
+await until("a↔b converge to 5 during partition", 30_000, async () => {
+ const [a, b] = await Promise.all([get(3000, "/api/board"), get(3001, "/api/board")]);
+ return a.total === 5 && b.total === 5;
+});
+const hubDuring = await get(3002, "/api/board");
+if (hubDuring.total !== 3) fail(`hub should lag at 3 during partition, has ${hubDuring.total}`);
+await until("locals drop to 1 connected bog", 20_000, async () => {
+ const [a, b] = await Promise.all([get(3000, "/api/status"), get(3001, "/api/status")]);
+ return a.connectedIds.length === 1 && b.connectedIds.length === 1;
+});
+console.log("act 2 PASS: partition held — locals kept syncing, hub lagged");
+
+// act 3: heal; hub catches up on both locals' partition-era writes
+await post(3002, "/api/react", { emoji: "👀" }); // hub wrote while partitioned too
+for (const p of [3000, 3001]) {
+ await post(p, "/api/peer-toggle", { url: HUB_SYNC, paused: false });
+}
+await until("act 3 global convergence (total 6 everywhere)", 30_000, async () => {
+ const totals = await Promise.all(PORTS.map(async (p) => (await get(p, "/api/board")).total));
+ return totals.every((t) => t === 6);
+});
+const finals = await Promise.all(PORTS.map((p) => get(p, "/api/board")));
+const canon = JSON.stringify(finals[0]);
+if (!finals.every((f) => JSON.stringify(f) === canon)) {
+ fail(`boards differ after heal: ${finals.map((f) => JSON.stringify(f)).join(" vs ")}`);
+}
+console.log("act 3 PASS: heal converged all three boards byte-identically");
+console.log("DEMO-LOCAL PASS");
+process.exit(0); // cleanup runs via the exit handler
+} catch (e) {
+ console.error(`DEMO-LOCAL FAIL: ${e.message}`);
+ process.exit(1);
+}
diff --git a/examples/sod-web/scripts/prepare-standalone.mjs b/examples/sod-web/scripts/prepare-standalone.mjs
new file mode 100644
index 0000000..a7560d0
--- /dev/null
+++ b/examples/sod-web/scripts/prepare-standalone.mjs
@@ -0,0 +1,20 @@
+// Makes the standalone output self-serving: Next's standalone server does
+// not include static assets by design; copy them in (same thing the
+// Dockerfile runtime stage does).
+import { cpSync, existsSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const app = join(dirname(fileURLToPath(import.meta.url)), "..");
+const standalone = join(app, ".next", "standalone");
+if (!existsSync(standalone)) {
+ console.error("no standalone output — run next build first");
+ process.exit(1);
+}
+cpSync(join(app, ".next", "static"), join(standalone, ".next", "static"), {
+ recursive: true,
+});
+if (existsSync(join(app, "public"))) {
+ cpSync(join(app, "public"), join(standalone, "public"), { recursive: true });
+}
+console.log("standalone output prepared");
diff --git a/examples/sod-web/scripts/smoke-peer.mjs b/examples/sod-web/scripts/smoke-peer.mjs
new file mode 100644
index 0000000..2e048fc
--- /dev/null
+++ b/examples/sod-web/scripts/smoke-peer.mjs
@@ -0,0 +1,15 @@
+// Child process for smoke.mjs: a second replica that serves sync sessions.
+// Usage: node smoke-peer.mjs — prints "READY ".
+import { createRequire } from "node:module";
+const require = createRequire(import.meta.url);
+const sod = require("../addon/index.js");
+
+const dir = process.argv[2];
+const id = sod.open(dir);
+sod.react("🔥");
+sod.react("🔥");
+sod.react("👀");
+const addr = sod.startServeLoop("127.0.0.1:0");
+console.log(`READY ${addr} ${id}`);
+// keep serving until the parent kills us
+setInterval(() => {}, 1_000);
diff --git a/examples/sod-web/scripts/smoke.mjs b/examples/sod-web/scripts/smoke.mjs
new file mode 100644
index 0000000..4e9fbbd
--- /dev/null
+++ b/examples/sod-web/scripts/smoke.mjs
@@ -0,0 +1,76 @@
+// Addon smoke test: single-replica behavior, then a two-process sync leg.
+// Run from examples/sod-web: npm run build:addon && npm run smoke
+import { spawn } from "node:child_process";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { createRequire } from "node:module";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const require = createRequire(import.meta.url);
+const sod = require("../addon/index.js");
+
+function assert(cond, msg) {
+ if (!cond) {
+ console.error(`FAIL: ${msg}`);
+ process.exit(1);
+ }
+}
+
+const dirA = mkdtempSync(join(tmpdir(), "sod-web-smoke-a-"));
+const dirB = mkdtempSync(join(tmpdir(), "sod-web-smoke-b-"));
+
+// --- single replica ---
+const idA = sod.open(dirA);
+assert(/^[0-9a-f]{32}$/.test(idA), "open returns hex id");
+sod.react("👍");
+sod.react("👍");
+sod.react("❤️");
+let b = sod.board();
+assert(b.total === 3, `total is 3, got ${b.total}`);
+const counts = Object.fromEntries(b.reactions.map((r) => [r.emoji, r.count]));
+assert(counts["👍"] === 2 && counts["❤️"] === 1, "per-emoji counts");
+sod.unreact("❤️");
+assert(sod.board().total === 2, "unreact decrements");
+let threw = false;
+try {
+ sod.unreact("❤️");
+} catch {
+ threw = true;
+}
+assert(threw, "guarded unreact throws at zero");
+let st = sod.status();
+assert(st.id === idA, "status id");
+assert(st.connectedIds.length === 0, "no peers yet");
+const own = st.vector.find((v) => v.origin === idA);
+assert(own && own.seq >= 4, "own feed advanced");
+
+// --- two-process sync ---
+const child = spawn(process.execPath, [join(here, "smoke-peer.mjs"), dirB], {
+ stdio: ["ignore", "pipe", "inherit"],
+});
+const ready = await new Promise((resolve, reject) => {
+ let buf = "";
+ child.stdout.on("data", (d) => {
+ buf += d.toString();
+ const m = buf.match(/READY (\S+) (\S+)/);
+ if (m) resolve({ addr: m[1], id: m[2] });
+ });
+ child.on("exit", (code) => reject(new Error(`peer died: ${code}`)));
+ setTimeout(() => reject(new Error("peer never became ready")), 15_000);
+});
+
+const refusals = await sod.syncWithPeer(`ws://${ready.addr}`);
+assert(refusals.length === 0, "clean sync has no refusals");
+b = sod.board();
+assert(b.total === 5, `converged total is 5 (2 local + 3 peer), got ${b.total}`);
+st = sod.status();
+assert(st.connectedIds.includes(ready.id), "peer counted as connected");
+assert(st.heardFrom === 1, `heard from 1 other bog, got ${st.heardFrom}`);
+
+child.kill();
+sod.close();
+rmSync(dirA, { recursive: true, force: true });
+rmSync(dirB, { recursive: true, force: true });
+console.log("SMOKE PASS");
diff --git a/examples/sod-web/tsconfig.json b/examples/sod-web/tsconfig.json
new file mode 100644
index 0000000..03f9484
--- /dev/null
+++ b/examples/sod-web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules", "addon"]
+}
diff --git a/sod/Cargo.toml b/sod/Cargo.toml
new file mode 100644
index 0000000..131f17c
--- /dev/null
+++ b/sod/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "sod"
+version = "0.0.1"
+edition = "2024"
+
+[features]
+default = ["fold-engine", "ws", "os-rng"]
+# The fold-backed Engine (pulls in fold and fjall — native only).
+fold-engine = ["dep:fold", "dep:fjall"]
+# Blocking websocket transport.
+ws = ["dep:tungstenite"]
+# ReplicaId::generate() via the OS RNG. Off on targets without getrandom
+# support; construct ReplicaId::from_bytes with caller-supplied entropy there.
+os-rng = ["dep:getrandom"]
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+postcard = { version = "1", features = ["use-std"] }
+blake3 = "1"
+fold = { path = "../fold", optional = true }
+fjall = { version = "3", default-features = false, optional = true }
+tungstenite = { version = "0.24", optional = true }
+getrandom = { version = "0.2", optional = true }
diff --git a/sod/README.md b/sod/README.md
new file mode 100644
index 0000000..b288fc1
--- /dev/null
+++ b/sod/README.md
@@ -0,0 +1,110 @@
+# sod
+
+Symmetric replication for fold apps. A **sod** is a replica: a local bog
+that always accepts writes and converges with its peers by exchanging what
+the other is missing — PouchDB's posture, with a protocol built for fold's
+data model.
+
+Spec: [`docs/superpowers/specs/2026-08-15-sod-design.md`](../docs/superpowers/specs/2026-08-15-sod-design.md).
+Runnable example + app template: [`examples/sod-demo`](../examples/sod-demo).
+
+## Why it converges
+
+Fold's write primitive is a Z-set delta (datum + signed multiplicity).
+Deltas commute: the multiset is the sum of applied deltas, and views are
+deterministic functions of the multiset. So replicas that hold the same set
+of frames hold the same views, regardless of arrival order or topology —
+convergence by algebra, not coordination. Sync is anti-entropy: exchange
+version vectors, stream missing frames, done.
+
+There are no client/server roles on the wire. Client↔server is a star of
+pairwise symmetric sessions; p2p is any other graph of the same sessions.
+Order-sensitive operations (uniqueness, claims) are an *application*
+pattern — route them to a designated replica as ordinary RPC — never
+protocol machinery.
+
+## The model
+
+- Every replica has a random 128-bit `ReplicaId`, born and dying with its
+ log (SOD-3).
+- A commit is a **frame**: `(prev_hash, origin, seq, event_time, deltas)`,
+ identified by the BLAKE3 hash of its bytes. Each origin's frames form a
+ hash chain (à la Secure Scuttlebutt): tamper-evident, equivocation-
+ detectable (a fork poisons that feed, SOD-2), and relayable through
+ untrusted peers — any peer can carry any feed, and receivers verify.
+- The log is the truth (SOD-1); the engine — fold, or anything else — is a
+ rebuildable cache with a transactional applied-cursor, so a crash at any
+ point heals on open (SOD-5).
+- The **watermark** (max event-time applied) is the only "now" (SOD-7);
+ sod itself never reads a clock — apps stamp event time at commit.
+- Sync sessions start with a protocol + app-schema version handshake and
+ refuse mismatches (SOD-9); the handshake carries the sender's replica
+ id, and completed sessions yield a `SyncReport` (peer id, the peer's
+ vector, any per-origin refusals) — which is how apps know who they're
+ connected to. Interrupted sessions need no cleanup: the version vector
+ is the resume point (SOD-6).
+
+## Ports (what makes it portable)
+
+| Port | Purpose | Shipped implementations |
+|---|---|---|
+| `engine::Engine` | "the bog machinery" materializing deltas | `MemEngine` (always; oracle + wasm-viable), `engine_fold::FoldEngine` (feature `fold-engine`) |
+| `store::LogStore` | append-only frame storage | `MemLog` (always), `log_file::FileLog` (torn-tail recovery) |
+| transport | drives the sans-io `sync::Session` | `transport::ws` blocking websockets (feature `ws`): `sync_with`/`serve` for simple hosts; for hosts that must never hold the replica while idle (web servers), `SyncListener`/`IncomingSession` on the accept side and `connect`/`OutgoingSession` on the dial side — connect first, borrow the replica only for the session |
+| entropy | `ReplicaId::generate` | `getrandom` (feature `os-rng`); or pass bytes via `ReplicaId::from_bytes` |
+
+Feature flags: `default = ["fold-engine", "ws", "os-rng"]`. The core —
+frames, vectors, log, replica, session, `MemEngine` — has no platform
+dependencies: `cargo check -p sod --no-default-features --target
+wasm32-unknown-unknown` passes and is enforced by `tests/wasm_check.rs`.
+
+## Targets
+
+- **Server / native / Node.js**: full stack. Node packaging is a per-app
+ napi-rs addon (see `examples/sod-demo/node`).
+- **Browser**: core + `MemEngine` compile to wasm32 today; an OPFS/
+ IndexedDB `LogStore` and a browser transport are follow-on port
+ implementations. Fold itself reaches the browser only once it grows a
+ storage port replacing fjall.
+- **React Native**: the native stack behind a UniFFI/JSI binding
+ (follow-on packaging; phones have real filesystems, fjall works).
+
+## Using it
+
+```rust
+use sod::sinks::Bag;
+use sod::{Replica, ReplicaId};
+use sod::{engine_fold::FoldEngine, log_file::FileLog, time::Watermark};
+
+let log = FileLog::open("my.sod/sod.log")?;
+let engine = FoldEngine::open("my.sod/db", Bag::::new("notes"), Watermark::new());
+let mut replica = Replica::open(my_persisted_id, log, engine)?;
+
+replica.commit(vec![(postcard::to_stdvec(¬e)?, 1)], event_time_ms)?;
+sod::transport::ws::sync_with("ws://peer:7171", &mut replica, SCHEMA)?;
+```
+
+Sod **consumes fold's public API and never modifies fold** — the
+applied-cursor is an ordinary pipeline node (sink name `sod_cursor`,
+reserved), and where a stock fold sink doesn't fit replication, sod ships
+its own in `sod::sinks`.
+
+Rules for a sod-compatible pipeline:
+
+1. Sinks must be pure functions of the net multiset (the differential
+ oracle test enforces this; it caught fold's stock `Bag` clamping
+ negative sums — use `sod::sinks::Bag` instead; fold's `Count` is safe
+ as-is).
+2. No wall-clock or arrival-order-dependent operators — in particular
+ fold's `Retain` is not yet sod-compatible (see the spec's Time section
+ for the analysis; an event-time retain in fold is the fix).
+3. Bump your app schema version whenever the datum type or pipeline
+ changes shape.
+
+## Tests
+
+`cargo test -p sod` runs, among others: a 100-case randomized convergence
+suite (interleaved writes, partial syncs, relays), torn-tail and
+crash-window recovery, equivocation adversaries, a golden log-format
+fixture, the fold-vs-MemEngine differential oracle, and the wasm32
+portability gate.
diff --git a/sod/src/engine.rs b/sod/src/engine.rs
new file mode 100644
index 0000000..2b29934
--- /dev/null
+++ b/sod/src/engine.rs
@@ -0,0 +1,162 @@
+//! The [`Engine`] port — "the bog machinery" that materializes deltas —
+//! and [`MemEngine`], the always-compiled deterministic multiset engine.
+//!
+//! An engine is whatever turns applied frames into readable state. The
+//! contract that makes crash healing (SOD-5) work: `apply` must commit the
+//! frame's deltas **and** the cursor advance for `(origin, seq)` atomically,
+//! and `applied` must report that cursor at open, so the replica can replay
+//! exactly the log suffix the engine has not yet seen.
+//!
+//! [`MemEngine`] is three things at once: the property-test oracle, the
+//! differential reference for richer engines, and the engine available on
+//! targets fold cannot reach yet (browsers). `FoldEngine`
+//! (feature `fold-engine`) is the full-powered native engine.
+
+use std::collections::BTreeMap;
+
+use crate::vector::VersionVector;
+use crate::{Frame, SodError};
+
+/// The bog machinery port.
+pub trait Engine {
+ /// Deterministically check that a frame is applicable — e.g. that its
+ /// datums decode as the pipeline type — WITHOUT mutating anything.
+ ///
+ /// The replica calls this **before** appending a frame to the log:
+ /// once a frame is logged it will be replayed on every open, so a
+ /// frame that deterministically fails `apply` would brick the replica.
+ /// `validate`-then-`apply` must agree: any frame that passes validate
+ /// must not fail apply for a deterministic reason.
+ fn validate(&self, _frame: &Frame) -> Result<(), SodError> {
+ Ok(())
+ }
+
+ /// Apply one frame's deltas plus the new watermark. The deltas and the
+ /// applied-cursor advance to `(frame.origin, frame.seq)` must commit
+ /// atomically.
+ fn apply(&mut self, frame: &Frame, watermark: u64) -> Result<(), SodError>;
+
+ /// The cursor durably applied through, per origin. Read at open to
+ /// replay exactly the un-applied log suffix.
+ fn applied(&self) -> VersionVector;
+
+ /// Seed the watermark at open, before any `apply` (default: no-op).
+ fn seed_watermark(&mut self, _wm: u64) {}
+}
+
+/// Deterministic in-memory multiset engine.
+///
+/// State is exactly the Z-set: `datum bytes → net multiplicity`, with zero
+/// entries removed (so a transient negative — a retraction arriving before
+/// its insert — is representable and visible, per the spec).
+#[derive(Default, Debug)]
+pub struct MemEngine {
+ multiset: BTreeMap, i64>,
+ applied: VersionVector,
+ watermark: u64,
+}
+
+impl MemEngine {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Canonical bytes of the whole multiset — the convergence probe used
+ /// by SOD-4 tests (`BTreeMap`, so iteration order is key order).
+ pub fn view_bytes(&self) -> Vec {
+ postcard::to_stdvec(&self.multiset).expect("multiset encoding is infallible")
+ }
+
+ /// Net multiplicity of one datum (0 if absent).
+ pub fn count(&self, datum: &[u8]) -> i64 {
+ self.multiset.get(datum).copied().unwrap_or(0)
+ }
+
+ /// Iterate `(datum, net multiplicity)` in datum order.
+ pub fn iter(&self) -> impl Iterator- , &i64)> {
+ self.multiset.iter()
+ }
+
+ pub fn watermark(&self) -> u64 {
+ self.watermark
+ }
+}
+
+impl Engine for MemEngine {
+ fn apply(&mut self, frame: &Frame, watermark: u64) -> Result<(), SodError> {
+ for (datum, mult) in &frame.payload {
+ let entry = self.multiset.entry(datum.clone()).or_insert(0);
+ *entry += mult;
+ if *entry == 0 {
+ self.multiset.remove(datum);
+ }
+ }
+ self.applied.set(frame.origin, frame.seq);
+ self.watermark = self.watermark.max(watermark);
+ Ok(())
+ }
+
+ fn applied(&self) -> VersionVector {
+ self.applied.clone()
+ }
+
+ fn seed_watermark(&mut self, wm: u64) {
+ self.watermark = self.watermark.max(wm);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{ReplicaId, ZERO_HASH};
+
+ fn frame(origin: u8, seq: u64, payload: Vec<(Vec
, i64)>) -> Frame {
+ Frame {
+ prev_hash: ZERO_HASH, // engines don't verify chains; the replica does
+ origin: ReplicaId([origin; 16]),
+ seq,
+ event_time: seq,
+ payload,
+ }
+ }
+
+ #[test]
+ fn apply_advances_cursor() {
+ let mut e = MemEngine::new();
+ e.apply(&frame(1, 1, vec![(b"a".to_vec(), 1)]), 10).unwrap();
+ e.apply(&frame(1, 2, vec![(b"a".to_vec(), 2)]), 20).unwrap();
+ e.apply(&frame(2, 1, vec![(b"b".to_vec(), 1)]), 15).unwrap();
+ assert_eq!(e.applied().get(&ReplicaId([1; 16])), 2);
+ assert_eq!(e.applied().get(&ReplicaId([2; 16])), 1);
+ assert_eq!(e.count(b"a"), 3);
+ assert_eq!(e.watermark(), 20);
+ }
+
+ #[test]
+ fn retraction_before_insert_goes_negative_then_zero_entry_removed() {
+ let mut e = MemEngine::new();
+ e.apply(&frame(1, 1, vec![(b"x".to_vec(), -1)]), 1).unwrap();
+ assert_eq!(e.count(b"x"), -1);
+ e.apply(&frame(2, 1, vec![(b"x".to_vec(), 1)]), 2).unwrap();
+ assert_eq!(e.count(b"x"), 0);
+ assert!(e.iter().next().is_none(), "zero entries are removed");
+ }
+
+ #[test]
+ fn view_bytes_order_independent() {
+ let fa = frame(1, 1, vec![(b"a".to_vec(), 2), (b"b".to_vec(), -1)]);
+ let fb = frame(2, 1, vec![(b"c".to_vec(), 5)]);
+ let fc = frame(3, 1, vec![(b"a".to_vec(), -2)]);
+
+ let mut e1 = MemEngine::new();
+ for f in [&fa, &fb, &fc] {
+ e1.apply(f, f.event_time).unwrap();
+ }
+ let mut e2 = MemEngine::new();
+ for f in [&fc, &fa, &fb] {
+ e2.apply(f, f.event_time).unwrap();
+ }
+ assert_eq!(e1.view_bytes(), e2.view_bytes());
+ assert_eq!(e1.watermark(), e2.watermark());
+ }
+}
diff --git a/sod/src/engine_fold.rs b/sod/src/engine_fold.rs
new file mode 100644
index 0000000..897e897
--- /dev/null
+++ b/sod/src/engine_fold.rs
@@ -0,0 +1,188 @@
+//! [`FoldEngine`]: the fold-backed [`Engine`] (feature `fold-engine`).
+//!
+//! Built entirely on fold's **public** API — sod consumes fold, it never
+//! modifies it. The applied-cursor (which frame each origin's feed has been
+//! applied through) is an ordinary pipeline node, [`AppliedCursor`], that
+//! sod wraps around the app's pipeline:
+//!
+//! - at [`init`](fold::pipeline::Push::init) it claims the sink name
+//! `sod_cursor` (collision-checked by fold like any sink) and recovers
+//! the persisted cursor from the startup snapshot — the same pattern
+//! fold's `Retain` uses for its sequence counter;
+//! - at [`commit`](fold::pipeline::Push::commit) it writes the `(origin,
+//! seq)` deposited by [`FoldEngine::apply`] into its keyspace, **inside
+//! the same fold transaction** as the frame's deltas.
+//!
+//! That one-transaction property is what makes crash healing exact
+//! (SOD-5): on open, the cursor tells the replica precisely which log
+//! suffix the fold db has not yet seen.
+//!
+//! Cursor layout: key = 16-byte origin id, value = 8-byte big-endian seq.
+//! App pipelines must not name a sink `sod_cursor`.
+
+use std::path::Path;
+use std::sync::{Arc, Mutex};
+
+use fold::pipeline::Push;
+use fold::stream::{PipelineInitCtx, Readable, Stream, WriteTx};
+use serde::de::DeserializeOwned;
+
+use crate::engine::Engine;
+use crate::time::Watermark;
+use crate::vector::VersionVector;
+use crate::{Frame, ReplicaId, SodError};
+
+/// State shared between [`FoldEngine`] and its [`AppliedCursor`] node.
+#[derive(Default)]
+struct CursorState {
+ /// Cursor recovered from the store at init.
+ loaded: VersionVector,
+ /// Cursor advance deposited by `apply` for the in-flight transaction.
+ pending: Option<([u8; 16], u64)>,
+}
+
+/// The pipeline node that persists the applied-cursor transactionally with
+/// the app pipeline it wraps. Public only because it appears in
+/// [`FoldEngine`]'s stream type; apps never construct it.
+pub struct AppliedCursor {
+ ks: Option,
+ shared: Arc>,
+ next: G,
+}
+
+impl Push for AppliedCursor
+where
+ D: Clone,
+ G: Push,
+{
+ type Reader<'tx, R: Readable + 'tx> = G::Reader<'tx, R>;
+
+ fn init(&mut self, init: &mut PipelineInitCtx<'_>) {
+ let ks = init.keyspace("sod_cursor");
+ let snapshot = init.snapshot();
+ {
+ let mut shared = self.shared.lock().unwrap();
+ for kv in snapshot.iter(&ks) {
+ let (k, v) = kv.into_inner().unwrap();
+ let origin = ReplicaId(k.as_ref().try_into().expect("cursor key is 16 bytes"));
+ let seq =
+ u64::from_be_bytes(v.as_ref().try_into().expect("cursor value is 8 bytes"));
+ shared.loaded.set(origin, seq);
+ }
+ }
+ self.ks = Some(ks);
+ self.next.init(init);
+ }
+
+ #[inline]
+ fn push(&mut self, tx: &mut WriteTx<'_>, data: &D, delta: isize) {
+ self.next.push(tx, data, delta);
+ }
+
+ fn commit(&mut self, tx: &mut WriteTx<'_>) {
+ if let Some((origin, seq)) = self.shared.lock().unwrap().pending.take() {
+ tx.insert(self.ks.as_ref().unwrap(), origin, seq.to_be_bytes());
+ }
+ self.next.commit(tx);
+ }
+
+ fn abort(&mut self) {
+ self.shared.lock().unwrap().pending = None;
+ self.next.abort();
+ }
+
+ fn reader<'tx, R: Readable>(&self, tx: &'tx R) -> Self::Reader<'tx, R> {
+ self.next.reader(tx)
+ }
+}
+
+pub struct FoldEngine> {
+ stream: Stream>,
+ shared: Arc>,
+ applied: VersionVector,
+ watermark: Watermark,
+}
+
+impl> FoldEngine {
+ /// Open the fold store at `path` with the app's `pipeline` (wrapped in
+ /// the cursor node), recovering the applied cursor from the store.
+ ///
+ /// `watermark` is the handle the app also passes to any clock-taking
+ /// pipeline operators; the engine advances it on every apply.
+ pub fn open(path: impl AsRef, pipeline: P, watermark: Watermark) -> Self {
+ let shared = Arc::new(Mutex::new(CursorState::default()));
+ let stream = Stream::new(
+ path,
+ AppliedCursor {
+ ks: None,
+ shared: shared.clone(),
+ next: pipeline,
+ },
+ );
+ let applied = shared.lock().unwrap().loaded.clone();
+ FoldEngine {
+ stream,
+ shared,
+ applied,
+ watermark,
+ }
+ }
+
+ /// The wrapped stream, for [`rtx`](Stream::rtx) view reads. The cursor
+ /// node is reader-transparent: `rtx` closures see the app pipeline's
+ /// reader shape unchanged.
+ pub fn stream(&self) -> &Stream> {
+ &self.stream
+ }
+
+ /// The engine's watermark handle.
+ pub fn watermark(&self) -> &Watermark {
+ &self.watermark
+ }
+}
+
+fn decode_payload(frame: &Frame) -> Result, SodError> {
+ let mut deltas = Vec::with_capacity(frame.payload.len());
+ for (bytes, mult) in &frame.payload {
+ let d: D = postcard::from_bytes(bytes)
+ .map_err(|_| SodError::Corrupt("frame datum does not decode as pipeline type"))?;
+ deltas.push((d, *mult));
+ }
+ Ok(deltas)
+}
+
+impl Engine for FoldEngine
+where
+ D: Clone + DeserializeOwned,
+ P: Push,
+{
+ /// A frame is applicable iff every datum decodes as the pipeline type.
+ /// Checked by the replica before the frame is logged (a logged frame
+ /// that cannot apply would fail replay on every open).
+ fn validate(&self, frame: &Frame) -> Result<(), SodError> {
+ decode_payload::(frame).map(|_| ())
+ }
+
+ fn apply(&mut self, frame: &Frame, watermark: u64) -> Result<(), SodError> {
+ // Decode every datum before touching the store, so a bad frame
+ // fails cleanly without a partial transaction.
+ let deltas = decode_payload::(frame)?;
+ self.watermark.advance(watermark);
+ self.shared.lock().unwrap().pending = Some((frame.origin.0, frame.seq));
+ self.stream.wtx(|tx| {
+ for (d, mult) in &deltas {
+ tx.push(d, *mult as isize);
+ }
+ });
+ self.applied.set(frame.origin, frame.seq);
+ Ok(())
+ }
+
+ fn applied(&self) -> VersionVector {
+ self.applied.clone()
+ }
+
+ fn seed_watermark(&mut self, wm: u64) {
+ self.watermark.advance(wm);
+ }
+}
diff --git a/sod/src/frame.rs b/sod/src/frame.rs
new file mode 100644
index 0000000..d22b7e0
--- /dev/null
+++ b/sod/src/frame.rs
@@ -0,0 +1,231 @@
+//! Frame encoding, BLAKE3 hashing, and the on-disk/wire record format.
+//!
+//! A frame's identity is the BLAKE3 hash of its encoded bytes (SOD-2), and
+//! every frame carries the hash of its predecessor from the same origin, so
+//! each origin's feed is a hash chain: tamper-evident, equivocation-
+//! detectable, and relayable through untrusted peers.
+//!
+//! The on-disk record format is `u32 LE body-length | 4-byte length-check
+//! (blake3 of the length bytes, truncated) | body | 32-byte blake3(body)`.
+//! The length-check exists so a corrupted length prefix is *detected*
+//! (interior corruption, refused) instead of being misread as a clean torn
+//! tail and silently truncating every valid record after it. (Sync
+//! transports serialize [`Frame`]s directly via postcard; records are a
+//! LogStore concern only.)
+
+use serde::{Deserialize, Serialize};
+
+use crate::SodError;
+
+/// Identity of a replica: 128 random bits, generated when its log is
+/// created, and never outliving the log (SOD-3). Deleting or resetting a
+/// replica's log requires generating a fresh id — reuse causes silent
+/// divergence at peers that remember the old feed.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct ReplicaId(pub [u8; 16]);
+
+impl ReplicaId {
+ /// Generate a fresh id from the OS RNG.
+ #[cfg(feature = "os-rng")]
+ pub fn generate() -> Self {
+ let mut b = [0u8; 16];
+ getrandom::getrandom(&mut b).expect("OS RNG unavailable");
+ ReplicaId(b)
+ }
+
+ /// Construct from caller-supplied entropy (for targets without the
+ /// `os-rng` feature, e.g. browsers passing `crypto.getRandomValues`).
+ pub const fn from_bytes(b: [u8; 16]) -> Self {
+ ReplicaId(b)
+ }
+}
+
+impl core::fmt::Display for ReplicaId {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ for b in self.0 {
+ write!(f, "{b:02x}")?;
+ }
+ Ok(())
+ }
+}
+
+impl core::fmt::Debug for ReplicaId {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ core::fmt::Display::fmt(self, f)
+ }
+}
+
+/// BLAKE3 hash of a frame's encoded bytes: the frame's identity.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct FrameHash(pub [u8; 32]);
+
+/// The `prev_hash` of the first frame (`seq == 1`) in a feed.
+pub const ZERO_HASH: FrameHash = FrameHash([0u8; 32]);
+
+impl core::fmt::Display for FrameHash {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ for b in self.0 {
+ write!(f, "{b:02x}")?;
+ }
+ Ok(())
+ }
+}
+
+impl core::fmt::Debug for FrameHash {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ core::fmt::Display::fmt(self, f)
+ }
+}
+
+/// One committed batch of deltas from one origin.
+#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
+pub struct Frame {
+ /// Hash of this origin's previous frame; [`ZERO_HASH`] at `seq == 1`.
+ pub prev_hash: FrameHash,
+ /// The replica that committed this frame.
+ pub origin: ReplicaId,
+ /// 1-based, contiguous per origin.
+ pub seq: u64,
+ /// Origin-stamped event time, milliseconds since the unix epoch.
+ /// Produced by the committing application; never observed as a clock
+ /// inside sod (SOD-7).
+ pub event_time: u64,
+ /// `(postcard-encoded datum, signed multiplicity)` deltas.
+ pub payload: Vec<(Vec, i64)>,
+}
+
+impl Frame {
+ /// The frame's body bytes (postcard).
+ pub fn encode(&self) -> Vec {
+ postcard::to_stdvec(self).expect("frame encoding is infallible")
+ }
+
+ /// The frame's identity: BLAKE3 of [`encode`](Frame::encode).
+ pub fn hash(&self) -> FrameHash {
+ FrameHash(*blake3::hash(&self.encode()).as_bytes())
+ }
+
+ /// Append the full record — `len | len-check | body | hash` — to `out`.
+ pub fn encode_record(&self, out: &mut Vec) {
+ let body = self.encode();
+ let len_bytes = (body.len() as u32).to_le_bytes();
+ out.extend_from_slice(&len_bytes);
+ out.extend_from_slice(&len_check(&len_bytes));
+ let hash = blake3::hash(&body);
+ out.extend_from_slice(&body);
+ out.extend_from_slice(hash.as_bytes());
+ }
+}
+
+/// Record header: 4-byte LE length + 4-byte length-check.
+const HEADER: usize = 8;
+
+fn len_check(len_bytes: &[u8; 4]) -> [u8; 4] {
+ blake3::hash(len_bytes).as_bytes()[..4].try_into().unwrap()
+}
+
+/// `decode_record` error when the length prefix cannot be trusted — it
+/// fails its check, or its value overflows record arithmetic — so the
+/// record cannot even be delimited. Log recovery treats this as interior
+/// corruption, never as a torn tail (torn appends produce *short*
+/// records, not garbled headers).
+pub const CORRUPT_LEN: &str = "record length untrustworthy";
+
+/// Decode and verify one record from the front of `buf`.
+///
+/// Returns `Ok(Some((frame, hash, consumed)))` on success, `Ok(None)` if
+/// `buf` holds only a clean partial record (more bytes needed — at the end
+/// of a log file this is a torn tail), and `Err(Corrupt)` if the record
+/// fails its length check, hash verification, or decoding.
+pub fn decode_record(buf: &[u8]) -> Result, SodError> {
+ if buf.len() < HEADER {
+ return Ok(None);
+ }
+ let len_bytes: [u8; 4] = buf[..4].try_into().unwrap();
+ if len_check(&len_bytes) != buf[4..HEADER] {
+ return Err(SodError::Corrupt(CORRUPT_LEN));
+ }
+ let len = u32::from_le_bytes(len_bytes) as usize;
+ // untrusted arithmetic: guard overflow on 32-bit targets (wasm32).
+ // An overflowing length is as untrustworthy as a failed check — same
+ // classification, so recovery treats both as interior corruption.
+ let total = match len.checked_add(HEADER + 32) {
+ Some(t) => t,
+ None => return Err(SodError::Corrupt(CORRUPT_LEN)),
+ };
+ if buf.len() < total {
+ return Ok(None);
+ }
+ let body = &buf[HEADER..HEADER + len];
+ let stored: [u8; 32] = buf[HEADER + len..total].try_into().unwrap();
+ let computed = blake3::hash(body);
+ if computed.as_bytes() != &stored {
+ return Err(SodError::Corrupt("record hash mismatch"));
+ }
+ let frame: Frame =
+ postcard::from_bytes(body).map_err(|_| SodError::Corrupt("frame decode failed"))?;
+ Ok(Some((frame, FrameHash(stored), total)))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ pub(crate) fn test_frame(origin_byte: u8, seq: u64, prev: FrameHash) -> Frame {
+ Frame {
+ prev_hash: prev,
+ origin: ReplicaId([origin_byte; 16]),
+ seq,
+ event_time: 1_000 + seq,
+ payload: vec![(vec![1, 2, 3], 1), (vec![4, 5], -2)],
+ }
+ }
+
+ #[test]
+ fn frame_roundtrip() {
+ let f = test_frame(7, 1, ZERO_HASH);
+ let mut rec = Vec::new();
+ f.encode_record(&mut rec);
+ let (decoded, hash, consumed) = decode_record(&rec).unwrap().unwrap();
+ assert_eq!(decoded, f);
+ assert_eq!(hash, f.hash());
+ assert_eq!(consumed, rec.len());
+ }
+
+ #[test]
+ fn decode_partial_tail_is_none() {
+ let f = test_frame(7, 1, ZERO_HASH);
+ let mut rec = Vec::new();
+ f.encode_record(&mut rec);
+ for cut in 0..rec.len() {
+ assert_eq!(decode_record(&rec[..cut]).unwrap(), None, "cut={cut}");
+ }
+ }
+
+ #[test]
+ fn decode_flipped_byte_is_corrupt() {
+ // Every byte of the record is tamper-evident: the length prefix
+ // via the length-check, the body and hash via BLAKE3. (A flipped
+ // length byte must NOT read as a clean partial record — that is
+ // how mid-file corruption silently truncated logs.)
+ let f = test_frame(7, 1, ZERO_HASH);
+ let mut rec = Vec::new();
+ f.encode_record(&mut rec);
+ for i in 0..rec.len() {
+ let mut bad = rec.clone();
+ bad[i] ^= 0xff;
+ assert!(
+ matches!(decode_record(&bad), Err(SodError::Corrupt(_))),
+ "flip at {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn hash_chains() {
+ let f1 = test_frame(7, 1, ZERO_HASH);
+ let f2 = test_frame(7, 2, f1.hash());
+ assert_eq!(f2.prev_hash, f1.hash());
+ assert_ne!(f1.hash(), f2.hash());
+ }
+}
diff --git a/sod/src/lib.rs b/sod/src/lib.rs
new file mode 100644
index 0000000..04ae06f
--- /dev/null
+++ b/sod/src/lib.rs
@@ -0,0 +1,88 @@
+//! Sod: symmetric replication for fold apps.
+//!
+//! A **sod** is a replica: a local database that always accepts writes and
+//! converges with its peers by exchanging what the other is missing. There
+//! are no client or server roles on the wire — client↔server is a star of
+//! pairwise symmetric sessions, p2p is any other graph of the same sessions.
+//!
+//! Convergence comes from algebra, not coordination: the write primitive is
+//! a Z-set delta (datum + signed multiplicity), deltas commute, and views
+//! are deterministic functions of the resulting multiset. Replicas that
+//! hold the same set of frames hold the same views.
+//!
+//! Everything platform- or engine-specific enters through a port:
+//!
+//! - [`engine::Engine`] — "the bog machinery" that materializes deltas.
+//! [`engine::MemEngine`] is the always-compiled oracle;
+//! `FoldEngine` (feature `fold-engine`) wraps a fold `Stream`.
+//! - [`store::LogStore`] — the append-only frame log.
+//! [`store::MemLog`] always; [`log_file::FileLog`] on filesystems.
+//! - transports (feature `ws`) drive the sans-io [`sync::Session`].
+//!
+//! Design spec: `docs/superpowers/specs/2026-08-15-sod-design.md`.
+
+pub mod engine;
+#[cfg(feature = "fold-engine")]
+pub mod engine_fold;
+pub mod frame;
+pub mod log_file;
+pub mod replica;
+#[cfg(feature = "fold-engine")]
+pub mod sinks;
+pub mod store;
+pub mod sync;
+pub mod time;
+pub mod transport;
+pub mod vector;
+
+pub use frame::{Frame, FrameHash, ReplicaId, ZERO_HASH, decode_record};
+pub use replica::Replica;
+pub use sync::{Msg, PROTOCOL_VERSION, Session, SyncReport, sync_pair};
+pub use vector::VersionVector;
+
+/// Errors across sod's ports and protocol.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SodError {
+ /// A record or frame failed structural or hash verification.
+ Corrupt(&'static str),
+ /// A frame's seq is not contiguous with the feed we hold.
+ Gap {
+ origin: ReplicaId,
+ have: u64,
+ got: u64,
+ },
+ /// Two distinct frames claimed the same `(origin, seq)` (SOD-2).
+ Equivocation { origin: ReplicaId, seq: u64 },
+ /// The origin's feed was previously poisoned; its frames are refused.
+ Poisoned(ReplicaId),
+ /// Sync handshake refused: protocol or schema version differs (SOD-9).
+ VersionMismatch {
+ ours: (u16, u32),
+ theirs: (u16, u32),
+ },
+ /// An I/O error from a LogStore or transport.
+ Io(String),
+}
+
+impl core::fmt::Display for SodError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ SodError::Corrupt(what) => write!(f, "corrupt record: {what}"),
+ SodError::Gap { origin, have, got } => {
+ write!(f, "gap in feed {origin}: have {have}, got {got}")
+ }
+ SodError::Equivocation { origin, seq } => {
+ write!(f, "equivocation in feed {origin} at seq {seq}")
+ }
+ SodError::Poisoned(origin) => write!(f, "feed {origin} is poisoned"),
+ SodError::VersionMismatch { ours, theirs } => write!(
+ f,
+ "version mismatch: ours protocol {}/schema {}, theirs protocol {}/schema {}",
+ ours.0, ours.1, theirs.0, theirs.1
+ ),
+ SodError::Io(e) => write!(f, "io: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for SodError {}
diff --git a/sod/src/log_file.rs b/sod/src/log_file.rs
new file mode 100644
index 0000000..042675b
--- /dev/null
+++ b/sod/src/log_file.rs
@@ -0,0 +1,124 @@
+//! [`FileLog`]: the filesystem [`LogStore`] with torn-tail recovery.
+//!
+//! One append-only file of records (`len | body | blake3`). On open the
+//! whole file is scanned and verified:
+//!
+//! - A clean partial record at the *end* of the file is a torn tail — the
+//! result of a crash mid-append — and is truncated away (SOD-5).
+//! - A record that fails hash verification *followed by nothing* is treated
+//! the same way (the crash corrupted the tail).
+//! - A bad record followed by further valid data is **corruption**, not a
+//! torn tail: `open` refuses with [`SodError::Corrupt`] rather than
+//! silently dropping interior data.
+//!
+//! Scanned frames are handed to the replica once via
+//! [`LogStore::take_frames`] — the log does not retain a second in-memory
+//! copy. Log compaction and streaming reads are future work recorded in
+//! the spec.
+
+use std::fs::{File, OpenOptions};
+use std::io::Write;
+use std::path::Path;
+
+use crate::frame::decode_record;
+use crate::store::LogStore;
+use crate::{Frame, SodError};
+
+pub struct FileLog {
+ file: File,
+ frames: Vec ,
+}
+
+fn io_err(e: std::io::Error) -> SodError {
+ SodError::Io(e.to_string())
+}
+
+impl FileLog {
+ /// Open (or create) the log at `path`, scanning, verifying, and
+ /// recovering as described in the module docs.
+ pub fn open(path: impl AsRef) -> Result {
+ let path = path.as_ref();
+ let bytes = match std::fs::read(path) {
+ Ok(b) => b,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
+ Err(e) => return Err(io_err(e)),
+ };
+
+ let mut frames = Vec::new();
+ let mut pos = 0usize;
+ let mut valid_end = 0usize;
+ while pos < bytes.len() {
+ match decode_record(&bytes[pos..]) {
+ Ok(Some((frame, _hash, consumed))) => {
+ frames.push(frame);
+ pos += consumed;
+ valid_end = pos;
+ }
+ // Clean partial tail: recoverable iff nothing follows —
+ // and by definition nothing does (it consumed the rest).
+ Ok(None) => break,
+ // The length prefix failed its check: the declared length
+ // is untrustworthy, so the record cannot be delimited and
+ // nothing beyond it can be located. Torn appends produce
+ // short records, not garbled headers — refuse (SOD-5's
+ // recovery must never silently drop interior data).
+ Err(SodError::Corrupt(crate::frame::CORRUPT_LEN)) => {
+ return Err(SodError::Corrupt(
+ "interior log corruption (record length check failed)",
+ ));
+ }
+ Err(_) => {
+ // A complete-but-bad record with a *trusted* length
+ // (its length-check passed). Torn tail only if nothing
+ // lies beyond its declared end; bytes past it mean
+ // interior corruption: refuse.
+ let len =
+ u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()) as usize;
+ let declared_end = pos + 8 + len + 32;
+ if declared_end < bytes.len() {
+ return Err(SodError::Corrupt(
+ "interior log corruption (bad record followed by data)",
+ ));
+ }
+ break;
+ }
+ }
+ }
+
+ if valid_end < bytes.len() {
+ // Torn tail: restore the file to the last valid record boundary.
+ let file = OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(path)
+ .map_err(io_err)?;
+ file.set_len(valid_end as u64).map_err(io_err)?;
+ file.sync_data().map_err(io_err)?;
+ }
+
+ let file = OpenOptions::new()
+ .append(true)
+ .create(true)
+ .open(path)
+ .map_err(io_err)?;
+ Ok(FileLog { file, frames })
+ }
+}
+
+impl LogStore for FileLog {
+ fn append(&mut self, frame: &Frame) -> Result<(), SodError> {
+ let mut rec = Vec::new();
+ frame.encode_record(&mut rec);
+ self.file.write_all(&rec).map_err(io_err)?;
+ Ok(())
+ }
+
+ fn sync(&mut self) -> Result<(), SodError> {
+ self.file.sync_data().map_err(io_err)
+ }
+
+ fn take_frames(&mut self) -> Vec {
+ std::mem::take(&mut self.frames)
+ }
+}
diff --git a/sod/src/replica.rs b/sod/src/replica.rs
new file mode 100644
index 0000000..23097fc
--- /dev/null
+++ b/sod/src/replica.rs
@@ -0,0 +1,384 @@
+//! [`Replica`]: the write path, crash recovery, and feed bookkeeping.
+//!
+//! A replica owns a [`LogStore`] (the truth, SOD-1) and an [`Engine`] (a
+//! rebuildable cache). Local commits and remote ingests both follow the
+//! log-first discipline: append (+ sync per policy), then apply — with the
+//! engine's own applied-cursor closing the crash window in either direction
+//! (log ahead of engine: replay at open; engine ahead of log: the apply
+//! guard skips re-application).
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use crate::engine::Engine;
+use crate::frame::{Frame, FrameHash, ReplicaId, ZERO_HASH};
+use crate::store::LogStore;
+use crate::vector::VersionVector;
+use crate::SodError;
+
+#[derive(Default)]
+struct Feed {
+ frames: Vec , // frames[seq-1]
+ hashes: Vec, // hashes[seq-1]
+}
+
+impl Feed {
+ fn head_hash(&self) -> FrameHash {
+ self.hashes.last().copied().unwrap_or(ZERO_HASH)
+ }
+}
+
+/// A sod: one replica of the shared multiset.
+pub struct Replica {
+ id: ReplicaId,
+ log: L,
+ engine: E,
+ feeds: BTreeMap,
+ vector: VersionVector,
+ engine_cursor: VersionVector,
+ poisoned: BTreeSet,
+ watermark: u64,
+}
+
+impl Replica {
+ /// Rebuild feeds, vector, and watermark from the log, verifying every
+ /// chain, then replay into the engine each frame beyond its applied
+ /// cursor (SOD-1, SOD-5).
+ pub fn open(id: ReplicaId, mut log: L, mut engine: E) -> Result {
+ let mut feeds: BTreeMap = BTreeMap::new();
+ let mut vector = VersionVector::new();
+ let engine_cursor = engine.applied();
+ let mut watermark = 0u64;
+
+ for frame in log.take_frames() {
+ let feed = feeds.entry(frame.origin).or_default();
+ let have = feed.frames.len() as u64;
+ if frame.seq != have + 1 {
+ return Err(SodError::Corrupt("log feed not contiguous"));
+ }
+ if frame.prev_hash != feed.head_hash() {
+ return Err(SodError::Corrupt("log feed chain broken"));
+ }
+ watermark = watermark.max(frame.event_time);
+ if frame.seq > engine_cursor.get(&frame.origin) {
+ engine.apply(&frame, watermark)?;
+ } else {
+ engine.seed_watermark(watermark);
+ }
+ feed.hashes.push(frame.hash());
+ vector.advance(frame.origin, frame.seq);
+ feed.frames.push(frame);
+ }
+
+ let engine_cursor = engine.applied();
+ Ok(Replica {
+ id,
+ log,
+ engine,
+ feeds,
+ vector,
+ engine_cursor,
+ poisoned: BTreeSet::new(),
+ watermark,
+ })
+ }
+
+ /// Commit a local write: chain a frame onto our own feed, append it to
+ /// the log, sync, and apply. `event_time` is stamped by the caller —
+ /// sod never reads a clock (SOD-7). Returns the new frame's hash.
+ pub fn commit(
+ &mut self,
+ payload: Vec<(Vec, i64)>,
+ event_time: u64,
+ ) -> Result {
+ let head = self.feeds.get(&self.id).map(Feed::head_hash).unwrap_or(ZERO_HASH);
+ let frame = Frame {
+ prev_hash: head,
+ origin: self.id,
+ seq: self.vector.get(&self.id) + 1,
+ event_time,
+ payload,
+ };
+ // A frame the engine can never apply must not reach the log — it
+ // would fail replay on every subsequent open (SOD-1/SOD-5).
+ self.engine.validate(&frame)?;
+ self.log.append(&frame)?;
+ self.log.sync()?;
+ let hash = frame.hash();
+ self.accept(frame, hash)?;
+ Ok(hash)
+ }
+
+ /// Ingest a frame received from a peer.
+ ///
+ /// - Already held with the same hash → `Ok(false)` (dedup, SOD-6).
+ /// - Already held with a different hash, or a broken chain link →
+ /// the origin's feed is poisoned and further frames refused (SOD-2).
+ /// - Seq beyond our head + 1 → [`SodError::Gap`] (peers must stream
+ /// per-origin contiguous suffixes).
+ /// - Otherwise: append to the log and apply → `Ok(true)`. Durability
+ /// batching is the caller's business: call
+ /// [`sync_log`](Replica::sync_log) at session boundaries.
+ pub fn ingest(&mut self, frame: Frame) -> Result {
+ let origin = frame.origin;
+ if frame.seq == 0 {
+ // seqs are 1-based; nothing on the wire guarantees that
+ return Err(SodError::Corrupt("frame seq must be >= 1"));
+ }
+ if self.poisoned.contains(&origin) {
+ return Err(SodError::Poisoned(origin));
+ }
+ let have = self.vector.get(&origin);
+ let hash = frame.hash();
+ if frame.seq <= have {
+ let known = self.feeds[&origin].hashes[frame.seq as usize - 1];
+ if known == hash {
+ return Ok(false);
+ }
+ return Err(self.fork_detected(origin, frame.seq));
+ }
+ if frame.seq > have + 1 {
+ return Err(SodError::Gap { origin, have, got: frame.seq });
+ }
+ let expected_prev = self
+ .feeds
+ .get(&origin)
+ .map(Feed::head_hash)
+ .unwrap_or(ZERO_HASH);
+ if frame.prev_hash != expected_prev {
+ // same (origin, seq) position as a chain we don't hold: a fork
+ return Err(self.fork_detected(origin, frame.seq));
+ }
+ // A frame the engine can never apply must not reach the log — it
+ // would fail replay on every subsequent open (SOD-1/SOD-5).
+ self.engine.validate(&frame)?;
+ self.log.append(&frame)?;
+ self.accept(frame, hash)?;
+ Ok(true)
+ }
+
+ /// A frame conflicting with our copy of `origin`'s feed. Foreign feeds
+ /// are poisoned (SOD-2); our **own** feed never is — we are its
+ /// authority, so a conflicting claim about us is the *peer's* forgery
+ /// (or a reused replica id), and self-poisoning would let one hostile
+ /// message halt local commits.
+ fn fork_detected(&mut self, origin: ReplicaId, seq: u64) -> SodError {
+ if origin != self.id {
+ self.poisoned.insert(origin);
+ }
+ SodError::Equivocation { origin, seq }
+ }
+
+ /// Log-accepted frame: update feeds/vector/watermark and guard-apply.
+ fn accept(&mut self, frame: Frame, hash: FrameHash) -> Result<(), SodError> {
+ self.watermark = self.watermark.max(frame.event_time);
+ if frame.seq > self.engine_cursor.get(&frame.origin) {
+ self.engine.apply(&frame, self.watermark)?;
+ self.engine_cursor.set(frame.origin, frame.seq);
+ }
+ let feed = self.feeds.entry(frame.origin).or_default();
+ feed.hashes.push(hash);
+ self.vector.advance(frame.origin, frame.seq);
+ feed.frames.push(frame);
+ Ok(())
+ }
+
+ /// Harden received frames (fsync); called at sync-session boundaries.
+ pub fn sync_log(&mut self) -> Result<(), SodError> {
+ self.log.sync()
+ }
+
+ pub fn id(&self) -> ReplicaId {
+ self.id
+ }
+
+ pub fn vector(&self) -> &VersionVector {
+ &self.vector
+ }
+
+ /// Max event-time across every frame held (SOD-7's watermark).
+ pub fn watermark(&self) -> u64 {
+ self.watermark
+ }
+
+ /// The frames of `origin`'s feed with `seq > after`, in seq order.
+ pub fn frames_after(&self, origin: &ReplicaId, after: u64) -> &[Frame] {
+ self.feeds
+ .get(origin)
+ .map(|f| &f.frames[after as usize..])
+ .unwrap_or(&[])
+ }
+
+ /// Origins whose feeds this replica has poisoned (SOD-2).
+ pub fn poisoned(&self) -> impl Iterator- {
+ self.poisoned.iter()
+ }
+
+ pub fn engine(&self) -> &E {
+ &self.engine
+ }
+
+ pub fn engine_mut(&mut self) -> &mut E {
+ &mut self.engine
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::engine::MemEngine;
+ use crate::store::MemLog;
+
+ fn id(b: u8) -> ReplicaId {
+ ReplicaId([b; 16])
+ }
+
+ fn replica(b: u8) -> Replica
{
+ Replica::open(id(b), MemLog::new(), MemEngine::new()).unwrap()
+ }
+
+ fn datum(s: &str) -> Vec {
+ s.as_bytes().to_vec()
+ }
+
+ #[test]
+ fn commit_chains_and_applies() {
+ let mut r = replica(1);
+ let h1 = r.commit(vec![(datum("a"), 1)], 100).unwrap();
+ let _h2 = r.commit(vec![(datum("a"), 2), (datum("b"), -1)], 200).unwrap();
+ assert_eq!(r.vector().get(&id(1)), 2);
+ assert_eq!(r.engine().count(b"a"), 3);
+ assert_eq!(r.engine().count(b"b"), -1);
+ assert_eq!(r.watermark(), 200);
+ let frames = r.frames_after(&id(1), 0);
+ assert_eq!(frames[0].prev_hash, ZERO_HASH);
+ assert_eq!(frames[1].prev_hash, h1);
+ }
+
+ #[test]
+ fn reopen_replays_only_unapplied() {
+ // Build a log of three frames; give the reopening engine a cursor
+ // that has already applied the first — only the suffix replays.
+ let mut r = replica(1);
+ r.commit(vec![(datum("a"), 1)], 10).unwrap();
+ r.commit(vec![(datum("b"), 1)], 20).unwrap();
+ r.commit(vec![(datum("c"), 1)], 30).unwrap();
+ let frames: Vec<_> = r.frames_after(&id(1), 0).to_vec();
+ let mut log = MemLog::new();
+ for f in &frames {
+ log.append(f).unwrap();
+ }
+
+ let mut pre = MemEngine::new();
+ pre.apply(&frames[0], 10).unwrap();
+ let r2 = Replica::open(id(1), log, pre).unwrap();
+ assert_eq!(r2.engine().count(b"a"), 1, "not double-applied");
+ assert_eq!(r2.engine().count(b"b"), 1);
+ assert_eq!(r2.engine().count(b"c"), 1);
+ assert_eq!(r2.watermark(), 30);
+ assert_eq!(r2.vector().get(&id(1)), 3);
+ }
+
+ #[test]
+ fn ingest_dedups() {
+ let mut a = replica(1);
+ a.commit(vec![(datum("a"), 1)], 10).unwrap();
+ let frame = a.frames_after(&id(1), 0)[0].clone();
+
+ let mut b = replica(2);
+ assert!(b.ingest(frame.clone()).unwrap());
+ assert!(!b.ingest(frame).unwrap(), "duplicate is a no-op");
+ assert_eq!(b.engine().count(b"a"), 1);
+ }
+
+ #[test]
+ fn ingest_gap_rejected() {
+ let mut a = replica(1);
+ a.commit(vec![(datum("a"), 1)], 10).unwrap();
+ a.commit(vec![(datum("b"), 1)], 20).unwrap();
+ let second = a.frames_after(&id(1), 1)[0].clone();
+
+ let mut b = replica(2);
+ match b.ingest(second) {
+ Err(SodError::Gap { have: 0, got: 2, .. }) => {}
+ other => panic!("expected gap, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn equivocation_poisons() {
+ let mut a = replica(1);
+ a.commit(vec![(datum("a"), 1)], 10).unwrap();
+ let honest = a.frames_after(&id(1), 0)[0].clone();
+
+ // a forged alternative frame 1 from the same origin
+ let mut forged = honest.clone();
+ forged.payload = vec![(datum("evil"), 1)];
+
+ let mut b = replica(2);
+ b.ingest(honest.clone()).unwrap();
+ match b.ingest(forged) {
+ Err(SodError::Equivocation { seq: 1, .. }) => {}
+ other => panic!("expected equivocation, got {other:?}"),
+ }
+ // origin is now poisoned, even for the honest frame
+ match b.ingest(honest) {
+ Err(SodError::Poisoned(o)) if o == id(1) => {}
+ other => panic!("expected poisoned, got {other:?}"),
+ }
+ // other origins unaffected
+ let mut c = replica(3);
+ c.commit(vec![(datum("fine"), 1)], 5).unwrap();
+ let fine = c.frames_after(&id(3), 0)[0].clone();
+ assert!(b.ingest(fine).unwrap());
+ }
+
+ #[test]
+ fn ingest_seq_zero_is_corrupt_not_panic() {
+ let mut b = replica(2);
+ let bad = Frame {
+ prev_hash: ZERO_HASH,
+ origin: id(1), // unknown origin — the old code indexed feeds and panicked
+ seq: 0,
+ event_time: 1,
+ payload: vec![(datum("x"), 1)],
+ };
+ match b.ingest(bad) {
+ Err(SodError::Corrupt(_)) => {}
+ other => panic!("expected corrupt, got {other:?}"),
+ }
+ // known origin, seq 0: same refusal (old code underflowed seq - 1)
+ let mut a = replica(1);
+ a.commit(vec![(datum("a"), 1)], 1).unwrap();
+ b.ingest(a.frames_after(&id(1), 0)[0].clone()).unwrap();
+ let mut bad = a.frames_after(&id(1), 0)[0].clone();
+ bad.seq = 0;
+ assert!(matches!(b.ingest(bad), Err(SodError::Corrupt(_))));
+ }
+
+ #[test]
+ fn forged_own_feed_frame_does_not_self_poison() {
+ let mut a = replica(1);
+ a.commit(vec![(datum("mine"), 1)], 10).unwrap();
+
+ // a hostile peer fabricates a conflicting frame claiming a's origin
+ let mut forged = a.frames_after(&id(1), 0)[0].clone();
+ forged.payload = vec![(datum("forged"), 1)];
+ match a.ingest(forged) {
+ Err(SodError::Equivocation { seq: 1, .. }) => {}
+ other => panic!("expected equivocation, got {other:?}"),
+ }
+ // our own feed is never poisoned: local commits keep working
+ assert_eq!(a.poisoned().count(), 0);
+ a.commit(vec![(datum("still fine"), 1)], 20).unwrap();
+ assert_eq!(a.vector().get(&id(1)), 2);
+ }
+
+ #[test]
+ fn watermark_is_max_event_time() {
+ let mut a = replica(1);
+ a.commit(vec![(datum("late"), 1)], 500).unwrap();
+ a.commit(vec![(datum("early"), 1)], 100).unwrap();
+ assert_eq!(a.watermark(), 500);
+ assert_eq!(a.engine().watermark(), 500);
+ }
+}
diff --git a/sod/src/sinks.rs b/sod/src/sinks.rs
new file mode 100644
index 0000000..66c2b70
--- /dev/null
+++ b/sod/src/sinks.rs
@@ -0,0 +1,132 @@
+//! Replication-safe fold sinks (feature `fold-engine`).
+//!
+//! A sink in a sod pipeline must be a **pure function of the net multiset**:
+//! under replication, a retraction can arrive before its matching insert,
+//! and any sink that clamps or drops on that transient makes its state
+//! depend on delta arrival order — replicas diverge (SOD-4).
+//!
+//! Fold's stock `Bag` clamps (a negative running sum is stored as absent,
+//! so `-1` then `+2` converges differently than `+2` then `-1`). Rather
+//! than patch fold — sod deliberately consumes fold's public API without
+//! modifying it — this module provides sinks with order-independent
+//! semantics. Fold sinks that are already pure functions of the multiset
+//! (e.g. `Count`, a plain signed sum) can be used as-is.
+
+use std::collections::BTreeMap;
+use std::marker::PhantomData;
+
+use fold::pipeline::Push;
+use fold::stream::{PipelineInitCtx, Readable, WriteTx};
+use serde::{Serialize, de::DeserializeOwned};
+
+/// Persistent counted multiset: each distinct element maps to its net
+/// multiplicity.
+///
+/// Elements are stored by their `postcard` encoding. Any **nonzero**
+/// running sum is persisted — including negative ones — so the stored
+/// state is a pure function of the net multiset regardless of delta
+/// arrival order. Readers surface only positive multiplicities.
+pub struct Bag {
+ name: String,
+ ks: Option,
+ // key-encoded data -> accumulated delta this tx (BTreeMap: deterministic
+ // flush order, per the no-hash-iteration rule at output boundaries)
+ pending: BTreeMap, i64>,
+ _p: PhantomData,
+}
+
+impl Bag {
+ /// `name` identifies this sink's keyspace and must be unique among all
+ /// named nodes in the pipeline.
+ pub fn new(name: impl Into) -> Self {
+ Bag {
+ name: name.into(),
+ ks: None,
+ pending: BTreeMap::new(),
+ _p: PhantomData,
+ }
+ }
+}
+
+/// Read handle for [`Bag`], pinned to one snapshot.
+pub struct BagReader<'tx, R: Readable, D> {
+ tx: &'tx R,
+ ks: fjall::SingleWriterTxKeyspace,
+ _p: PhantomData,
+}
+
+impl<'tx, R: Readable, D: DeserializeOwned> BagReader<'tx, R, D> {
+ /// Iterate all `(element, multiplicity)` pairs with multiplicity > 0,
+ /// ordered by the element's `postcard` encoding. Elements currently at
+ /// a negative running sum (retraction seen before its insert) are
+ /// skipped.
+ pub fn iter(&self) -> impl Iterator- + '_ {
+ self.tx.iter(&self.ks).filter_map(|kv| {
+ let (key, val) = kv.into_inner().unwrap();
+ let n = i64::from_be_bytes(val.as_ref().try_into().unwrap());
+ if n <= 0 {
+ return None;
+ }
+ let d: D = postcard::from_bytes(&key).unwrap();
+ Some((d, n))
+ })
+ }
+
+ /// Whether `d` has multiplicity > 0.
+ pub fn contains(&self, d: &D) -> bool
+ where
+ D: Serialize,
+ {
+ let key = postcard::to_stdvec(d).unwrap();
+ self.tx
+ .get(&self.ks, &key)
+ .unwrap()
+ .map(|v| i64::from_be_bytes(v.as_ref().try_into().unwrap()) > 0)
+ .unwrap_or(false)
+ }
+}
+
+impl
Push for Bag {
+ type Reader<'tx, R: Readable + 'tx> = BagReader<'tx, R, D>;
+
+ fn init(&mut self, init: &mut PipelineInitCtx<'_>) {
+ self.ks = Some(init.keyspace(&self.name));
+ }
+
+ fn push(&mut self, tx: &mut WriteTx<'_>, data: &D, delta: isize) {
+ tx.buf.clear();
+ postcard::to_io(data, &mut tx.buf).unwrap();
+ *self.pending.entry(tx.buf.clone()).or_insert(0) += delta as i64;
+ }
+
+ fn commit(&mut self, tx: &mut WriteTx<'_>) {
+ let ks = self.ks.clone().unwrap();
+ while let Some((key, delta)) = self.pending.pop_first() {
+ if delta == 0 {
+ continue;
+ }
+ let cur = tx
+ .get(&ks, &key)
+ .map(|v| i64::from_be_bytes(v.as_ref().try_into().unwrap()))
+ .unwrap_or(0);
+ let new = cur + delta;
+ if new != 0 {
+ tx.insert(&ks, &key, new.to_be_bytes());
+ } else {
+ tx.remove(&ks, &key);
+ }
+ }
+ }
+
+ fn abort(&mut self) {
+ self.pending.clear();
+ }
+
+ fn reader<'tx, R: Readable>(&self, tx: &'tx R) -> Self::Reader<'tx, R> {
+ BagReader {
+ tx,
+ ks: self.ks.clone().unwrap(),
+ _p: PhantomData,
+ }
+ }
+}
diff --git a/sod/src/store.rs b/sod/src/store.rs
new file mode 100644
index 0000000..1cd41c7
--- /dev/null
+++ b/sod/src/store.rs
@@ -0,0 +1,58 @@
+//! The [`LogStore`] port — where a replica's frames persist — and
+//! [`MemLog`], the trivial in-memory implementation.
+//!
+//! Sod's only durability requirement is an append-only sequence of verified
+//! frame records (SOD-1); everything else about a platform's storage is the
+//! implementation's business. Filesystems get
+//! [`FileLog`](crate::log_file::FileLog); browsers get an OPFS/IndexedDB
+//! implementation as a follow-on; tests get [`MemLog`].
+
+use crate::{Frame, SodError};
+
+/// Append-only frame storage.
+///
+/// Implementations perform torn-write recovery *at open* — by the time a
+/// `LogStore` value exists, every frame it reports has already been
+/// hash-verified, and a torn tail (if the platform can produce one) has
+/// been discarded.
+pub trait LogStore {
+ /// Append one frame. Durability is governed by [`sync`](LogStore::sync).
+ fn append(&mut self, frame: &Frame) -> Result<(), SodError>;
+
+ /// Harden all appended frames against crashes (fsync or equivalent).
+ fn sync(&mut self) -> Result<(), SodError>;
+
+ /// Hand over every stored frame, in append order. Called exactly once,
+ /// at [`Replica::open`](crate::Replica::open) — the replica owns the
+ /// in-memory copy from then on, so implementations must not retain
+ /// frames after this (that would hold every frame in memory twice).
+ fn take_frames(&mut self) -> Vec ;
+}
+
+/// In-memory log: for tests, oracles, and replicas whose durability is
+/// delegated elsewhere.
+#[derive(Default)]
+pub struct MemLog {
+ frames: Vec ,
+}
+
+impl MemLog {
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+impl LogStore for MemLog {
+ fn append(&mut self, frame: &Frame) -> Result<(), SodError> {
+ self.frames.push(frame.clone());
+ Ok(())
+ }
+
+ fn sync(&mut self) -> Result<(), SodError> {
+ Ok(())
+ }
+
+ fn take_frames(&mut self) -> Vec {
+ std::mem::take(&mut self.frames)
+ }
+}
diff --git a/sod/src/sync.rs b/sod/src/sync.rs
new file mode 100644
index 0000000..a0dd80e
--- /dev/null
+++ b/sod/src/sync.rs
@@ -0,0 +1,322 @@
+//! The sans-io sync session: anti-entropy between two replicas.
+//!
+//! A [`Session`] owns no sockets — it consumes and produces [`Msg`] values
+//! and mutates the local [`Replica`]; transports (`transport::ws`, tests,
+//! future browser transports) decide how bytes move and in what order.
+//!
+//! Protocol: each side sends [`Msg::Hello`] (versions + version vector,
+//! SOD-9); on receiving the peer's Hello, a side streams every frame the
+//! peer lacks — **all origins it holds**, not just its own feed, which is
+//! what makes relay and mesh topologies work — as batched [`Msg::Frames`],
+//! per-origin contiguous, followed by [`Msg::Done`]. A session is finished
+//! when both sides have sent and received `Done`.
+//!
+//! There is no session state to persist: the version vector *is* the
+//! resume point, so a session killed at any byte is simply re-run (SOD-6).
+
+use serde::{Deserialize, Serialize};
+
+use crate::engine::Engine;
+use crate::frame::Frame;
+use crate::replica::Replica;
+use crate::store::LogStore;
+use crate::vector::VersionVector;
+use crate::SodError;
+
+/// Bumped on any wire-format or protocol change (SOD-9).
+pub const PROTOCOL_VERSION: u16 = 1;
+
+/// Frames per [`Msg::Frames`] batch.
+const BATCH: usize = 256;
+
+/// One sync-protocol message; postcard-encoded by transports.
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub enum Msg {
+ Hello {
+ protocol: u16,
+ schema: u32,
+ /// The sender's replica id — peers are identifiable, which is
+ /// what lets applications show "N bogs connected".
+ id: crate::ReplicaId,
+ vector: VersionVector,
+ },
+ Frames(Vec ),
+ Done,
+}
+
+/// The outcome of one completed sync session.
+#[derive(Debug, Clone)]
+pub struct SyncReport {
+ /// Who we synced with.
+ pub peer: crate::ReplicaId,
+ /// The peer's version vector as of its `Hello`.
+ pub peer_vector: VersionVector,
+ /// Per-origin refusals recorded while the session continued (SOD-2);
+ /// at most one per origin. Surface these — a silently-refused feed is
+ /// a feed that silently stopped replicating.
+ pub skipped: Vec,
+}
+
+/// One replica's half of one sync session.
+pub struct Session {
+ schema: u32,
+ sent_done: bool,
+ peer_done: bool,
+ peer: Option<(crate::ReplicaId, VersionVector)>,
+ skipped: Vec,
+ // origins already recorded in `skipped` — one refusal per origin per
+ // session, so a hostile peer flooding forged frames cannot grow
+ // `skipped` without bound
+ skipped_origins: std::collections::BTreeSet,
+}
+
+impl Session {
+ /// `schema` is the application's schema version: replicas whose apps
+ /// disagree on it refuse to sync rather than corrupt (SOD-9).
+ pub fn new(schema: u32) -> Self {
+ Session {
+ schema,
+ sent_done: false,
+ peer_done: false,
+ peer: None,
+ skipped: Vec::new(),
+ skipped_origins: Default::default(),
+ }
+ }
+
+ /// Our opening message.
+ pub fn hello(&self, r: &Replica) -> Msg {
+ Msg::Hello {
+ protocol: PROTOCOL_VERSION,
+ schema: self.schema,
+ id: r.id(),
+ vector: r.vector().clone(),
+ }
+ }
+
+ /// Feed one inbound message; returns outbound messages (possibly none).
+ ///
+ /// Frames from an origin that equivocates (or is already poisoned) are
+ /// skipped and recorded in [`skipped`](Session::skipped) — the session
+ /// continues for other origins (SOD-2). A [`SodError::Gap`] or version
+ /// mismatch is a hard protocol error.
+ pub fn on_msg(
+ &mut self,
+ r: &mut Replica,
+ msg: Msg,
+ ) -> Result, SodError> {
+ match msg {
+ Msg::Hello {
+ protocol,
+ schema,
+ id,
+ vector,
+ } => {
+ if protocol != PROTOCOL_VERSION || schema != self.schema {
+ return Err(SodError::VersionMismatch {
+ ours: (PROTOCOL_VERSION, self.schema),
+ theirs: (protocol, schema),
+ });
+ }
+ self.peer = Some((id, vector.clone()));
+ let poisoned: Vec<_> = r.poisoned().copied().collect();
+ let mut out: Vec = Vec::new();
+ for (origin, theirs, _have) in r.vector().ahead_of(&vector) {
+ if poisoned.contains(&origin) {
+ continue;
+ }
+ // chunk each origin's suffix directly: one clone per
+ // frame, and every batch stays per-origin contiguous
+ for chunk in r.frames_after(&origin, theirs).chunks(BATCH) {
+ out.push(Msg::Frames(chunk.to_vec()));
+ }
+ }
+ out.push(Msg::Done);
+ self.sent_done = true;
+ Ok(out)
+ }
+ Msg::Frames(frames) => {
+ for frame in frames {
+ let origin = frame.origin;
+ match r.ingest(frame) {
+ Ok(_) => {}
+ Err(e @ (SodError::Equivocation { .. } | SodError::Poisoned(_))) => {
+ if self.skipped_origins.insert(origin) {
+ self.skipped.push(e);
+ }
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Ok(Vec::new())
+ }
+ Msg::Done => {
+ self.peer_done = true;
+ r.sync_log()?;
+ Ok(Vec::new())
+ }
+ }
+ }
+
+ /// True once we have both sent and received `Done`.
+ pub fn finished(&self) -> bool {
+ self.sent_done && self.peer_done
+ }
+
+ /// Per-origin refusals recorded while the session continued (SOD-2).
+ /// At most one entry per origin per session — bounded regardless of
+ /// how many refused frames a peer sends.
+ pub fn skipped(&self) -> &[SodError] {
+ &self.skipped
+ }
+
+ /// Consume the session, yielding its report — `None` if the session
+ /// never saw the peer's `Hello` (e.g. it ended on a version-mismatch
+ /// or transport error before the handshake completed). Refusals
+ /// recorded before an error are still readable via
+ /// [`skipped`](Session::skipped) before consuming.
+ pub fn report(self) -> Option {
+ let (peer, peer_vector) = self.peer?;
+ Some(SyncReport {
+ peer,
+ peer_vector,
+ skipped: self.skipped,
+ })
+ }
+}
+
+/// Drive a complete session between two in-process replicas. Returns
+/// `(a's report, b's report)`.
+pub fn sync_pair(
+ a: &mut Replica,
+ b: &mut Replica,
+ schema: u32,
+) -> Result<(SyncReport, SyncReport), SodError> {
+ use std::collections::VecDeque;
+ let mut sa = Session::new(schema);
+ let mut sb = Session::new(schema);
+ // FIFO delivery: batches beyond the first must arrive in send order,
+ // or contiguous-suffix ingestion fails with a gap
+ let mut to_b: VecDeque = VecDeque::from([sa.hello(a)]);
+ let mut to_a: VecDeque = VecDeque::from([sb.hello(b)]);
+ while !(sa.finished() && sb.finished() && to_a.is_empty() && to_b.is_empty()) {
+ if let Some(m) = to_b.pop_front() {
+ to_a.extend(sb.on_msg(b, m)?);
+ }
+ if let Some(m) = to_a.pop_front() {
+ to_b.extend(sa.on_msg(a, m)?);
+ }
+ }
+ let (ra, rb) = (sa.report(), sb.report());
+ match (ra, rb) {
+ (Some(ra), Some(rb)) => Ok((ra, rb)),
+ // both sessions completed (loop above finished), so this is
+ // unreachable in practice; classify defensively rather than panic
+ _ => Err(SodError::Io("session ended before Hello".into())),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::engine::MemEngine;
+ use crate::frame::ReplicaId;
+ use crate::store::MemLog;
+
+ fn replica(b: u8) -> Replica {
+ Replica::open(ReplicaId([b; 16]), MemLog::new(), MemEngine::new()).unwrap()
+ }
+
+ #[test]
+ fn two_replica_session_converges() {
+ let mut a = replica(1);
+ let mut b = replica(2);
+ a.commit(vec![(b"a1".to_vec(), 1)], 10).unwrap();
+ a.commit(vec![(b"a2".to_vec(), 2)], 20).unwrap();
+ b.commit(vec![(b"b1".to_vec(), -1)], 30).unwrap();
+
+ sync_pair(&mut a, &mut b, 1).unwrap();
+
+ assert_eq!(a.vector(), b.vector());
+ assert_eq!(a.engine().view_bytes(), b.engine().view_bytes());
+ assert_eq!(a.watermark(), b.watermark());
+ }
+
+ #[test]
+ fn hello_carries_id() {
+ let a = replica(1);
+ let s = Session::new(1);
+ match s.hello(&a) {
+ Msg::Hello { id, .. } => assert_eq!(id, ReplicaId([1; 16])),
+ other => panic!("expected hello, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn sync_pair_reports_peers() {
+ let mut a = replica(1);
+ let mut b = replica(2);
+ a.commit(vec![(b"x".to_vec(), 1)], 10).unwrap();
+ let b_vector_before = b.vector().clone();
+
+ let (ra, rb) = sync_pair(&mut a, &mut b, 1).unwrap();
+ assert_eq!(ra.peer, ReplicaId([2; 16]));
+ assert_eq!(rb.peer, ReplicaId([1; 16]));
+ assert_eq!(ra.peer_vector, b_vector_before, "vector snapshot from Hello");
+ assert!(ra.skipped.is_empty() && rb.skipped.is_empty());
+ }
+
+ #[test]
+ fn large_diff_crosses_batch_boundary() {
+ // >BATCH frames force multiple Frames messages; delivery must be
+ // FIFO or the second batch arrives before the first and gaps out.
+ let mut a = replica(1);
+ let mut b = replica(2);
+ for i in 0..300u64 {
+ a.commit(vec![(i.to_be_bytes().to_vec(), 1)], i).unwrap();
+ }
+ b.commit(vec![(b"from b".to_vec(), 1)], 7).unwrap();
+
+ sync_pair(&mut a, &mut b, 1).unwrap();
+
+ assert_eq!(a.vector(), b.vector());
+ assert_eq!(a.engine().view_bytes(), b.engine().view_bytes());
+ assert_eq!(b.vector().get(&ReplicaId([1; 16])), 300);
+ }
+
+ #[test]
+ fn version_mismatch_refuses() {
+ let mut a = replica(1);
+ let mut b = replica(2);
+ a.commit(vec![(b"x".to_vec(), 1)], 10).unwrap();
+
+ let sa = Session::new(1);
+ let mut sb = Session::new(2);
+ let hello_a = sa.hello(&a);
+ match sb.on_msg(&mut b, hello_a) {
+ Err(SodError::VersionMismatch { ours, theirs }) => {
+ assert_eq!(ours, (PROTOCOL_VERSION, 2));
+ assert_eq!(theirs, (PROTOCOL_VERSION, 1));
+ }
+ other => panic!("expected mismatch, got {other:?}"),
+ }
+ // nothing was exchanged
+ assert_eq!(b.vector().get(&ReplicaId([1; 16])), 0);
+ }
+
+ #[test]
+ fn relay_carries_third_party_frames() {
+ let mut a = replica(1);
+ let mut b = replica(2);
+ let mut c = replica(3);
+ a.commit(vec![(b"from-a".to_vec(), 1)], 10).unwrap();
+
+ sync_pair(&mut a, &mut b, 1).unwrap();
+ // c never talks to a — only to b, which relays a's feed
+ sync_pair(&mut b, &mut c, 1).unwrap();
+
+ assert_eq!(c.vector().get(&ReplicaId([1; 16])), 1);
+ assert_eq!(c.engine().count(b"from-a"), 1);
+ assert_eq!(c.engine().view_bytes(), a.engine().view_bytes());
+ }
+}
diff --git a/sod/src/time.rs b/sod/src/time.rs
new file mode 100644
index 0000000..b17c22a
--- /dev/null
+++ b/sod/src/time.rs
@@ -0,0 +1,60 @@
+//! The watermark: sod's only notion of "now" (SOD-7).
+//!
+//! The watermark is the maximum event-time across every frame applied so
+//! far. Max is commutative and associative over the replicated frame set,
+//! so the watermark converges exactly as the data does — it is time *as
+//! data*, never a wall clock.
+//!
+//! Note the deliberate limitation recorded in the spec: fold's `Retain`
+//! (a processing-time window) stamps records at arrival, and arrival order
+//! differs per replica, so no injected clock — this one included — makes
+//! processing-time windows convergent. Time-windowed operators need an
+//! event-time retain in fold before they are sod-compatible; until then
+//! the watermark serves application reads.
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+/// A shared, monotonically-advancing watermark handle.
+#[derive(Clone, Default)]
+pub struct Watermark(Arc);
+
+impl Watermark {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Max event-time applied so far.
+ pub fn get(&self) -> u64 {
+ self.0.load(Ordering::Acquire)
+ }
+
+ /// Advance to at least `wm` (monotonic max).
+ pub fn advance(&self, wm: u64) {
+ self.0.fetch_max(wm, Ordering::AcqRel);
+ }
+
+ /// A `Fn() -> u64` closure over this watermark, in the shape fold's
+ /// clock-taking operators accept.
+ pub fn clock(&self) -> impl Fn() -> u64 + Clone + 'static {
+ let inner = self.0.clone();
+ move || inner.load(Ordering::Acquire)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn watermark_is_monotonic_max() {
+ let w = Watermark::new();
+ let c = w.clock();
+ w.advance(10);
+ w.advance(5);
+ assert_eq!(w.get(), 10);
+ assert_eq!(c(), 10);
+ w.advance(20);
+ assert_eq!(c(), 20);
+ }
+}
diff --git a/sod/src/transport/mod.rs b/sod/src/transport/mod.rs
new file mode 100644
index 0000000..6201afa
--- /dev/null
+++ b/sod/src/transport/mod.rs
@@ -0,0 +1,5 @@
+//! Transports drive the sans-io [`Session`](crate::sync::Session) over real
+//! connections. First implementation: blocking websockets ([`ws`]).
+
+#[cfg(feature = "ws")]
+pub mod ws;
diff --git a/sod/src/transport/ws.rs b/sod/src/transport/ws.rs
new file mode 100644
index 0000000..c544767
--- /dev/null
+++ b/sod/src/transport/ws.rs
@@ -0,0 +1,286 @@
+//! Blocking websocket transport (feature `ws`).
+//!
+//! Wire: each protocol [`Msg`] is one binary websocket message, postcard-
+//! encoded. The exchange is strictly half-duplex to be deadlock-free over
+//! blocking sockets:
+//!
+//! - initiator: send Hello → recv Hello → send our Frames…Done → recv theirs
+//! - responder: recv Hello → send Hello → recv their Frames…Done → send ours
+//!
+//! Both sides end up with the union either way — symmetry is a property of
+//! the protocol, not of who dialed. There is nothing to resume: an
+//! interrupted session leaves both replicas correct (SOD-6) and the next
+//! session picks up from the version vectors.
+
+use std::net::{TcpListener, TcpStream, ToSocketAddrs};
+use std::time::Duration;
+
+use tungstenite::{Message, WebSocket};
+
+/// Bound on TCP connect to a peer (an unroutable host must fail fast —
+/// callers may be holding user-visible state).
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
+/// Bound on any single socket read/write during a session, so a stalled
+/// peer cannot pin a session (and whatever borrows it holds) forever.
+const IO_TIMEOUT: Duration = Duration::from_secs(10);
+
+fn set_io_timeouts(stream: &TcpStream) -> Result<(), SodError> {
+ stream.set_read_timeout(Some(IO_TIMEOUT)).map_err(io_err)?;
+ stream.set_write_timeout(Some(IO_TIMEOUT)).map_err(io_err)
+}
+
+use crate::engine::Engine;
+use crate::replica::Replica;
+use crate::store::LogStore;
+use crate::sync::{Msg, Session, SyncReport};
+use crate::SodError;
+
+fn io_err(e: E) -> SodError {
+ SodError::Io(e.to_string())
+}
+
+fn send(
+ sock: &mut WebSocket,
+ msg: &Msg,
+) -> Result<(), SodError> {
+ let bytes = postcard::to_stdvec(msg).map_err(io_err)?;
+ sock.send(Message::Binary(bytes.into())).map_err(io_err)
+}
+
+fn recv(sock: &mut WebSocket) -> Result {
+ loop {
+ match sock.read().map_err(io_err)? {
+ Message::Binary(b) => {
+ return postcard::from_bytes(&b)
+ .map_err(|_| SodError::Corrupt("undecodable sync message"));
+ }
+ // tungstenite answers pings itself on the next read/write;
+ // ignore everything that isn't a protocol message
+ Message::Close(_) => return Err(SodError::Io("peer closed mid-session".into())),
+ _ => {}
+ }
+ }
+}
+
+/// Run one session over an established socket. `initiator` fixes the
+/// half-duplex order; see the module docs.
+fn run_session(
+ sock: &mut WebSocket,
+ r: &mut Replica,
+ schema: u32,
+ initiator: bool,
+) -> Result {
+ let mut session = Session::new(schema);
+
+ if initiator {
+ send(sock, &session.hello(r))?;
+ }
+ let peer_hello = recv(sock)?;
+ // Our Frames…Done for the peer, computed from their Hello.
+ let ours = session.on_msg(r, peer_hello)?;
+ if !initiator {
+ send(sock, &session.hello(r))?;
+ }
+
+ if initiator {
+ for m in &ours {
+ send(sock, m)?;
+ }
+ loop {
+ let msg = recv(sock)?;
+ session.on_msg(r, msg)?;
+ if session.finished() {
+ break;
+ }
+ }
+ } else {
+ loop {
+ let msg = recv(sock)?;
+ session.on_msg(r, msg)?;
+ if session.finished() {
+ break;
+ }
+ }
+ for m in &ours {
+ send(sock, m)?;
+ }
+ }
+ session
+ .report()
+ .ok_or_else(|| SodError::Io("session ended before Hello".into()))
+}
+
+/// An established outbound connection, waiting for its session to run.
+///
+/// Connecting is separate from running so hosts can dial **without
+/// borrowing the replica** — a dial to an unreachable peer must never
+/// stall writes (the wifi-kill case is exactly when peers are
+/// unreachable). Connect first, borrow the replica only for the session.
+pub struct OutgoingSession {
+ sock: WebSocket,
+}
+
+/// Dial `url` (`ws://host:port` only in v1) with bounded connect and I/O
+/// timeouts. Owns no replica.
+pub fn connect(url: &str) -> Result {
+ let rest = url
+ .strip_prefix("ws://")
+ .ok_or_else(|| SodError::Io(format!("unsupported url (v1 speaks ws:// only): {url}")))?;
+ let host_port = rest.split('/').next().unwrap_or(rest);
+ // try every resolved address (a v6-first resolver on a v4-only route
+ // must fall through to the A record, not burn the timeout and fail)
+ let mut last_err = SodError::Io(format!("no address for {host_port}"));
+ let mut stream = None;
+ for addr in host_port.to_socket_addrs().map_err(io_err)? {
+ match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
+ Ok(s) => {
+ stream = Some(s);
+ break;
+ }
+ Err(e) => last_err = io_err(e),
+ }
+ }
+ let stream = stream.ok_or(last_err)?;
+ set_io_timeouts(&stream)?;
+ let (sock, _resp) = tungstenite::client(url, stream)
+ .map_err(|e| SodError::Io(format!("websocket handshake failed: {e}")))?;
+ Ok(OutgoingSession { sock })
+}
+
+impl OutgoingSession {
+ /// Run the whole session as initiator; the replica is borrowed only
+ /// for this call. Closes the socket on exit either way.
+ pub fn run(
+ mut self,
+ r: &mut Replica,
+ schema: u32,
+ ) -> Result {
+ let result = run_session(&mut self.sock, r, schema, true);
+ let _ = self.sock.close(None);
+ result
+ }
+}
+
+/// Dial `url` and run one full sync session (connect + [`OutgoingSession::run`]).
+///
+/// Hosts embedding sod in a live server should call [`connect`] first and
+/// borrow the replica only for `run`. The report identifies the peer and
+/// carries any per-origin refusals the session recorded while continuing
+/// (SOD-2) — surface a non-empty `skipped` to the user.
+pub fn sync_with(
+ url: &str,
+ r: &mut Replica,
+ schema: u32,
+) -> Result {
+ connect(url)?.run(r, schema)
+}
+
+/// A bound sync listener. Owns **no replica** — hosts embedding sod in a
+/// live server accept on a dedicated thread and borrow the replica only
+/// per session (sessions are milliseconds), so writes and syncs interleave
+/// on one lock without ever holding it while idle.
+pub struct SyncListener {
+ listener: TcpListener,
+}
+
+impl SyncListener {
+ /// Bind `addr` (e.g. `127.0.0.1:7300`, or port `0` for ephemeral).
+ pub fn bind(addr: &str) -> Result {
+ Ok(SyncListener {
+ listener: TcpListener::bind(addr).map_err(io_err)?,
+ })
+ }
+
+ /// The actually-bound address (resolves port `0`).
+ pub fn local_addr(&self) -> Result {
+ self.listener.local_addr().map_err(io_err)
+ }
+
+ /// Block until a peer connects **and** completes the websocket
+ /// handshake. Still owns no replica.
+ ///
+ /// Failed handshakes (stray TCP probes, port scanners) are logged and
+ /// retried internally — they are the network's noise, not the
+ /// caller's problem. `Err` means the *listener* itself failed.
+ pub fn accept(&self) -> Result {
+ loop {
+ let (stream, _addr) = self.listener.accept().map_err(io_err)?;
+ if let Err(e) = set_io_timeouts(&stream) {
+ eprintln!("sod: could not set socket timeouts: {e}");
+ continue;
+ }
+ match tungstenite::accept(stream) {
+ Ok(sock) => return Ok(IncomingSession { sock }),
+ Err(e) => eprintln!("sod: websocket handshake failed: {e}"),
+ }
+ }
+ }
+}
+
+/// A handshaken inbound connection, waiting for its session to run.
+pub struct IncomingSession {
+ sock: WebSocket,
+}
+
+impl IncomingSession {
+ /// Run the whole session as responder; the replica is borrowed only
+ /// for this call. Closes the socket on exit either way.
+ pub fn run(
+ mut self,
+ r: &mut Replica