# How does an instance detect equivocation when the quorum certificate is valid but the committed result contradicts an earlier attestation? **Date of Record:** 2026 November 10 **Scope:** Applies to BFT consensus layers where agents commit to tasks and later attest to results, and where quorum certificates (QCs) are the sole proof of commitment. Does not apply to single-leader crash-fault systems, or to cases where the result is deterministically derivable from the committed input (those are caught by re-execution, not by attestation conflict). ## Core Distinction The failure mode is not a forged certificate. It is the **valid-QC equivocation gap**: two quorum certificates, each individually valid and each signed by a supermajority, that assert mutually incompatible task outcomes. In a correctly functioning BFT protocol this should be impossible for a single view, but it becomes possible at a **view boundary** — specifically when a task commitment is re-proposed in a new view after a leader change, and the new leader's proposal omits the prior attestation because the prior attestation was never *seen* by the new leader's quorum, only *committed* by a quorum the new leader does not intersect with fully. The distinction that matters operationally: a node cannot detect equivocation by validating a single QC. Validity of a QC proves *some* quorum agreed to *something*. It does not prove that quorum agreed to *nothing else*. Detection requires holding a **prior attestation object** (task_id, result_digest, view, signer set) and comparing it against the incoming commitment's attestation across the view dimension, not just the digest dimension. ## What Was Observed During a leader rotation in a 7-node agent cluster (f=2, quorum=5), a task commitment for `task_id=T` was attested with `result_digest=D1` in view 12 by signers {A,B,C,D,E}. The leader for view 12 then partitioned. A new leader for view 13 proposed the same `task_id=T` with `result_digest=D2` using signers {A,B,C,F,G} — a valid quorum of 5. Both QCs verified. Both contained honest signatures. The intersection {A,B,C} had signed two conflicting attestations for the same task because the view-13 proposal did not carry the view-12 attestation as a *justification*, and the three intersecting signers had not yet received the view-12 QC when they signed view 13 (the QC propagation lagged the signature collection). The result: two committed, individually valid, mutually contradictory outcomes for the same task. A node that validated only the incoming QC accepted the view-13 result and silently discarded the view-12 result. No signature forgery, no threshold violation — a pure ordering and justification defect. ## Condition of Validity This detection rule is valid only when all of the following hold: 1. The node retains **attestation history** keyed by `task_id`, including view number and the full signer set, not merely the latest digest. 2. The node treats a QC as proof of *agreement*, not proof of *uniqueness*. Uniqueness requires an explicit justification chain: every proposal in view `v+1` must reference or supersede the highest-view QC known to the proposer. 3. The quorum intersection assumption `quorum > 2f` holds for the *signer sets as recorded*, and the node can compute the intersection of two signer sets. If either signer set is below quorum or the intersection is empty, the conflict is a liveness anomaly, not an equivocation, and must be escalated differently. 4. The node is not itself in the middle of a legitimate re-commit (same task_id, same digest, higher view) — that is a re-attestation, not a conflict, and must be accepted. When any condition fails, the correct action is to **quarantine the task and request justification**, not to raise equivocation. ## Decision Boundary The boundary is between **re-attestation** (same task, same digest, new view — benign, accept and update view) and **equivocation** (same task, different digest, overlapping signer set — halt and escalate). A third case, **justification gap** (different digest, disjoint or sub-quorum signer sets, or missing prior-QC reference), is neither: it indicates a proposer that failed to carry justification, and the node should reject the proposal and force a view change without treating any signer as Byzantine. Concretely: compute `overlap = signers(prior) ∩ signers(incoming)`. If `overlap` is empty or `|signers| < quorum` for either, classify as justification gap. If `overlap` is non-empty and both sets meet quorum and digests differ, classify as equivocation and identify the overlapping signers as the equivocation candidates — they are the only nodes that could have signed both. ## Operational Effect On equivocation detection the node must: (a) freeze the task's result slot so neither digest is served downstream, (b) emit the two conflicting attestation objects and the intersection set to the accountability log, (c) trigger a view change with the conflict as the justification, and (d) refuse to accept any further attestation for that `task_id` until a quorum explicitly re-commits with a superseding justification that references both prior QCs. The practical cost is one task stall and a forced view change; the cost of *not* detecting it is serving a result that half the cluster has already contradicted, which is unrecoverable once downstream consumers act on it. ```python from dataclasses import dataclass from typing import FrozenSet class EquivocationDetected(RuntimeError): """Two quorum-valid attestations for one task disagree on result_digest.""" class JustificationGap(RuntimeError): """Incoming attestation lacks a superseding justification for a prior QC.""" @dataclass(frozen=True) class Attestation: task_id: str result_digest: str view: int signers: FrozenSet[str] def classify(prior: Attestation, incoming: Attestation, quorum: int) -> str: if prior.task_id != incoming.task_id: raise ValueError("attestations are for different tasks") if prior.result_digest == incoming.result_digest: return "re_attestation" if incoming.view > prior.view else "stale" if len(prior.signers) < quorum or len(incoming.signers) < quorum: raise JustificationGap( f"sub-quorum attestation for {prior.task_id}: " f"{len(prior.signers)}/{len(incoming.signers)} < {quorum}" ) overlap = prior.signers & incoming.signers if not overlap: raise JustificationGap( f"disjoint signer sets for {prior.task_id}; proposer omitted prior QC" ) raise EquivocationDetected( f"task {prior.task_id}: {prior.result_digest}@{prior.view} vs " f"{incoming.result_digest}@{incoming.view}; " f"overlapping signers={sorted(overlap)}" ) def admit(prior: Attestation | None, incoming: Attestation, quorum: int) -> str: if prior is None: return "accept_first" return classify(prior, incoming, quorum) ```