Distributed CRDTs

Eventually-consistent access grants using Conflict-Free Replicated Data Types (CRDTs). Multiple nodes can independently grant/revoke capabilities and merge.

CrdtState

#![allow(unused)]
fn main() {
use schubert::crdt::{CrdtState, CrdtGrant, VersionVector};

let mut node_a = CrdtState::new();
let mut node_b = CrdtState::new();

// Node A grants a capability
node_a.apply(CrdtGrant::grant("alice", "read"))?;

// Node B grants a capability (concurrently)
node_b.apply(CrdtGrant::grant("alice", "write"))?;

// Merge — both grants survive
node_a.merge(&node_b)?;
assert!(node_a.has_grant("alice", "read"));
assert!(node_a.has_grant("alice", "write"));
}

Version Vectors

Each grant carries a version vector tracking causal history:

#![allow(unused)]
fn main() {
let grant = CrdtGrant::grant("alice", "read");
println!("Version: {:?}", grant.version());
}

Last-Write-Wins

Conflicting grants (same principal, same capability) resolve via last-write-wins:

#![allow(unused)]
fn main() {
// Node A grants, Node B revokes concurrently
let grant = CrdtGrant::grant("alice", "read");
let revoke = CrdtGrant::revoke("alice", "read");

// Merge resolves to the operation with the higher timestamp
node_a.apply(grant)?;
node_a.merge(&node_b)?; // state_b has the revoke with higher timestamp
}

Merge Properties

  • Commutative: a.merge(b) == b.merge(a)
  • Associative: (a.merge(b)).merge(c) == a.merge(b.merge(c))
  • Idempotent: a.merge(a) == a

Grant Tombstones (v0.5.0)

Blanket (principal, capability) revocation cannot target one specific issuance — and the LWW map is the wrong tool for "kill this bearer now", because concurrent ops can resurrect it. So grant-aware revocation (ADR-0002) is a grow-only tombstone set keyed by the issuance nonce (the signed 16-byte field every GrantToken has carried since v0.5.0):

#![allow(unused)]
fn main() {
use schubert::crdt::CrdtState;

let mut state = CrdtState::new(2, 4)?;

// Tombstone one bearer: pass the grant's signed nonce from the crypto layer.
state.revoke_grant(grant.nonce, "node-a", timestamp_ms)?;
assert!(state.is_grant_revoked(&grant.nonce));
}

Merge is union, so a tombstoned issuance can never be resurrected by any merge order — the property federation consumers need. Renewal is unaffected: re-issue produces a fresh nonce (ADR-0001), and only the old bearer dies. Tombstones accumulate (grow-only); pruning is an operator concern. The TypeScript GrantCRDT mirrors this exactly (revokeGrant(nonceHex) / isGrantRevoked(nonceHex)), with snapshots carrying the set and readers tolerating pre-v0.5.0 snapshots.