feat: Introduce Hybrid Logical Clocks in protobuf, update architecture documentation, and add HLC test cases.

This commit is contained in:
2025-12-20 14:29:03 +01:00
commit e1405bb910
20 changed files with 522 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Rust / Cargo
/target/
Cargo.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
+48
View File
@@ -0,0 +1,48 @@
[workspace]
resolver = "2"
members = [
"lattice-core",
"lattice-net",
"lattice-store",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
[workspace.dependencies]
# Workspace crates
lattice-core = { path = "lattice-core" }
lattice-net = { path = "lattice-net" }
lattice-store = { path = "lattice-store" }
# Networking (Iroh)
iroh = "0.95"
iroh-gossip = "0.95"
# Cryptography
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
# Serialization
prost = "0.13"
prost-types = "0.13"
prost-build = "0.13"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Utilities
thiserror = "2"
tracing = "0.1"
bytes = "1"
# Testing
tokio-test = "0.4"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = "warn"
+112
View File
@@ -0,0 +1,112 @@
# Architecture
## Ideas
- SigChains Ed25519-signed, hash-chained append-only logs per node. Trust via local signature verification.
- Log-Based State: KV store derived by replaying entries. Watermarks enable safe log pruning + snapshots.
- Offline-First: Iroh for networking. Vector clocks identify missing entries on reconnect—converges mathematically.
- Full Replication: All nodes keep all logs until watermark consensus, then prune and snapshot.
## Concepts
- Transitive Pairing. Nodes can introduce new nodes to the mesh.
## Stack
- rust
- iroh
- prost protocol buffers
### Bootstrap
- New peers request a full state snapshot from their first connection.
- The snapshot allows them to skip replaying the entire log history.
- After bootstrap, the node receives incremental updates via gossip.
### Networking
- Designed for mobile clients that may only sync a few times per day.
- When peers connect, they exchange vector clocks to identify missing entries.
- Missing entries are fetched via unicast.
- MAX_DRIFT should be generous (e.g., hours) to accommodate sleeping devices.
Networking modes:
- Active (servers/laptops on power): Frequent gossip broadcasts, proactive sync.
- Low-power (mobile/battery): Pull-based sync on wake. Query peers instead of relying on push gossip.
## Parts
### Nodes
- Identified by their Ed25519 public key.
- Private key stored locally in `identity.key` (not replicated).
- Node data stored in KV:
- `/nodes/{pubkey}/info` = static metadata (name, added_by, added_at)
- `/nodes/{pubkey}/status` = `active` | `dormant` | `disabled`
- `/nodes/{pubkey}/role` = `server` | `device` (optional, hints sync priority)
- Inviting a node = writing entries to `/nodes/{pubkey}/...`.
- Liveness: Each node tracks `last_seen` locally (from watermark gossip). UI alerts if a peer hasn't been seen for threshold (e.g., 30 days). User decides to mark dormant/disabled.
- Status effects:
- `active`: Normal sync participant, blocks watermark until acknowledged.
- `dormant`: Excluded from watermark consensus, can be reactivated.
- `disabled`: Permanently removed from mesh.
- Sync priority: Low-power clients prefer peers marked as `server` or recently active.
### Data Model
- Keys are flat strings using path conventions (e.g., `/nodes/{pubkey}`, `/config/sync/interval`).
- Prefix queries via string matching (sorted map enables efficient range scans).
- State is computed by replaying `Put`/`Delete` operations from all authors.
- Entry ordering: by HLC timestamp, then by author ID as tiebreaker.
- Conflicts resolved by last-write-wins (using the ordering above).
### Timestamps (Hybrid Logical Clocks)
Timestamps use HLC `<wall_time, counter>` with Causal Clamping:
- Each entry includes an HLC and a reference to its parent (prev_hash).
- Standard HLC: `new_hlc = max(local_wall_clock, max_seen_hlc + 1)`.
- On receive: if `entry.hlc > local_wall_clock + MAX_DRIFT`, clamp to `parent.hlc + 1`.
- All nodes compute the same clamped time from the parent (deterministic).
- Genesis entries (no parent) with future timestamps are dropped.
Pre-flight check (before signing):
- Compare local_clock to max_peer_hlc (from recent gossip/entries).
- If `local_clock > max_peer_hlc + MAX_DRIFT`, use `max_peer_hlc + 1` instead.
- This catches future-clock nodes before they poison the log.
Authors apply their own entries through the standard receive path to ensure consistent clamping.
### Storage
Each node stores logs as one file per author:
```
data/
├── identity.key # Local node's Ed25519 private key
├── logs/
│ └── {author_id_hex}.log # Append-only SignedEntry stream per author
└── state.db # redb: KV snapshot + vector clocks + indexes
```
- Logs: Append-only binary files per author, containing serialized `SignedEntry` messages.
- State DB (redb): Combined KV state, vector clocks, and indexes. Updated as entries are applied.
#### state.db Tables (redb)
```
Table Key Value Purpose
─────────────────────────────────────────────────────────────────────────────
kv String (path) Vec<u8> Replicated key-value data
vector_clocks [u8; 32] (author_id) u64 (max seq) Track sync state per author
entry_index (author_id, seq) u64 (offset) Fast entry lookup by position
meta String Vec<u8> System metadata (own_seq, watermark, etc.)
```
### Watermarks
- Nodes gossip their watermarks periodically (throttled).
- A watermark is a vector clock: how much of each author's log the node has seen.
- All nodes keep all logs (own + others) for redundancy until watermark consensus.
- Once all peers have acknowledged entries, they can be pruned and replaced by the snapshot.
- If a node is offline too long, it re-bootstraps with a fresh snapshot when it reconnects.
+37
View File
@@ -0,0 +1,37 @@
# Test Cases
## Timestamp / HLC
### Time-traveling node applies own entry
- Node X has clock at year 2050
- Node X creates entry, signs, broadcasts
- All nodes (including X) should clamp to parent.hlc + 1
- Verify: X's state.db matches other nodes' state.db
- Failure mode: X applies using 2050, others use 101 → divergence
### Clamping with no parent (genesis entry)
- Node X creates first-ever entry with future timestamp
- All nodes should DROP the entry (no parent to anchor to)
- Verify: entry is not applied anywhere
### Out-of-order entry arrival
- Entry B (hlc=91) arrives after Entry A (hlc=100)
- Both write to same key
- Verify: A's value wins (LWW with timestamp tracking)
- Verify: no rollback needed, just comparison on apply
### Clock drift detection
- Node consistently sees its entries clamped
- Verify: UI alerts user about clock being ahead
### Clock in past (Pi without RTC, boots at 1970)
- Node X has clock at 1970
- Node X receives entries from peers with HLC around 2024
- Standard HLC: X uses max(1970, peer_hlc + 1) = peer_hlc + 1
- Verify: X's entries slot in correctly (no special handling needed)
### Pre-flight peer sanity check (future clock)
- Node X has clock at 2050
- Before creating entry, X compares local_clock to max_peer_hlc
- If local_clock > max_peer_hlc + MAX_DRIFT, use max_peer_hlc + 1
- Verify: X's entry uses sane timestamp, all nodes agree
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "lattice-core"
description = "Core types for Lattice: nodes, sigchains, entries, and vector clocks"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ed25519-dalek = { workspace = true }
rand = { workspace = true }
prost = { workspace = true }
thiserror = { workspace = true }
bytes = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+9
View File
@@ -0,0 +1,9 @@
//! Log entries (atomic operations)
/// An atomic, batched operation in the sigchain.
///
/// Entries are the fundamental unit of change in Lattice.
/// The KV store is a "view" generated by replaying these entries.
pub struct Entry {
// TODO: operation data, signature, prev_hash
}
+17
View File
@@ -0,0 +1,17 @@
//! Lattice Core
//!
//! Core types for the Lattice distributed mesh:
//! - **Node**: Identity with Ed25519 keypair
//! - **SigChain**: Append-only cryptographically signed log
//! - **Entry**: Atomic operations in the log
//! - **VectorClock**: Causality tracking for reconciliation
pub mod node;
pub mod sigchain;
pub mod entry;
pub mod vector_clock;
pub use node::Node;
pub use sigchain::SigChain;
pub use entry::Entry;
pub use vector_clock::VectorClock;
+30
View File
@@ -0,0 +1,30 @@
//! Node identity and cryptographic keys
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::OsRng;
/// A node in the Lattice mesh.
///
/// Each node has an Ed25519 keypair used for signing sigchain entries
/// and establishing trust within the network.
pub struct Node {
signing_key: SigningKey,
}
impl Node {
/// Generate a new node with a random keypair.
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
Self { signing_key }
}
/// Get the node's public key (identity).
pub fn public_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
}
/// Get the signing key for creating signatures.
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Cryptographic SigChain (append-only signed log)
/// An append-only log where each entry is cryptographically signed
/// and hash-linked to the previous entry.
pub struct SigChain {
// TODO: entries, hash chain
}
impl SigChain {
/// Create a new empty sigchain.
pub fn new() -> Self {
Self {}
}
}
impl Default for SigChain {
fn default() -> Self {
Self::new()
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Vector clocks for causality tracking
use std::collections::HashMap;
/// A vector clock for tracking "how much" of each node's log has been seen.
///
/// Used during reconciliation to identify missing entries between peers.
pub struct VectorClock {
clocks: HashMap<[u8; 32], u64>,
}
impl VectorClock {
/// Create a new empty vector clock.
pub fn new() -> Self {
Self {
clocks: HashMap::new(),
}
}
/// Get the clock value for a node (returns 0 if not present).
pub fn get(&self, node_id: &[u8; 32]) -> u64 {
self.clocks.get(node_id).copied().unwrap_or(0)
}
/// Set the clock value for a node.
pub fn set(&mut self, node_id: [u8; 32], value: u64) {
self.clocks.insert(node_id, value);
}
/// Increment the clock for a node.
pub fn increment(&mut self, node_id: [u8; 32]) {
let current = self.get(&node_id);
self.set(node_id, current + 1);
}
}
impl Default for VectorClock {
fn default() -> Self {
Self::new()
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "lattice-net"
description = "Networking layer for Lattice using Iroh"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
lattice-core = { workspace = true }
iroh = { workspace = true }
iroh-gossip = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
bytes = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+3
View File
@@ -0,0 +1,3 @@
//! Gossip protocol for broadcasting changes
// TODO: Implement gossip using iroh-gossip
+8
View File
@@ -0,0 +1,8 @@
//! Lattice Networking
//!
//! Networking layer using Iroh:
//! - **Gossip**: Broadcasting changes across the mesh
//! - **Unicast**: Point-to-point communication for reconciliation
pub mod gossip;
pub mod unicast;
+3
View File
@@ -0,0 +1,3 @@
//! Unicast communication for direct peer-to-peer messaging
// TODO: Implement unicast using iroh
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "lattice-store"
description = "Log-based KV store with snapshots and watermarks"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
lattice-core = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+12
View File
@@ -0,0 +1,12 @@
//! Lattice Store
//!
//! Log-based Key-Value store:
//! - State is a "view" generated by replaying entries
//! - Watermarks for agreeing on safe compaction points
//! - Snapshots for replacing old logs
pub mod store;
pub mod snapshot;
pub mod watermark;
pub use store::Store;
+6
View File
@@ -0,0 +1,6 @@
//! Snapshots for log compaction
/// A verified snapshot that can replace old log entries.
pub struct Snapshot {
// TODO: snapshot data, watermark, verification
}
+35
View File
@@ -0,0 +1,35 @@
//! The main KV store
use std::collections::HashMap;
/// A log-based Key-Value store.
///
/// The store is a dynamic "view" generated by replaying entries.
pub struct Store {
data: HashMap<Vec<u8>, Vec<u8>>,
}
impl Store {
/// Create a new empty store.
pub fn new() -> Self {
Self {
data: HashMap::new(),
}
}
/// Get a value by key.
pub fn get(&self, key: &[u8]) -> Option<&[u8]> {
self.data.get(key).map(|v| v.as_slice())
}
/// Set a value (this would normally go through the log).
pub fn set(&mut self, key: Vec<u8>, value: Vec<u8>) {
self.data.insert(key, value);
}
}
impl Default for Store {
fn default() -> Self {
Self::new()
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Watermarks for agreeing on safe compaction points
/// A watermark representing a safe cut-off point for log compaction.
///
/// Nodes use watermarks to agree on which entries can be safely
/// deleted and replaced with snapshots.
pub struct Watermark {
// TODO: watermark data
}
+66
View File
@@ -0,0 +1,66 @@
syntax = "proto3";
package lattice;
// 1. The Wrapper (What flies over the wire)
message SignedEntry {
// The serialized bytes of the 'Entry' message.
// We keep this as raw bytes so the signature verification is stable.
bytes entry_bytes = 1;
// Ed25519 Signature of 'entry_bytes'
bytes signature = 2;
// Public Key of the author (32 bytes)
bytes author_id = 3;
}
// 2. The Log Entry (The Atomic Unit)
message Entry {
// Versioning allows us to change the format radically later if needed
uint32 version = 1;
// Ordering Metadata
bytes prev_hash = 2; // Link to previous entry (32 bytes)
uint64 seq = 3; // Monotonic sequence number
HLC timestamp = 4; // Hybrid Logical Clock
// The Batch of Operations
repeated Operation ops = 5;
}
// Hybrid Logical Clock
message HLC {
uint64 wall_time = 1; // Unix timestamp (ms)
uint32 counter = 2; // Logical counter for same-ms ordering
}
// 3. The Operation (The Change)
message Operation {
// "oneof" is how Protobuf handles Rust Enums
oneof op_type {
PutOp put = 1;
DeleteOp delete = 2;
// Future: MergeOp merge = 3;
}
}
message PutOp {
string key = 1;
bytes value = 2; // Raw bytes allows storing images, JSON, binary, etc.
}
message DeleteOp {
string key = 1;
}
// 4. The Sync Handshake (Vector Clocks)
message SyncState {
repeated Frontier frontiers = 1;
HLC sender_hlc = 2; // Sender's current clock (for peer time awareness)
}
message Frontier {
bytes author_id = 1; // Ed25519 public key (32 bytes)
uint64 max_seq = 2; // Highest sequence number seen from this author
}