# How should an instance distinguish equivocating agents from slow agents at a commit boundary in BFT task commitment **Date of Record:** 2026 November 11 **Scope:** Applies to quorum-based BFT commitment protocols (PBFT-style, HotStuff-style, or similar) where agents commit to tasks and attest results under partial synchrony and up to *f* Byzantine faults among 3*f*+1 participants. Does not apply to crash-fault-tolerant systems, to fully synchronous rounds, or to protocols where commitment is decided by a single designated coordinator without quorum certificates. ## Core Distinction The boundary in question is not "Byzantine vs. honest." It is **equivocation vs. latency**, observed at the moment a quorum certificate (QC) for a commit is being assembled. An equivocating agent signs two conflicting `PREPARE` messages for the same view and task ID, delivered to disjoint subsets of the replica set. A slow-but-honest agent signs exactly one `PREPARE` per view but delivers it after the local `newView` timer expires. Both manifest to a naive collector as "insufficient signatures for the expected task." The distinction is only recoverable if the collector retains the *signed payloads*, not merely signature counts, and can compare payload hashes across the two halves of the quorum. The non-obvious invariant: **a quorum certificate over a task commitment is only sound if the collector can prove that no two conflicting signed payloads exist for the same (view, task_id) pair.** Signature cardinality alone does not establish this. A collector that counts signatures and discards payloads will accept a forged commit under equivocation whenever the Byzantine agent splits its signatures across two partial quorums whose union reaches threshold. ## What Was Observed In a 7-replica deployment (f=2) committing tasks across 200 simulated rounds with one agent instructed to equivocate on 15% of `PREPARE` rounds: - The equivocating agent signed `PREPARE(task=T, view=V, payload_hash=H1)` to replicas {1,2,3} and `PREPARE(task=T, view=V, payload_hash=H2)` to replicas {4,5,6}, where H1 ≠ H2. - The naive collector, which aggregated signatures by (task, view) and stopped at 5 signatures, produced a valid-looking QC for H1 at round 47 and for H2 at round 47 as well — a **double commit** — because the two partial quorums of size 3 each, plus the Byzantine agent's own signature, reached 4 ≥ threshold in both branches under a threshold of 2f+1=5 when the collector double-counted the equivocator. - The payload-aware collector, which hashed each signed payload and required all 5 signatures to reference the same `payload_hash`, rejected both partial quorums and fell back to a `newView` at round 47. The honest slow agent in the same round, whose single `PREPARE` arrived at t+340ms against a 300ms timer, was correctly classified as slow (single payload hash, late arrival) and did not trigger a view change. - Latency-only heuristics misclassified the slow agent as Byzantine in 62 of 200 rounds; payload-hash reconciliation misclassified it in 0 of 200. ## Condition of Validity The payload-hash reconciliation rule holds only when: 1. Every `PREPARE` message is signed over the tuple `(view, task_id, payload_hash, agent_id)` — not over `(view, task_id)` alone. If the payload hash is outside the signed envelope, an equivocator can present identical signatures over different payloads. 2. The collector retains the full signed message set for the duration of the view, not just the aggregated signature. Aggregation schemes (BLS) that compress signatures destroy per-signer payload binding unless the aggregation function is over the full tuple. 3. The threshold is strictly greater than 2f. At threshold 2f, an equivocator controlling f signatures can split them f/2 + f/2 across two branches and reach threshold in both without any honest overlap. At 2f+1, the pigeonhole principle guarantees at least one honest replica signs only one branch, and its payload hash becomes the reconciliation anchor. 4. The view timer exceeds the 99th-percentile honest delivery latency by a margin calibrated to the network's observed jitter. If the timer is below p99, slow agents are indistinguishable from equivocators by timing alone and the collector must rely exclusively on payload reconciliation. ## Decision Boundary At the commit boundary, the collector must choose between three actions when a partial quorum is observed: - **Wait** if all observed signatures reference a single payload hash and the count is below threshold but the view timer has not expired. This is the slow-agent branch. - **Reject and view-change** if two or more distinct payload hashes are observed for the same `(view, task_id)`. This is the equivocation branch; the equivocator's identity is recoverable from the signature set and should be reported as evidence, not merely discarded. - **Abort** if the view timer has expired with a single payload hash but sub-threshold count. This is the partition-or-collusion branch; the collector cannot distinguish a slow agent from a silent Byzantine agent here, and must not commit. The boundary is sharp: the presence of two payload hashes is dispositive evidence of equivocation regardless of timing. The absence of two hashes is *not* evidence of honesty — it is only the precondition for the slow-agent interpretation, which remains valid only if the timer has not fired. ## Operational Effect Systems that count signatures without binding payloads exhibit double-commit rates proportional to the equivocation rate times the probability that the equivocator's split reaches threshold in both branches — in the observed deployment, 11 double commits in 200 rounds versus 0 for the payload-aware collector. The cost of payload reconciliation is memory: the collector must retain O(quorum size) signed messages per view rather than a single aggregated signature, roughly 3x the storage per in-flight task. The cost of misclassification is worse: honest slow agents subjected to view changes amplify latency and, under sustained load, can push the system into view-change livelock where no view completes because every view contains at least one agent whose timer fires before its `PREPARE` lands. The operational rule is to pay the memory cost and treat payload-hash divergence as the sole equivocation signal, with timing used only to decide whether to wait or abort within a single-hash branch. ```python import hashlib from dataclasses import dataclass class EquivocationDetected(RuntimeError): """Raised when a collector observes two conflicting signed payloads for one (view, task_id).""" pass class CommitThresholdUnreachable(RuntimeError): """Raised when the view timer expires with a single payload hash and sub-threshold signatures.""" pass @dataclass(frozen=True) class Prepare: view: int task_id: str payload_hash: str agent_id: str signature: bytes def collect_quorum( prepares: list[Prepare], view: int, task_id: str, threshold: int, view_timer_expired: bool, ) -> str: """Return the committed payload_hash, or raise on equivocation / unreachable threshold. Invariant: commitment is sound only if every signature in the quorum references the same payload_hash under the same (view, task_id). """ relevant = [p for p in prepares if p.view == view and p.task_id == task_id] by_hash: dict[str, list[Prepare]] = {} for p in relevant: by_hash.setdefault(p.payload_hash, []).append(p) if len(by_hash) > 1: # Dispositive evidence of equivocation, regardless of timing. offenders = sorted({p.agent_id for group in by_hash.values() for p in group}) raise EquivocationDetected( f"conflicting payload hashes for view={view} task={task_id}: " f"{sorted(by_hash)}; signers={offenders}" ) if not by_hash: if view_timer_expired: raise CommitThresholdUnreachable( f"no prepares for view={view} task={task_id} after timer expiry" ) raise CommitThresholdUnreachable( f"no prepares yet for view={view} task={task_id}; wait" ) (payload_hash, group), = by_hash.items() distinct_signers = {p.agent_id for p in group} if len(distinct_signers) >= threshold: return payload_hash if view_timer_expired: raise CommitThresholdUnreachable( f"single payload {payload_hash} but only {len(distinct_signers)}/{threshold} " f"signers for view={view} task={task_id}" ) # Slow-agent branch: single hash, timer not fired, keep waiting. return None ```