This document explains the key engineering patterns and abstractions used in Subduction. It's intended for contributors and anyone trying to understand WTF is going on in the codebase.
Start here, then dive into design/ for detailed protocol specs:
design/handshake.mdβ Connection authentication protocoldesign/sedimentree.mdβ The core data structuredesign/sync/β Sync algorithm detailsdesign/security/β Threat model and mitigations
The FutureForm trait (from future_form) is the foundation for making Subduction work across both native Rust (with Tokio) and WebAssembly (single-threaded, no Send/Sync).
βββββββββββββββββββββββββββββ
β FutureForm β
β (trait from future_form) β
ββββββββββββββ¬ββββββββββββββββ
β
ββββββββββββββββββββ΄βββββββββββββββββ
βΌ βΌ
ββββββββββββββββββ βββββββββββββββββββ
β Sendable β β Local β
β (Send + Sync) β β (single-thread)β
β β β β
β BoxFuture<T> β β LocalBoxFuture β
ββββββββββββββββββ βββββββββββββββββββ
β β
βΌ βΌ
Tokio runtime Wasm runtime
Multi-threaded JS event loop
Why? JavaScript's single-threaded model means futures can't be Send. But we want the same core logic to work in both environments. FutureForm lets us write generic code that works with either.
Usage pattern:
// Trait definition - generic over FutureForm
pub trait Storage<K: FutureForm> {
fn load(&self, id: Id) -> K::Future<'_, Result<Data, Error>>;
}
// Implementation for both forms using the macro
#[future_form::future_form(Sendable, Local)]
impl<K: FutureForm> Storage<K> for MyStorage {
fn load(&self, id: Id) -> K::Future<'_, Result<Data, Error>> {
K::from_future(async move {
// async implementation
})
}
}The #[future_form::future_form(Sendable, Local)] macro generates two impl blocks β one for each form. K::from_future() wraps the async block in the appropriate future type.
The main Subduction struct has many generic parameters:
pub struct Subduction<
'a,
F: FutureForm, // Sendable or Local
S: Storage<F>, // Storage backend
C: Connection<F>, // Network connection type
P: ConnectionPolicy<F> + StoragePolicy<F>, // Access control
M: DepthMetric, // Hash β depth mapping
const N: usize, // ShardedMap shard count
>This looks intimidating but serves a purpose: compile-time configuration. The entire sync stack is assembled at compile time with little dynamic dispatch for hot paths.
Typical instantiations:
| Context | F | S | C | M |
|---|---|---|---|---|
| CLI server | Sendable |
FsStorage |
UnifiedWebSocket |
CountLeadingZeroBytes |
| Wasm browser | Local |
JsStorage |
JsConnection |
WasmHashMetric |
Access control is split into two traits:
pub trait ConnectionPolicy<K: FutureForm> {
type ConnectionDisallowed: Error;
fn authorize_connect(&self, peer: PeerId) -> K::Future<'_, Result<(), Self::ConnectionDisallowed>>;
}
pub trait StoragePolicy<K: FutureForm> {
type FetchDisallowed: Error;
type PutDisallowed: Error;
fn authorize_fetch(&self, peer: PeerId, id: SedimentreeId) -> K::Future<'_, Result<(), Self::FetchDisallowed>>;
fn authorize_put(&self, requestor: PeerId, author: PeerId, id: SedimentreeId) -> K::Future<'_, Result<(), Self::PutDisallowed>>;
fn filter_authorized_fetch(&self, peer: PeerId, ids: Vec<SedimentreeId>) -> K::Future<'_, Vec<SedimentreeId>>;
}Why separate? Connection-level auth (is this peer allowed to connect at all?) is different from document-level auth (can this peer read/write this specific document?). The filter_authorized_fetch method enables efficient batch authorization for subscription-based forwarding.
OpenPolicy is the permissive default (allows everything). KeyhivePolicy integrates with the Keyhive access control system for real authorization.
The handshake protocol uses signed challenges with nonces. NonceCache prevents replay attacks:
pub struct NonceCache { /* ... */ }
impl NonceCache {
pub async fn try_claim(&self, peer: PeerId, nonce: Nonce, timestamp: TimestampSeconds)
-> Result<(), NonceReused>;
}Uses time-based buckets for efficient expiry with lazy cleanup:
ββββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββ
β Bucket 0 β Bucket 1 β Bucket 2 β Bucket 3 β
β 0-3 min β 3-6 min β 6-9 min β 9-12 min β
ββββββββββββ΄βββββββββββ΄βββββββββββ΄βββββββββββ
β
rotates as time advances
- 4 buckets Γ 3 min = 12 min window (covers 10 min
MAX_PLAUSIBLE_DRIFT) - Lazy GC: buckets cleared during
try_claim()viaadvance_horizon() - No background task needed
- Concrete type, not a trait (only one implementation needed)
pub trait Spawn<K: FutureForm> {
fn spawn<F>(&self, future: F) -> AbortHandle
where
F: Future<Output = ()> + 'static;
}Each connection gets its own task, enabling:
- True parallelism for signature verification on multi-core
- Panic isolation (one bad connection doesn't crash the server)
- Clean abstraction over
tokio::spawnvswasm_bindgen_futures::spawn_local
Sedimentree organizes CRDT data into depth-stratified layers based on content hash:
Depth 0: ββββ (0+ leading zero bytes β few per fragment)
β require more leading zero bytes
Depth 1: ββββββββ (1+ leading zero bytes β more per fragment)
β
Depth 2: ββββββββββββββββββββββββ (2+ leading zero bytes β most per fragment)
This enables efficient sync: compare summaries at higher depths first, then drill down only where differences exist. Like a B-tree for content-addressed data.
Client Server
β β
β 1. TCP/WebSocket connect β
β ββββββββββββββββββββββββββββββββββββββββββΊ β
β β
β 2. Signed<Challenge> β
β ββββββββββββββββββββββββββββββββββββββββββΊ β
β { audience, timestamp, nonce } β
β Client identity from signature β
β β
β 3. Signed<Response> β
β ββββββββββββββββββββββββββββββββββββββββββ β
β { challenge_digest, server_timestamp } β
β Server identity from signature β
β β
β 4. ConnectionPolicy::authorize_connect() β
β checked on both sides β
β β
βΌ βΌ
Authenticated Authenticated
Errors use associated types on traits, not concrete types:
pub trait Storage<K: FutureForm> {
type Error: std::error::Error;
// ...
}
pub trait Connection<K: FutureForm> {
type SendError: std::error::Error;
type RecvError: std::error::Error;
type CallError: std::error::Error;
// ...
}This lets each implementation define its own error types while keeping the core generic. The thiserror crate is used for deriving Error implementations.
subduction_core/
βββ connection/
β βββ handshake.rs # Challenge/Response protocol
β βββ manager.rs # Spawn trait, connection lifecycle
β βββ message.rs # Wire protocol messages
β βββ nonce_cache.rs # Replay protection
βββ crypto/
β βββ nonce.rs # Cryptographic nonces
β βββ signed.rs # Signed<T> wrapper
β βββ signer.rs # Signer trait
βββ policy/
β βββ capability.rs # Fetcher/Putter fat capabilities
β βββ connection.rs # ConnectionPolicy trait
β βββ storage.rs # StoragePolicy trait
βββ subduction.rs # Main sync logic
βββ timestamp.rs # TimestampSeconds newtype
The pattern is foo.rs + foo/ for modules with submodules (Rust 2018+ style), not foo/mod.rs.
| Level | Tool | Location |
|---|---|---|
| Unit tests | #[test] |
Inline in modules |
| Property tests | bolero |
Dev dependencies |
| Integration | Round-trip tests | subduction_websocket/tests/ |
| E2E | Playwright | subduction_wasm/e2e/ |
pub struct PeerId([u8; 32]);
pub struct SedimentreeId([u8; 32]);
pub struct Nonce(u128);
pub struct TimestampSeconds(u64);These prevent mixing up different 32-byte arrays or timestamps with other integers.
Subduction stores Arc<S> for storage. This enables:
- Sharing across connection tasks
- Fat capabilities (
Fetcher/Putter) that bundle storage access with authorization proof
NonceCache is also wrapped in Arc internally for sharing across handshakes.
Prefer types that make invalid states unrepresentable:
Signed<T>can only be created by signingVerified<T>can only be created by verification- Capabilities encode what operations are permitted
- Read
subduction_core/src/subduction.rsfor the main sync logic - Check
subduction_websocket/src/tokio/server.rsfor a complete instantiation - Run tests:
cargo test --workspace - Run the CLI:
cargo run -p subduction_cli -- server
Check .ignore/CONTEXT.md for session-specific notes, or the design docs in design/.