Phase 2: Propose → Vote → Commit

In-depth walk through big-flow steps 7–11 on one shard: who proposes, how validators vote, how a quorum certificate appears, when blocks become final, and how the mempool reacts. Assumes Phase 1 left txs in Pending. Each section tells the human story first; code excerpts are evidence.

⏱️ ~1.5–2 hours 📊 In-depth 🎯 Hyperscale-rs
Tx flow phases
In scope
  1. queue_ready_proposal
  2. try_propose
  3. BuildProposal
  4. Gossip votes
  5. QC
  6. commit_block_and_buffered
  7. mempool on_block_committed

Repo note (2025 refactor): per-shard HotStuff-2 lives in the shard crate (ShardCoordinator, was crates/bft). A separate beacon chain (BeaconCoordinator, crates/beacon) publishes the topology snapshot each epoch—who validates which shard. This phase focuses on shard propose→commit unless a step explicitly mentions beacon witnesses.

Maps to the big diagram

Big flow stepThis phase
7 Proposer selection§1 — proposer_for(shard, round), can_propose (parent-anchored committee)
8 Block proposal§2–4 — try_propose, BuildProposal, on_proposal_built
9 Validators vote§5 — PersistAndBroadcastVote, BlockVote gossip
10 QC formed§6 — VerifyAndBuildQuorumCertificate, on_qc_formed
11 Block commit§9–10 — two-chain rule, CommitBlock, mempool transitions
12+ ExecutionPhase 3

0 — Prerequisite mental model

1 — Who gets to propose this block? (step 7)

The shard does not vote on “who should be leader for this block.” Everyone runs the same formula on the consensus committee for the tip’s parent-anchored window (TopologySchedule entry for epoch_for(parent_qc.wt)): given shard and round, take round % committee.len() over that committee list. If you know the tip’s committee and round 1, every honest node computes the same leader id—no election message required.

On your machine, ShardCoordinator::can_propose asks: “Am I that leader, and am I allowed to build again at this round?” Only then does this process build and broadcast a block. Followers still validate and vote; they just do not assemble the proposal bytes.

Gotcha — small committees: With 5 validators, the same node is proposer again every 5 rounds (not every 5 heights). That is expected round-robin, not “unfair repetition.”
Gotcha — Byzantine header: Admission checks proposer_for(shard, header.round()) on the parent-anchored committee matches the header’s claimed proposer; wrong proposer → reject before voting.

In-repo proof — round-robin proposer; can_propose gates local builds

// crates/types/src/topology/snapshot.rs
pub fn proposer_for(&self, shard: ShardId, round: Round) -> ValidatorId {
    let committee = self.consensus_committee_for_shard(shard);
    let index = usize::try_from(round.inner() % committee.len() as u64).unwrap();
    committee[index]
}

// crates/shard/src/coordinator.rs — can_propose (simplified)
let Some(committee) = self.tip_committee(topology_schedule) else { return false; };
if committee.proposer_for(self.local_shard, round) != self.me {
    return false;
}
// … also: not dissolved, round > last_voted_round, no in-flight/deferred build

2 — Roles: proposer, BFT coordinator, vote aggregator

Proof snippets below are trimmed from the hyperscale-rs tree. Ellipsis (...) hides boilerplate; follow data-file links for full sources.

Three names get conflated; they are different hats worn inside the same validator program:

The proposer (leader) is whoever the round-robin says should package the next block at this height and round—only that machine should actually build and broadcast the proposal.

The BFT coordinator is the consensus brain every validator runs locally—not a person elected over the network. It remembers pending blocks, votes, certificates, timeouts, and commit rules. Each peer has its own copy; they must converge given the same messages.

The QC builder is whichever validator first collects enough votes on a block hash—often the proposer, but not by rule. Others usually learn the resulting certificate from the next block’s parent link instead of a dedicated QC broadcast.

In-repo proofShardCoordinator is per-node state machine state, not a network role

// crates/shard/src/coordinator.rs — struct ShardCoordinator { ... }
// crates/node/src/state/mod.rs — NodeStateMachine owns shard_coordinator: ShardCoordinator

In-repo proof — QC builder = first local quorum, not proposer-only (vote_keeper.rs, state/participation/shard.rs)

// Every validator runs the same vote handler — no can_propose gate on votes
ProtocolEvent::BlockVoteReceived { vote } => {
    self.shard_coordinator.on_block_vote(self.topology.snapshot(), vote)
}

// accept_vote → maybe_trigger_verification when quorum is possible
// (no proposer check anywhere in this path)
pub fn accept_vote(...) -> Vec<Action> {
    ...
    self.maybe_trigger_verification(topology, block_hash)
}

pub fn maybe_trigger_verification(...) -> Vec<Action> {
    if !vote_set.should_trigger_verification(total_power) {
        return vec![];
    }
    vec![Action::VerifyAndBuildQuorumCertificate {
        block_hash,
        votes_to_verify,
        verified_votes,
        total_voting_power: total_power,
        ...
    }]
}

3 — What a block is made of (and what triggers building one)

A block is not “mempool dumped to disk.” The proposer assembles a header (consensus metadata + cryptographic roots) and a body (full txs, execution certificates, provisions) drawn from several local sources:

IngredientWhere it comes fromTypical trigger to include it
Parent link + parent_qcLatest certified QC on this shardAlways — chains consensus
State rootJMT / storage after applying parent statebuild_proposal reads storage
TransactionsMempool ready_transactions()Tx admitted (Phase 1) → proposal latch
Finalized wavesExecution coordinatorWave finalized → latch
ProvisionsCross-shard coordinator (Phase 4)Provisioning complete → latch
Manifest (gossip)Hash lists onlyHeader broadcast; peers fetch missing bodies

Events that flip the proposal latch: new mempool admission, QC formed, block commit, genesis/recovery, provisions ready—anything that might change what the next block should contain. That latch is a single boolean (“try proposing soon”), not a queue of transactions.

Block anatomy — cards stack across the course in teaching order (Phase 1 pre-block → here). green = new on this card; grey = you already saw it green on an earlier card; default muted = shown for context, introduced later. Hover underlined fields for glossary tooltips.

4 — Latch → try_propose → guards

A

Something changed—maybe we should propose

When a new tx lands in the mempool, a QC forms, or a block commit happens, the node flips a small proposal latch: “after I finish handling this event, check whether there is block material worth packaging.” That latch is not a queue of transactions—it is a reminder to look.

B

Gather what could go in the next block

At the end of the current state-machine turn, if the latch was set, the node collects ready txs, finalized execution waves, and cross-shard provisions—whatever the protocol allows into the next height—and asks the BFT layer whether to act. Still no block bytes on the wire; this is planning.

C

Only the leader builds—and only if the gates allow

If this validator is not the designated proposer for this height and round, nothing happens (quiet defer). If a build is already in flight for the same slot, or vote locks forbid re-proposing, same story. When the stars align, the node requests a block build and the I/O layer reads storage, assembles header and body, and returns when done.

There is still no alarm clock that proposes every N milliseconds—only this “something changed → gather → maybe build” dance, plus view change when the leader stalls.

In-repo proof — latch + post-dispatch try_propose (no ProposalTimer)

// crates/shard/src/coordinator.rs
pub const fn queue_ready_proposal(&mut self) {
    self.verification.queue_ready_proposal();
}

// crates/node/src/state/mod.rs — end of handle()
if self.shard_coordinator.take_ready_proposal() {
    actions.extend(self.try_event_driven_proposal());
}

// crates/node/src/state/participation/proposal.rs
pub(super) fn try_event_driven_proposal(&mut self) -> Vec<Action> {
    let inputs = self.gather_proposal_inputs(...);
    self.shard_coordinator.try_propose(self.topology.snapshot(), &inputs.ready_txs, ...)
}
// crates/shard/src/coordinator.rs — try_propose (guards)
if !self.can_propose(topology_snapshot, next_height, round) { return vec![]; }
// can_propose: tip-committee proposer_for match + no duplicate in-flight BuildProposal for (height, round)
...

5 — The leader actually builds the block (step 8)

Think of a relay inside one validator: consensus has decided that this node may propose and which txs, waves, and provisions to try; storage and networking now do the heavy lifting.

First, the hot thread hands work to the block builder: read the ledger’s versioned state (the Jellyfish Merkle Tree / JVT layer), compute the new state root, and pack a header plus body. The BFT coordinator never assembles those bytes—it only asked for the job.

When storage finishes, the result comes back as an internal “proposal built” event. The leader stores the block as pending, sends peers a header and a hash-only manifest (table of contents—not the user’s Radix manifest from Phase 1), and signs its own validator vote (BLS committee crypto, not wallet Ed25519).

Why a Merkle tree? The state root in the header is one fingerprint of the whole ledger after this block. Peers can check agreement without replaying every key. Tx lists in the block also use Merkle-style commitments; the JMT is the key–value state piece—hover the glossary entries for the distinction.

Gotcha — stale ProposalBuilt: A separate in-flight proposal tracker ties storage callbacks to the dispatched BuildProposal. Late callbacks for an old height/round are dropped so repeated try_propose cannot double-broadcast.

In-repo proof — BFT emits Action::BuildProposal; runner/storage builds bytes

// crates/shard/src/coordinator.rs — try_propose returns:
return self.build_and_dispatch_proposal(
    topology_snapshot, next_height, round, ProposalKind::Normal { ... },
);
// → Action::BuildProposal { height, round, parent_qc, transactions, ... }

6 — Other validators read the proposal (still step 8)

The leader does not email everyone a full block first. Peers receive a compact header (who proposed, parent certificate, cryptographic roots) and a manifest—essentially a packing list of tx hashes, wave ids, and provision hashes. Bodies can arrive afterward via fetch if something was missing locally.

Each follower checks: was this really the right proposer? do the roots and timestamps make sense? does the manifest match what we can reconstruct? If yes, the validator signs a committee vote agreeing to that block hash. If pieces are still missing, the block waits in a pending slot until data shows up or the cleanup timer gives up (timing module).

In-repo proof — manifest type and gossip shape

// crates/types/src/block/manifest.rs
pub struct BlockManifest {
    tx_hashes: BoundedVec<TxHash, MAX_TXS_PER_BLOCK>,
    cert_ids: BoundedVec<WaveId, MAX_FINALIZED_TX_PER_BLOCK>,
    provision_hashes: BoundedVec<ProvisionHash, MAX_PROVISIONS_PER_BLOCK>,
}

7 — Votes: how they travel and where they land (step 9)

After a validator accepts a proposal header, it does not broadcast a QC—it signs a BlockVote: “I agree this block hash at this height and round.” That vote is BLS-signed validator identity, separate from user Ed25519 in Phase 1.

Are votes sent by gossip?

Not on the same path as transaction gossip. User txs use libp2p gossipsub topics (Phase 1). Consensus uses round-blocking notifications with class Consensus: BlockHeaderNotification (proposal) and BlockVoteNotification (vote). Votes are described in-repo as unicast notifications to committee peers (and toward likely next proposers via vote_recipients)—high-priority, not “fire-and-forget epidemic gossip” like TransactionGossip. The network handler turns an incoming BlockVoteNotification into ProtocolEvent::BlockVoteReceived on the pinned thread.

How votes are gathered locally

There is no shared “vote cloud.” Each validator keeps its own ledger of votes per block hash:

If votes are slow, the round feels stuck until view change—but slow aggregation alone is temporarily suppressed while a healthy pending block is within MAX_PROGRESS_WAIT (see timing module).

Equivocation vs revote?

Equivocation = conflicting votes for the same height and round. Revote = voting again after a new round at the same height (leader timed out, new proposer) — allowed.

In-repo proof — vote notification + local VoteSet (block_vote.rs, vote_set.rs)

// block_vote.rs — 2f+1 matching votes create a QuorumCertificate
// Sent via unicast notification to committee members

// coordinator.rs — after accepting header
Action::SignAndBroadcastBlockVote { block_hash, height, round, next_proposers, ... }
// → network → BlockVoteReceived on every node that receives it

8 — What creates the QC, and who puts it in a header? (step 10)

This is the piece newcomers mix up: votes do not automatically become a QC on the wire. A QC is built inside a validator process once enough verified votes exist for one block hash.

What gives you a QC?

2f+1 stake-weighted, cryptographically verified BlockVotes on the same block_hash. When VoteSet crosses that threshold, the node emits Action::VerifyAndBuildQuorumCertificate (often on the crypto worker pool). verify_and_build_qc checks the BLS batch and, if still at quorum, calls build_qc_from_verified to produce a QuorumCertificate value (aggregated signatures + metadata: height, round, block hash, parent hash, weighted timestamp).

Who creates it? Whichever validator first accumulates quorum on that hash—the proposer has no monopoly. That node runs the same aggregation code; others may run it milliseconds later when their vote sets fill in. The result is returned as ProtocolEvent::QuorumCertificateFormedShardCoordinator::on_qc_formed updates latest_qc on that machine.

Does the QC go into the block that was just voted on?

No. Block at height H is voted on; the QC you build certifies block H. That QC is stored as latest_qc locally and carried forward as the chain’s “highest certified block.” It is embedded in the next proposal:

End-to-end picture (one height)

  1. Proposer broadcasts BlockHeaderNotification for block B.
  2. Committee members verify → sign → send BlockVoteNotifications.
  3. Each node’s VoteSet for B fills; at quorum, someone builds QC(B).
  4. on_qc_formed may trigger two-chain commit for B’s parent and latch the next proposal.
  5. Later proposer for B’s child puts QC(B) in parent_qc of the new header.
Gotcha — QC vs vote: Votes are per-validator messages; the QC is the aggregated proof that quorum voted for one hash. There is no required standalone “QC gossip topic”—the next header’s parent_qc is the normal propagation path.

In-repo proof (1/3) — votes are wire messages; QC is built locally (block_vote.rs, action_handlers.rs)

// block_vote.rs — votes gossip; QC is not a separate notification type
/// Vote on a block proposal. 2f+1 matching votes create a `QuorumCertificate`.
pub struct BlockVoteNotification { pub vote: BlockVote }

// action_handlers.rs — verify_and_build_qc runs on whichever node triggered it
if !VotePower::has_quorum(verified_power, total_voting_power) {
    return QcVerificationResult { qc: None, ... };
}
let qc = build_qc_from_verified(
    block_hash, shard_id, height, round, parent_block_hash, ...);

// coordinator.rs — on_qc_result on that same node
if let Some(qc) = qc {
    return vec![Action::Continuation(
        ProtocolEvent::QuorumCertificateFormed { block_hash, qc },
    )];
}

In-repo proof (2/3) — two ways to learn a QC: aggregate locally or read parent_qc (coordinator.rs)

// Path A — you built QC locally (CommitSource::Aggregator)
actions.extend(self.try_two_chain_commit(topology_snapshot, qc, CommitSource::Aggregator));

/// Called from both `on_qc_formed` (when we build the QC locally) and
/// `on_block_header` (when we learn about a QC via the next block's
/// `parent_qc`). This ensures all validators commit regardless of whether
/// they received votes directly.
fn try_two_chain_commit(..., source: CommitSource) -> Vec<Action> { ... }

// Path B — you missed vote aggregation; child header carries the cert
fn absorb_parent_qc_from_header(..., header: &BlockHeader) -> Vec<Action> {
    if header.parent_qc().is_genesis() {
        return actions;
    }
    // verify signature, adopt into latest_qc, try_two_chain_commit(..., CommitSource::Header)
    ...
}

In-repo proof (3/3) — next proposer embeds QC in header, not the block being voted on (proposal.rs)

// assemble_build_action — parent_qc certifies the parent block link
let (parent_block_hash, parent_qc) = chain.proposal_parent();

// types — QC at height N commits parent at N-1 (two-chain rule)
pub fn committable_hash(&self) -> Option<BlockHash> {
    Some(self.parent_block_hash)
}

9 — Block commit: what it means on each validator (step 11)

Accurate two-chain rule in this repo: when your node holds a QC for block at height H+1, it may commit block H (the QC’s parent_block_hash / committable_hash). You do not need a QC on H+2 to commit H. Seeing block H+2 is only a convenient way to receive QC(H+1) inside parent_qc.

Block commit is not a decorative stamp on the header. On each validator, commit means:

Every honest validator runs this locally when its two-chain condition fires—the proposer does not “commit for the shard.”

In-repo prooftry_two_chain_commit and commit side effects

// crates/shard/src/coordinator.rs
fn try_two_chain_commit(...) -> Vec<Action> {
    // QC at height N → emit BlockReadyToCommit for parent block hash
    vec![Action::Continuation(ProtocolEvent::BlockReadyToCommit {
        block_hash: committable_hash, qc: certifying_qc, source,
    })]
}

// crates/node/src/state/participation/shard.rs — on_block_committed
actions.extend(self.mempool.on_block_committed(...));
actions.extend(self.execution.on_block_committed(...));

10 — What on_block_committed does to the mempool

Phase 1 left admitted txs in Pending on each validator’s local mempool—a BTreeMap<TxHash, PoolEntry> (hash → body + status), mutated only on the pinned ShardLoop thread. Phase 2’s block commit is the moment consensus says “this block is canonical on my node.” The node then calls MempoolCoordinator::on_block_committed with the CertifiedBlock (block + the QC that justified committing it).

Three fates for transactions

1. Tx is in the committed block. For each tx hash in block.transactions(), the mempool sets status to Committed(height) if it was still Pending, removes it from the “ready for proposal” set, and records state-node locks so conflicting txs cannot be proposed on top too early. It also emits EmitTransactionStatus so RPC clients can see the update. This is **consensus inclusion**, not “Radix finished”: the tx is now part of the committed chain on this validator, but execution may still be in flight.

If you voted on a block whose txs you never gossiped locally, the coordinator may **insert** those txs into the pool first (fetched for validation) and then mark them Committed in the same pass—so “in mempool” and “in block” can meet at commit time, not only at Phase 1 admission.

2. Tx is still Pending and not in this block. Nothing removes it. It remains in the ready set for a future try_propose (another leader, another height). The proposer of block B simply did not pick it—censorship or capacity, not expiry by default.

3. Terminal outcomes from certificates in the same block. The same handler walks block.certificates() (finalized waves bundled in the commit). When a certificate says a tx completed or aborted, the mempool can move to Completed / Aborted, evict the entry, and tombstone the hash so gossip cannot re-admit it. Heavy execution-wave orchestration is Phase 3, but the mempool’s commit hook already applies **per-tx decisions** carried in the committed block.

Chain time advances

The mempool stores current_height and current_ts from the committing block. current_ts is taken from the QC’s weighted_timestamp—the BFT-agreed clock for this shard, not wall clock on your laptop. That timestamp anchors validity-window checks (“is this tx still within its declared lifetime?”) and tombstone pruning on the cleanup timer. Until commit, Phase 1 expiry rules were provisional; after commit, the chain’s time base moves forward for everyone on this node.

Why block commit triggers another proposal latch

After a successful block commit, BFT often calls queue_ready_proposal() again. Committing block H frees the pipeline for height H+1: new QC parent, new ready txs that were waiting, new execution artifacts. The latch does not mean “re-propose the same block”—it means “the world changed; see if the leader should build the next one.”

Gotcha — Committed ≠ sent to Radix yet. Committed means “included in a block this node treats as committed.” The execution coordinator (Phase 3) consumes that block to run manifests and advance waves; mempool status and execution progress are linked but not the same instant.

In-repo proof — mempool structure + commit hook

// crates/mempool/src/coordinator.rs
pool: BTreeMap<TxHash, PoolEntry>,  // status: TransactionStatus

// crates/node/src/state/participation/shard.rs — on_block_committed
actions.extend(self.mempool.on_block_committed(self.topology.snapshot(), certified));

Liveness timers (not proposal) — genesis arms ViewChange + Cleanup

// crates/shard/src/coordinator.rs — initialize_genesis
self.queue_ready_proposal();
Action::SetTimer { id: TimerId::ViewChange, ... }

11 — Design Q&A (curated)

What is view change in hyperscale-rs?

Each round names one proposer for the current height. If that leader does not drive the shard forward (no timely QC / proposal progress), the ViewChangeTimer expires and every node runs the same timeout handler: advance the round, reset the timer, and let the next validator in the round-robin try. That is implicit view change—no separate PBFT-style view-change vote round. Hover the blue dotted terms for the glossary card; full timing constants live in Timers reference.

Why no periodic proposal timer?

Proposals are driven by new work (mempool ready set, new QC, provisions). Liveness when the leader stalls is ViewChangeTimerview change → new proposer_for — see timing module.

Can the proposer censor txs in the block body?

Yes at the mempool-selection layer (omit txs). Mitigation: view change rotates leader; users gossip to all validators (Phase 1); eventually another proposer includes the tx if it stays valid and ready.

Can a validator vote for two different blocks at the same height and round?

Honest nodes: no. After the first vote at a height, VoteKeeper::lock_decision blocks voting for a different block hash until timeout unlock or QC-based unlock — even across rounds (see test_vote_locking_prevents_conflicting_block in coordinator.rs).

Byzantine validators: yes, in theory. A vote is a BLS signature over block_vote_message(shard, height, round, block_hash); a malicious validator can sign two different hashes at the same (height, round). That is equivocation. In-repo handling is deterministic: batch verify_vote_batch rejects forgeries (wrong message vs claimed hash); after verification, record_received_vote flags a second different block at the same (height, round) and logs EQUIVOCATION DETECTED (first verified vote wins in the map). Slashing / governance uses the pair of valid signatures as evidence — not an on-chain assumption inside this repo.

Two proposals at the same height and round? An honest proposer does not double-build: can_propose skips duplicate in-flight (height, round). A Byzantine leader can still gossip two valid headers (same proposer_for, different bodies → different hashes); followers keep separate VoteSets per block_hash. Safety if both got QCs relies on quorum intersection + 2-chain commit, not on “only one proposal exists on the wire.”

Does only the proposer commit?

No. Every validator commits when its local two-chain condition fires — proposer is just the one who built the block at H.

What does a validator “committing” a block mean technically? How is that state inferred and stored?

Trigger (consensus, not proposer-only): When this node holds a QC for block H+1, the two-chain rule says block H is committable: QuorumCertificate::committable_hash() is the parent hash, committable_height() is qc.height().prev(). try_two_chain_commit emits BlockReadyToCommit { block_hash, qc: certifying_qc } — whether the QC was built locally or learned from block H+1’s parent_qc.

In-memory BFT bookkeeping: on_block_ready_to_commit requires the assembled block at committed_height + 1, then record_block_committed advances committed_height, committed_hash, committed_state_root, registers txs/certs/provisions in the dedup index, and prunes old pending state.

Durable chain write: The coordinator emits Action::CommitBlock (if this node already ran VerifyStateRoot and cached a PreparedCommit) or CommitBlockByQcOnly (recomputes JMT via prepare_block_commit in the shard_loop). BlockCommitCoordinator batches those into commit_prepared_blocks on embedded RocksDB (RocksDbStorage in production; SimStorage in simulation).

Downstream “commit” handlers: After persistence (or immediately under low lag), ProtocolEvent::BlockCommitted { certified } runs the node orchestrator: mempool Pending → Committed, remote-header liveness, provision pruning, etc. The block proposer additionally BroadcastCommittedBlockHeader so other shards learn the header+QC; followers still commit from their own two-chain path when they have the data.

Is committed-header gossip verified the same way as block-vote BLS (Phase 2 voting)?

No — three different crypto paths.

1. Block votes (consensus QC): Each vote signs block_vote_message(shard, height, round, block_hash). When building a QC, verify_vote_batch uses same-message BLS batching — every signature in the batch must be over the same message for that block.

2. Proposal headers (parent QC): When you accept a leader’s header, you verify its parent_qc via VerifyQcSignature (aggregated committee BLS on the parent block) before voting — that is QC verification, not “batch verify the header bytes.”

3. After commit — CommittedBlockHeaderGossip (cross-shard): The proposer broadcasts CommittedBlockHeader { header, qc } plus sender and a relay attestation: BLS over committed_block_header_message(shard, height, block_hash) (domain-separated from vote messages). The ShardLoop queues gossips in a committed_header_batch accumulator and, on flush, verifies each sender signature on the crypto pool, then emits RemoteHeaderReceived. Remote shards still run QC checks on the embedded certificate before trusting state_root for provisions / light-client style proofs. This path does not use Phase 1’s per-transaction Ed25519 checks.

12 — Design debate: pros, cons, mitigations

Event-driven propose

Work happens when there is content; no empty tick load.

Cost

Leader can stall without proposing while holding the round open until MAX_PROGRESS_WAIT / view change.

Mitigation: View-change timeout + implicit round advance.

QC in next header

Bandwidth-efficient; ties commit evidence to chain structure.

Cost

Harder to debug “where is QC(H)?” for newcomers.

Mitigation: Trace parent_qc on H+1; metrics on CommitSource (aggregator vs header).

Per-validator commit

No central committer; matches BFT replication model.

Cost

Sync lag → validator commits behind; execution/mempool see delayed current_ts.

Mitigation: Sync protocol + check_sync_health on cleanup timer.

13 — Phase 2 checklist

Quiz — Phase 2 (tricky)

Pass threshold 70%. Requires Phase 1 + this module’s gotchas.