The committee state machine
audience: contributors
Source: crates/zipnet-node/src/committee.rs.
Trait shape
impl StateMachine for CommitteeMachine {
type Command = Command;
type Query = Query;
type QueryResult = QueryResult;
type StateSync = LogReplaySync<Self>;
fn signature(&self) -> UniqueId { ... }
fn apply(&mut self, cmd: Command, ctx: &dyn ApplyContext) { ... }
fn query(&self, q: Query) -> QueryResult { ... }
fn state_sync(&self) -> LogReplaySync<Self> { LogReplaySync::default() }
}
LogReplaySync is the default; the committee state is small (< 1 KB per
round) so replaying the log on catch-up is cheap. When we add per-round
archival in v2 we’ll swap in a snapshot strategy.
Commands
pub enum Command {
OpenRound(LiveRound),
SubmitAggregate(AggregateEnvelope),
SubmitPartial(PartialUnblind),
}
Each command is idempotent:
OpenRound: resetscurrentto a freshInFlight(header). If a previous round was not finalized, its state is silently dropped — the leader is the authority on when to move on.SubmitAggregate: first valid submission wins. Duplicates from follower forwarding are silently ignored. Validation checks:- round matches
current.header.round, - payload length matches
config.params.broadcast_bytes(), - participant set is non-empty,
- every participant is in
current.header.clients(no rogue clients).
- round matches
SubmitPartial: first partial per(round, server)wins. Validation:- round matches,
- partial length matches,
serveris incurrent.header.servers.
When a partial submission brings the total to N_S and an aggregate has
been submitted, apply() calls zipnet_core::server::finalize(...) and
pushes the resulting BroadcastRecord into self.broadcasts. Everything
after that is apply()-synchronous and deterministic.
Queries
pub enum Query {
LiveRound,
CurrentAggregate,
PartialsReceived,
RecentBroadcasts(u32),
}
Queries are read-only and do not replicate. The apply-watcher task on
each server uses weak-consistency queries to drive its side effects
(mirror LiveRound to LiveRoundCell, push broadcasts into the
Broadcasts vec collection, issue partial submissions when an aggregate
appears).
Signature versioning
fn signature(&self) -> UniqueId {
let tag = format!(
"zipnet.committee.v{WIRE_VERSION}.slots={}.bytes={}.min={}",
self.config.params.num_slots,
self.config.params.slot_bytes,
self.config.min_participants,
);
UniqueId::from(tag.as_str())
}
signature() is folded into the GroupId by mosaik, alongside the
GroupKey (derived from DEPLOYMENT.derive("committee")) and the
consensus config. Therefore:
- Bumping
WIRE_VERSION(wire or params breaking change) isolates old nodes from new. - Changing
num_slots,slot_bytes, ormin_participantslikewise forces a fresh group, so nodes can’t silently fork on divergent config. - Changing any field of the deployment fingerprint (
Config+ datum schema) disjoins the deployments; twoacme.mainnet/acme.testnetdeployments share noGroupIdeven under identical params. See design-intro — The underlying principle.
If you add a field to CommitteeConfig or change apply semantics
without touching signature(), two nodes with incompatible code will
form the same group and diverge at the apply level. Always bump the
signature string when apply() or Command semantics change. That’s
the invariant.
What this machine guarantees vs. does not
The state machine guarantees round ordering, exactly-once partial admission, and deterministic finalization under Raft’s normal crash- fault tolerance. It deliberately guarantees nothing about anonymity — anonymity is a property of the cryptographic protocol (any-honest-server DC-net algebra, see Threat model), not of consensus. Byzantine committee members cannot break anonymity via the state machine path; they can only withhold or submit bogus partials, which is an availability problem.
Apply-context usage
ApplyContext exposes deterministic metadata. We use it only in a debug
log right now:
debug!(
round = %header.round,
"committee: opening round at index {:?}",
ctx.log_position(),
);
Anything derived from ctx is safe to use in state mutation because
mosaik guarantees it is identical on every replica. If v2 needs a
per-round random salt, pulling it from ctx.log_position() and
ctx.current_term() is the deterministic path.
The apply-watcher
The reason apply() doesn’t write directly to the public collections:
apply() is synchronous and must be free of I/O to keep the state
machine deterministic. Side effects on the outside world go through a
task that polls the group after every commit advance:
tokio::select! {
_ = group.when().committed().advanced() => {
let live = group.query(Query::LiveRound, Weak).await?.into();
let agg = group.query(Query::CurrentAggregate, Weak).await?.into();
let recent = group.query(Query::RecentBroadcasts(8), Weak).await?.into();
reconcile_into_collections(live, agg, recent).await;
maybe_submit_my_partial(agg).await;
}
// ...
}
This is the same pattern the mosaik book recommends for “state machine emits events, side-effect task consumes them”. Because queries are weak-consistency reads of the local replica, they are lock-free and fast; by the time we see the commit advance, the local apply has already run.
Idempotency and replays
- A follower that crashes mid-apply replays the log on recovery. Because
apply()is deterministic, replaying yields the same state. - A client that never sees its round finalized and retries on the next
LiveRoundis safe: the new round has a freshRoundId, new pads, new envelope. No anti-replay logic is needed at the protocol layer. - An aggregator retrying
SubmitAggregateafter a leader flip is safe: the state machine rejects duplicates. - A server retrying
SubmitPartialafter its own restart is safe for the same reason.
Sizes of in-flight state
| Field | Size per round |
|---|---|
LiveRound.clients | N * 32 bytes |
LiveRound.servers | N_S * 32 bytes |
aggregate.aggregate | B bytes (default 16 KiB) |
partials | N_S * (32 + 8 + B) bytes |
Finalization pushes one BroadcastRecord (size: B + N*32 + N_S*32) into
self.broadcasts which is retained in RAM indefinitely in v1. For
long-running deployments you will want external archival; see
Operators — Accounting and audit.