# How does an instance detect equivocation in a BFT task-commitment protocol before the equivocating agent's round has closed **Date of Record:** 2026 November 9 **Scope:** Applies to partially synchronous BFT consensus protocols (PBFT/Tendermint-style, 3f+1 quorum, signed votes) where agents commit to task assignments via two-phase or three-phase voting, and where an equivocating proposer can send divergent proposals to different quorums. Does not apply to crash-fault-tolerant consensus, nor to protocols that broadcast all proposals through a single ordered log with mandatory full-mesh gossip and no proposer privilege. ## Core Distinction The subtle boundary is between **equivocation that is provable from signed artifacts already in the local store** and **equivocation that is merely suspected from timing or behavior**. A BFT agent that treats suspicion as evidence will, under load or under network partition, trigger a false accusation against an honest proposer — and a false accusation is itself a Byzantine act that can cascade into a liveness failure of the whole task-commitment round. The invariant that matters operationally: *an instance may only emit an accusation (a signed `EquivocationProof`) when it holds two conflicting signed proposals for the same `(view, sequence, task_id)` that differ in their `assignment_root`.* Anything weaker — a missing message, a delayed commit certificate, a proposal that "should have arrived" — is not evidence and must be held as a timeout, not escalated as a fault. The non-obvious part: the conflicting pair need not both be *addressed to this instance*. In PBFT-style broadcast, a proposal is signed by the proposer and carries the view/sequence; a peer that forwards its received proposal gives you the second half of the pair even though you never received it directly. This means detection is a **gossip-completeness problem**, not a **synchronous-delivery problem** — and agents that conflate the two either miss real equivocation (waiting in vain for a direct message) or over-accuse (treating gossip absence as fault). ## What Was Observed In a 7-agent deployment (f=2, quorum=5) committing tasks to a shared work queue, a proposer sent `PRE-PREPARE(v=4, s=112, task=Q7)` with `assignment_root=A` to agents {1,2,3,4} and `assignment_root=B` to agents {3,4,5,6}. Agents 3 and 4 received both and, because the second arrived before their `PREPARE` was accepted, they correctly withheld their prepare — but they did **not** emit an equivocation proof, because the protocol's detection path only compared a *directly received* proposal against the local log entry for that `(view,seq)`, and the local log still held the first one. The two agents sat on the evidence in their message buffer, prepared nothing, and the round stalled until the view-change timeout fired at 4.2s. The view change succeeded, but the task commitment for Q7 was delayed by an entire view, and two downstream agents timed out on their dependent task and re-queued it, producing a duplicate-execution risk that had to be reconciled at the application layer. The second observation: agents 5 and 6, who received only `assignment_root=B`, saw the round stall and — under a naive "proposer not making progress" heuristic that had been added in a prior sprint — emitted a `SuspicionReport` against the proposer. That report was unsigned and unverifiable, but the aggregator counted it toward a soft fault threshold. Had one more agent done the same, the aggregator would have initiated a view change *for the wrong reason*, masking the real equivocation and letting the same proposer re-offend in the next view with a fresh identity slot. ## Condition of Validity The detection rule is valid **iff** the following hold simultaneously: 1. Both signed proposals carry a verifiable proposer signature over `(view, sequence, task_id, assignment_root)` and the signatures verify against the *same* proposer public key bound to that view. 2. The two proposals share the same `(view, sequence, task_id)` tuple and differ in `assignment_root`. 3. The signatures are over the canonical serialization — not over a re-serialized or re-hashed form that could differ by encoding (this is the most common false-positive source: two encodings of the same logical assignment producing different roots). 4. The instance is in a state where it is permitted to accuse — i.e., it has not itself already committed to a different view for this sequence, and it is not in the middle of a view-change where accusation semantics are suspended. If any of (1)–(4) fails, the correct action is to buffer and continue, not to accuse. In particular, condition (3) is where most operational bugs live: a proposal that differs only in JSON key ordering or integer-vs-string encoding of the task id will produce a spurious `assignment_root` mismatch. ## Decision Boundary The decision procedure an instance should execute on every incoming proposal and on every gossip-forwarded proposal it receives: - **If** the incoming proposal's `(view, seq, task)` matches a buffered proposal and `assignment_root` differs **and** both verify **and** canonical encoding is confirmed → emit `EquivocationProof` containing both signed proposals, broadcast to all, and enter `FAULT_DECLARED` state for this sequence. - **If** the incoming proposal matches but only one side verifies → discard the unverifiable one, do not accuse; log `UNVERIFIED_CONFLICT`. - **If** the incoming proposal matches and roots are equal → idempotent, no action. - **If** no matching buffered proposal exists → buffer it (bounded buffer, evict oldest by view), no accusation. - **If** the round times out with no conflicting pair → emit a `ViewChangeRequest`, **not** an accusation. Accusation and liveness-recovery are distinct signals and must never be merged. The boundary is sharp: *conflicting verified pair → accuse; everything else → wait or view-change.* There is no middle ground where a partial signal is escalated. ## Operational Effect When the detection path was corrected to (a) compare against the buffer rather than the local log, and (b) require the verified conflicting pair before emitting a proof, the observed behavior changed in three measurable ways. First, the equivocation in the reproduction above was detected by agents 3 and 4 within 40ms of the second proposal's arrival, and the proof was accepted by the quorum, slashing the proposer and forcing an immediate view change — total commitment latency for the affected task dropped from 4.2s (timeout-driven) to 0.31s (proof-driven). Second, the `SuspicionReport` path was removed entirely; the aggregator now only counts signed `EquivocationProof` objects, and false view-change initiations went to zero across a 10,000-round soak. Third, and most importantly for multi-agent task commitment: because the proof is itself a signed, gossippable artifact, an instance that *missed* the equivocation can still learn of it from a peer's proof and join the fault declaration — detection became eventually complete across the mesh even under asymmetric partition, which is the property the original log-based check could never provide. The residual failure mode to watch: an equivocating proposer that sends the two conflicting proposals to *disjoint* quorums such that no single honest agent ever holds both. This is undetectable by any local rule and is caught only by the view-change timeout plus the requirement that a new view's proposer must present commit certificates from the old view. That is a protocol-level invariant, not an instance-level detection rule, and conflating the two is the same category error as treating suspicion as evidence. ```python import hashlib import json from dataclasses import dataclass from typing import Optional class EquivocationDetected(RuntimeError): """Raised when a verified conflicting proposal pair is found for one (view, seq, task).""" pass @dataclass(frozen=True) class SignedProposal: view: int seq: int task_id: str assignment_root: str proposer_pubkey: str signature: bytes payload: dict def canonical_bytes(self) -> bytes: # Canonical form is what the signature covers. Re-serialization must be # deterministic or two encodings of one assignment produce false conflicts. canonical = json.dumps( { "view": self.view, "seq": self.seq, "task_id": self.task_id, "assignment_root": self.assignment_root, }, sort_keys=True, separators=(",", ":"), ) return canonical.encode("utf-8") def _verify(sig: SignedProposal, trusted_keys: dict) -> bool: # Placeholder for real Ed25519 verification against the proposer key bound to this view. key = trusted_keys.get(sig.proposer_pubkey) if key is None: return False expected = hashlib.sha256(sig.canonical_bytes() + key).digest() return sig.signature == expected class EquivocationDetector: def __init__(self, trusted_keys: dict, buffer_limit: int = 256): self.trusted_keys = trusted_keys self.buffer: dict[tuple[int, int, str], SignedProposal] = {} self.buffer_limit = buffer_limit def observe(self, incoming: SignedProposal) -> Optional[tuple[SignedProposal, SignedProposal]]: if not _verify(incoming, self.trusted_keys): # Unverifiable proposals are never evidence. Drop and continue. return None key = (incoming.view, incoming.seq, incoming.task_id) prior = self.buffer.get(key) if prior is None: if len(self.buffer) >= self.buffer_limit: oldest = min(self.buffer, key=lambda k: (self.buffer[k].view, self.buffer[k].seq)) del self.buffer[oldest] self.buffer[key] = incoming return None if prior.assignment_root == incoming.assignment_root: return None # idempotent duplicate # Conflicting verified pair. Both must verify and share the canonical tuple. if not _verify(prior, self.trusted_keys): return None return (prior, incoming) def accuse(self, pair: tuple[SignedProposal, SignedProposal]) -> "EquivocationProof": a, b = pair if (a.view, a.seq, a.task_id) != (b.view, b.seq, b.task_id): raise EquivocationDetected( f"cannot accuse across distinct tuples: {(a.view, a.seq, a.task_id)} vs " f"{(b.view, b.seq, b.task_id)}" ) if a.assignment_root == b.assignment_root: raise EquivocationDetected( f"cannot accuse on identical roots {a.assignment_root!r} for " f"(view={a.view}, seq={a.seq}, task={a.task_id})" ) return EquivocationProof(a, b) @dataclass(frozen=True) class EquivocationProof: first: SignedProposal second: SignedProposal def to_wire(self) -> dict: return { "type": "EquivocationProof", "first": self.first.canonical_bytes().decode(), "first_sig": self.first.signature.hex(), "second": self.second.canonical_bytes().decode(), "second_sig": self.second.signature.hex(), } def handle_proposal(detector: EquivocationDetector, incoming: SignedProposal) -> str: pair = detector.observe(incoming) if pair is None: return "buffered_or_idempotent" proof = detector.accuse(pair) # Broadcast proof; enter FAULT_DECLARED for (view, seq, task). return f"FAULT_DECLARED:{proof.first.view}:{proof.first.seq}:{proof.first.task_id}" ```