myxce/.kiro/specs/raft-ha-core-safety/research.md
Rongsong Shen 08bdaba3dc docs(raft-ha-core-safety): close election layer — 5.5/5.6 done, bug-1296/1286 fixed, 10-round soak validates
10-round chaotic soak: 10/10 rounds elect in a single term increment (4->13),
zero escalation; round 7+ (old bug-1296 liveloc) clean. Election liveness layer
(tasks 5.5/5.6/5.7) DONE. Remaining soak FAIL (committed_job_loss=5,
divergence=11) is wholly the task 5.9 / ha-commit-durability catch-up gap
(bug-795/828/851/1306) — job 0-0-1782854622-0 lost at round 8, rounds 1-7
job-loss-free.

- tasks.md: 5.5 + 5.6 flipped [x] (subsumed by 5.7 soak validation)
- buglog: bug-1296 FIXED (resonance, confirmed 10-round); bug-1286 FIXED
  (5db734a NULL-guard); bug-1197 SUPERSEDED by bug-1298 refcount
- research.md: Phase 1 closeout / 10-round validation section
2026-06-30 21:26:00 +00:00

856 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gap Analysis — raft-ha-core-safety
_Generated by `/kiro-validate-gap`. Information over decisions: options and findings, not final choices._
## Headline Finding (reframes the spec)
The `(term, lsn)` commit-identity + reconcile + commit-safety machinery the requirements describe **already exists and is wired at the baseline**. Confirmed:
- **Library half is 100% complete and validated**: `raft-log-replication` (24/24, G1/G2/G3/G5) and `raft-commit-safety` (18/18, G4 Figure-8 + G6 vote freshness). All five callbacks present in `src/lib/raft/raft.{c,h}`.
- **Controller adapter is 16/20 wired** by `ha-term-safety`: all five callbacks implemented in `s_real_cbs` (`ha.c:1480/1539/1656/1711/1739`), primary append routed through `raft_log_append` (`ha.c:3408+`), heartbeat clean (no `on_persist_entry` on `n_entries==0`), per-LSN term index (`persist.c:414/476/487/752`), config markers, and `walcompare` (`src/controller/testsuite/walcompare.c`).
- **Baseline `21e26a1` already contains all of it.** `git`: `21e26a1` is itself a `raft-drive-async` task-2.3 commit, ancestor of HEAD, and contains `s_real_cbs` + `walcompare`. So the machinery is NOT absent at baseline.
**Yet the live chaotic-failover soak still fails on this branch AND on baseline `21e26a1`**: `walcompare FAIL: committed-prefix divergence at LSN 9` (round 3) and standby `ctrl-2` rejoin failure (round 4), with intermittent round-N election stall.
**Therefore raft-ha-core-safety is NOT a build-the-machinery spec. It is a residual-defect + convergence/liveness + live-verification spec.** The work is: root-cause why divergence survives the existing reconcile path under overlapping chaotic failover; close the convergence and election-liveness gaps no completed spec actually closed; and finish the live chaotic soak that `ha-term-safety` left permanently BLOCKED on env. **Do not rebuild the `(term,lsn)`/callback machinery — extend and correct it.**
## Requirement-to-Asset Map
| Req | Capability | Existing asset | Gap tag |
|-----|-----------|----------------|---------|
| R1 Committed-prefix safety | quorum commit + Figure-8 guard | `raft_advance_commit` raft.c:769-821; `on_commit_advanced` ha.c:1739 | **Constraint** — machinery present but live soak still diverges at LSN 9 → residual defect |
| R2 Per-entry commit identity | term stamped from leader; per-LSN term index; legacy-0→1 | raft.c:571-580; `persist_set_term` persist.c:476; `record_entry_term` persist.c:414/752; `WAL_LEGACY_TERM` persist.c:456 | **Done (verify)** — confirm no path still emits term-0 under chaos |
| R3 Divergence detect + repair | logOk + conflict-truncate-then-persist on AE intake | raft.c:701-765; `on_truncate_from` ha.c:1711; `walcompare` detector | **Constraint** — repair fires on AE-intake path only; rejoin/catch-up path may bypass it (see Research Needed #1) |
| R4 Single leader per term | term++ on election, vote-once, step-down, pre-vote, vote freshness | raft.c:105-249; `raft_cb_vote_eligible` ha.c:342 | **Constraint** — two-leaders-per-term window under churn not yet proven closed live |
| R5 Standby rejoin → 3-of-3 | catch-up apply, applied-lsn floor, snapshot backfill, contiguity guard | `ha_apply_wal_catchup_entry` ha.c:2233; `ha_advance_applied_lsn` ha.c:2078; `ha_handle_snapshot_chunk` ha.c:2492; contiguity persist.c:839 | **Missing (effective)** — bug-1042 standby-never-converges has NO dedicated owning task; `ha-convergence-recovery` claims it but soak still fails round 4 |
| R6 Election liveness under churn | election restart, candidate advertise floor | `raft_start_election` raft.c:105; `ha_candidate_advertise_lsn` ha.c:3921 | **Missing (effective)** — round-N stall not closed; no deterministic churn test |
| R7 RED→GREEN harness | `xce-test-ha` soak, `walcompare`, `controller_unit_test`, `test_ha_dispatch_integration` | `.claude/skills/xce-test-ha/`; `walcompare.c`; `src/xce.build/controller/controller_unit_test` | **Missing** — no deterministic harness that *forces* chaotic divergence/rejoin/stall as a RED unit/integration gate (current soak is probabilistic) |
| R8 Acceptance soak gate | real-fs release soak; BIN/XT overridable | `run-ha-soak.sh` (BIN/XT env override added this branch); non-ASAN release build | **Unknown** — never passed at 6+ chaotic rounds; was BLOCKED on overlayfs `fdatasync` (bug-924) |
## Existing-Code Constraints (must respect)
- **Reconcile is single-seam**: divergence repair happens only inside `raft_on_append_entries_request` (logOk → `on_truncate_from` → re-persist, raft.c:730-765). Any code path that brings a node's log forward *without* routing through this function (e.g. a snapshot/catch-up stream that writes WAL directly) will not detect or repair a divergent committed prefix.
- **Truncate must never cut the majority-committed prefix** (raft.c monotonic commit; promotion truncate-clamp `max(safe_committed_lsn, durable_tip)` ha.c:999-1000, bug-1060 fix). R3.4 depends on this clamp holding under chaos.
- **Persist-before-ACK durability is owned by `ha-commit-durability`, not this spec.** R1/R2 must not weaken it. `on_commit_advanced` is a barrier trigger, not the durability mechanism.
- **`raft-drive-async` is out of scope and dormant.** Do not change SlowLane / peer-control dispatch / endpoint-quiescence behavior (Boundary Context).
- **Client-side bug-854** (libxce primary discovery) can fail the soak harness at EXIT-1 even when server data safety passes. Must be isolated so it does not mask/forge R8 verdicts.
## Implementation Approach Options
### Option A — Extend & correct existing machinery (recommended)
Keep all library + adapter machinery. Root-cause the residual divergence, route the rejoin/catch-up path through the reconcile seam, close the convergence + liveness gaps with targeted fixes, add a deterministic chaotic harness, finish the live soak.
- ✅ No rebuild of consensus-critical code that already passed library + clean-failover validation
- ✅ Smallest blast radius; aligns with TLA+ already-proven invariants
- ❌ Requires precise root-cause of a hard-to-repro chaotic defect before fixing
- ❌ Risk of "fix the symptom not the invariant violation" if harness stays probabilistic
### Option B — New convergence/divergence-repair subsystem
Build a standalone post-failover reconcile/repair component separate from the AE-intake seam.
- ✅ Clean place to own rejoin reconcile + unrecoverable-divergence fencing
- ❌ Duplicates logic that already lives in `raft_on_append_entries_request`; two reconcile paths is exactly the class of bug being fixed
- ❌ High risk of new two-leaders/two-reconcile divergence
### Option C — Hybrid (Option A + new deterministic harness component)
Extend/correct in place (A), but build the **deterministic chaotic harness** (R7) as genuinely new test components (forced overlapping kills, injected divergent prefix, scripted round-N stall) rather than relying on the probabilistic soak.
- ✅ Keeps consensus code as A; isolates the new surface to test infrastructure where new code is safe
- ✅ Gives a RED gate that pins each fix to an invariant violation
- ❌ Harness design is itself non-trivial (deterministic chaos, no sleeps, gate/probe seams like `test_ha_dispatch_integration.c`)
## Effort & Risk
| Item | Effort | Risk | Justification |
|------|--------|------|---------------|
| Root-cause residual LSN-9 divergence | M | High | Chaotic, intermittent; survives existing reconcile — invariant violation is subtle |
| Route rejoin/catch-up through reconcile seam (R3/R5) | M | High | Touches recovery + commit prefix; must not cut majority-committed |
| Election liveness under churn (R6) | M | Medium | Election machinery exists; needs churn-stall root cause + restart guarantee |
| Deterministic chaotic harness (R7) | L | Medium | New, but test-only; pattern exists (`test_ha_dispatch_integration.c`) |
| Live 6+round real-fs soak (R8) | M | High | Was permanently blocked on overlayfs `fdatasync` (bug-924); needs real-fs env + bug-854 isolation |
| **Total** | **L–XL** | **High** | Consensus correctness under chaos + env-gated live verification |
## Research Needed (carry to design phase)
1. **Why does divergence survive the reconcile seam?** Trace the rejoin/catch-up path (`ha_apply_wal_catchup_entry` ha.c:2233, snapshot `ha.c:2492`): does it route through `raft_on_append_entries_request` so logOk/`on_truncate_from` fire, or does it write WAL directly and bypass conflict repair? Hypothesis: catch-up streams `[watermark..committed]` directly (per memory bug-795/828 family) → no `(term,lsn)` conflict check on rejoin.
2. **Is LSN-9 divergence in the committed or uncommitted prefix?** If committed, the violation is upstream (two-leaders-per-term or premature commit-advance), not a repair gap. Use `walcompare` `(lsn→term,pcrc)` output to classify.
3. **Two-leaders-per-term under overlapping kills** — does pre-vote + vote-once fully close the window when kills overlap election timeouts? Map against `raft/raft.tla` Election Safety.
4. **bug-1042 ownership** — `ha-convergence-recovery` claims the rejoin path but soak still fails round 4. Determine whether its tasks are actually landed on this branch or stranded, to avoid duplicate work.
5. **R8 env** — confirm real-fs (ext4) soak runs now via `run-ha-soak.sh` BIN/XT override + non-ASAN release build; quantify how bug-924 (overlayfs) and bug-854 (client discovery) are excluded from the R8 verdict.
## Recommendation for Design Phase
- **Preferred: Option C** (extend/correct in place + new deterministic chaotic harness). Treat the spec as residual-defect + convergence + liveness + live-verification, explicitly **not** a machinery rebuild.
- **Key design decisions to make**: (a) route rejoin/catch-up through the single reconcile seam vs. add a guarded repair at promotion; (b) classify LSN-9 as committed vs uncommitted before choosing repair vs upstream fix; (c) harness determinism mechanism (forced-kill gates/probes) for R7; (d) how R8 isolates bug-854/bug-924 so the gate measures only server-side committed-job safety.
- **Carry forward**: Research Needed #1–#5; verify every change against `raft/raft.tla` (Election Safety, Leader Completeness, State Machine Safety, Commit Ordering/Durability) per `src/controller/CLAUDE.md`.
---
# Design-Phase Discovery (kiro-spec-design)
## ROOT CAUSE (term-blind catch-up bypass) — ⚠️ SUPERSEDED / REFUTED by task 1.3
> **This hypothesis was tested live (task 1.3) and REFUTED.** The real defect is cross-term catch-up *payload* loss (see §"Task 1.3 — Live divergence reproduction & bisect" below). The divergent entry carries the correct term and arrives through the reconcile seam; the term-blind path is a latent hazard but not the observed cause. Retained below for the diagnostic record.
Deep-trace of the standby catch-up/rejoin paths found a **split** where one branch bypasses the single reconcile seam:
| Path | Entry | Routes through `raft_on_append_entries_request`? | logOk | conflict-truncate | term stamped |
|------|-------|---|---|---|---|
| 1 Term-carrying | `entry->term != 0` → `ha_apply_term_entry_via_library` (ha.c:2173) → `ha_consume_append_entries` (ha.c:2383) → raft.c:646 | YES | YES (raft.c:701) | YES (raft.c:735) | leader term via `persist_set_term` |
| 2 **Term-blind (defect)** | `entry->term == 0` → `ha_apply_single_entry` (ha.c:2097) → `persist_wal_write_replicated` (persist.c:802) DIRECT | **NO** | **NO** | **NO** | **standby's own `p->term`** (persist.c:862) |
| 3 Snapshot | `ha_handle_snapshot_chunk` (ha.c:2492) → `persist_load_snapshot` sets applied_lsn=checkpoint | NO | N/A | N/A | N/A (canonical state) |
| 4 Stream+ACK | `ha_stream_wal_to_standby` (config_repl.c:2032) feeds Path 1 or 2 by `entry->term` | depends | depends | depends | depends |
| 5 `ha_rejoin_reconcile` | ha_convergence.c:254 — **now a no-op stub** (committed-watermark truncate retired; relies on library) | NO | N/A | retired | N/A |
**Divergence mechanism (LSN 9):**
1. An entry reaches a standby via Path 2 (term-blind). `persist_wal_write_replicated` stamps `hdr.term = standby p->term` (wrong) and records it (`record_entry_term`, persist.c:902). No logOk, no conflict check.
2. The correct term-carrying entry for the same LSN later arrives via Path 1. The persist **idempotency guard** (`if lsn+1 <= p->current_lsn return 0`, persist.c:822) **silently no-ops** — the divergent entry is never overwritten/repaired.
3. `walcompare` (testsuite/walcompare.c:135) compares `(term, payload_crc)` per committed LSN; the member with the term-blind write shows a different `term` → `WALCMP_DIVERGENT`. `match_term` maps raw 0 → `WAL_LEGACY_TERM`, so legacy-0 vs genuine-term at the same LSN reads as divergence.
**Why the existing `(term,lsn)` machinery does not catch it:** all conflict detection lives *inside* `raft_on_append_entries_request`; Path 2 never enters it, and the idempotency guard treats "LSN already present" as "done" regardless of term.
## Synthesis
> ⚠️ **D1/D3 partially superseded by task 1.3.** The "eliminate every bypass of the one seam / collapse Path 2 into Path 1" framing assumed the term-blind hypothesis, now refuted. The single-seam invariant (D2) and harness-first discipline (D4) still hold; but the actual fix is the cross-term catch-up streamer payload integrity + a `(term,lsn,payload-crc)`-aware guard (see §"Task 1.3"), not seam-bypass elimination. Term-blind retirement remains as defensive hardening only.
- **Generalization (D1):** R1/R2/R3/R4 are one underlying invariant — *every* entry that enters a member's log must pass the single `(term,lsn)` reconcile seam. The fix is not four mechanisms but **eliminating every bypass of the one seam** + making persistence `(term,lsn)`-conflict-aware rather than LSN-only-idempotent.
- **Build vs adopt (D2):** Adopt the existing library seam (`raft_on_append_entries_request` + `on_truncate_from` + `persist_set_term`) — already validated (raft-log-replication/raft-commit-safety). Build nothing new in the consensus core; only route into it and harden the persist guard. Reuse `ha_rejoin_reconcile` intake hook (currently stub) rather than a second reconcile path.
- **Simplification (D3):** Do not add a separate "divergence repair subsystem" (gap-analysis Option B rejected) — that would create the exact second-reconcile-path class of bug being fixed. Collapse Path 2 into Path 1.
- **Harness-first (D4):** The live bug is intermittent/chaotic. Make it deterministic: a RED unit test injects a term-blind/divergent entry at an LSN and asserts the seam repairs it; a chaotic soak mode forces overlapping kills. RED before any fix (R7), pins each fix to a `walcompare`/invariant violation.
## Open risks carried to tasks
- Confirm Path 2 is the *only* divergence source live (harness RED test will confirm/refute; if soak still diverges after Path-2 closure, classify next via `walcompare (term,lsn,pcrc)`).
- Snapshot boundary (Path 3): post-install AE intake must reconcile against the snapshot's `(term,lsn)`; verify no committed entry below the checkpoint can diverge.
- Standby reconvergence (R5) and election-liveness (R6) are separable seams from divergence-repair; phaseable but kept in one spec per the unified acceptance gate.
- bug-854 (client discovery) and bug-924 (overlayfs fdatasync) must be excluded from the R8 verdict so the gate measures server-side committed-job safety only.
---
# Design Review Resolution (kiro-validate-design)
Three critical issues raised and folded into design.md (GO):
1. **Repair-vs-clamp conflation (correctness).** Split truncate into two contracts: *promotion-truncate* keeps the bug-1060 `max(safe_committed_lsn, durable_tip)` clamp; *follower-AE-conflict-truncate* obeys Raft Log Matching and truncates the conflicting suffix regardless of local `durable_tip` (a conflicting entry was never majority-committed → safe to cut). Applying the promotion clamp on the follower path would have converted repairable divergence into a permanent fence and failed R8. Updated: §Existing Architecture, §Unified Entry Ingest, §Persist Conflict Guard, §Error Handling, Traceability 3.x, Testing.
2. **R4 two-leaders-per-term unverified.** Added a falsifiable Election Safety assertion (≤1 committing-primary per term under overlapping kills, mapped to raft.tla); failure promotes a concrete adapter-gate guard from verification to required work. Updated: §Election Liveness, Testing Integration.
3. **Hypothesis-validation risk.** RED gate (7.1) must reproduce the *live* `walcompare` divergence and bisect to a seam before synthetic `(term,lsn)` injection counts; baseline 21e26a1 already retired live term-0 (ha-term-safety 3.4), so the term-blind path may not be the live trigger. Contingency: if soak still diverges after Path-2 closure, re-classify via `walcompare (term,lsn,pcrc)` and add a fix task — do not mark R1/R8 done on green unit tests alone. Updated: §Deterministic Chaotic Harness, Testing E2E + RED-first.
Verdict: **GO** (unconditional after edits). Architecture unchanged; refinements were bounded contract clarifications.
---
# Task 1.3 — Live divergence reproduction & bisect
_Generated by the task-1.3 investigation subagent (REPRODUCE + CLASSIFY + BISECT). Evidence-backed; no production code changed. Real-fs (ext4) `~/tmp`, non-ASAN release binaries `src/build.release/controller/{xce_controller,xce_test}` (built 08:48 this day)._
## TL;DR / VERDICT
**The design's primary "term-blind catch-up bypass" hypothesis is REFUTED for the reproduced live committed divergence.** The divergent committed entry has the **CORRECT term**, was ingested **through** the single library reconcile seam (`ae_append` → `on_persist_entry`), and the LSN-only idempotency no-op was **not** the mechanism. The real defect is **payload loss on cross-term catch-up**: a committed `JOB_SUBMIT` entry is persisted on the catching-up member with `payload_len=0` (empty body) while its `(term, lsn)` are correct. It is a sibling of the bug-795/828/1060 committed-prefix-loss family, located in the **catch-up replication payload path**, not in the `(term,lsn)`/idempotency-guard seam Phase 2 currently targets.
## Commands run & reproduction status
- Two `--chaotic` soak attempts via `run-ha-soak.sh` (rounds=6, `XCE_HA_BIN/XT` → release binaries, dir `~/tmp/ha-1p3`) **both aborted at cold-start** with `FAIL: no primary elected within 45s`. A `--sequential` attempt failed identically.
- **This cold-start "election stall" is a HARNESS/LOG-BUFFER ARTIFACT, not a consensus stall.** Evidence: in every aborted run a primary WAS elected on time (e.g. `ctrl-1 → CTRL_PRIMARY (term=2)` logged ~15s after start, `quorum_reached`), yet `wait_for_primary_log` never tripped. All three nodes' last log line is the identical cleanup-kill timestamp (= start+45s): the 08:48 release build **block-buffers its `--logfile` and only flushes on signal**, so the harness's log-grep primary detection (`latest_primary_term`) cannot observe progress live. The OLD binaries used by the prior passing sequential soaks (`~/tmp/xce-soak-{rel,bl}.log`, 02:56/03:01) did not have this buffering behaviour. The client (`ctrlstatus`) path is unaffected.
- **Reproduced cleanly** by driving the cluster with a client-based (log-buffer-immune) driver (`scratchpad/drive.sh`, fresh deploy `~/tmp/ha19`, sequential kill→restart→re-elect, 6 rounds). Committed-prefix divergence appeared by round 1 and persisted: round 1 LSN 8 (present=2), round 2 LSN 8 (present=2), **round 3 LSN 9 (present=3 — all members hold it but disagree)**. Driver wedged at round 4 on a transient quorum loss; the frozen WAL dirs were classified directly.
## walcompare --report classification (frozen `~/tmp/ha19`, `--committed 25`)
Decisive divergence — **committed LSN 9, class=committed, compared=3, present=3** (every member holds the entry; they disagree on payload):
```
WALCMP_DIVERGENCE lsn=9 class=committed compared=3 present=3
member 0 (ctrl-1): term=3 pcrc=00000000 <-- MINORITY / divergent
member 1 (ctrl-2): term=3 pcrc=512dc5b5 <-- majority
member 2 (ctrl-3): term=3 pcrc=512dc5b5 <-- majority
```
Same pattern at committed LSN 10 (ctrl-1 pcrc=0 vs majority 06e88dac) and LSN 11 (ctrl-1 pcrc=0 vs majority fea75587). **Term is identical (3) across all three members; only the payload-CRC diverges.** (ctrl-3 additionally shows `status=MISSING` for LSN 12-19 — a separate catch-up-lag/convergence gap from the round-4 quorum loss, bug-1042 family, not the payload defect.)
Raw `waldump` confirms the payload loss, not a term mismatch:
```
ctrl-1 (divergent): 9 | 3 | JOB_SUBMIT | PLEN=0 | 00000000 | jobid=?
ctrl-2/ctrl-3 (maj): 9 | 3 | JOB_SUBMIT | PLEN=3560 | 512dc5b5 | jobid=0-0-1782550813-0 ...
```
The origin is intact — both majority members hold the full 3560-byte body with a decodable jobid; **ctrl-1 lost the payload bytes during catch-up while keeping the correct type (JOB_SUBMIT) and term (3).**
## Bisect — the concrete ingest path
ctrl-1's `--logfile` (fully flushed post-kill) traces exactly how it acquired the divergent entries:
1. ctrl-1 was the term-1 cold-start primary (wrote LSN 1-7), killed round 1, **restarted round 2** (`persist_recover done — recovered durable_floor=7 current_lsn=8`). It then needed LSN 8-onward.
2. `09:00:13` — term-3 leader ctrl-3 streamed catch-up `op 37 walCatchupEntry`, but starting at `prevIdx=8` (entry 9) while ctrl-1 only held up to 7 → library `ae_reject_logmismatch`, `ha_consume_append_entries … success=0`. Nothing written. (ctrl-3 then died — round 2 failover.)
3. `09:00:26` — new term-4 leader ctrl-2 streamed `op 37 walCatchupEntry` from `prevIdx=7`. The **library accepted and APPENDED**: `RAFT[p0] term=4 event=ae_append`, `ha_consume_append_entries … prevIdx=7 n=1 → success=1 match=8`, then match=9, 10, 11, 12. These were **fresh appends through the reconcile seam (`on_persist_entry`)**, not idempotent no-ops (current_lsn was 8 immediately prior). LSN 9-11 landed with `payload_len=0`.
4. In the SAME catch-up, LSN 13-15 (stored term=4, the streaming leader's OWN term) landed with FULL payloads. **Signature: same-term catch-up preserves payload; cross-term catch-up of prior-term entries (stored term 3 streamed under leader term 4) drops it.**
Code-path mapping (read at the research anchors):
- Receiver `ha_consume_append_entries` (ha.c:2410-2440) copies the body **unconditionally** (`if (payload && plen>0) memcpy(...)`, ha.c:2438) from `e->raw_payload`/`e->raw_payload_len`. So the empty body **arrived empty on the wire** → the loss is SEND-side / source-side, not in the seam's persist.
- Both documented send paths nominally read the payload: batch streamer `ha_stream_wal_to_standby` (config_repl.c:2154-2161, `pread` when `hdr.payload_len>0`, and sets `entry->term = persist_entry_term(...)` + `entry->leader_current_term = leader_term`, config_repl.c:2130-2151) and the single-entry `on_send_append_entries` → `ha_read_wal_entry_at` (ha.c:1611-1640). Neither *statically* drops a cross-term payload — so the loss is either (a) the streaming leader (ctrl-2) **itself held an empty copy at stream time** and the full payload was reconciled into ctrl-2's WAL only later (a transitive empty-propagation across the failover chain), or (b) the wire encode/decode of the body for these messages. Distinguishing (a) vs (b) requires one instrumented run capturing per-entry `payload_len` at the leader's send and at the receiver's `on_persist_entry` for a cross-term LSN — see "Implied next work".
## Why the term-blind hypothesis is refuted here
- Predicted by design.md ROOT CAUSE: `entry->term==0` → `ha_apply_single_entry` (ha.c:2097) → `persist_wal_write_replicated` DIRECT, **wrong term** (standby's `p->term`), idempotency no-op of the later correct entry.
- Observed: the entry's **term is correct (3)**, it was ingested via `ha_apply_term_entry_via_library` → `ha_consume_append_entries` → `on_persist_entry` (the term-carrying LIBRARY seam, `event=ae_append`), and the catch-up was a **fresh append** (no idempotency-guard no-op involved). The streamer stamps a non-zero term (config_repl.c:2141), so the `term==0` → `ha_apply_single_entry` branch (ha.c:2253/2291 gate `sub->term != 0`) is not taken for these entries.
## VERDICT
- **Term-blind catch-up bypass (wrong-term + LSN-only idempotency no-op): REFUTED** as the trigger of the reproduced live committed divergence.
- **Actual trigger: payload-loss on cross-term catch-up** — committed `JOB_SUBMIT` entries persisted with `payload_len=0` (term/type correct) on a member that catches up prior-term entries from a higher-term leader; the divergence is a **payload-CRC mismatch at an identical `(term,lsn)`**, in the committed prefix. A member carrying this can (and in round 3 did) become primary, so it is a silent committed-state corruption in the bug-795/828/1060 family.
## Implied next work (steers Phase 2)
1. **Re-scope the Phase-2 fix away from the term-stamp/idempotency seam.** Collapsing Path-2 into Path-1 (design D3/D1) will NOT fix this divergence — the entry already goes through Path-1 with the correct term. The fix must target the **catch-up payload source-of-truth**: add an instrumented capture of `payload_len` at `ha_stream_wal_to_standby`/`on_send_append_entries` (send) and `on_persist_entry` (receive) for cross-term LSNs to prove send-side empty vs transitive-empty, then fix whichever drops/propagates the empty body.
2. **Harden the persist conflict guard to be `(term,lsn,payload)`-aware**, not term-only: a committed `(term,lsn)` whose payload-CRC disagrees with the quorum must be detected/repaired (or the member fenced), since term-only reconcile is blind to this class.
3. **Fix the divergence detector / RED gate to assert payload-CRC at committed LSN even when terms match** (it already does via `pcrc`; keep `walcompare --report (term,lsn,pcrc)` as the canonical RED classifier — it caught this).
4. **R6/Harness defect (secondary):** the release build's log buffering defeats `run-ha-soak.sh`'s log-based `wait_for_primary_log`/`primary_from_log` primary detection (false "election stall"). The chaotic acceptance gate (R8) needs client-based (`ctrlstatus`) primary detection, or the controller must line-buffer/flush its INFO log, before the soak can run unattended on release binaries.
## Evidence artifacts (under ~/tmp, ephemeral)
- `~/tmp/ha19/` — frozen 3-member deploy with the reproduced LSN 9/10/11 payload divergence (`controller/ctrl-{1,2,3}/data`, `logs/ctrl-1.log`).
- `~/tmp/ha19-drive.log` — driver round-by-round output (divergence first seen round 1).
- `~/tmp/ha-1p3-run{1,2}.log`, `~/tmp/ha-1p3-seq.log` — the cold-start log-buffer "stall" runs.
---
# Phase-2 interim live validation (post-2.4, real-fs release rebuild)
Rebuilt the release controller at HEAD 63b9c5a (2.2 payload-aware guard + 2.4 streamer fail-loud) and ran a chaotic soak on real ext4 (`~/tmp/ha-verify-2p4`). The 4-round run **wedged (timed out ~9m40s) on an election stall** before reaching its own verdict — the un-fixed Phase-5 liveness defect. Froze the WALs and measured directly with `walcompare --report`:
- **Universally-held committed prefix (LSN 1..11) is content-identical across all 3 members** (`walcompare: OK — committed prefixes identical`).
- **No present-but-empty committed divergence** — the bug-1.3 signature (one member `pcrc=0/PLEN=0` PRESENT where the majority holds a full payload) is **absent**. The `pcrc=00000000` entries at LSN 1/8/12/16 are present+identical on ALL members (legitimate empty-payload marker/NOOP entries, same term+crc everywhere).
- Remaining apparent divergences are all `status=MISSING` tail entries (ctrl-1 tip=19, ctrl-2=15, ctrl-3=11) — **catch-up lag** on lagging/killed members, not committed-content corruption. Classifying these as "committed divergence" only happens when an inflated `--committed` boundary over-claims the committed region; at the true majority-held boundary they are uncommitted tail / in-flight catch-up.
**Conclusion:** the Phase-2 fix empirically eliminates the cross-term payload-loss divergence on the live repro. A *clean end-to-end* `committed_divergence=0` soak verdict is now gated on:
1. **Phase 5 (election liveness under churn)** — the chaotic soak wedges on an election stall before completing, so it never reaches its verdict line. This is the top blocker for the acceptance gate (7.2).
2. **Phase 4 (standby reconvergence)** — lagging members must finish catching up so the tail `MISSING` resolves once the cluster stabilizes.
**Steering implication:** Phase 3 (snapshot-boundary reconcile) is defense-in-depth for a path NOT implicated in the live repro; Phases 5 and 4 are on the critical path to a clean soak verdict. Consider doing 5 (and 4) before 3.
---
# Phase 4/5 — Post-Phase-2 stall root cause (investigation, salvaged from a hung run)
Reproduced the chaotic-soak wedge on real-fs release binaries (HEAD with Phase-2 fixes). The committed/payload prefix is clean (Phase-2 holds). The remaining wedge is a **reconvergence-triggered spurious-failover loop**, NOT a simple missing-election-restart.
## Evidence (frozen `~/tmp/ha-estall` logs)
- **ctrl-1 = stable primary at term 6**: `became_leader`, repeated `repl_broadcast` / `term=6 event=log_append`. The cluster HAS a leader.
- **ctrl-3 reconverges (slow but works)**: `term=6 ae_recv → ae_reject_logmismatch` ×4, then `ae_already_have`. The AE log-matching backtrack converges for a short divergent suffix.
- **ctrl-2 = the stall**. It recovered as a former primary with an uncommitted higher tail (`durable_floor=15 current_lsn=16`, entries 12-15 at an old term) vs the leader's committed 11. Its log shows a ~15s flap cycle, repeating indefinitely:
1. `handle_ctrlhello: primary control channel established (fd=6)` → receives heartbeats → re-attaches CTRL_STANDBY.
2. ~12-15s later: `ha_on_primary_ctrl_fd_rdhup: primary_ctrl_fd RDHUP → begin PreVote failover (Req 1.1)`.
3. `ha_start_prevote_async (prospective term=6)` → `ha_prevote_round_timeout_fn: PreVote round timed out before a decision — treating as LOSS, staying CTRL_STANDBY` (NO `pre_vote_reply_in` — peers see the primary alive and don't grant).
4. Primary reconnects → loop repeats. (Last heartbeat and last prevote-timeout share the same timestamp — the channel is flapping, not cleanly down.)
## Root cause
The primary↔ctrl-2 control channel keeps **RDHUP-flapping** (~15s period). Because "control-channel RDHUP = primary died → failover" is the designed trigger (CLAUDE.md), each flap spawns a spurious PreVote failover that **cannot win** (the primary is genuinely alive, so the other voters don't grant), times out, and re-arms. The likely driver of the flap: ctrl-2 cannot reconverge its **divergent uncommitted tail (12-15)** against the leader — the catch-up/AE path stalls or the leader drops the channel — so ctrl-2 never settles as a follower and the channel cycles. Net: the recovered node never rejoins → no stable 3-of-3 → soak `wait_for_full_convergence` times out (the observed wedge).
## What the fix must address (Phase 4 + Phase 5, intertwined)
1. **Reconvergence (Phase 4 / R5):** a standby that recovers with a divergent *uncommitted* tail must truncate it (follower-AE-conflict-truncate contract) and catch up to the leader — the same way ctrl-3 does for a short suffix — so the control channel stays up. Determine why ctrl-2's longer/former-primary divergent tail does not reconcile while ctrl-3's short one does (candidate: the backtrack doesn't reach far enough before the channel drops, or the former-primary recovery advertises a tip that the leader's nextIndex backtracking never crosses).
2. **Spurious-failover suppression (Phase 5 / R6/R4):** a transient/flapping control-channel RDHUP must not spawn an unwinnable PreVote storm while the primary is demonstrably alive (peers still see it). A standby should prefer to **reconnect + catch up** on RDHUP before (or instead of) launching failover, or back off failover when its own PreVotes repeatedly get no quorum because a current-term leader exists. (The retry itself works — R6 retry is present — but it retries an election that can never win instead of rejoining.)
## RED test direction (5.1 / 4.1)
- Force a recovered standby holding a divergent uncommitted tail (former-primary shape, tip above the new leader's committed) to rejoin a cluster with a live higher-term primary, and assert it **reconverges to follower (truncates tail, catches up) without entering a PreVote-failover loop** — i.e. no repeated `ha_begin_failover`/`PreVote timed out` while a current-term primary is reachable. This is deterministically constructible with the 1.4 kill/election + apply-gap seams plus a divergent-tail fixture.
## Process note
The dispatched investigation subagent backgrounded the soak and hung (~no findings returned); this section was reconstructed by the parent directly from the frozen `~/tmp/ha-estall` controller logs.
---
# Task 7.2 — Acceptance chaotic soak (first run with Phase-2+5 fixes): NO-GO + new finding
Ran `run-ha-soak.sh --chaotic 6` on real-fs (ext4 `/home/claude/tmp/ha-7p2`) with the
**release binaries rebuilt at HEAD `01419e1`** (Phase-2 payload fix + Phase-5 5.2
spurious-failover suppression). Frozen at `/home/claude/tmp/ha-7p2-frozen`.
## What PASSED (job-safety + the landed fixes)
- **Zero committed-JOB loss.** Every round the job-verify confirmed all tracked jobs
present on the new primary (round1 6/6, round2 9/9, round3 12/12).
- **Zero TRUE committed divergence.** `walcompare --committed 11` over the frozen
WALs: **"OK — committed prefixes identical"** across all 3 members. The Phase-2
payload-loss signature (present=3, one member `pcrc=0` vs majority real) is ABSENT.
`lsn=8` is `pcrc=0` *identically on all three* — a legit empty NOOP marker, not loss.
- **The counted `walcompare` divergences are catch-up LAG, not corruption.** The only
boundary-19 divergence is `lsn=12 present=2`: ctrl-3 (tip=11, restarted round 3) is
MISSING it; ctrl-1/ctrl-2 hold it with matching content. All "divergences" are
`present=2` MISSING tails of restarted-but-uncaught followers (tips ctrl-1=19,
ctrl-2=15, ctrl-3=11), which an inflated `--committed` boundary over-claims as
committed — they are uncommitted/in-flight catch-up tail, exactly the research.md
Phase-2-interim note.
- **5.2 spurious-failover suppression confirmed working.** Rounds 1–3 each elected a
strictly higher-term primary (ctrl-3 t3 → ctrl-2 t4 → ctrl-1 t5): liveness intact,
the fix did not over-suppress. The round-4 RDHUP logs show the GENUINE-loss path
("primary_ctrl_fd RDHUP → begin PreVote failover"), NOT the suppression message —
correct, because the primary was truly dead (`ha_heartbeat_is_fresh==0`).
## What FAILED — round-4 election stall (NEW residual liveness defect)
After 3 overlapping kills, round 4 kills ctrl-1 (tip 19, the only fully-up-to-date
node). The two survivors are both restarted-and-lagging (ctrl-2 tip=15, ctrl-3 tip=11),
and their PreVote rounds **repeatedly time out before a decision** (no grant quorum):
each loops `ha_start_prevote_async → ha_prevote_round_timeout_fn (LOSS) → RDHUP →
ha_begin_failover → …` indefinitely, prospective terms frozen (ctrl-2=5, ctrl-3=6,
never bumped because no PreVote ever WINS). The soak wedged here until the 10-min cap.
**This is NOT the spurious-failover flap (5.2 closed that) and NOT a job-safety bug.**
A SAFE candidate exists — ctrl-2 holds the full committed prefix [1..11] (Leader
Completeness satisfied) and is the freshest survivor — yet the election never converges
on it under sustained overlapping churn. Observed contributors (need a dedicated
RED→bisect): freshness mutual-denial (`PreVote DENIED … reason=freshness, floor lsn=11`)
between two lagging survivors, the in_catch_up-denies-all-votes gate (Req 2.5; obs 1240
"a catching-up node sends zero PreVote replies") with no live primary left to catch up
FROM, and/or peer-to-peer vote connections not established between two nodes that each
only held a control channel to the now-dead primary.
## Verdict
**7.2 = NO-GO.** Job safety (R8.1/R8.2) holds; the acceptance liveness criterion
(R8.3 — reconverge 3-of-3 + elect after EVERY kill round) fails at round 4. Per the
design's chaotic-harness Contingency, this is a recorded new finding, not closure:
added follow-up **task 5.3** (overlapping-churn election stall — a lagging-but-safe
survivor must be electable; the catching-up/freshness vote gates must not deadlock when
the primary is gone). 7.2 stays blocked on 5.3. Re-run 7.2 after 5.3 lands.
---
# Task 5.3 + 7.2 re-run — CORRECTED root cause: the soak stall is bug-924, not consensus
After landing task 5.3 (commit bf88681 — gate the `in_catch_up` PreVote deny on a
LIVE primary), the chaotic soak was re-run on real-fs release binaries
(`/home/claude/tmp/ha-7p2b`, controller rebuilt 02:44 with all Phase-2+5.2+5.3 fixes).
## Result: round-4 stalls AGAIN — but the cause is the persist fdatasync wedge (bug-924), NOT election logic
- Rounds 1–3 elect a higher-term primary each round (term 4→5→6); job safety holds.
- Round 4 "NO new primary within window" — and ctrl-2's PreVote rounds get **zero
replies** (timeout, not LOST), because the *other* survivor **ctrl-3 is wedged**:
its persist flusher spins on `flusher fallback fdatasync FAILED (errno=22 Invalid
argument)` from 02:45:49 onward and stops answering PreVotes. ctrl-3's process is
alive but non-responsive — jammed in the durability layer, not the election layer.
- This is the **bug-924** WAL-fdatasync-on-overlay artifact (memory obs 1476/1477):
the WAL segment's `fdatasync` returns EINVAL in this sandbox. A file-level repro of
the exact controller pattern (`open O_RDWR|O_CREAT|O_TRUNC` → `posix_fallocate(64MB)`
→ write → `fdatasync`) SUCCEEDS on `/home/claude/tmp`, `/home/rshen/projects/xce`
(both ext4) and `/dev/shm` — so it is the controller's live overlay-backed WAL fd
under chaotic restart, not a plain fs limitation. bug-924 is a **Non-Goal of this
spec** (design §Non-Goals / §Out of Boundary: "the overlayfs `fdatasync`
environment defect (bug-924) — excluded from the acceptance verdict, not fixed
here"); durability sync is owned by `ha-commit-durability`.
## Re-attribution of the round-4 stall (corrects the first §"Task 7.2" entry)
The first soak's round-4 stall was attributed to the `in_catch_up` mutual-deny. That
IS a real latent defect — 5.3 reproduces it deterministically at unit level (a
catching-up survivor with no live primary denied a fresh PreVote) and fixes it — but
it is NOT what blocks the soak. The soak's actual round-4 blocker in BOTH runs is the
bug-924 fdatasync wedge that makes a survivor non-responsive. 5.3 is kept as a valid
correctness fix (unit RED→GREEN, `test_ha_core_safety` 20 Tests / 0 Failures); its
6-round-soak observable clause shares 7.2's environment block.
## 7.2 status: BLOCKED ON ENVIRONMENT (bug-924), not a consensus NO-GO
The consensus work this spec owns is unit-complete and validated: Phase-2 payload
integrity (committed prefix byte-identical across members), 5.2 spurious-failover
suppression, 5.3 catching-up-survivor electability. The live ≥6-round acceptance soak
cannot reach a clean verdict in this sandbox because the controller's WAL `fdatasync`
fails (bug-924) and wedges nodes mid-soak — the same env block that left
`ha-term-safety`'s live soak permanently BLOCKED. 7.2 (and 5.3's soak clause) require a
real-fs host/CI where the controller WAL `fdatasync` succeeds, OR the soak harness must
detect the fdatasync-wedge and exclude it from the liveness verdict (a Phase-1 harness
improvement), OR bug-924 is resolved by `ha-commit-durability`. The consensus fixes do
not need re-work for this; it is an environment/durability-layer blocker.
---
# Task 7.2 on ~/tmp (non-overlay) — DEFINITIVE 6-round blocker: a controller restart SEGFAULT
Re-ran the chaotic soak on the user-provided non-overlay path `~/tmp` (= `/home/claude/tmp`)
with the release binaries (Phase-2 + 5.2 + 5.3) and a harness change to **capture each
controller's stdout+stderr** (`start_node` previously sent it to `/dev/null`, hiding all
crash output — the key diagnostic gap).
## What is now PROVEN
- **Elections work under chaotic churn.** A 2-round chaotic soak COMPLETES (both rounds
elect a higher-term primary). In 6-round runs, rounds 1–3 each elect (terms climb
4→5→6). 5.2 + 5.3 hold live. Job safety holds (zero committed-job loss every run).
- **The 6-round blocker is a RELEASE-BUILD SEGFAULT on restart**, captured at last in the
per-node stderr: `start.sh: line 14: <pid> Segmentation fault (core dumped) … xce_controller
--myname ctrl-3 …`. A controller **segfaults a few seconds after being restarted into the
churning cluster** (during recovery→failover/PreVote). One node down → cluster at 1-of-3 →
the lone survivor cannot reach quorum → "NO new primary within window." The harness's other
stderr lines are the expected `kill -9` ("Killed"); only the restarted node shows SEGV.
- **This is NOT** the in_catch_up eligibility deadlock (5.3 fixes that, unit-proven), **NOT**
bug-924 fdatasync (that wedged a node in ONE earlier run but is absent here — zero
`fdatasync FAILED`), and **NOT** the consensus logic this spec owns. It is a restart/rejoin
stability crash.
## Why it could not be root-caused in this sandbox
- The crash is a **heisenbug**: it does NOT reproduce under the ASAN controller build (all
ASAN-run stderr shows only harness `kill -9`, no SEGV / no ASAN report) — memory-layout /
timing dependent.
- No `gdb` and no usable core: `core_pattern` pipes to a non-running `systemd-coredump`, so
the "core dumped" core is discarded; `coredumpctl`/`gdb` are absent from the env.
- Root-causing requires a debug-non-ASAN build with core dumps + gdb (or a host where the
systemd-coredump pipeline is live), i.e. a tooling environment this sandbox lacks.
## Harness accuracy note (separate, in-scope)
The soak counts `committed_divergence` via `walcompare --report --committed $((1 + 2*njobs))`
(run-ha-soak.sh:543/663). That boundary is the OPTIMISTIC job-count estimate, not the true
majority-committed LSN, so a lagging follower's not-yet-replicated tail (uncommitted on that
member) is mis-counted as committed divergence. The completed 2-round soak reported
`committed_divergence=3 committed_job_loss=0` — and a frozen-WAL `walcompare` at the true
boundary shows the committed prefix byte-identical (the 3 are catch-up-lag MISSING tails, not
corruption). `ctrlstatus` already reports `committedLsn`; the boundary should use it. (Even
fixed, the 6-round verdict stays blocked by the segfault above.)
## 7.2 status (corrected): blocked by a restart SEGFAULT, not consensus, not bug-924
Consensus correctness (Phase-2 payload integrity, 5.2 spurious-failover suppression, 5.3
catching-up-survivor electability) is unit-complete and confirmed live for every round that
completes. The clean ≥6-round acceptance verdict is blocked by the restart segfault — added
follow-up **task 5.4** (root-cause+fix the chaotic-restart crash; needs core+gdb / debug env)
and **bug-1281**. The harness `committedLsn`-boundary fix is a separate Phase-1 accuracy
improvement.
---
# Task 7.2/5.4 — UNIFIED root cause via gdb: round-N stall is a restart-stability bug (I/O wedge / SIGSEGV), not a consensus-logic gap
Wrapped the release `xce_controller` under `gdb -batch` (handles RT signals; dumps all-thread
bt on SIGSEGV) via `XCE_HA_BIN=<gdb-wrapper>` and re-ran the chaotic soak on `~/tmp`.
## Decisive findings
- **Two round-4 faces, ONE root cause.** Plain release: a restarted node **SIGSEGVs**
(captured `Segmentation fault (core dumped) … --myname ctrl-3`). Under gdb the crash does NOT
fire (ptrace perturbs the memory bug); instead the same restarted node's **inbound-accept /
iomgr path WEDGES** — ctrl-3's last `cluster.c:737 server sock accepted` is 03:42:03, yet it
keeps firing its own election timer (`pre_vote_start`) to 03:51:59. It campaigns but can
neither accept peers' PreVote connections nor get answered; ctrl-2's outbound PreVote connects
hang on the dead listener (zero connect-fail logs). The two restarted survivors can't exchange
votes → permanent round-4 stall. Release SEGV and gdb accept-hang = the same restart-time
corruption manifesting differently.
- **Restart-STABILITY bug, not a consensus-logic gap.** PreVote/eligibility/freshness (incl. the
5.3 fix) are correct; the node is simply I/O-dead after restart. Predates this session —
baseline `21e26a1` failed round 3–4 (requirements.md symptom 3). 5.2/5.3 sit in the
restart/failover path so 5.4 must rule them out during the backtrace, but they did not
introduce the instability.
- **Cannot backtrace in this sandbox.** Heisenbug: invisible under ASAN (no SEGV) AND under gdb
(becomes a hang, not a catchable signal); `core_pattern` is read-only (pipes to a non-running
systemd-coredump) so the release core is discarded. Needs a non-ASAN DEBUG build (-O0 -g) run
directly under gdb so the SIGSEGV is caught with symbols, or a host with a live coredump pipe.
## Net for 7.2
Elections + job safety are sound for every round that COMPLETES (2-round soak PASSES; 6-round
rounds 1–3 elect higher-term primaries; committed prefix byte-identical; zero job loss). The
≥6-round acceptance verdict is blocked by the restart-stability bug (bug-1281 / task 5.4),
reachable at round 4 (kill the up-to-date node → two restarted survivors). Spec-owned consensus
correctness (Phase-2/5.2/5.3) is unaffected.
## Harness wins
`start_node` now captures per-node stdout+stderr (committed 75243fb) — this exposed the SEGV.
The gdb-wrapper (`XCE_HA_BIN=<wrapper>` → `gdb -batch … --args <release ctrl>`) is the reusable
technique to catch a restarted node's fault once a debug+core env is available.
---
# Task 7.2/5.4 — ROOT CAUSE NAILED via gdb: ABBA lock-order deadlock in src/lib async dispatch (UPSTREAM raft-drive-async)
Wrapped the release controller under gdb -batch with a SIGQUIT handler that dumps all-thread
backtraces; reproduced the round-4 hang; kill -QUIT'd the two wedged survivors. ctrl-2 and ctrl-3
showed the IDENTICAL two-thread deadlock.
## The deadlock (both nodes)
- io_worker (netio.c:612): takes ep->lock (netio.c:672) and, still holding it, runs
vtask_enq -> dataq_enq which blocks on q->lock (dataq.c:81). Order: ep->lock -> q->lock.
- vdispatcher_entry: dataq_deq (dataq.c:65) takes q->lock for the WHOLE match scan and runs the
match callback vtask_eligible (vtaskmgr.c:255) -> endpoint_is_task_blocked (netio.c:1001) which
blocks on ep->lock. Order: q->lock -> ep->lock.
- ABBA: io_worker holds ep wants q; vdispatcher holds q wants ep -> permanent deadlock. io_worker
dies -> the node stops accepting / answering PreVotes -> two restarted survivors can't elect ->
round-4 stall. In release the same ep/q race under churn corrupts -> SIGSEGV (the earlier crash).
## Exact defect + minimal fix
dataq_deq invokes a match callback that acquires a SECOND lock (ep->lock via the
endpoint_is_task_blocked eligibility gate) while holding q->lock — inverting the ep->lock->q->lock
order io_worker uses. Minimal fix: make ep->busy atomic and read it locklessly in
endpoint_is_task_blocked (remove the ep->lock acquire from under q->lock); or io_worker drops
ep->lock before dataq_enq; or impose one global lock order.
## Ownership: UPSTREAM (raft-drive-async / core libxce async-io), NOT this spec
Entirely in src/lib async-I/O dispatch (netio.c io_worker/endpoint, vtaskmgr.c eligibility,
dataq.c) — the VTASK dispatch / endpoint-blocked eligibility layer raft-drive-async owns and this
spec's Non-Goals exclude. Per upstream-ownership protocol: route to raft-drive-async (bug-1282),
keep 7.2 blocked until the dispatch lock-order is repaired, then re-run the gate. This is the gate
working as designed: 7.2 must be GREEN before raft-drive-async flips dispatch flags ON, and it just
caught a real dispatch-layer deadlock.
## Consensus correctness (this spec) — unaffected
Phase-2 payload integrity, 5.2 spurious-failover suppression, 5.3 catching-up electability are
correct and unit-validated; elections + job safety hold for every round that completes. The
round-4 blocker is the upstream dispatch deadlock, not consensus logic.
---
# bug-1282 FIXED + bug-1283 isolated (the round-4 stall had TWO causes)
Implemented the bug-1282 fix (commit 25e741d): promoted `endpoint_t.busy` to `_Atomic int` and
made `endpoint_block_tasks`/`endpoint_unblock_tasks`/`endpoint_is_task_blocked` lockless, removing
the `ep->lock` acquire from the vtask-eligibility path that ran under the dataq `q->lock`.
## Verified
- **Deadlock gone:** post-fix gdb soak shows ZERO `__lll_lock_wait` threads across all 3 nodes and
`endpoint_is_task_blocked` no longer appears in any lock path. The ABBA inversion is eliminated.
- **No regression:** `controller_unit_test` all green (incl. `test_ha_core_safety` 20/0); libxce +
controller + unit test build clean.
## But the round-4 stall had TWO independent causes
A plain release soak WITH the fix STILL segfaults a restarted standby at round 4 (ctrl-3,
"Segmentation fault (core dumped)"). **Deadlocks hang; they do not SIGSEGV** — so this crash is a
SEPARATE memory bug (now bug-1283), not bug-1282. It is most likely an endpoint-lifetime UAF under
chaotic restart (bug-1197 family): the dispatch eligibility path reads `vdata->u.iodata.ep` /
endpoint state while io_worker tears down or mutates the same endpoint. It is a heisenbug —
invisible under ASAN and under gdb (gdb perturbs it into a cold-start timeout / no crash) — and the
sandbox has no usable core (read-only `core_pattern` → non-running systemd-coredump), so a
backtrace needs a non-ASAN DEBUG (-O0 -g) build under gdb or a host with a live coredump pipeline.
## Net for 7.2
The bug-1282 deadlock fix removes ONE of the two round-4 blockers (the iomgr wedge). The remaining
blocker is bug-1283 (the restart segfault), likely UPSTREAM raft-drive-async / core libxce endpoint
quiescence. 7.2 stays blocked on bug-1283; consensus correctness (Phase-2/5.2/5.3) is unaffected and
elections + job safety hold for every round that completes.
---
# bug-1286 (the round-4 segfault) ROOT-CAUSED — NULL ep->owner — via the new /xce-build debug profile
The `/xce-build ... debug` profile added this session (non-ASAN `-O0 -g3`, no sanitizer → `build.dbg`)
reproduced the heisenbug AND gave a symbolized backtrace under gdb (ASAN and gdb-on-release both
perturbed it away). The captured SIGSEGV:
```
Thread received SIGSEGV
#0 ha_dispatch_gate (ha=0x3ff0, opcode=37) controller/ha.c:3194 (atomic_load &ha->role)
#1 myreq_handler controller/cluster.c:251
#2 vtask_entry controller/cluster.c:693
#3 run_task / vcpu_entry lib/vtaskmgr.c
```
Root cause: `myreq_handler` does `cluster_t* c = (cluster_t*)ep->owner;` then
`ha_dispatch_gate(&c->ha, opcode)`. On a chaotic restart an inbound endpoint dispatches a
`walCatchupEntry` (op 37) vtask while `ep->owner == NULL`, so `c == NULL` and
`&c->ha == NULL + offsetof(cluster_t,ha) == 0x3ff0` — a garbage pointer `ha_dispatch_gate`
dereferences → SIGSEGV. (DISTINCT from the bug-1282 deadlock, which is fixed; deadlocks hang,
they do not crash.) Fix: NULL-guard `c = ep->owner` in `myreq_handler` (drop/reject the request if
the endpoint's owner is not yet attached) instead of dereferencing; deeper, ensure `ep->owner` is
set at accept time before the endpoint is monitored for input and never cleared while a vtask is in
flight. Tracked as bug-1286 (an earlier auto-logged entry already held the id bug-1283).
7.2 now has ONE remaining blocker: bug-1286 (root-caused; small NULL-guard fix). bug-1282 (deadlock)
is fixed. Consensus correctness (Phase-2/5.2/5.3) is unaffected.
---
# Both fixes verified (bug-1282 + bug-1286) — round-4 now a CLEAN election-liveness cross-deny
6-round chaotic soak on real-fs release WITH bug-1282 (deadlock) + bug-1286 (NULL ep->owner crash)
both fixed: rounds 1-3 elect; round 4 has NO segfault, NO fdatasync, NO iomgr deadlock (both fixes
held, confirmed). The round-4 stall is now the GENUINE consensus-liveness edge the crash/deadlock
were masking:
- ctrl-1 (primary term 6) killed round 4.
- ctrl-2 (recovered durable_floor=15, the FRESHER survivor) denies ctrl-3 on FRESHNESS (ctrl-3
advertises 12 < 15) - correct.
- ctrl-3 (floor 12) denies the fresher ctrl-2 on ELIGIBILITY (raft_cb_vote_eligible), repeatedly
for 2+ minutes - because ctrl-3 still believes its primary (the dead ctrl-1) is alive
(in_catch_up + heartbeat-not-yet-stale, primary_ctrl_fd still >=0). So the legitimate winner
(ctrl-2) never gets ctrl-3's grant -> stall.
This is requirements.md symptom #3 (round-N election stall) in pure form. Contributors to chase:
(a) ctrl-3's "primary alive" view not expiring within the round - the heartbeat-fresh/in_catch_up
eligibility deny (gated on primary_ctrl_fd>=0 && ha_heartbeat_is_fresh by task 5.3) does not lift
because last_activity is not going stale; SUSPECT task 5.2's ha_arm_heartbeat_timeout_vtask re-arm
resetting last_activity=now on the RDHUP-suppress path keeps a node falsely-fresh after the primary
dies - revisit whether the suppress path should reset last_activity. (b) a stale primary_ctrl_fd to
the dead primary not cleared promptly. Next Phase-5 task: a node denying on heartbeat-freshness/
in_catch_up must detect a genuinely-dead primary within the failover window and release the deny so
the freshest survivor is electable.
## Session net
bug-1282 (ABBA iomgr deadlock) and bug-1286 (round-4 restart SIGSEGV, NULL ep->owner) are FIXED and
VERIFIED (no crash / no deadlock / no fdatasync across round-4 churn). The remaining 7.2 blocker is
this election-liveness cross-deny - the hard core of Phase 5, no longer masked.
---
# Task 5.5 fix VERIFIED as progress; round-4 now reaches a DEEPER layer (dueling-candidate term escalation)
Landed the 5.5 fix (commit b8453b2: advertise persist_entry_term(p, log_len) as lastLogTerm instead
of the stale c->persist.term) and re-ran the 6-round soak. Round-4 frozen state: ctrl-2 tip 15 @
term 5 (freshest), ctrl-3 tip 11 @ term 4 (staler).
## 5.5 is correct and made real progress
BEFORE the fix the freshest survivor advertised a too-low lastLogTerm and EVERY voter's logOk
rejected it — it could not even win a PreVote. AFTER the fix ctrl-2 WINS PreVote (pre_vote_won) AND
enters the real election ("PreVote round WON — entering real election"). The advertised-term
asymmetry is fixed; the fresher candidate is now electable in principle.
## The deeper layer it exposed (next task)
ctrl-2 wins PreVote -> real election (term N) -> RequestVote -> replies but NO became_leader ->
retries at term N+1, N+2 ... prospective term climbing 21->25->28->29 unbounded. Mechanism: ctrl-2's
primary (dead ctrl-1) keeps RDHUP'ing ~every 15s; 5.2 does NOT suppress (primary genuinely dead),
so each RDHUP re-triggers ha_begin_failover -> fresh PreVote -> real election -> term++. ctrl-2 and
ctrl-3 duel in lockstep (each denies the other's real vote on already-voted-this-term / stale-term),
and the deterministic ~15s RDHUP cadence defeats the randomized-backoff tiebreak that normally
resolves a split vote. Neither reaches majority; the term escalates without converging.
Classic Raft split-vote / dueling-candidates liveness problem, amplified by the RDHUP failover
re-trigger. SEPARATE layer from 5.5. Next: break the duel (per-node randomized failover backoff /
election jitter that desynchronizes the two survivors; and/or do not re-trigger a fresh failover
via RDHUP while a real election the node just entered is still within its timeout window).
## Session net (4 layers peeled on the round-4 stall)
1. bug-1282 ABBA iomgr deadlock — FIXED (25e741d).
2. bug-1286 NULL ep->owner restart SIGSEGV — FIXED (5db734a).
3. bug-851/5.5 stale advertised lastLogTerm — FIXED (b8453b2), verified: the fresher survivor now
wins PreVote + enters the real election (could not before).
4. REMAINING: dueling-candidate split-vote + unbounded term escalation under the RDHUP failover
cadence — the round-4 election round still does not complete. New follow-up.
Consensus SAFETY intact throughout (no two leaders; job safety holds every completed round); this is
purely election LIVENESS under sustained churn.
---
# Task 5.6 fix VERIFIED (unit) + reduces churn; round-4 exposes a 5th layer (vote-reply term-lag escalation)
Landed 5.6 (commit 81cb09a: ha_on_primary_ctrl_fd_rdhup drops an RDHUP while role==CTRL_ELECTING
or pre_vote_in_progress, instead of restarting failover). Unit RED→GREEN (test_ha_core_safety 22
Tests / 0 Failures); the soak's term churn is slower (peaked ~24-31 vs ~28-29+ before at the same
elapsed time) — the fix does prevent RDHUP from preempting an in-flight election.
## The 5th layer (next task 5.7)
Round 4 still stalls. The freshest survivor ctrl-2 (15@t5) repeatedly wins PreVote, enters the real
election, AND ctrl-3 GRANTS it (vote_granted) — ctrl-3 is voting FOR ctrl-2, not leapfrogging
(ctrl-3 wins only 1 prevote total). But ctrl-2 never tallies a majority: its log shows every grant
as **vote_reply_stale_drop** — the reply's term is LOWER than ctrl-2's current election term.
Mechanism: ctrl-2's term escalates via repeated election timeouts faster than ctrl-3 can process
the request and reply, so ctrl-3's grant (stamped at ctrl-3's lower term) is always one term behind
ctrl-2's current term → DropStaleResponse → not counted → election times out → term++ → the grant
for the new term is again stale. Self-perpetuating term escalation; ctrl-2 never wins despite a
willing grantor.
election_timeout_ms = 5000, jitter = 200ms (HA_DEFAULT_*). The 5s window should suffice for a warm
RTT, so the lag points at: a just-restarted ctrl-2 with COLD peer connections (each real-vote opens
a fresh endpoint_connect + SSL handshake; raft_cb_send_vote_request), plus parallel connect attempts
to the dead ctrl-1 (DEFAULT_CONNECT_TIMEOUT_MS) — the grant from ctrl-3 lands after ctrl-2 has moved
on. Fix directions (5.7): (a) don't bump the election term while a vote round-trip for the current
term is still outstanding (give the grant time to land before re-escalating); (b) warm/reuse the
peer vote connection so the grant RTT < election_timeout; (c) larger election-timeout jitter so the
two survivors desynchronize and one's term stops racing the other's reply. Preserve Election Safety
(DropStaleResponse itself is correct Raft — the bug is the escalation cadence, not the drop).
## Session net (5 layers peeled on the round-4 stall; 4 fixed)
1. bug-1282 ABBA iomgr deadlock — FIXED (25e741d).
2. bug-1286 NULL ep->owner restart SIGSEGV — FIXED (5db734a).
3. bug-851/5.5 stale advertised lastLogTerm — FIXED (b8453b2), verified prevote-win.
4. 5.6 RDHUP preempts in-flight election — FIXED (81cb09a), unit-verified, reduces churn.
5. REMAINING (task 5.7): vote-reply term-lag / unbounded term escalation under cold-connection churn
— the freshest survivor is granted but its term out-races the grant. New follow-up.
Consensus SAFETY intact throughout (no two leaders; DropStaleResponse correct; zero job loss every
completed round). Purely election LIVENESS convergence under sustained overlapping-kill churn.
---
## Task 5.7 fix VERIFIED + round-4 blocker re-rooted (2026-06-30)
**5.7 (election-timeout resonance) — FIXED, commit 74784b2.**
The 5.6 note guessed the 5.7 cause as "term re-escalates faster than the grant
can land." Reading the code pinned the *mechanism* precisely: the per-CAMPAIGN
election watchdog (`raft_start_election` → `on_schedule_timeout` →
`raft_cb_schedule_timeout`, ha.c) was armed with a **FIXED** `election_timeout_ms`
— **no randomization**. Only the *startup-discovery* path (`ha_wait_for_heartbeat_or_timeout`)
jittered; the recurring campaign watchdog did not. raft.c:157 passes the bare
`s->election_timeout_ms` straight through.
A fixed deadline + a roughly-constant cold-connection grant RTT **RESONATE**: if
ctrl-3's grant consistently lands a hair *after* a constant deadline, every
campaign loses by the *same* margin forever → the campaign times out, the term
re-escalates, and the now-stale grant is dropped by `DropStaleResponse`
(raft.c:453, which is *correct* Raft). Terms climb 25→30+. The bug was never
"RTT genuinely > 5 s"; it was that the deterministic cadence locks the grant
permanently just outside every window.
**Fix (Ongaro thesis §3.4 — randomized election timeout, re-drawn each campaign):**
new `ha_jittered_election_delay(ha, base)` returns `base + rand[0, base]` (the
canonical [T, 2T] interval), wired into `raft_cb_schedule_timeout` so the watchdog
is re-randomized per campaign. Done in the HA adapter so the pure, sim-tested raft
library stays deterministic. Independent of `election_jitter_max_ms` (which tunes
only the sub-second startup/stagger jitter) so it spreads even at jmax=200; never
below the floor so genuine-loss failover is not sped up past `election_timeout_ms`.
Unit: `test_5_7_campaign_timeout_is_randomized` (CORE) + `test_5_7_floor_preserved_and_zero_safe`
(PIN). RED verified by stubbing the jitter to 0 → the randomization test fails with
the resonance message while the PIN stays green. `test_ha_core_safety` 24 Tests / 0
Failures. (Aside: the full aggregate `controller_unit_test` binary cannot reach the
ha_core_safety suite because an EARLIER suite, `test_ha_vep_teardown`, SIGSEGVs in
`iomgr_fini`→`SSL_CTX_free` under openssl 3.6 — unrelated to this change; run the
suite in isolation with `-t test_ha_core_safety`.)
**Soak confirmation:** a fresh ASAN 6-round chaotic soak now elects with **BOUNDED
terms** — rounds 1→3 elect ctrl-3/ctrl-2/ctrl-1 at terms **4→5→6** (not 25→30+).
The unbounded escalation is gone; zero job loss on every completed round (3/6/9/12
tracked jobs all present on each new primary). The election-liveness layer is DONE.
**Round-4 blocker RE-ROOTS to a non-election crash (task 5.8 / bug-1298).**
Round 4 still stalls, but no longer for any election reason. ctrl-3 **ABORTs** on
an ASAN heap-use-after-free:
```
UAF READ size 4 endpoint_is_task_blocked netio.c:1008 (vtask dispatcher T3)
vtask_eligible vtaskmgr.c:255 ← dataq_deq_first_match ← vdispatcher_entry
FREED BY (T2) endpoint_killer cluster.c:813 (io_worker)
handle_endpoint_error cluster.c:108 ← handle_error ← io_worker
ALLOC endpoint_get netio.c:748 ← handle_connection cluster.c:741
```
io_worker (T2) tears down a connection on error and `free()`s the endpoint via
`endpoint_killer` while the vtask dispatcher (T3) concurrently reads `ep->busy` on
that just-freed endpoint during its dataq eligibility scan. The endpoint is freed
while still referenced by a pending vtask; nothing drains its queued vtasks before
the free and no refcount keeps it alive across the dispatcher read. ctrl-3 dies →
the cluster drops to 1/3 (the round-4 survivors are the just-restarted ctrl-2 and a
now-dead ctrl-3) → no quorum → "NO new primary within window." ctrl-2's logs show
only PreVote-round timeouts (`prospective term=6`, never wins) — the textbook
symptom of a lost quorum peer, not an election defect.
Same netio surface and routing precedent as task 5.4/bug-1282: an async-io dispatch
concurrency bug, **UPSTREAM-owned** by raft-drive-async / core libxce. Likely a
latent UAF that the bug-1282 lockless `ep->busy` read unmasked (the dispatcher now
reaches the read without contending on `ep->lock`). Logged as **bug-1298**; tracked
as task 5.8. Consensus safety (Phase-2/5.2/5.3/5.5/5.6/5.7) is sound; elections and
job safety hold for every round that completes.
---
# Cross-Spec Gap Analysis (2026-06-30)
Generated by `/kiro-validate-gap ha-term-safety raft-drive-async raft-ha-core-safety ha-consistency-invariants`.
## Summary
4-spec HA safety family: **ha-term-safety COMPLETE**, **raft-drive-async DORMANT** (all tasks done, production flags OFF), **raft-ha-core-safety IN PROGRESS** (12/26 tasks, Phase 5 critical path — task 5.7), **ha-consistency-invariants DESIGN ONLY**. One blocker to production: raft-ha-core-safety 7.2 chaotic soak PASS.
## Dependency Graph
```
ha-consistency-invariants (umbrella — defines invariants)
├── ha-term-safety (term recording + reconcile) ← COMPLETE
├── raft-ha-core-safety (payload safety + election liveness + soak gate) ← IN PROGRESS
└── raft-drive-async (non-blocking drive threads) ← DORMANT
gates on: raft-ha-core-safety 7.2 PASS
```
## Implementation State
| Spec | Tasks Done | Tasks Remaining | Status |
|------|-----------|----------------|--------|
| ha-term-safety | 20/20 | 0 | COMPLETE (build green, 175 tests, clean failover verified) |
| raft-ha-core-safety | 12/26 | 14 (5.7 critical) | IN PROGRESS (Phases 1-2+4+5.1-5.4 done; Phase 3 not started) |
| raft-drive-async | 17/17 | 0 (dormant flags) | DORMANT (production flags OFF; gates on raft-ha-core-safety 7.2) |
| ha-consistency-invariants | 0/0 | design only | DESIGN ONLY (no tasks generated) |
## Critical Path
```
raft-ha-core-safety 5.7 (vote-reply term-lag) → 7.2 (soak PASS) → raft-drive-async flags ON
```
## Remaining Gaps
1. **Task 5.7** (NOT STARTED): vote-reply term-lag — candidate's term escalates faster than grant RTT, all grants arrive stale. Fix: don't bump election term while vote round-trip outstanding, or warm peer connections.
2. **Task 7.2** (BLOCKED on 5.7): acceptance chaotic soak gate — ≥6 rounds, zero divergence, zero job loss. Currently: rounds 1-6 pass, round 7 stalls on PreVote cross-denial (bug-1296). Election liveness is the LAST remaining data-path gap; payload safety (Phase 2) and reconvergence (Phase 4) are proven.
3. **Phase 3** (snapshot-boundary reconcile): lowest priority — snapshot install is rare (16-byte sentinel today).
4. **raft-drive-async flags**: peerctrl/snapshot/configpush all default OFF. Flag-flip gate = raft-ha-core-safety 7.2 PASS + borrowed-ep refcount for snapshot/configpush lane.
5. **ha-consistency-invariants**: umbrella spec with approved design, no tasks. Close by verifying all invariants are satisfied by the three implementation specs.
## Risk
| Risk | Likelihood | Impact |
|------|-----------|--------|
| Task 5.7 reveals further election liveness layers (5.5→5.6→5.7 chain) | HIGH | Schedule slip; budget 2-3 more iterations |
| Chaotic soak still fails after 5.7 | MEDIUM | 7.2 blocks raft-drive-async; consider sequential-only fallback |
| Raft-drive-async flag-flip reveals new races | LOW | Gradual rollout: peerctrl first |
## Recommendations
1. **Immediate**: Fix task 5.7 (vote-reply term-lag) — approach (a): don't bump term while vote outstanding.
2. **Short-term**: Complete raft-ha-core-safety 5.7→7.2→7.3, then flip raft-drive-async flags.
3. **Medium**: Generate tasks from ha-consistency-invariants design; close the umbrella loop.
---
## Task 5.8 fix VALIDATED + round-4..6 reveals pre-existing commit-loss (2026-06-30)
**5.8 (endpoint teardown-vs-dispatch UAF, bug-1298) — FIXED, commit 6e05dc1.**
Chosen fix (user picked the principled option over the targeted deferred-teardown
patch): an endpoint REFERENCE COUNT consolidating the prior ad-hoc lifetime
patches. endpoint_t.refcnt (_Atomic) + heap_alloc; endpoint_get → refcnt=1 (owner);
vtask_enq takes a per-vtask ref BEFORE publishing the task to the dispatcher,
run_task / taskq_item_killer release it; every heap-ep teardown drops its owner ref
via endpoint_unref (last unref fini+frees). Embedded eps (client ctx->ep,
heap_alloc=0) are never freed by unref. So the dispatcher reads ep->busy on a queued
vtask without the io_worker freeing it underneath — the UAF is structurally
impossible. Also fixed several latent ep-struct leaks (endpoint_fini-only sites).
Validation: controller_unit_test 182 tests / 0 failures (test_ha_vep_teardown
OFF-path test updated — it asserted the now-fixed struct leak). 6-round ASAN chaotic
soak: ZERO AddressSanitizer reports, ctrl-3 survives round 4, ALL 6 rounds elect at
bounded terms (5→6→7→8). The round-4 crash-stall is gone.
**The soak now reaches rounds 4-6 for the FIRST TIME — and reveals a pre-existing
committed-job-loss (task 5.9, NOT a regression).**
Verdict: committed_job_loss=4, committed_divergence=7 → FAIL. But this is NOT a
regression of 5.8 nor of any previously-fixed bug. Proof:
1. ONE specific committed job (0-0-1782829181-1) goes missing at round 4 and stays
missing through rounds 5 and 6, across THREE different elected primaries
(ctrl-3, ctrl-2, ctrl-3). A permanent committed-prefix gap that propagates as
catch-up streams FROM the gapped node — the exact bug-795/828/851 signature.
2. Rounds 1-3 are CLEAN (all jobs present, zero divergence). Loss begins precisely
at round 4 — the first round whose overlapping kills elect a JUST-RESTARTED node
(ctrl-2 restarted, ctrl-1 killed, ctrl-3 — itself recently restarted — elected).
A protocol/catch-up condition, not a memory condition.
3. This session changed ZERO WAL/commit/catch-up/replication code (persist.c,
config_repl.c, commit.c, repl*). The files touched are cluster.c, ha.c/.h,
netio.c, vtaskmgr.c (ep refcount + the 5.7 election timeout) + 2 test files. The
commit-durability path is byte-identical to the session start — it CANNOT have
regressed because it did not change.
4. ASAN is fully clean — no refcount-induced UAF/double-free/leak. A refcount bug
would crash (caught by ASAN) or corrupt broadly; it cannot selectively delete
one committed job's payload from a WAL.
5. No chaotic soak EVER reached rounds 4-6 before (every prior run crashed/stalled
at round 3-4: bug-851 cross-deny → bug-1282 deadlock → bug-1286 segv → 5.5/5.6/
5.7 election stalls → bug-1298 UAF). There is no prior green baseline for rounds
4-6 to regress FROM — this is a first observation.
Conclusion: bug-795/828/851 FAILOVER CATCH-UP GAP — a restarted node rejoins and is
elected primary without backfilling the full committed prefix (empty-rejoin catch-up
streams [watermark..committed], no snapshot backfill → permanent gap). EXTERNAL spec
ha-commit-durability (+ raft term/log-safety). Tracked as task 5.9; route there.
## Phase 1 closeout — 10-round chaotic soak VALIDATES the election layer (2026-06-30)
Re-ran the chaotic soak at the full 10-round budget (`run-ha-soak.sh --chaotic 10`,
deploy `/home/claude/tmp/ha-1296`) to confirm bug-1296 (the round-7 RDHUP→PreVote→timeout
liveloc) is gone after tasks 5.5/5.6/5.7.
**Election liveness: PASS (10/10).** Every kill round elected a new primary in a single
term increment — terms walked 4→5→6→7→8→9→10→11→12→13, one step per round, ZERO escalation.
Round 7 (the historical bug-1296 liveloc point) and rounds 8–10 all elected cleanly. The
per-campaign jittered watchdog (5.7) + don't-preempt-in-flight-election (5.6) fully resolve
the resonance. bug-1296 marked FIXED.
**Consistency: FAIL (expected, task 5.9 / EXTERNAL).** Final verdict
`committed_divergence=11 committed_job_loss=5 result=FAIL`. Job `0-0-1782854622-0` went
missing at round 8 and stayed absent on the reachable primary through rounds 8/9/10 and the
settle — a permanent committed-prefix gap. This is the documented bug-795/828/851/1306
failover catch-up gap (a just-restarted node elected primary without backfilling the full
committed prefix), NOT an election fault and NOT a regression: rounds 1–7 are job-loss-free,
the loss onset tracks kill timing, and the election machinery is perfect across all 10 rounds.
The per-round `compared=3 present=2` divergence before round 8 is the just-restarted node
lagging its backfill (no convergence wait), which the catch-up backfill (Phase 2) closes.
Phase 1 (election liveness) is DONE. The remaining soak FAIL is wholly Phase 2 (task 5.9 /
ha-commit-durability catch-up backfill).