sod: symmetric replication for fold apps (local-first bogs, server/Node/browser-ready core) - #2
Open
hhff wants to merge 35 commits into
Open
sod: symmetric replication for fold apps (local-first bogs, server/Node/browser-ready core)#2hhff wants to merge 35 commits into
hhff wants to merge 35 commits into
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Engine("the bog machinery" —MemEnginealways compiled;FoldEnginebehind the defaultfold-enginefeature),LogStore, sans-io syncSession+ transports (ws), entropy.Fold is consumed, never modified
git diff main -- foldis empty. The applied-cursor is an ordinaryPushnode (AppliedCursor, sink namesod_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:Bagclamps negative running sums (order-dependent state under replication orderings), andRetain(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
examples/sod-demo/node) — verified cross-runtime: a Node replica and native CLI replicas converging over websocket, including third-party feeds learned by relay.wasm32-unknown-unknown— enforced bytests/wasm_check.rs. OPFS/IndexedDB LogStore + browser transport are follow-on port implementations.Testing
MemEngineover randomized delta sequences (this test caught the foldBagclamp).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
🤖 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/IncomingSessionandconnect/OutgoingSessionsplit 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);Hellocarries the sender's replica id and sessions yield aSyncReport, which is what powers the "N bogs connected" badge; bounded connect/IO timeouts with all-address dial fallback.Verified:
scripts/demo-local.mjsscripts 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 withhttps://sod-web-demo.fly.devover 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
HOSTNAMEinjection, a mutual-dial lock cycle, aSession::reportpanic path, and deploy-config issues among them). All fixed with regression coverage.