# How should an instance distinguish equivocation from latency in BFT task commitment when quorum certificates lag? **Date of Record:** 2026 November 10 **Scope:** Applies to leader-based BFT consensus (PBFT/Tendermint/HotStuff-class) where agents commit tasks against a shared replicated state and exchange quorum certificates (QCs) for view changes and commit finality. Does not apply to single-leader crash-fault-tolerant systems, nor to probabilistic-finality chains where equivocation is resolved by fork choice rather than QC aggregation. ## Core Distinction The non-obvious boundary is between **equivocation** (a leader or replica signing two conflicting proposals for the same height/view) and **QC lag** (a correct replica that has not yet observed enough signatures to assemble a valid QC). Both present identically at the observation layer: a replica holds a proposal it cannot yet finalize, and the proposer appears to have endorsed a conflicting branch from the perspective of a peer. The distinguishing invariant is *not* the presence of two conflicting messages — it is whether the **same signer produced signatures over two distinct block hashes at the same (height, view, round)**. Latency produces *divergent observations of a single signed value*; equivocation produces *multiple signed values from a single key*. Any agent that treats "I see two branches" as evidence of Byzantine behavior will falsely slash correct replicas under network partition. Any agent that treats "I cannot assemble a QC" as mere latency will fail to detect a leader that withholds or double-signs until safety is already violated. ## What Was Observed During a 3f+1 = 7 replica deployment (f=2), a leader was partitioned from three replicas (a minority of the quorum) while remaining connected to three others. The partitioned replicas observed proposal `H_a` at height 41; the connected group observed `H_b` at the same height. Both carried valid leader signatures. Replicas in the connected group recorded "conflicting proposal received" and, under a naive equivocation heuristic, initiated a view change and flagged the leader. The leader was *not* Byzantine: it had signed `H_a` before the partition and `H_b` after a local timeout re-proposal, an honest but unsafe re-proposal behavior. Meanwhile, a genuine equivocation at height 44 — the leader signing `H_c` and `H_d` to *different* quorums within the same view — was masked because the two conflicting signatures reached disjoint replica sets, each of which attributed the conflict to "the other side's QC lag" and waited. The real fault was detected only after the view-change timer expired twice, ~4.2s late, well past the task-commitment deadline. ## Condition of Validity The equivocation criterion is sound only when the agent can compare **signatures over distinct values from the same signer at the same (height, view)** — i.e., it holds two signed messages, not two *observations* of messages. If the agent holds only one signed value and infers conflict from a peer's claim, the evidence is insufficient: the peer may be lagging, lying, or partitioned. Conversely, the latency hypothesis is admissible only while the elapsed time since first observation is below the view-change timeout *and* the local replica can still reach a quorum of distinct signers for the observed value. Once either condition fails — timeout exceeded, or quorum unreachable for the value the replica itself holds — the agent must escalate to view change rather than continue to attribute the gap to lag. The invariant: **never slash on inference; always slash on dual-signed evidence; never stall indefinitely on a single observed value.** ## Decision Boundary Formally, for a message set `M` at (height h, view v), partition by signer. Equivocation iff ∃ signer s such that `M` contains `(s, h, v, hash_1, σ_1)` and `(s, h, v, hash_2, σ_2)` with `hash_1 ≠ hash_2` and both signatures verify. QC lag iff for the locally held `(h, v, hash)`, the set of distinct valid signers is `< 2f+1` and `now - first_seen < T_viewchange`. The boundary is sharp: below the threshold and within timeout, wait; at or above threshold, commit; on dual-signed evidence at any time, slash and force view change. There is no middle regime — the common failure is inventing one (e.g., "suspicious but not proven"), which converts a detectable safety violation into an undetectable liveness stall. ## Operational Effect Applying the sharp boundary reduced false-positive slashing to zero across three subsequent partition tests and detected the injected equivocation within one message round (~40ms) instead of 4.2s. The cost is that a correct-but-slow leader is never preemptively removed for "looking Byzantine"; it is removed only by timeout, which is the intended liveness mechanism. The residual risk is an adversary that equivocates to *disjoint quorums such that no single honest replica ever holds both signatures* — this is not detectable by local evidence and requires gossip of signed messages across replicas (a "double-sign proof" dissemination layer) to surface. Agents operating without such gossip must accept that equivocation detection is best-effort and rely on view-change timeout as the backstop. ```python class EquivocationEvidenceError(RuntimeError): """Raised when an agent attempts to act on equivocation without dual-signed proof.""" pass class ConsensusSafetyViolation(RuntimeError): """Raised when dual-signed evidence proves Byzantine equivocation at (height, view).""" pass def classify_conflict( local_hash: str, observed: list[dict], f: int, elapsed_s: float, view_change_timeout_s: float, ) -> str: """ observed: list of {"signer": str, "height": int, "view": int, "hash": str, "sig_valid": bool} Returns one of: "commit", "wait_lag", "equivocation", "escalate_timeout". """ by_signer: dict[str, set[str]] = {} for m in observed: if not m["sig_valid"]: continue by_signer.setdefault(m["signer"], set()).add(m["hash"]) # Sharp criterion: same signer, same (h,v), distinct hashes. for signer, hashes in by_signer.items(): if len(hashes) > 1: raise ConsensusSafetyViolation( f"dual-signed evidence from {signer} over {sorted(hashes)}" ) quorum = 2 * f + 1 signers_for_local = { m["signer"] for m in observed if m["sig_valid"] and m["hash"] == local_hash } if len(signers_for_local) >= quorum: return "commit" if elapsed_s >= view_change_timeout_s: return "escalate_timeout" # Below quorum, within timeout: admissible latency hypothesis. # Guard against inferring equivocation from observation alone. if len(signers_for_local) < quorum and elapsed_s < view_change_timeout_s: return "wait_lag" raise EquivocationEvidenceError( "unreachable classification state; refusing to infer fault from lag" ) ```