Skip to content

sod: symmetric replication for fold apps (local-first bogs, server/Node/browser-ready core) - #2

Open
hhff wants to merge 35 commits into
mainfrom
worktree-sod
Open

sod: symmetric replication for fold apps (local-first bogs, server/Node/browser-ready core)#2
hhff wants to merge 35 commits into
mainfrom
worktree-sod

Conversation

@hhff

@hhff hhff commented Aug 16, 2026

Copy link
Copy Markdown
Member

What this is

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 offline-first posture, with a protocol built for fold's data model). Client↔server and decentralized p2p are the same wire protocol — a star and a mesh of identical pairwise sessions.

Design spec: docs/superpowers/specs/2026-08-15-sod-design.md · Implementation plan: docs/superpowers/plans/2026-08-15-sod-implementation.md · Crate docs: sod/README.md · Runnable template: examples/sod-demo.

Why it converges

Fold's write primitive is a Z-set delta (datum + signed multiplicity). Deltas commute, so the multiset — and every deterministic view over it — is order-independent: replicas holding the same frames hold the same views, by algebra rather than coordination. Sync is anti-entropy: exchange version vectors, stream missing frames. Order-sensitive operations (uniqueness, claims) are an app-level pattern (route to a designated replica), never protocol machinery.

The model

  • Per-origin hash-chained feeds (à la Secure Scuttlebutt): frames identified by BLAKE3, chained by prev-hash — tamper-evident, equivocation-poisoning (never of one's own feed), relayable through untrusted peers.
  • Log is truth (append-only, torn-tail recovery, tamper-evident length prefixes); engines are rebuildable caches with a transactional applied-cursor, so a crash at any point heals on open.
  • Ports everywhere: Engine ("the bog machinery" — MemEngine always compiled; FoldEngine behind the default fold-engine feature), LogStore, sans-io sync Session + transports (ws), entropy.
  • Watermark time: max event-time applied is the only "now"; sod never reads a clock.

Fold is consumed, never modified

git diff main -- fold is empty. The applied-cursor is an ordinary Push node (AppliedCursor, sink name sod_cursor) that commits atomically with app deltas via fold's public API; where a stock sink doesn't fit replication, sod ships its own (sod::sinks::Bag). Two upstream findings for fold are documented rather than patched: Bag clamps negative running sums (order-dependent state under replication orderings), and Retain (processing-time window) cannot converge under any injected clock — the spec's Time section has the analysis; an event-time retain is the proposed fix.

Targets

  • Server / native / Node.js: full stack today. Node packaging is a per-app napi-rs addon (examples/sod-demo/node) — verified cross-runtime: a Node replica and native CLI replicas converging over websocket, including third-party feeds learned by relay.
  • Browser: the core (frames, vectors, log, replica, session, MemEngine) compiles for wasm32-unknown-unknown — enforced by tests/wasm_check.rs. OPFS/IndexedDB LogStore + browser transport are follow-on port implementations.
  • React Native: the native stack behind a future UniFFI/JSI binding (phones have real filesystems; fjall works).

Testing

  • 100-case randomized convergence suite: N replicas, interleaved writes, partial/interrupted syncs, relays — equal vectors ⇒ byte-identical views.
  • Crash tests: torn-tail truncation at every byte offset, crash-window (log-ahead-of-engine) healing, interior-corruption refusal (including corrupted length prefixes).
  • Adversarial tests: equivocation poisoning, forged own-feed frames, seq-0 frames, version-mismatch refusal, stray-TCP-probe on one-shot serve.
  • Differential oracle: fold engine vs MemEngine over randomized delta sequences (this test caught the fold Bag clamp).
  • Golden log-format fixture (byte-frozen) and the wasm32 portability gate.

Adversarial review loop

A multi-dimension review of the full branch produced 10 verified findings (8 CONFIRMED) — including a sync that could never converge past 256 frames (LIFO message drainage), log-first appends that could brick a replica on undecodable frames, silent log truncation via corrupted length prefixes, a one-message remote panic, and silently-swallowed feed poisoning. All 10 were fixed with regression tests, then adversarially re-reviewed: all confirmed closed, and the re-review's residual items (overflow-length classification, bounded refusal recording, an own-feed forgery spec clarification) were addressed in a follow-up commit.

Try it

cargo test -p sod
cargo run -p sod-demo -- ./a add hello
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
cargo run -p sod-demo -- ./b list

🤖 Generated with Claude Code

Added: sod-web — the three-bog demo

A Next.js emoji reaction board where every instance embeds its own sod replica via a per-app napi addon (examples/sod-web/). The demo topology: a Fly.io hub + localhost:3000 + localhost:3001. Kill the wifi and the two locals keep syncing over loopback (a real partial partition); reconnect and the hub absorbs both sides — relay included — and all three boards converge byte-identically. The top nav shows it live: each instance is tinted by its replica id's hue, and connected peers appear as dots in their colors.

Sod additions this required (all additive, fold still untouched): SyncListener/IncomingSession and connect/OutgoingSession split accept and dial from session-run so embedding hosts borrow the replica only per-session — never while idle or dialing (blackhole-dial verified: <25 ms API latency while the loop dials a dead IP); Hello carries the sender's replica id and sessions yield a SyncReport, which is what powers the "N bogs connected" badge; bounded connect/IO timeouts with all-address dial fallback.

Verified: scripts/demo-local.mjs scripts the full three-act partition/heal rehearsal (passes); addon smoke test with a two-process sync leg (passes); Docker image built and exercised end-to-end including a host↔container sync. deployed to Fly (sanctuary-computer org) and verified: a laptop replica synced bidirectionally with https://sod-web-demo.fly.dev over the public internet (sync on raw-TCP port 10700, dedicated IPv4).

This work went through the same adversarial review loop: an interim round caught a demo-killing bug (dialing an unreachable peer while holding the replica lock — exactly the wifi-kill moment) plus five others; the final round contributed ten more findings (IPv6-first dial fallback, Fly HOSTNAME injection, a mutual-dial lock cycle, a Session::report panic path, and deploy-config issues among them). All fixed with regression coverage.

hhff and others added 30 commits August 15, 2026 18:44
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…targets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gates)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n poisoning

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stream::meta_keyspace / Stream::meta_snapshot open meta_{name} partitions
outside the sink_* namespace, and Tx::meta exposes the raw store
transaction so layered bookkeeping (e.g. replication cursors) commits
atomically with pipeline pushes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bag stored max(0, sum)-ish state: a retraction arriving before its
matching insert removed the entry, so '-16 then +10' converged to 10
while '+10 then -16' converged to absent — state depended on delta
arrival order, which breaks replay and replication (found by sod's
differential test). Nonzero sums now persist; readers surface only
positive multiplicities, so the observable contract is unchanged and
the stored state is a pure function of the net multiset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ial oracle test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…4 reader scoping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…a transcript

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverts all fold edits. The applied-cursor becomes AppliedCursor, an
ordinary Push node wrapping the app pipeline (sink name 'sod_cursor',
collision-checked by fold; recovers from the init snapshot like Retain
does) — same one-transaction atomicity, no meta-keyspace API needed.
The Bag clamp fix moves to sod::sinks::Bag; fold's stock Bag stands
as an upstream report instead of a carried patch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- sync_pair delivered messages LIFO: >BATCH diffs could never converge
  (FIFO VecDeque + >BATCH regression test)
- validate-before-append: a frame the engine can't apply is refused
  before logging, instead of bricking every subsequent open
- record format grows a 4-byte length-check: corrupted length prefixes
  are interior corruption (refused), not silent tail truncation; checked
  arithmetic guards 32-bit length overflow (golden fixture regenerated)
- seq==0 wire frame was a remote panic; now Corrupt
- own feed is never self-poisoned by forged frames; sync refusals are
  returned to callers (sync_pair/sync_with/serve/demo/addon) not swallowed
- serve counts only completed sessions toward max_sessions (stray TCP
  probe can no longer consume a one-shot serve; test with raw-TCP probe)
- LogStore::take_frames ends double frame retention (log + replica)
- Hello handler chunks per-origin suffixes directly (one clone, not two)
- demo/addon guard 'remove' against hidden negative-multiplicity debt
- spec updated: record format, recovery policy, SOD-2 own-feed exception,
  Engine::validate, batching/async noted as future work

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nded refusal recording, spec forgery clarification

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…panel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hhff and others added 5 commits August 16, 2026 17:29
…ed end-to-end

Three-stage build (addon → Next standalone → slim runtime) validated
locally: containerized hub served UI + sync port; a host instance dialed
it and both converged bidirectionally. Fly deploy itself not yet run
(needs the user's Fly account; flyctl unavailable here).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… init, atomic unreact

- ws transport: connect() split from session run so dialing an
  unreachable peer never borrows the replica (blackhole-verified: reacts
  stay <25ms while the loop dials a dead IP — the wifi-kill moment no
  longer freezes the app); bounded connect (5s) and socket I/O (10s)
  timeouts; SyncListener::accept absorbs failed handshakes internally
- addon: unreact check+commit under one replica borrow (no TOCTOU with
  the sync worker); syncWithPeer connects before locking
- app: instrumentation.ts boots the replica + serve loop at server start
  (a restarted hub no longer waits for a page visit to hear peers);
  standalone start script + prepare-standalone (no next-start warning);
  demo-local spawns servers directly so cleanup kills them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ws::connect tries every resolved address (v6-first resolvers on
  v4-only routes no longer burn the timeout and fail)
- Session::report returns Option; no panic on pre-Hello error paths
- addon: status vector is an origin-ordered array (deterministic output
  boundary); serve loop sheds inbound sessions via bounded try-lock
  (breaks the mutual-dial lock cycle) and caps listener-error spin
- fly.toml dockerfile path relative to config dir; Dockerfile pins
  HOSTNAME=0.0.0.0 for Fly's injected env; .dockerignore anchors
  node_modules/.next/*.node at every depth
- demo-local cleans up children and temp dirs on every exit path
- sod README documents the dial half of the connect-first pattern

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…over the internet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… data dirs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant