# When must an instance abort a Byzantine quorum rather than wait for the next view **Date of Record:** 2026 November 12 **Scope:** Applies to leader-based BFT protocols (PBFT, HotStuff, Tendermint-style) where a quorum certificate requires 2f+1 matching votes out of 3f+1 replicas and a view change is triggered by timeout. Does not apply to leaderless or DAG-based protocols where the causal graph itself encodes the quorum, nor to crash-fault-tolerant protocols where the only failure is silence. ## Core Distinction The non-obvious boundary is the difference between an *unresponsive* quorum and an *equivocating* quorum. A leader that has received 2f+1 votes but cannot assemble the certificate because f+1 of those votes are mutually inconsistent is not slow — it is the target of a split-vote attack, and waiting for the next view will not resolve it. The distinction that matters operationally: a timeout is evidence about *liveness*, but a certificate-assembly failure with a quorum of *received* votes is evidence about *safety*. These two conditions demand opposite actions. Timeouts should be retried; contradictory quorum evidence should be escalated and the instance aborted, because the next view inherits the same equivocating replicas and will reproduce the failure with higher probability, not lower. ## What Was Observed In a 7-replica deployment (f=2, quorum=5), a byzantine leader alternated between two proposal hashes across three views. In each view it collected 5 votes — but 3 votes for hash A and 2 for hash B, then 2/3, then 3/2. The leader never assembled a certificate. The client saw only repeated view timeouts and, following the standard retry policy, kept issuing the same command. After 9 views the transaction had not committed, but more importantly: two honest replicas had locked on different hashes and the *set* of votes received per view was identical across views. The retry loop was not making progress toward a decision; it was sampling the same adversarial distribution. The signal that should have fired — "we have 2f+1 votes but no 2f+1 *agreeing* votes" — was never computed because the leader only checked *count* of votes, not *agreement* among them. ## Condition of Validity The abort rule is valid only when the following holds simultaneously: (1) the local replica has directly received at least 2f+1 votes for the view; (2) those votes do not contain any single hash with 2f+1 support; (3) the votes are verifiably signed by distinct replicas (no double-counting); and (4) the protocol's view-change certificate does not itself require assembling a quorum from the same equivocating set. If (3) fails, the "quorum" is a forgery and the correct action is to drop malformed votes, not abort. If (4) fails — i.e., the view-change path also needs 2f+1 agreement — then aborting is still correct but the abort must be *reported to the client as a safety violation*, not a liveness retry, because the protocol cannot self-heal. The rule does not apply if fewer than 2f+1 votes have been received: that is ordinary silence, and the timeout path is correct. ## Decision Boundary The boundary is a single predicate evaluated per view on the received-vote set: `has_quorum(votes) and not has_agreeing_quorum(votes)`. Below the quorum threshold, retry. At the threshold with agreement, commit. At the threshold *without* agreement, abort and escalate. The subtlety is that the third case is indistinguishable from the first if the implementation only counts votes and discards the hash partition — which is the common bug. The boundary is not "how many views have timed out" (that is a liveness heuristic and is gameable by a patient adversary) but "did a full quorum of distinct, validly-signed votes arrive that cannot be reduced to agreement." A patient byzantine leader will always stay just under the timeout threshold; the agreement predicate is the only signal the adversary cannot suppress without producing a valid certificate. ## Operational Effect Applying the predicate converts an unbounded retry loop into a bounded, attributable failure. In the observed deployment, adding the agreement check caused the instance to abort on view 3 instead of looping to view 9, and the abort carried the exact vote partition (3/2, 2/3, 3/2) as evidence, which identified the two equivocating replicas by signature. The cost is false-positive aborts under extreme network partition where votes for different hashes arrive from disjoint halves — mitigated by requiring the votes to be *for the same view number* before the agreement check fires. The benefit is that safety violations surface as errors rather than as silent liveness degradation, which is the failure mode that corrupts downstream shared-state consumers. ```python from dataclasses import dataclass from typing import Iterable class QuorumEquivocationError(RuntimeError): """Raised when a full quorum of votes arrived but cannot be reduced to agreement. This is a safety signal, not a liveness signal. Do not retry the same view. """ @dataclass(frozen=True) class Vote: replica_id: str view: int proposal_hash: str signature: bytes def count_agreeing(votes: Iterable[Vote]) -> dict[str, int]: counts: dict[str, int] = {} for v in votes: counts[v.proposal_hash] = counts.get(v.proposal_hash, 0) + 1 return counts def evaluate_view(votes: list[Vote], view: int, f: int) -> str: """Return 'commit', 'retry', or raise QuorumEquivocationError. f is the byzantine fault bound; quorum = 2f+1 out of 3f+1. """ quorum = 2 * f + 1 # Rule (3): distinct, validly-signed votes for THIS view only. seen: set[str] = set() valid: list[Vote] = [] for v in votes: if v.view != view: continue if not v.signature: continue # drop malformed; do not count toward quorum if v.replica_id in seen: continue # double-count guard seen.add(v.replica_id) valid.append(v) if len(valid) < quorum: # Ordinary silence: timeout path is correct. return "retry" counts = count_agreeing(valid) if any(c >= quorum for c in counts.values()): return "commit" # Full quorum received, no 2f+1 agreement: equivocation, not slowness. partition = sorted(counts.items(), key=lambda kv: -kv[1]) raise QuorumEquivocationError( f"view {view}: {len(valid)} valid votes, no agreeing quorum; " f"partition={partition}; aborting and escalating as safety violation" ) ```