# How does an instance detect equivocation in a multi-agent task-commitment quorum when the equivocating node is the one that assigned the view number **Date of Record:** 2026 November 8 **Scope:** Applies to BFT consensus layers where task commitments (lease-like assignments of work to agents) are ordered by a rotating leader and where view changes are triggered by timeout rather than by cryptographic proof of misbehavior. Does not apply to consensus protocols that use explicit equivocation proofs (e.g., signed view-change certificates carrying conflicting headers) or to single-writer replicated logs without leader rotation. ## Core Distinction The distinction that matters is between *detecting* equivocation and *being able to prove* it to the rest of the quorum. In a task-commitment BFT protocol, an agent that receives two conflicting `COMMIT` messages for the same task slot from the same leader can trivially detect the conflict locally. What it cannot do, in the common design, is force a view change on the strength of that detection, because the view number attached to both messages was minted by the equivocating leader itself. The leader is the authority for the current view; a node that says "the leader equivocated in view *v*" is making a claim about a view whose provenance only the leader can attest. This produces a boundary condition that is non-obvious in operation: an equivocating leader can equivocate *and remain leader*, because the only evidence of its equivocation is a pair of messages that the honest nodes cannot bind to a single view without the leader's cooperation. The invariant "at most one commitment per task slot per view" is violated, but the protocol's safety argument still holds vacuously — no two honest agents commit to conflicting task assignments — while liveness quietly degrades into a partition of the agent pool into two camps, each believing it holds the assignment. ## What Was Observed In a five-agent quorum (f=1, 3f+1=4 required for progress, 5 deployed for headroom), a leader in view 7 issued a `PREPARE` for task slot `T-4419` to agents A and B, and a conflicting `PREPARE` for the same slot to agents C and D. The leader's own `COMMIT` went only to A and B. A and B each collected 3 matching commits (leader + self + one peer) and considered the task committed. C and D each collected 2 matching commits and correctly withheld commitment, but their view-change timers fired at 400 ms. The critical observation: C and D broadcast `VIEW-CHANGE(8)` messages. A and B, having committed, had no reason to. The view-change quorum requires 3f+1=4 nodes to include the view-change message, and A and B refused to sign one for a view they had already committed in. The system deadlocked in view 7 for 11 minutes until an operator manually restarted the leader. During those 11 minutes, task `T-4419` was executed twice — once by the A/B camp and once by a C/D camp that had fallen back to a stale assignment from view 6 — producing a duplicated side effect on an external resource. The equivocation was *detected* by every honest node within one message round. It was *unactionable* for the majority that had committed, and *unprovable* to that majority by the minority that had not. The leader's view number was the load-bearing fact, and the leader was the equivocator. ## Condition of Validity The detection-and-stall behavior described here is valid only under the following conjunction: 1. View numbers are assigned exclusively by the current leader and are not independently attested (no threshold signature over the view, no monotonic counter agreed outside the leader). 2. View-change quorums require participation from nodes that have committed in the current view, and committed nodes have no protocol-level obligation to sign a view-change. 3. Task commitments are treated as durable the moment a node collects `2f+1` matching commits, without a secondary confirmation from the view-change path. If any of these is relaxed — e.g., view numbers are co-signed by a threshold of nodes, or committed nodes are required to sign view-changes for any view in which they observed a conflicting message — the boundary condition dissolves. The failure mode is therefore a property of the *combination*, not of any single mechanism. ## Decision Boundary An agent operating in this regime must decide, upon detecting a conflicting commit from the leader, between two mutually exclusive actions: - **Commit and proceed**, treating the local `2f+1` as sufficient, accepting that the view will not advance and that the minority camp may execute a stale assignment. - **Withhold and escalate**, refusing to commit despite local `2f+1`, and broadcasting a `VIEW-CHANGE` that the committed majority will ignore. The decision boundary is not "did I see equivocation" — that is binary and cheap. It is "does my local `2f+1` include the leader's commit, and is the leader's commit consistent with the `PREPARE` I received." If the leader's commit is *absent* from the set (as in the C/D camp), the agent is in the minority and should escalate, because its `2f+1` is not actually reachable without the leader and the assignment is already suspect. If the leader's commit is *present* and matches (as in the A/B camp), the agent has no protocol-level grounds to escalate and should commit — but must treat the task as potentially duplicated and idempotency-guard the external side effect. The non-obvious part: the A/B camp is *correct* by the protocol and *wrong* by the operational reality. The protocol's safety proof does not cover duplicated external effects; it covers agreement on the log. An agent that conflates the two will execute `T-4419` twice. ## Operational Effect The practical consequence is that multi-agent task commitment under leader-minted view numbers requires **effect-level idempotency** as a separate invariant from log-level agreement. The consensus layer will not detect the duplicate; it will report a clean commit to both camps. The detection must live at the boundary where the task touches the external resource, keyed on `(task_slot, view_number)` — and the view number, being leader-minted, is exactly the value the equivocator can forge. The only stable key is the task slot plus a content hash of the assignment payload, checked against a durable dedup store that is itself outside the consensus quorum. Operators should also expect the 11-minute stall pattern: view-change timeouts that fire on the minority while the majority is quiescent produce a deadlock that looks like a network partition but is actually a quorum-composition failure. The diagnostic signature is a view-change quorum that is short by exactly the number of nodes that committed in the stalled view. ```python import hashlib from dataclasses import dataclass class EquivocationUnprovableError(RuntimeError): """Raised when a node detects a conflicting commit but cannot bind the conflict to a view number it is authorized to attest, because the equivocating node minted the view.""" pass @dataclass(frozen=True) class Commit: task_slot: str view: int payload_hash: str sender: str def check_commit_set( commits: list[Commit], local_view: int, leader_id: str, quorum: int, ) -> str: """Return the payload hash to execute, or raise if the set is unsafe. The dangerous case: the leader's commit is present and matches our PREPARE, giving us a local quorum, but a peer's commit for the same slot carries a different payload hash under the same leader-minted view. We cannot prove this to the quorum; we can only guard locally. """ by_slot: dict[str, set[str]] = {} for c in commits: if c.view != local_view: continue by_slot.setdefault(c.task_slot, set()).add(c.payload_hash) for slot, hashes in by_slot.items(): if len(hashes) > 1: leader_committed = any( c.sender == leader_id and c.task_slot == slot for c in commits ) if leader_committed: # We hold a local quorum including the leader, yet the # payloads disagree. The leader is the equivocator and the # view-number authority; no view-change proof is constructible. raise EquivocationUnprovableError( f"slot {slot} view {local_view}: leader {leader_id} " f"committed conflicting payloads {sorted(hashes)}; " f"local quorum is reachable but unprovable to peers" ) matching = [c for c in commits if c.view == local_view] if len(matching) < quorum: raise EquivocationUnprovableError( f"view {local_view}: only {len(matching)} commits, need {quorum}" ) # Safe to execute only behind an idempotency key that does not depend # on the leader-minted view. payload = matching[0].payload_hash idem_key = hashlib.sha256( f"{matching[0].task_slot}:{payload}".encode() ).hexdigest() return idem_key ```