Skip to content

fix: dispute two-candidate consensus splits - #581

Open
huangminghuang wants to merge 3 commits into
masterfrom
fix/wire-362-two-candidate-disputes
Open

fix: dispute two-candidate consensus splits#581
huangminghuang wants to merge 3 commits into
masterfrom
fix/wire-362-two-candidate-disputes

Conversation

@huangminghuang

@huangminghuang huangminghuang commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Resolves WIRE-362’s post-boundary consensus deadlock for any no-strict-majority split with two or more candidate envelope versions.
  • sysio.msgch::evalcons opens a Tier-1 dispute after the epoch boundary whenever at least two versions have no strict majority, including a silent otherwise-eligible batch operator.
  • sysio.chalg::opendispute and msgch share a two-candidate floor; a one-version tally remains non-disputable. Pre-boundary and strict-majority paths are unchanged.
  • Synced with the current origin/master without conflicts.

Coverage

  • End-to-end chkconsevalconsopendispute regressions cover 1–1 and 3–3 ties, verify candidate tallies, resolve via Tier-1, and verify the outpcons winner.
  • A 1–1 split of three eligible operators proves the silent third operator no longer prevents dispute opening or Tier-1 resolution.
  • opendispute accepts two candidates and still rejects one.

Generated artifacts

  • Regenerated sysio.chalg.wasm: 112d1c92d6c8bca69554bec109527ebfa8156c444a7b54207464ece1b5154652.
  • Regenerated sysio.msgch.wasm: 47a2b9fc64367c393d9fddbc621e62c5e218c51d68258f970a4dd00ecabea25a.
  • After the origin/master merge, a fresh release CDT build again byte-matched both tracked WASMs and ABIs; no further artifact change was needed.

Deliberate dispute behavior

  • After a dispute opens, evalcons is a no-op for that outpost and epoch; a late delivery cannot re-open or automatically resolve it. Post-boundary non-delivery is treated as a miss.
  • chkdispute has no timeout escape. Without Tier-1 quorum and strict majority, the dispute remains open and the epoch paused.

Validation

  • Post-merge planned full build and contracts_unit_test -- --sys-vm passed.
  • Artifact-copy CI mode: both candidate-floor tests and all three affected chkcons regressions passed.
  • The independent review found no actionable issue.
  • Sysio PR CI: all required checks passed, including Ubuntu, UBSAN, ASAN, assertion, and GCC builds.
  • Remote full-platform E2E: passed against the current Sysio and tools PR refs. The full Bazel build, artifact verification, and dynamic pooled standard flow suite passed. The workflow-default stress exclusion remains intentional.
  • Companion platform-flow PR: fix: cover two-candidate dispute slashing wire-tools-ts#83
    • Two-candidate slashing flow passed against the initial Sysio head in 445.9s.
    • Unchanged termination control passed against the same build in 977.8s.
    • Both runs used the heartbeat monitor and reported no fatal chain diagnostics.
  • The earlier isolated comparison dispatch failed before any job was allocated, so it is not treated as test evidence.
  • git diff --check passed.

Change-Id: I073d15645a2334b4cf0075d8e3f1ecba3a49d0db
@huangminghuang
huangminghuang marked this pull request as ready for review August 21, 2026 18:09
@huangminghuang
huangminghuang requested review from a team and heifner August 21, 2026 18:09
@heifner

heifner commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Not the same class of change as #573 — but drop the terminality gate

Reviewed against #573 (closed today), since both descend from the same root: after #569, eligible_batch_operators() intersects the seated group with live opreg ACTIVE status and carries no floor, so the eligible set can shrink mid-epoch. They are not the same change, and the reason #573 died does not transfer.

Reachability inverts

#573 needed the ACTIVE roster to fall below batch_operator_minimum_active (21 = 7 × 3), which bootstrapped batch operators make near-unreachable in code — termcheck early-returns on is_bootstrapped, meets_role_min returns true, they are created ACTIVE. Inert when the roster is healthy.

This PR's condition needs one operator, not a roster collapse. A 7-member group losing a single member mid-epoch gives eligible == 6; a 3–3 split then has no strict majority possible (3 * 2 > 6 is false) and, on master, no dispute path either — maybe_open_dispute returns at seen_checksums.size() < 3 (sysio.msgch.cpp:1190). Permanent deadlock.

That is the same mid-window attrition #573 accepted as residual scope, at even arity instead of arity-1. .claude/rules/opp-consensus.md already states even sizes are reachable "whenever the serving group is short of its configured size."

Blast radius is much smaller

#573 mutated group membership, breaking the "window groups are disjoint by construction" invariant that batch_operator_plugin (my_group, last match wins) and OPPInbound._resolveChunkPosition (first match) rely on in opposite directions — the 462 × EffectAccountMissing that failed flow-batch-operator-termination.

This PR changes only when a dispute opens. No membership, no scheduling, no attestation the outposts position against. The opendispute >= 3>= 2 widening sits behind require_auth(MSGCH_ACCOUNT), so msgch's rule is the effective gate. #573's failure mode has no analogue here.

The terminality gate should go

The total_deliveries != group_size branch adds a precondition that a missing operator makes permanently unsatisfiable. A 1–1 split of 3 eligible with the third operator silent never reaches total_deliveries == group_size, can never form a majority (needs 2), and so never opens a dispute — the same permanent stall this PR sets out to fix, one arity over. And the silent operator cannot be terminated out of the way, because termcheck only runs from advance, which is what is stalled.

That is backwards: the case where adjudication is most needed — an operator is gone and the remainder disagree — is the case terminality refuses to adjudicate.

The premise behind the gate does not hold either. We do not expect any divergent envelope versions, much less two. Post-boundary, ≥2 distinct versions with no strict majority is already the anomaly — that is the dispute trigger. And the boundary is a deadline, not a checkpoint: an operator that has not delivered by then has burned the entire epoch and is a miss, not a straggler. opp-consensus.md already defines late delivery as a benign no-op.

Resolution then works exactly as intended: Tier-1 votes canonical, resolvedisp dispatches the winner, and the next advance slashes every operator whose delivered checksum ≠ winner. Silence is never slashed — it stays on the recorddel/termcheck miss ladder. So the 1–1-of-3 case lands correctly: the wrong deliverer is slashed, the silent one rides the miss ladder to termination.

Suggested change — this makes the diff smaller, not larger:

  • delete the total_deliveries != group_size branch and the total_deliveries parameter from maybe_open_dispute
  • msgch::maybe_open_dispute: seen_checksums.size() < 3< 2
  • chalg::opendispute: candidates.size() >= 3>= 2

Boundary check and majority check are unchanged and both already sit ahead of it. That the silent-operator hole closes without a special case is a good sign the gate was the wrong shape.

Two consequences, both accepted

1. Once a dispute exists, evalcons no-ops for that bucket.

// If a dispute has been opened for this (outpost, epoch), the dispute-vote flow owns its
// resolution and winner dispatch (via `resolvedisp`). evalcons is a no-op for this bucket
// so a late delivery cannot re-open the dispute or re-dispatch the envelope.

So opening a dispute forecloses free automatic resolution by a late delivery. Accepted — post-boundary non-delivery is already a miss.

2. chkdispute has no timeout escape, and that is fine.

// After the deadline: a quorum of cast votes AND a strict majority of cast votes.
// No plurality / tie-break — an unresolved tally just keeps waiting for more votes.
if (!resolved) return;

Past the deadline you still need cast >= Q where Q = N/2 + 1 of the snapshotted electorate. If a majority of Tier-1 owners never vote, the dispute stays OPEN, open_disputes never decrements, and the epoch stays paused indefinitely.

This is an accepted design decision, not a gap to close. The trade is deliberate: replace "operators must deliver" with "a majority of Tier-1 must vote." Pausing until Tier-1 votes is the correct outcome — the alternatives (plurality, or abandon-and-resume) either pick a winner on weak evidence or hand back the original deadlock. Worth stating explicitly in the PR body so it is a recorded decision rather than an implicit one, and worth confirming the batch-operator crank actually drives chkdispute on its ~15s cadence under the flows.

Still needs the isolated A/B

The PR body notes the platform-flow gates are pending. That is exactly the state #573 was in before the run that closed it — 659/659 unit green, independent reviews passed, and an earlier GREEN 5-flow gate on a prior commit. It still regressed once the right flow ran against the right head.

Same protocol as runs 32486851217 / 32492513750: every ref pinned by full SHA, wire-sysio the only variable, PR head vs master. flow-batch-operator-slashing as the target (it is the flow that produces non-canonical deliveries) and flow-batch-operator-termination as the control, since that is the one #573 broke.

Change-Id: Id0cbaa4fa450435cdc928b501a90222f65c2f38e
@huangminghuang huangminghuang changed the title fix: resolve terminal two-candidate consensus ties fix: dispute two-candidate consensus splits Aug 21, 2026
Change-Id: I510b5f4ee60976920fa494faef70406eeb0f86a7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants