Wire fleet-wide Byzantine-fault-tolerant agreement on a committed op log, reusing joule-code-collab's proven LockstepCluster commit rule, with the signed proposals carried over the same transport as everything else (socket / TCP / QUIC). - `serve --members <hex keys>` enables a node as a consensus member. - New control methods: `consensus_propose` (sign + buffer this node's op, return it), `propose` (buffer a peer's op after verifying the signature and membership), `commit_round` (commit the buffered set, return the agreed-log hash + op ids). A node also reports its `pubkey` in `state`. - `consensus` subcommand orchestrates one round across the local node + paired remotes: every member proposes, proposals are broadcast to all, each commits, and the returned hashes are compared — agreement iff they match. Verified: a deterministic test (3 members reach a byte-identical, order- independent agreed log; a non-member op is refused) and a live 3-daemon round where all members commit 3 ops and return the same agreed-log hash. Co-Authored-By: Claude Opus 4.8 <[email protected]>
73 lines
2.7 KiB
Rust
73 lines
2.7 KiB
Rust
//! BFT consensus, deterministically: three members each propose a signed op, all
|
|
//! proposals reach every member, and each commits to a byte-identical agreed log —
|
|
//! while a non-member's op is rejected. This exercises the same `Round` the daemon
|
|
//! drives over QUIC/TCP/socket via `consensus_propose`/`propose`/`commit_round`.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use joule_code_schema::{ContentId, Tier};
|
|
use joule_coded::consensus::Round;
|
|
use joule_core::ops::build_coding_op;
|
|
|
|
/// Build a signed op for `intent` authored by `key`, with its prior.
|
|
fn member_op(key: &SigningKey, intent: &str) -> (joule_code_cargo::SignedOp, ContentId) {
|
|
let op = build_coding_op(
|
|
"consensus",
|
|
"",
|
|
intent,
|
|
intent.to_string(),
|
|
Tier::L1,
|
|
key.verifying_key().to_bytes(),
|
|
);
|
|
let prior = op.prior.clone();
|
|
(op.sign(key).expect("sign"), prior)
|
|
}
|
|
|
|
#[test]
|
|
fn three_members_reach_byte_identical_agreed_log() {
|
|
let keys: Vec<SigningKey> = (1u8..=3).map(|i| SigningKey::from_bytes(&[i; 32])).collect();
|
|
let members: Vec<[u8; 32]> = keys.iter().map(|k| k.verifying_key().to_bytes()).collect();
|
|
|
|
// Each member proposes its own op (distinct author ⇒ distinct op).
|
|
let proposals: Vec<_> = keys.iter().map(|key| member_op(key, "round-1")).collect();
|
|
|
|
// Three independent member views each receive all proposals and commit.
|
|
let mut hashes = Vec::new();
|
|
for _ in 0..members.len() {
|
|
let mut round = Round {
|
|
members: members.clone(),
|
|
proposals: Vec::new(),
|
|
};
|
|
for (op, prior) in &proposals {
|
|
round.accept(op.clone(), prior.clone()).expect("accept valid member op");
|
|
}
|
|
let (hash, committed) = round.commit();
|
|
assert_eq!(committed.len(), 3, "all three valid proposals commit");
|
|
hashes.push(hash);
|
|
}
|
|
assert!(
|
|
hashes.windows(2).all(|pair| pair[0] == pair[1]),
|
|
"honest members must agree on a byte-identical log: {hashes:?}"
|
|
);
|
|
|
|
// Buffering order does not change the agreed log (commit is op-id ordered).
|
|
let mut reversed = Round {
|
|
members: members.clone(),
|
|
proposals: Vec::new(),
|
|
};
|
|
for (op, prior) in proposals.iter().rev() {
|
|
reversed.accept(op.clone(), prior.clone()).expect("accept");
|
|
}
|
|
assert_eq!(reversed.commit().0, hashes[0], "order-independent agreed log");
|
|
|
|
// A non-member's op is rejected before it can enter the round.
|
|
let intruder = SigningKey::from_bytes(&[9u8; 32]);
|
|
let (op, prior) = member_op(&intruder, "round-1");
|
|
let mut round = Round {
|
|
members,
|
|
proposals: Vec::new(),
|
|
};
|
|
assert!(
|
|
round.accept(op, prior).is_err(),
|
|
"a non-member proposal must be refused"
|
|
);
|
|
}
|