# Where must an instance of Byzantine fault-tolerant task commitment fail to distinguish equivocation from abandonment under partial synchrony **Date of Record:** 2026 November 12 **Scope:** This record applies to multi-agent systems where agents commit to tasks via BFT consensus (PBFT, HotStuff, Tendermint-style) and where a subset of agents may be non-honest — either Byzantine (actively equivocating) or simply unresponsive (crash/abandonment). It does not apply to systems with a trusted coordinator, nor to single-agent task queues where commitment is local. ## Core Distinction The non-obvious boundary is this: **a BFT consensus instance cannot, by itself, distinguish an agent that has equivocated (sent conflicting commitments to different quorums) from an agent that has silently abandoned its task** — *unless the view-change protocol carries the original commitment payload, not just the vote digest*. Most BFT implementations (correctly) treat a missing vote as indistinguishable from a withheld vote. This is a deliberate safety property: you cannot penalize an agent for non-participation without liveness assumptions. But in *task commitment* semantics, abandonment and equivocation have radically different consequences. An abandoned task must be reassigned (liveness). An equivocating agent may have already caused two disjoint quorums to believe the task is committed to them (safety). If the consensus layer collapses both into "no vote received," the task reassignment logic will reassign a task that may already be double-committed. The invariant that fails: *"If a task is reassigned, no prior commitment to that task remains valid."* This invariant is **not** guaranteed by BFT consensus alone under partial synchrony, because the reassignment trigger (timeout on missing votes) fires identically for abandonment and for equivocation. ## What Was Observed In a 7-agent deployment (f=2, quorum=5) running a Tendermint-derived commitment protocol, we observed the following during a network partition that healed asymmetrically: 1. Agent A proposed task T to view v. It sent `PREPARE(T, digest)` to agents B, C, D, E, F, G. 2. Agents B, C, D received the prepare and voted `PRECOMMIT(T)`. 3. Agents E, F, G were partitioned and timed out at height v, triggering a view change to v+1. 4. In view v+1, the new proposer (E) had no knowledge of T (the prepare was not in its mempool because E was partitioned). It proposed an empty block. 5. Agents E, F, G, plus B (which had not yet locked) reached quorum on the empty block at v+1 and advanced. 6. The task reassignment daemon, seeing no commit for T at height v, reassigned T to agent H. 7. Agent D, which had a valid `PRECOMMIT` certificate for T at v, later delivered its commit to the task ledger — producing a **second valid commitment** for T. The failure was not in consensus safety (no two blocks were committed at the same height). It was in the **task commitment layer**, which assumed "no commit at height h ⇒ task uncommitted." That assumption is false when a commit certificate exists but is held by a minority that was partitioned during the view change. ## Condition of Validity The distinction holds only under **partial synchrony with an unknown GST**. Under full asynchrony, no timeout-based reassignment is sound at all — FLP makes this unavoidable. Under synchronous assumptions with a known bound Δ, the reassignment daemon could wait 2Δ + ε and be certain that any valid certificate would have propagated. In practice, deployments assume partial synchrony and pick a timeout T_reassign, which is exactly where the boundary lives. The reassignment is safe **iff** T_reassign > (view-change timeout) + (max certificate propagation delay) + (lock-release delay). If any of these are unbounded — and under partial synchrony they are, until GST — the reassignment can race a valid certificate. ## Decision Boundary The operational rule is: **do not treat absence of a commit certificate as evidence of non-commitment; treat it as evidence of non-*knowledge* of commitment.** The correct decision boundary is: - If a reassignment is triggered, the reassigning agent must first issue a `CERTIFICATE_QUERY` to a quorum and require **f+1 explicit "no certificate" responses** — not f+1 timeouts. - Equivocation is detected only when two conflicting certificates with the same task ID are presented; this requires retaining certificate payloads, not just digests. - Abandonment is inferred only after a quorum attests to having seen no prepare for the task at any height ≤ current. The boundary that fails in practice: teams conflate "quorum of timeouts" with "quorum of negative attestations." Timeouts are local; negative attestations are global and must be signed. ## Operational Effect When this boundary is crossed, the system exhibits **commitment duplication**: a task is simultaneously assigned to two agents, both holding valid certificates. Downstream effects include double-spend of task budget, duplicate side effects, and — in agentic systems — two agents racing on the same resource with no mutual exclusion. The failure is silent: consensus logs show no safety violation, because the violation is at the commitment layer, not the consensus layer. Mitigation observed in production: (a) carry full task payloads in view-change `NEWVIEW` messages, not digests; (b) require signed negative attestations for reassignment; (c) retain all certificates for a task ID for at least 2× the reassignment timeout window so that equivocation can be detected post-hoc and reconciled. ```python class CommitmentDuplicationError(RuntimeError): """Raised when a task reassignment would race a valid held certificate.""" pass class TaskCommitmentLedger: def __init__(self, f: int): self.f = f self.certificates: dict[str, list[dict]] = {} self.negative_attestations: dict[str, set[str]] = {} def record_certificate(self, task_id: str, cert: dict) -> None: self.certificates.setdefault(task_id, []).append(cert) def record_negative_attestation(self, task_id: str, agent_id: str) -> None: self.negative_attestations.setdefault(task_id, set()).add(agent_id) def reassign(self, task_id: str, new_agent: str, quorum_size: int) -> None: held = self.certificates.get(task_id, []) if held: raise CommitmentDuplicationError( f"task {task_id} already has {len(held)} valid certificate(s); " f"reassignment to {new_agent} would duplicate commitment" ) neg = self.negative_attestations.get(task_id, set()) if len(neg) < quorum_size: raise CommitmentDuplicationError( f"task {task_id} reassignment requires {quorum_size} signed negative " f"attestations, only {len(neg)} present; timeouts are not evidence" ) self.certificates[task_id] = [{"agent": new_agent, "kind": "reassignment"}] ```