fix(controller-status): gate electedTerm/electedAt on elected_at_ms, not primaryName

Live 3-node E2E validation (task 7.6) found that cmd_ctrlstatus's
electedTerm/electedAt lines (task 7.5) gated on NULLSTR(primaryName),
which is unconditionally empty on every non-primary responder in this
build (ctrlstatus_fill only self-reports the primary's own name --
ha->primary_host is vestigial, a pre-existing Req 2 behavior). This made
electedTerm/electedAt print "unknown" on every standby, forever, even
though elected_at_ms was correctly, non-zero, and convergently populated
the whole time (term/committedLsn/appliedLsn, sourced the same
unconditional way, DID correctly display and converge on the same query).

Fix: gate on status->elected_at_ms == 0 instead -- the field stays
exactly 0 until a real promotion stamps it (either road) or a heartbeat
carries a learned value from an actual primary, unlike term (which can
legitimately bump from mere vote/candidacy activity before any primary
is elected). ctrlstatus_fill itself is untouched; this is purely a
cmd_ctrlstatus display-layer correction.

Also corrects design.md's "Design Delta -- Requirement 12" Locked
Decision 3 (and its dependent traceability/service-contract text) to
document this live-E2E finding, mirroring the spec's existing delta
convention (Req 10/11's own live-E2E correction).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
This commit is contained in:
Rongsong Shen 2026-09-16 08:56:55 +00:00
parent d982d34eb5
commit 808396618c
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
2 changed files with 26 additions and 17 deletions

View file

@ -3098,15 +3098,23 @@ int cmd_ctrlstatus(int argc, char* argv[])
NULLSTR(status->primaryName) ? "unknown" : status->primaryName);
/* Election term + time of the CURRENT primary (Req 12.1/12.3/12.4/12.6):
* gated on the SAME primaryName-known/unknown derivation the Primary:
* line above already computes (Req 2.2/2.3) -- a stale/learned
* elected_at_ms left over from a since-lost primary must never surface
* once primaryName itself reports "unknown" (design.md Locked Decision
* 3). electedTerm is DERIVED client-side from the existing `term` field
* (no new wire field, design.md Locked Decision 1); electedAt renders
* `elected_at_ms` on the same clock/format as responderTime below,
* reusing ctrlstatus_format_time_ms (design.md Locked Decision 4). */
if (NULLSTR(status->primaryName)) {
* gated on elected_at_ms itself, NOT on primaryName (design.md Locked
* Decision 3, corrected 2026-09-16 / task 7.6 live E2E finding).
* primaryName is unconditionally empty on every non-primary responder
* (ctrlstatus_fill only self-reports the primary's own name; a
* pre-existing Req 2 behavior, unrelated to this delta), so gating on it
* made electedTerm/electedAt read "unknown" on every standby forever,
* even once a real election had converged. elected_at_ms stays exactly 0
* until a real promotion stamps it (ha_do_promote, task 7.2) or a
* heartbeat carries a learned value from an actual primary (task 7.3) --
* unlike `term`, which can legitimately bump on mere vote/candidacy
* activity (raft term adoption) before any primary is actually elected,
* so `term == 0` would be the wrong gate. electedTerm is DERIVED
* client-side from the existing `term` field (no new wire field,
* design.md Locked Decision 1); electedAt renders `elected_at_ms` on the
* same clock/format as responderTime below, reusing
* ctrlstatus_format_time_ms (design.md Locked Decision 4). */
if (status->elected_at_ms == 0) {
printf("electedTerm: unknown\n");
printf("electedAt: unknown\n");
} else {