Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: resets current to a fresh InFlight(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).
  • SubmitPartial: first partial per (round, server) wins. Validation:
    • round matches,
    • partial length matches,
    • server is in current.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, or min_participants likewise 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; two acme.mainnet / acme.testnet deployments share no GroupId even 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 LiveRound is safe: the new round has a fresh RoundId, new pads, new envelope. No anti-replay logic is needed at the protocol layer.
  • An aggregator retrying SubmitAggregate after a leader flip is safe: the state machine rejects duplicates.
  • A server retrying SubmitPartial after its own restart is safe for the same reason.

Sizes of in-flight state

FieldSize per round
LiveRound.clientsN * 32 bytes
LiveRound.serversN_S * 32 bytes
aggregate.aggregateB bytes (default 16 KiB)
partialsN_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.