- config-upgrade-snapshot, raft-adapter-conformance: correct stale
phase metadata (both showed "tasks-generated" though every task was
already complete) and record their validate-impl GO decisions with
known-limitations notes (bug-1471/bug-1472-remainder, bug-1474
angle-b) for future spec ownership.
- component-failover-adoption: catch up design/requirements/research
docs to the Phase 6 fix (bug-1548/bug-1549, commit d479b707) that
landed after this spec was already marked closed.
- Promote 3 roadmap backlog items to specs (2026-08-10 discovery):
component-rejoin-resync (apiserver/scheduler rejoin resync),
worker-rejoin-resync (worker job-fact resync, hard prereq for
raft-apply-decoupling's facts half), scheduler-sim-diagnostics
(open-ended scheduler REPL debug/troubleshoot capability).
- roadmap.md updated to reflect all of the above.
- kiro-steering/kiro-steering-custom SKILL.md: fix stale template path
(templates/steering/ -> .kiro/settings/templates/steering/) plus
minor doc corrections in the other kiro-spec-* skills.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
27 KiB
Research & Design Decisions
Summary
- Feature:
component-failover-adoption - Discovery Scope: Extension (light discovery — libxce, worker, scheduler, apiserver already exist; this wires three consumers onto an existing library mechanism)
- Key Findings:
libxce-failover(shipped, GO 2026-07-18) already built a complete, transparent, per-call multi-master retry/rotate mechanism (XCECLI_FAILOVER_CALL/XCECLI_FAILOVER_CALL_COUNT/XCECLI_FF_CALLinclient_internal.h, wired into every publicxcecli_*call inclient.c, includingxcecli_joinand the initialxcecli_startconnect). It requires onlyxce_client_config_t.nmasters > 1to activate. No production code setsnmastersabove 1.- A rejoin hook (
xcecli_set_reconnect_handler) exists specifically so a consumer can re-send its join after a transparent reconnect. It has zero production callers — only its own setter and a unit test reference it. - A second, older, half-built and unused primitive (
xce_connect_cluster()/xce_reconnect_init/step/fini()insrc/lib/cluster.c) also exists. Its production connect path (do_connect()) is a stub that always returnsNULLoutside unit tests, and it operates on rawendpoint_t*, notxcecli_ctx_t. No production code calls it. It predates/overlapslibxce-failoverand is superseded by it. - This is a genuine "wire it up" feature, not a "build failover" feature — confirmed by the roadmap:
component-failover-adoption(roadmap.md:64) was already recorded 2026-06-16 with this exact framing, HARD-blocked onlibxce-failover, now unblocked.
Research Log
Why do worker/scheduler/apiserver each hand-roll a candidate loop instead of using the library mechanism?
- Context: Initial verification (this conversation) found worker, scheduler, and apiserver each independently loop over configured candidates, calling
xcecli_ctx_init/xcecli_start/xcecli_joinfresh for each candidate withnmastershardcoded to1per attempt. - Sources Consulted:
src/lib/client.c,src/lib/client_internal.h,src/worker/src/controller_connector.c,src/scheduler/src/scheduler.c,src/frontend/apiserver/backend/clib.go,roadmap.md. - Findings:
xcecli_start(client.c:125-188) already rotates through ALL configured masters on the very first connect (bug-854 fix, comment at line 136).xcecli_join(client.c:291-300) is wrapped inXCECLI_FAILOVER_CALL, so it already retries and rotates on standby/redirect/transport-error responses. The three daemons' hand-rolled loops predate this (or were never updated after it landed) and now duplicate — imperfectly — what the library does correctly. - Implications: The fix is subtractive as much as additive: delete the per-daemon candidate loops, populate
nmasters/masters[]/master_ports[]fully once, and let the existing library mechanism do the rest. This directly satisfies Requirements 1 and most of Requirement 2 without new retry/rotation logic.
Does the library mechanism, once enabled, also cover runtime reconnect (Requirement 2) automatically?
- Findings: Yes for any component whose normal operation already calls
xcecli_*functions periodically or on demand — every one of those calls is wrapped inXCECLI_FAILOVER_CALL/XCECLI_FAILOVER_CALL_COUNT, so a transport failure on any call transparently triggers rotate+reconnect+retry within that same call. Fire-and-forget calls (xcecli_nop,xcecli_heartbeat) useXCECLI_FF_CALLinstead: a bounded single best-effort reconnect attempt, never the full retry budget — this is the library's own liveness-safety design for exactly the concern in Requirement 3. - Implications: Scheduler's
xcecli_jobdispatchcalls (scheduler.c:1259, called fromsched_publish_dispatchon the main driver-loop thread — see "Thread-affinity" correction below, notsched_allocator) and apiserver's per-request backend calls (gCtx.ctxin clib.go) get transparent reconnect for free oncenmastersis set correctly — no new polling/reconnect loop needs to be written for either component.
Does anything need a NEW re-join after a transparent reconnect, beyond what the library does automatically?
- Findings: The library's rejoin hook exists precisely because reconnecting the transport does not re-establish application-level cluster membership — a new primary does not automatically know a worker/scheduler/apiserver rejoined unless it re-sends its join (role) message.
xcecli_reconnect_cb"runs on the calling thread" and returns 0 to resume the interrupted op or <0 to treat the rejoin as failed (loop rotates/backs off again). - Implications: All three components must register a rejoin callback that re-issues
xcecli_join(ctx, <role>, ...)(with worker also re-sendinghostattrs_t). Without it, a component would reconnect at the transport level but the new primary would not recognize it as joined — likely surfacing as silently-dropped pushes/notifications rather than an obvious error.
Thread-affinity of blocking retry calls (Requirement 3)
- Findings: Worker already isolates all controller-connection work (
controller_connector_start,xcecd_join_cluster, and the periodicchecking_connection()health-check) onto one dedicated thread (start_controller_connector_thread/connector_to_controller), separate fromhardware_monitor/system_monitor/process_managerupdate calls inxcecd_start(). This isolation already satisfies Requirement 3 structurally for the worker — it is not something this feature needs to newly build, only preserve while removing the redundant candidate loop. - Corrected 2026-08-10:
xcecli_jobdispatch(scheduler.c:1259) does NOT execute fromsched_allocator. It is called fromsched_publish_dispatch, reached only via the decision-drain loop insidexce_sched_driver_run_normal_mode_round(driver.c:765-786), which runs on the single main-loop thread (main.c'swhile(1),main.c:348-349).sched_allocator(scheduler.c:295-392) only doescbuf_get(&core.jbuf, ...)thencbuf_put(&core.decisions, ...)— it never callsxcecli_jobdispatchitself. The scheduler's real pipeline is three stages joined by twocbufs:sched_job_picker→jbuf(capacity 1,core.c:161, the scheduler-core determinism invariant) →sched_allocator→decisions(capacityndecisions=5,core.c:84) → the main-loop thread →xcecli_jobdispatch. When that call blocks in retry/backoff, the main thread stops drainingdecisions;sched_allocatorandsched_job_pickercan still produce a few more jobs before their owncbuf_putcalls block. So the two threads are buffered for a bounded, occupancy-dependent grace window, not indefinitely isolated — the earlier "benign, does not stall the job picker" claim was wrong; both threads do eventually stall for the remainder of an outage. Corrected 2026-08-10 (second pass): the grace window's size depends on how fulldecisionswas at the moment of the stall (guaranteed floor as low as 1-2 jobs, ceilingndecisions+1=6) — an earlier version of this note claimed "~6-job" as if it were the guaranteed size; that was the best-case ceiling, not a floor. See design.md's SchedulerControllerClient section for the full derivation, the Req 3 AC1→AC2 scope-narrowing, and Requirement 7 (the actual load-bearing guarantee: no job lost regardless of how many the buffer absorbs). - apiserver: unlike the C daemons, ALL request-handling goroutines that touch the backend share the same
gCtx.ctx, serialized by the library's per-contextfailover.lock. Under a real outage this could queue every concurrent API request behind the full retry budget, which is a client-experience problem (Requirement 4), not a cluster-liveness problem. Needs a lightweight fail-fast guard in the Go layer — see Design Decisions. - Implications: No new dedicated thread is required for worker (existing isolation already suffices). Scheduler needs no new thread either, but Req 3 AC1 needs its wording narrowed to match the bounded (not indefinite) buffering the existing
jbuf/decisionscbufs actually provide — see design.md Critical Issue note. apiserver needs a new, small piece of state (not a thread) to short-circuit blocking calls when the backend is known-down.
Configuration surface mismatch: N_MAX_MASTERS vs MAX_HA_CANDIDATES
- Findings:
src/include/client.h:28definesN_MAX_MASTERS (3)— the hard cap onxce_client_config_t.masters[].src/include/config.h:78definesMAX_HA_CANDIDATES 4— the cap onxce_cluster_config_t.candidates[]used byxce_parse_peers. These are different constants.xce_test(test.c:360-367) already handles the mismatch correctly: parse up to 4 candidates, then clamp toN_MAX_MASTERS(3) with aWARNif truncated. - Implications: Any new shared conversion helper must replicate this exact clamp-and-warn behavior (not silently drop entries, not error out) so all components behave identically when 4 candidates are configured. Scope expanded 2026-08-10 (explicit user direction): reconciling the two constants is now IN scope —
N_MAX_MASTERSis redefined to derive fromMAX_HA_CANDIDATES(config.hbecomes the single source of truth, Req 1 AC7), raising it 3→4. Source audit confirmed this is safe: no wire-protocol array assumes 3 (masters[]is never serialized), no header-include cycle (config.hdepends only on<stdint.h>), andxce_test's clamp already referencesN_MAX_MASTERSsymbolically so it absorbs the raise automatically. Two latent bugs found by this audit that the raise would otherwise silently trip:src/worker/src/main.c:414andsrc/scheduler/src/main.c:314both hardcode a literal> 3check instead of referencing the macro — need fixing to> N_MAX_MASTERSalongside the raise.src/lib/testsuite/test_libxce_retry.c'sfo_make_client_masters()fixture also hardcodes exactly 3 literal host/port entries then copiesN_MAX_MASTERSslots — needs a 4th literal added or it silently copies an uninitialized slot post-raise.
TLS/CA-cert wiring gap (pre-existing, out of boundary)
- Findings:
xce_client_config_t(the struct actually consumed byxcecli_ctx_init/xcecli_start, i.e. the real connection path) has no CA-cert field at all — onlynmasters/masters/master_ports/from/key. Theca_cert_paththat worker/scheduler's--ca-certflag populates onxce_cluster_config_tis validated byxce_cluster_config_validate()but is only ever read by the orphanedxce_connect_cluster()stub, never by the real connect path. - Implications: This is a pre-existing gap unrelated to candidate-list wiring — the real connection already establishes TLS today (single-master) via whatever global SSL context setup exists outside this struct. Not this feature's concern; noted here so it isn't mistaken for something this design should fix.
Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|---|---|---|---|---|
Adopt existing xce_client_config_t / XCECLI_FAILOVER_CALL mechanism (chosen) |
Populate the full candidate list once per component, register a rejoin callback, delete hand-rolled loops | Already built, tested (libxce-failover), and exactly what xce_test uses today; minimal new code |
None material — this is the reference-verified path | Matches roadmap's own framing of this backlog item |
Complete and adopt xce_connect_cluster()/xce_reconnect_* |
Finish the stubbed do_connect(), wire it under each daemon |
N/A | Duplicate mechanism to the one already shipped; raw-endpoint_t API doesn't carry the join/request/reply protocol the daemons need — would need its own substantial new integration layer on top |
Rejected — would build a second, redundant failover mechanism |
| Build a new per-component reconnect loop | Fix each daemon's existing hand-rolled loop in place, independently | Minimal library changes | Perpetuates 3 divergent implementations (the root problem this feature exists to fix); does not gain the library's already-tested retry/backoff/classification logic | Rejected — directly contradicts the roadmap item's stated goal |
Design Decisions
Decision: Adopt the existing xcecli_ctx_t / XCECLI_FAILOVER_CALL mechanism; do not build new failover logic
- Context: Requirements 1-3 need multi-candidate connect + runtime reconnect + liveness-safe retry.
- Alternatives Considered: see Architecture Pattern Evaluation above.
- Selected Approach: Each component populates a fully-formed
xce_client_config_t(all configured candidates, not just the first) once, callsxcecli_ctx_init/xcecli_start/xcecli_joinonce, and registers a rejoin callback viaxcecli_set_reconnect_handler. All subsequentxcecli_*calls already retry/rotate/reconnect transparently. - Rationale: The mechanism is shipped, tested, and is the exact one
xce_test'smasterssetting already uses correctly. Building anything new would duplicate it. - Trade-offs: None material for worker/scheduler. For apiserver, moving off the current Go-level per-peer loop means the CGO layer becomes responsible for candidate rotation instead of Go — acceptable since it is strictly more capable (transparent runtime reconnect, not just startup).
- Follow-up: Confirm during implementation that worker's hardware/process-monitor update calls truly run off the connector thread (Validation Hook, not re-verified byte-for-byte in this research pass).
Decision: One new shared C helper converts xce_cluster_config_t → xce_client_config_t
- Context: All three components need to turn a parsed/validated candidate list into the
nmasters/masters[]/master_ports[]triple, replicatingxce_test's clamp-to-N_MAX_MASTERS-with-WARN behavior (see mismatch finding above). Today this logic exists ad hoc intest.conly. - Alternatives Considered: (1) Duplicate the clamp/copy logic in worker, scheduler, and apiserver's CGO layer separately. (2) One shared helper in
src/lib/cluster.c(wherexce_parse_peers/xce_cluster_config_validatealready live), declared inxce/cluster.h. - Selected Approach: (2) — a single
xce_cluster_config_to_client_config()helper, reused by worker (C), scheduler (C), and apiserver (via CGO). - Rationale: DRY; the clamp-and-warn behavior must be identical across components (Requirement 6's config-syntax-parity spirit extends naturally to the derived struct, not just the input string); one function is one thing to test.
- Trade-offs: None — this is pure generalization of logic that would otherwise be copy-pasted three times.
Decision: apiserver calls the existing C xce_parse_peers/xce_cluster_config_validate via CGO instead of maintaining a second Go parser
- Context: Requirement 6 requires apiserver's candidate-list syntax to match scheduler/worker exactly. apiserver currently has its own hand-written Go
parsePeerfunction. - Alternatives Considered: (1) Keep/harden the Go parser to match C syntax by inspection. (2) Call the real C parser via CGO (apiserver already links libxce via CGO for everything else).
- Selected Approach: (2).
- Rationale: Guarantees byte-identical parsing forever (no drift risk), and apiserver already pays the CGO cost for every other backend interaction.
- Trade-offs: One more CGO surface, consistent with the existing
backend/package pattern.
Decision: apiserver gets a small Go-side "backend health" circuit breaker for Requirement 4
- Context: Requirement 4 needs apiserver to fail fast toward its own HTTP clients when the backend is down, rather than queue every request behind the library's full retry budget.
- Alternatives Considered: (1) Let every request block on the existing per-ctx lock and rely on Gin/client-side timeouts. (2) Track backend health from call outcomes + the rejoin callback, and check it before issuing a blocking backend call.
- Selected Approach: (2) — new
backend/health.go: an atomic "degraded since" state, set when a call's classified outcome is retry-budget-exhaustion, cleared when the rejoin callback fires (successful reconnect+rejoin). Backend-call wrappers inclib.gocheck it first and return a clear error immediately while degraded, instead of entering the blocking call. - Rationale: Small, localized, reuses signals the library already produces (call rc, rejoin callback) rather than adding new polling.
- Trade-offs: A short window exists where the flag is stale (a request arrives just as the backend goes down, before any call has failed) — acceptable, matches typical circuit-breaker behavior; the request in flight simply experiences the one blocking retry cycle before the flag is set for subsequent requests.
Decision: rejoin callbacks must be lock-free flag-setters, never direct xcecli_join calls
- Context: A second
/kiro-validate-designpass, cross-checking the library-invoked rejoin path againstclient_failover.cdirectly rather than only its header comments, found thatXCECLI_FAILOVER_CALLholdsac->failover.lock— a plain, non-recursivepthread_mutex_t(mylock_init→pthread_mutex_init(l, NULL)) — for its entire retry loop, andxcecli_invoke_rejoin()(which calls the registered rejoin callback) runs synchronously inside that loop, lock held (client_failover.c:397). Every component's original design had its rejoin callback callxcecli_join(...)directly — itselfXCECLI_FAILOVER_CALL-wrapped, so it would try to re-lock the same mutex on the same thread: a guaranteed self-deadlock on the very first real failover. This was never caught becausexcecli_set_reconnect_handlerhas zero production callers and the existing unit test's callback never calls back into the client (it's a no-op probe). - Alternatives Considered: (1) Have the callback call
xcecli_joindirectly (original design — deadlocks). (2) Expose a new, lock-free/raw join primitive from libxce for rejoin callbacks to use (would require modifyinglibxce-failover, violating this spec's Allowed Dependencies boundary of consuming it read-only). (3) Callback only sets a lightweight flag and returns immediately (no I/O, no lock); the daemon's own calling code checks and clears the flag after the triggering call returns (lock free by then) and issues the realxcecli_joinas a separate call. - Selected Approach: (3), applied uniformly to worker (
checking_connection), scheduler (sched_publish_dispatch, the main-loop thread's actualxcecli_jobdispatchcall site — not the allocator, corrected 2026-08-10), and apiserver (each backend-call wrapper, after itsC.xcecli_*call). apiserver's heartbeat-triggered path (Task 4.4) was already structured this way by construction (it acts afterxcecli_heartbeatreturns) and needed no change. - Rationale: Fixes the deadlock without touching
libxceat all, staying inside this spec's stated boundary; the fix is a small, uniform pattern applied identically across all three components rather than a special case. - Trade-offs: One extra check-and-clear step after each relevant call site, in exchange for correctness; negligible cost.
- Follow-up: Worth flagging upstream to
libxce-failover's owners that the rejoin-hook contract (client.h's doc comment forxcecli_reconnect_cb) doesn't document this lock-held constraint — a future consumer without this spec's context could reintroduce the deadlock. Not fixed here (out of boundary), but the regression-guard unit test added in design.md's Testing Strategy exists specifically because of this gap in the upstream contract's documentation.
Decision: apiserver's rejoin registration uses a C shim in clib.go, not a standalone reconnect.go export
- Context:
/kiro-validate-designreview flagged that design.md originally describedreconnect.goas "a//exported Go function registered viaxcecli_set_reconnect_handler," implying direct registration. This codebase already documents (wire_dispatch_support.go:154-159, explaining whygo_push_handlerneeds thexce_backend_handlerC shim) that cgo symbol resolution is per-file — aC.<symbol>reference only resolves in the file holding its own matchingexterndeclaration. - Alternatives Considered: (1) Register the exported Go function directly from
reconnect.go. (2) Add a static C shim +externdeclaration inclib.go's own cgo preamble (mirroring the provenxce_backend_handlerpattern) and register that. - Selected Approach: (2).
- Rationale: Matches the codebase's own documented, working pattern instead of risking the same resolution failure
xce_backend_handlerwas built to avoid. - Trade-offs: None — same amount of code, just correctly placed.
- Follow-up: none; resolved by construction in design.md.
Decision: apiserver's heartbeat prober explicitly triggers rejoin on recovery
- Context:
/kiro-validate-designreview found, by direct inspection ofclient_internal.h/client_failover.c, thatXCECLI_FF_CALL(backingxcecli_heartbeat, apiserver's only idle-period liveness probe) never callsxcecli_invoke_rejoin— onlyXCECLI_FAILOVER_CALL(the full-retry path used by write calls) does. Left unaddressed, apiserver would repair its TCP connection via the heartbeat but never re-join, so push notifications (Req 2.4) would stay dead during any failover where no write request happens to arrive first. - Alternatives Considered: (1) Switch the periodic prober from
xcecli_heartbeat(fire-and-forget) toxcecli_hello(full retry, rejoin-bearing) — the same mechanism worker'schecking_connection()already uses. (2) Keepxcecli_heartbeat(preserves its existing wire semantics/side effects on the controller) and have the Go-side tick callback explicitly detect a failure-to-success transition and manually invoke the same rejoin logic used elsewhere. - Selected Approach: (2).
- Rationale: Preserves
xcecli_heartbeat's existing wire behavior (unknown/unverified whether the controller treats the heartbeat opcode differently from hello for apiserver accounting) while closing the gap with a small, self-contained Go-side check — no library changes, no behavior change to any other component. - Trade-offs: A few extra lines in
heartbeat.go; the rejoin logic now has two call sites (the cgo shim and the heartbeat callback), both invoking the samereconnect.gofunction, so no duplicated join-payload logic. - Follow-up: none; resolved by construction in design.md.
Decision: Do not touch xce_connect_cluster()/xce_reconnect_*
- Context: This second, unused, stubbed primitive exists in the same area of the codebase.
- Selected Approach: Leave as-is; do not adopt, complete, or remove it in this spec.
- Rationale: Out of this feature's boundary — removing dead code is a separate hardening decision, not required to satisfy any requirement here, and touching it risks scope creep into an unrelated cleanup.
- Follow-up: Worth a future hardening backlog note (superseded/dead code) — not filed as part of this spec.
Gap Re-Validation (2026-08-10)
Re-verified all code citations against the current scheduler-framework branch (post scheduler-core refactor, post worker CLI cleanup commits 9ffb1d5–0df1cf5).
Worker (xcecd)
CLI changes orthogonal to spec. The 6 worker commits removed -c/-p/-m flags, config-file loading, xcecd_conf.c/h, and worker.yaml. Added -v verbose logging, fixed logdir default and -h ordering. None touched controller_connector.c/controller_connector.h — the failover-relevant code is unchanged.
Bug-1510 confirmed still present. cc_ctx->cluster_cfg = cluster_cfg at current main.c:537 still runs AFTER xcecd_start() at main.c:531. controller_connector_start() at xcecd.c:515 performs the first join via init_client_config() (controller_connector.c:129-160, nmasters=1 at line 144) before cluster_cfg is populated. Spec tasks 2.1–2.3 correctly target this.
Line drift (minor):
| spec cite | current |
|---|---|
main.c:610 |
main.c:537 |
controller_connector.c:127-158 |
controller_connector.c:129-160 |
controller_connector.c:237-311 |
controller_connector.c:239-312 |
Task 2.3 validation hook stale. Task says to verify hardware_monitor_update/system_monitor_update/process_manager_update don't run on connector thread. These three are one-shot init calls at xcecd.c:476-480 — never called again. The actual recurring liveness duties are worker_process_update() (main loop, line 577) and resource_sync_thread (dedicated thread, resource_sync.c:293-330). The architectural property (connector thread isolated) IS correct, but the named evidence is wrong. Recommend rewording to reference main loop/resource_sync_thread.
Scheduler
HA candidate loop untouched. scheduler.c:564-624 (was ~462-495 at spec-write). Loop logic unchanged; only line numbers shifted by scheduler-core refactor (doc comments, core.c extraction, --sim CLI additions in main.c).
No-reconnect claim confirmed. No xcecli_set_reconnect_handler call in any scheduler file. No reconnect path after sched_init().
Line drift (minor):
| spec cite | current |
|---|---|
scheduler.c:462-495 |
scheduler.c:564-624 |
scheduler.c:1289 (xcecli_jobdispatch) |
scheduler.c:1259 |
scheduler.c:511,516 (pthread_create) |
scheduler.c:626,632 |
research.md L22: scheduler.c:1289 |
scheduler.c:1259 |
research.md L30: scheduler.c:511,516 |
scheduler.c:626,632 |
New structural note for design: main.c now early-returns for --sim mode (line 296), inserted between peers-parsing block and sched_init(). Doesn't affect normal-mode path — peers block still runs before sched_init() for non---sim — but design should note this guard exists.
Apiserver
Not re-verified in depth (no flagged changes in this area). Light sanity check confirms RegisterToBackend still loops config.Peers with per-peer tryConnect.
Requirements.md pre-existing issues
Duplicate Background section. Lines 10-22 and 24-34 are byte-identical — copy-paste artifact at spec-creation time. Should deduplicate.
Summary
All architectural findings and target bugs in the spec remain substantively accurate. Only citation line numbers and task 2.3 wording need refreshing. No design decisions invalidated. Spec is ready for implementation.
Risks & Mitigations
- Deleting worker's/scheduler's hand-rolled candidate loops could regress subtle existing behavior (e.g., worker's specific WARN logging on standby redirect) — mitigate by preserving equivalent log statements around the new single-call path, verified in code review.
- apiserver's circuit breaker could mask a fixed backend as still-degraded if the clearing signal (rejoin callback) doesn't fire in some edge case — mitigate with a bounded TTL on the degraded flag (re-probe after a short timeout) rather than relying solely on the rejoin callback.
N_MAX_MASTERS(3) vsMAX_HA_CANDIDATES(4) mismatch could silently drop a 4th configured candidate on any component — mitigate by making the shared helper's WARN-and-clamp behavior loud and identical everywhere (already the plan).
References
src/lib/client.c,src/lib/client_internal.h,src/include/xce/client_failover.h— the shippedlibxce-failovermechanism this feature adopts.src/controller/testsuite/test.c:345-382— the reference (xce_test) usage pattern..kiro/steering/roadmap.md:64—component-failover-adoptionbacklog item this spec formalizes..kiro/steering/ha-async-io.md— thread-role contract informing Requirement 3.