admin-config-tool's spec.json blocked_on and tasks.md both still described
the OP_upgradeConfig dispatch-gate defect as an open blocker. It was fixed
2026-07-25 (bug-1468, commit 959b0cda) by config-upgrade-snapshot task 8.6,
which independently hit the same wall building a live OP_upgradeConfig
sender -- the fix's own comment in cluster.c already cross-references this
spec. The controller-side path has since been exercised live and
successfully many times via config-upgrade-snapshot's --config-upgrade
soak. All 23 admin-config-tool tasks are already [x]; this was pure
tracking-metadata debt, not a reopened implementation gap.
Also appends a cross-spec gap analysis (admin-config-tool x
config-upgrade-snapshot x clusterconfig-schema-split) to all three specs'
research.md, confirming clean schema integration between the latter two
and flagging the one remaining real gap: a new commit-timeout issue in
config-upgrade-snapshot task 8.6's force-elect step, surfaced now that
bug-1477 is fixed, not yet root-caused.
45 KiB
Gap Analysis — config-upgrade-snapshot (2026-06-24)
Scope
Make a configuration upgrade install one identical, entity-bearing state snapshot
(with the new config embedded) on every member at the config-change marker's
(term, lsn) boundary, replacing today's config-push + restart + per-node WAL
replay (which can materialize divergent state — bug-857). Closes bug-857.
GATING BLOCKER (hard dependency):
entity-bearing-snapshots. Today snapshots carry zero entity state (an LSN sentinel). This spec consumes an entity-bearing snapshot it cannot produce. It cannot reach a testable GREEN untilentity-bearing-snapshotslands or is co-scoped. Every "Missing" below traces back to this one absence. See §"Gating Blocker".
Requirement-to-Asset Map
R1 — Full snapshot at the config-change boundary
| Need | Existing asset (file:line) | Gap |
|---|---|---|
Materialize a snapshot at the marker's (term,lsn) |
persist_checkpoint / write_snapshot (persist.c:1247-1291) writes a checkpoint snapshot at checkpoint_lsn |
Missing — snapshot is a 16-byte sentinel ("XCESNAP\0" + LSN + optional 41-byte config-ref tail); carries no entities. No way to pin it to the marker boundary vs. an arbitrary checkpoint LSN. |
| Snapshot pins identical materialized state | dual-zarray COW: xzarray_freeze/xzarray_thaw (zarray.c:695-757), test_zarray_cow.c |
Missing (dormant) — built + unit-tested but no production callers: kvmgr.c:297 vfreeze=NULL /* wired in P0-2 */, vthaw=NULL. |
| No member re-derives via replay | upgrade path do_upgrade_config_with_bytes (config_repl.c:1495-1677); NOTICE at step 9 (config_repl.c:1641-1655) |
Constraint (current behavior is the bug) — step 9 sets restart_requested; on restart each node replays its own pre-upgrade WAL under the new config (config-dependent via lost_and_found). This is exactly what R1.3 forbids. |
| Transfer out-of-band, not in WAL payloads | ha_handle_snapshot_chunk (ha.c:2424-2515) + persist_load_snapshot (persist.c:1594); OPCode_snapshotTransfer dispatch-gated (ha.c:2842/2874/2962) |
Exists (reusable transport) — chunked, size-checked, atomic snap.part→rename, then persist_load_snapshot. Reusable as-is; it only ever moves a sentinel today. |
R2 — Configuration embedded in the upgrade snapshot
| Need | Existing asset (file:line) | Gap |
|---|---|---|
| Embed new config in snapshot (one artifact) | snapshot config-ref tail (persist.c:1263-1274, read-back 1581-1590): {present, version_tag(8), sha256(32)} |
Partial / Missing — only a {tag, sha} reference is embedded, not the config bytes and not the entity state. Need the actual config bytes + entities in one installable artifact. |
| Supersede ha-term-safety R7 config-push for upgrades | R7 out-of-band byte push driven from config_commit_change (config_repl.c:478-510) |
Missing — upgrade still uses the R7 interim byte-push path; no branch that delivers config via the snapshot instead. |
| Keep config bytes out of WAL payloads | WAL_CONFIG_MARKER is {version_tag, sha256} only — config_marker_encode (config_repl.c:40-69), bytes never in WAL |
Already satisfied — marker carries no bytes; invariant holds today. |
R3 — Activation gating
| Need | Existing asset (file:line) | Gap |
|---|---|---|
| Don't serve/activate new config until snapshot installed | install-before-activate already structural in ha_handle_snapshot_chunk (rename+load on full receipt) |
Partial — transport installs before load, but there is no activation gate tying "config active" to "upgrade snapshot for this (term,lsn) installed." Today activation = restart-then-replay. |
| Verify embedded config vs marker content hash | config_committed_marker (config_repl.c:216) yields {tag, sha}; hash-verify-before-activate already the rule for R7 byte push (steering ha-async-io.md §per-entry-term) |
Missing for snapshot — the verify-against-marker logic exists for the byte-push artifact; must be extended to the snapshot-embedded config. |
| Reject + keep retrying on mismatch (no self-terminate) | loud-fence-and-stay-alive invariant (controller/CLAUDE.md "Process Lifecycle") |
Constraint — reject path MUST be a loud serve-fence + retry, never exit/abort. New code must honor this. |
R4 — Post-upgrade consistency invariant
| Need | Existing asset (file:line) | Gap |
|---|---|---|
| Identical materialized state across members | none — no entity snapshot to compare | Missing — depends entirely on R1/R2 landing. |
| Identical active config across members | config_committed_marker derives committed config from (term,lsn) |
Partial — config identity is reachable via the marker; state identity is not. |
| Invariant holds across upgrade × failover interleave | Raft (term,lsn) reconcile + promotion-truncate (ha-term-safety, raft.c) |
Unknown — needs design: how a mid-upgrade snapshot interacts with promotion-truncate and catch-up streaming when a leader dies mid-install. |
R5 — Reproduction and regression
| Need | Existing asset (file:line) | Gap |
|---|---|---|
| Reproduce bug-857 divergence (RED) | waldump (testsuite/waldump.{c,h}), /xce-test-ha chaotic-multi-kill soak, planned snapdump/walcompare (roadmap Hardening §) |
Partial — waldump exists; snapdump/walcompare (entity-less-snapshot + WAL-divergence detectors) are unbuilt and are the natural assertion tools here. |
| Pass on fixed code | — | Missing — fix does not exist yet. |
Gating Blocker — entity-bearing-snapshots (hard, explicit)
This is the single dependency that gates the whole spec.
- Evidence it's absent:
write_snapshot(persist.c:1247) andload_snapshot(persist.c:1555) handle only magic + LSN + a{tag,sha}config-ref tail — no job/queue/host/resource entities. The header comment literally says "Full Cap'n Proto serialization is Task 11 when recovery needs to read it back." - Why it blocks: R1.1, R2.1, R4.1 all require an artifact that carries materialized entity state. Without it, "install the same snapshot on every member" delivers an empty sentinel — divergence is unavoidable.
- What's already paid down (de-risks it): the COW substrate exists
(
xzarray_freeze/thaw,zarray.c:695-757, O(1) freeze, GC-filtered thaw, unit-tested) and the transport is reusable (ha_handle_snapshot_chunk+persist_load_snapshot). What's missing is the payload: serialize entities under per-object locks during the freeze window into the snapshot file, and deserialize them onload_snapshot. COW is single-generation/slot-level only — one consumer owns freeze/thaw (roadmap §entity-bearing-snapshots).
Consequence for this spec: requirements/design can proceed, but tasks cannot
reach implementation until entity-bearing-snapshots is implemented or
co-scoped. spec.json.blocked_on already records this.
Also depends on ha-term-safety R7 for the (term,lsn) config-change marker
that the snapshot anchors on — that marker machinery is implemented
(config_commit_change, config_committed_marker, persisted config-ref).
Implementation Approach Options
Option A — Extend the existing checkpoint/upgrade path
Wire vfreeze/vthaw into kvmgr, teach write_snapshot/load_snapshot to
serialize/deserialize entities, and in do_upgrade_config_with_bytes (after the
marker commits, before restart) checkpoint an entity-bearing snapshot at the
marker (term,lsn), embed the config bytes, stream it via snapshot_transfer_s,
and gate activation on install. Drop the per-node-replay restart for the upgrade case.
- ✅ Reuses the live transport + COW; minimal new files; one consistent snapshot format.
- ✅ Naturally supersedes the R7 byte-push for upgrades.
- ❌ Bloats
persist.csnapshot codec and the already-largeconfig_repl.cupgrade path. - ❌ Couples the generic checkpoint snapshot format to upgrade-specific embedding (config bytes) unless cleanly factored.
Option B — New "upgrade snapshot" component
A dedicated module owns: materialize-at-marker, embed config, drive transport,
activation gate, and the mismatch fence — leaving persist.c to expose only an
entity (de)serialize primitive and config_repl.c to call into it.
- ✅ Clean separation; upgrade-specific logic isolated and unit-testable.
- ✅ Keeps
config_repl.c/persist.cfrom growing further. - ❌ More wiring across persist ↔ new module ↔ ha transport ↔ config_repl.
- ❌ Risk of a second snapshot-format path diverging from the generic checkpoint one.
Option C — Hybrid (recommended)
- Foundation (in/with
entity-bearing-snapshots): wire COW into kvmgr and add the generic entity (de)serialize towrite_snapshot/load_snapshot— Option A style, shared by recovery + state transfer. - Upgrade layer (this spec): a thin new helper (Option B style) that, given an
entity-bearing snapshot capability, materializes at the marker
(term,lsn), embeds the config bytes, streams via the existing transport, and adds the activation gate + hash-verify-vs-marker + loud-fence-on-mismatch. Replace the step-9 restart/replay for the upgrade case; keep restart/replay as the fallback only until the foundation lands.
- ✅ Generic snapshot work is reused (recovery, faster rebuild) — not upgrade-only.
- ✅ Upgrade-specific gating/verification stays isolated and testable.
- ✅ Phased: foundation can land + be validated before the upgrade layer flips on.
- ❌ Requires careful coordination across the two specs (format ownership, ordering).
Effort & Risk
| Dimension | Rating | One-line justification |
|---|---|---|
| This spec alone (assuming entity-bearing snapshots exist) | L (1–2 wks) | Transport + marker + COW exist; new work is materialize-at-marker, config embed, activation gate, hash-verify, mismatch-fence, and the failover-interleave regression. |
| Including the gating dependency | XL (2+ wks) | entity-bearing-snapshots is itself non-trivial (entity serialization under per-object locks, COW wiring, recovery read-back) and must land first. |
| Risk | High | Hard blocker not yet started; touches consensus-adjacent state at a (term,lsn) boundary interleaved with failover (R4.3); snapshot/promotion-truncate/catch-up interaction is unverified; must preserve the never-self-terminate invariant. |
Recommendations for Design Phase
Preferred approach: Option C (Hybrid) — land entity-bearing snapshots as a
shared foundation (COW into kvmgr + generic entity (de)serialize in
write_snapshot/load_snapshot), then add a thin upgrade layer that materializes
at the marker, embeds the config, reuses snapshot_transfer_s, and adds activation
gating + hash-verify + loud-fence.
Key decisions to settle in design:
- Spec sequencing — co-scope
entity-bearing-snapshotsinto this spec's delivery, or hard-gate this spec behind it? (Recommend gate; promote to design only once the foundation has a tasks plan — matchesroadmap.md.) - Snapshot format — one unified entity-bearing format used by both recovery and upgrade, with the config-bytes embed as an optional section? Avoid a second format.
- Materialize-at-marker semantics — snapshot must reflect state at exactly the
marker
(term,lsn), not "latest checkpoint." Define how the freeze window aligns to the committed marker LSN. - Activation gate — bind "new config active" to "upgrade snapshot for this
(term,lsn)installed + hash-verified vsconfig_committed_marker"; mismatch ⇒ loud serve-fence + retry (never exit —controller/CLAUDE.md). - Retire the per-node restart/replay for the upgrade case (step 9,
config_repl.c:1657-1676) once the snapshot path is proven.
Research-Needed (carry forward):
- Upgrade × failover interleave (R4.3): how does a mid-upgrade entity snapshot
interact with promotion-truncate,
(term,lsn)reconcile, and ACK-driven catch-up if the leader dies mid-install? Define the recovery story. - COW single-generation constraint vs. a checkpoint racing an upgrade snapshot
(only one freeze/thaw consumer allowed —
zarray.c/ roadmap note). - Build
snapdump+walcompare(roadmap Hardening §) first — they are the RED/GREEN assertion tools for R5 (entity-less snapshot detection + cross-node WAL/state diff).
Update — 2026-07-21 (dependencies resolved; requirements extended; harness gap analysis)
This section is current as of 2026-07-21 and supersedes the stale parts of the 2026-06-24 analysis above (kept for history). Do not re-derive the gating status or tool inventory from the sections above without reading this first.
Gating blocker — RESOLVED
entity-bearing-snapshots— satisfied 2026-07-09 byha-entity-snapshots(21/21 tasks, fully approved). It shippedwrite_snapshot/load_snapshot(persist.c) as a direct Cap'n-Proto entity-encode/decode of job and queue records at checkpoint time — not the COWxzarray_freeze/thawpath this research doc's Option A/C assumed (that machinery is still dormant,kvmgr.c:297). Design must build on the realwrite_snapshot/load_snapshotinterface, not the COW one.ha-term-safety(owns the(term,lsn)config-change marker this spec anchors on) —implementation-complete, GO-validated 2026-07-17, 46/46 acceptance criteria mapped, live chaotic soak clean.- Net effect: this spec is unblocked.
spec.json.blocked_onshould be cleared and the phase can move to design once requirements are approved.
Tool inventory — CORRECTED (contradicts the "unbuilt" note above)
snapverify, snapdiff, and walcompare/walcompare_report all already
exist (src/controller/testsuite/), built 2026-07-02 during bug-1386/1389
triage. The line-57/176 framing above ("planned", "unbuilt", "build first") is
stale — do not re-propose building them.
snapverify_path— structural-only checkpoint-file validator (magic, format_version, frame walk to EOF). Reportshas_cfgrefas a bool but does not decode or assert the tag/hash value.snapdiff_paths— byte-for-byte comparator between two checkpoint files, with chunk-boundary diagnosis. Built for source-vs-received comparison but directly reusable for a determinism check (two independently materialized snapshots at the same boundary should be byte-identical).walcompare/walcompare_report— cross-member committed-prefix divergence detector, compares(term, pcrc)per LSN, and already has a per-member checkpoint floor concept (an LSN below a member's own installed-snapshot floor is "legitimately compacted," never flagged divergent —ha-entity- snapshotstask 7.1 /raft-ha-core-safetytask 5.10a). It does not compare active configuration across members.
Requirements extended this session (2026-07-21)
requirements.md gained content not reflected in this doc's "Recommendations"
section (items 1-5 above predate these):
- Req 1.5 — snapshot materialization is triggered synchronously by
marker-apply but executed through the existing async checkpoint mechanism
(
persist_checkpoint/schedule_checkpoint/write_snapshot, delaymgr-driven, CAS-guarded single-in-flight), not inlined into the raft apply path — answers the "should this be one atomic Raft FSM operation" question: the marker commit already is one FSM op; materialization should be triggered by it but run via the existing decoupled checkpoint mechanism (never block a vtask handler); cross-node install must never be — it's inherently async. - Req 2.4 — every checkpoint taken after a config change embeds the
current config, not only the marker-triggered one, so a member joining
after the upgrade snapshot has rotated away (
rotate_snapshots,snap_keep_n) still gets config + state together. Piggybacks on the existingscan_latest_committed_config_ref(persist.c:1999), which already re-stamps the latest committed config reference into every checkpoint — Req 2 extends that from a reference to the full embedded bytes. - Req 5 (new) — Crash-safety and idempotent resumption: deterministic
snapshot content (any member's independent materialization at the same
(term,lsn,config-hash)is identical), crash-safe resumption if a member dies mid-materialization, idempotency guard reusing the existingcheckpoint_in_progressCAS, and leader-change-mid-distribution resumption. - Req 6.3 (was Req 5.2) — regression test must inject a crash/restart and a leadership change specifically between marker-commit and snapshot-install- complete, not just rely on general chaotic-kill timing.
Invariant risk carried forward from this session's controller-invariant check (2026-07-21)
Checked the new requirements (esp. Req 3's activation gate) against
controller/CLAUDE.md's TLA+ invariants and Protocol Verification Checklist.
One real precedent-risk, already fixed at the requirements level (Req 1.8) but
flagging for design/implementation attention:
- bug-1418 (open, NOT FIXED) is the same failure shape this spec's Req 3
risks reintroducing in a new flavor. bug-1418: a newly-elected primary whose
apply is behind (
apply_divergent=1) hits a liveness deadlock — the existing promotion gate blocks it hard, repeatedly, rather than degrading gracefully; the (unimplemented) proposed fix is "become primary, self-apply, then serve." Req 3.2/3.3 of this spec assume exactly that graceful pattern for materialization-lag instead of apply-lag. Since the apply-lag version of this pattern is proven broken in production today, design must not implement Req 3's gate as a hard promotion block (Req 3.1 already commits to this — no separate promotion-eligibility mechanism) and must verify the "keep serving under last-verified config" fallback (Req 3.3) is genuinely implemented, not degrade into the same stall bug-1418 exhibits. - Root cause of the need for Req 1.8 (apply must not itself re-home entities; activation is a separate, later-gated step): without that split, Req 3.3 ("keep serving under last-verified configuration" while materialization is incomplete) is incoherent — there would be no old interpretation left to serve under once apply had already mutated live entity state. This is the same family of apply-ordering subtlety behind bug-1417/1418/1419.
- Design-verification item (unconfirmed, not yet checked in code): does
persist_load_snapshot/load_snapshotadvancedurable_lsn(not justcheckpoint_lsn/current_lsn) when a member catches up via the transport- install path (Req 1.2's second branch)? If not, a snapshot-caught-up member may not correctly count toward commit majority for LSNs at/below the snapshot boundary even though it durably holds that state via the snapshot file rather than raw WAL — a possible Commit Durability invariant gap specific to the transport-install path. Verify in design before assuming Req 1.2's two paths are equally safe.
Harness gaps for design phase (new, from this session's analysis)
Three items, only two are genuinely new build work:
- No cross-member config comparator (new gap).
walcomparediffs WAL only. Req 4.2 ("every member's active configuration identical") has no assertion tool. Extendwalcompare(or add a sibling) to decode+compare the embedded config tag/hash (and eventually bytes) per member, mirroring how it already compares(term,pcrc). Also extendsnapverifyto decode/assert the cfgref tag+hash value, not just report its presence. - No deterministic fault-injection at the marker-apply → forced-checkpoint-
durable window (new gap, needed for Req 5.2/6.3).
/xce-test-ha --chaotickills at random points — could hit this narrow window eventually, too flaky for a CI regression gate. Precedent for the right shape of hook already exists twice inconfig_repl.c:ha_stream_body_read_fault_hook,xce_snapshot_chunk_hook(both#ifdef XCE_TESTINGseams). Add an equivalent hook at the marker-apply/forced-checkpoint boundary. - No deterministic leadership-change-mid-distribution injection (new gap, Req 5.4/6.3). Same precedent pattern as #2 — a targeted "kill/step-down primary exactly after marker commits, before all standbys ACK install," not reliance on chaos-soak probability.
- Reusable, no new work needed:
snapdiff_pathsfor the Req 5.1 determinism check;/xce-test-ha --force-snapshotfor Req 2.4's fresh-join-after-rotation (already exercises wiped-node-rejoins-empty via bug-1386's own test mode — just needs its assertions extended once config embedding lands);config_commit_change/do_upgrade_config_with_bytes(bug-970) already wiresWAL_CONFIG_MARKERinto the live upgrade path, so the bug-857 repro (Req 6.2) is triggerable today with existing tooling — no blocker there.
Update — 2026-07-23 (design discovery; requirements extended to 7 requirements/36 ACs)
Requirements went through several rounds of review this session (host/queue admin-state frames, bug-1418 re-verified fixed, bootstrap-vs-upgrade boundary, Design Invariants section, Requirement 7 added for new-mechanism regression coverage). This update captures the DESIGN-level discovery done to write design.md, current as of 2026-07-23, extension-scope (light discovery — existing system, no new external dependency).
Discovery: exact hook points for the marker → materialization trigger (Req 1.1, 1.5, 1.8)
The single unified apply seam is apply_committed_entry (persist.c:2751-3132), confirmed as the ONLY live path for entity mutation on every member — primary online-apply (persist_apply_pump_run → persist_apply_pump_kick, vtask-pool dispatched, persist.c:3383), standby normal apply (ha_standby_advance_committed_and_apply, heartbeat-learned commit position, ha.c:2596-2613/2688-2700 — supersedes the older "apply on receipt" bug-1043 fix as of 2026-07-10), and boot/restart replay (persist.c:3920). The sibling dispatcher apply_wal_entry (persist.c:2583-2714) has zero live callers (grep-confirmed) — fully superseded, dead code, not a second seam to worry about. WAL_CONFIG_MARKER currently falls into apply_committed_entry's generic default: no-op (persist.c:3126-3131) and apply_wal_entry's equivalent no-op (persist.c:2697-2707, irrelevant given no callers). Design decision: add one new explicit case WAL_CONFIG_MARKER: in apply_committed_entry only — it fires identically for primary and standby, satisfying Req 1.2's "every member... via that member's own local materialization once it has applied the marker itself" for free, with no per-role branching needed.
Today's restart+replay behavior being replaced lives in do_upgrade_config_with_bytes (config_repl.c:1591-1773, step 9, config_repl.c:1753-1772): atomic_store(&c->restart_requested, 1) + cbuf_put(&c->wbuf, &stop_val). An existing NOTICE comment at config_repl.c:1737-1751 already names this exact spec by path and describes the target end-state verbatim — a strong signal the upgrade path's own maintainers already anticipated this design. No function-pointer hook exists at that NOTICE site (grepped, none) — it is a plain fall-through, confirming the real trigger point is the apply_committed_entry case above, not this step-9 comment site itself. Once the new case lands, step 9's restart/replay becomes dead code for the upgrade path and should be removed (not left as a parallel fallback — Design Invariant "never leave two mechanisms doing the same job").
Discovery: checkpoint triggering — no forced/immediate entry point exists yet (Req 1.5)
persist_checkpoint() (persist.c:2262-2321) is unconditionally synchronous/blocking (fdatasync-backed write_snapshot, directory scans) and has exactly one production trigger path today — schedule_checkpoint() (persist.c:2238-2256), fired only by a WAL-record-count threshold inside persist_wal_async() (persist.c:921-928). No wall-clock periodic timer exists in the live code (an older design doc's checkpoint_requested/persist_checkpoint_timer_fn was never implemented — grep-confirmed absent). No force_checkpoint()/persist_checkpoint_now() entry point exists — but a second direct, unconditional call to persist_checkpoint() already exists at ha.c:3308 inside ha_snapshot_do_send() ("Req 4.1: create a FRESH checkpoint..."), proving the function itself already tolerates being force-invoked outside the record-count path (it already handles the -EBUSY a concurrent scheduled checkpoint can return, via the shared checkpoint_in_progress CAS, persist.h:415).
Two existing patterns already solve "trigger without blocking the caller" in this codebase — delay_task/delaymgr enqueue (what schedule_checkpoint does; the actual checkpoint I/O then runs on the delaymgr thread itself, per direct trace of delay_worker/run_delay_tasks, delay.c:60/cluster.c:988-991 — delaymgr has no thread-pool hand-off) and the dedicated slow-lane executor (what ha_snapshot_do_send uses, ha.c ~6120-6160, specifically because persist_checkpoint is blocking/unbounded — this is the pattern ha-async-io.md:29 documents as the bug-1198 fix for inline-on-iomgr checkpoint I/O). Design decision: the new WAL_CONFIG_MARKER case in apply_committed_entry calls persist_checkpoint() directly, not via either existing indirection. This is safe specifically because apply_committed_entry (via the apply pump) already runs on its own vtask-pool worker thread, never the iomgr or delaymgr thread — it IS the async-dispatched context, so no further hand-off is needed for THIS call site (unlike schedule_checkpoint's WAL-threshold trigger, which fires from arbitrary vtask handlers appending WAL entries and genuinely needs delaymgr's async return). This gives Req 1.5's "immediate and deterministic" trigger for free — no new delay/lane plumbing required — while the CAS (checkpoint_in_progress) already guards against a racing periodic checkpoint.
Note for design (not a requirements gap, an implementation-order note): scan_latest_committed_config_ref/write_snapshot's existing persist_checkpoint call sequence (persist.c:2301/2305) always stamps checkpoint_lsn = last_applied_lsn (persist.c:2292-2295) — i.e. a marker-triggered forced checkpoint calling persist_checkpoint() at the moment apply_committed_entry handles the marker will naturally pin checkpoint_lsn to the marker's own (term,lsn), satisfying Req 1.1's "materialize at that marker's boundary" without any new LSN-targeting parameter — persist_checkpoint() doesn't need to accept an explicit target LSN; it just needs to run at the right moment, which the new apply-time case guarantees by construction.
Discovery: the activation gate has a direct, reusable precedent (Req 3)
ha_dispatch_gate (ha.c:3809-3975, single call site cluster.c:276, gating every wire request — client and peer alike, both HA replication opcodes and apiserver-relayed job/queue/host opcodes) already composes multiple independent boolean fences in sequence for the CTRL_PRIMARY role: role check, then a config-serve-fence check (config_serve_fenced(ha->raft_c), ha.c:3827), then a write-quorum check. This is a direct structural precedent already in production for exactly what Req 3 needs — a config-dependent, state-changing-request fence that is orthogonal to role/quorum, with its own opcode allowlist (replication + read-only ops stay admitted while fenced).
The existing config_serve_fence (ha.h:307-322, set/cleared by config_activate_committed in config_repl.c:304-377, hash-verifies committed marker bytes against locally-held config bytes) is the closer of the two fence precedents found (the other being recovery_unrecoverable, which only gates promotion, not live serving) because it already: (i) blocks client-request serving via ha_dispatch_gate, (ii) also blocks promotion (ha.c:1425-1440, ha_config_promotion_fenced()), and (iii) self-clears via a fully-wired, non-blocking retry loop (config_schedule_activate_retry → delaymgr trampoline → vtask does the actual blocking verify I/O, config_repl.c:415-507), triggered from the commit-advance callback (ha.c:2197-2198) — the exact "loud ERROR once, stay alive, keep retrying, never self-terminate" shape controller/CLAUDE.md mandates, already proven in production. Design decision: add a new atomic fence field on ha_state_t (sibling to config_serve_fence, e.g. upgrade_snapshot_fence), a matching setter/verify function mirroring config_activate_committed's hash-verify-then-clear shape, and one more fence check inside ha_dispatch_gate's CTRL_PRIMARY branch (composed the same way the existing config-serve-fence check already is — not folded into that same field, since the two conditions are logically independent: config-serve-fence is about byte-mismatch, the new fence is about materialization-not-yet-complete for an otherwise-matching config) — reusing config_schedule_activate_retry's exact trampoline shape for the new fence's own retry loop, and ha_config_promotion_fenced()'s pattern for gating promotion the same way.
Discovery: durable_lsn is NOT advanced by snapshot-install — real but bounded gap (Req 1.2, 5.x)
Traced definitively (grep + direct read of every function in the call path): ha_handle_snapshot_chunk (ha.c:3014-3171) → persist_load_snapshot/load_snapshot (persist.c:2456-2575) never touches p->durable_lsn anywhere — only checkpoint_lsn/current_lsn/cfgref_* get set. This is asymmetric with the boot-recovery path (persist_recover, persist.c:4338-4353), which DOES re-derive durable_lsn from the recovered WAL tip. Verdict: real, confirmed gap, but bounded to this node's OWN promotion-eligibility/self-advertised-freshness bookkeeping during a narrow live-catch-up window (persist_durable_floor consumers: pre-promotion gate ha.c:1318-1352, promotion truncate-floor clamp ha.c:1259-1262, self-advertised candidate position ha.c:4540-4546) — the existing fence-and-retry machinery (ha_arm_promotion_fence_timer) already handles this safely (conservative refusal to self-promote, never corruption). It is NOT a commit-durability/majority-counting safety gap for OTHER members counting a snapshot-caught-up peer, because majority_candidate_locked (commit.c:167-172) already excludes any standby with in_catch_up != 0 from majority evaluation, and that flag is cleared only by a real WalAck reaching catchup_derive_target (repl.c:713-739, via ha_walack_on_ack) — a separate, already-safe gate keyed off real acks, not the snapshot event. Design decision: out of this spec's scope to fix (it predates this spec, applies to any snapshot-install today including plain recovery-driven catch-up, not upgrade-specific) — but flag it as a design.md Risk/Note so a future spec can pick it up (advancing durable_lsn on snapshot-install the same way boot-recovery does would close it cleanly, mirroring persist.c:4338-4353's ASSIGN semantics).
Discovery: test tooling — three reusable as-is, two confirmed genuine new-build gaps
Confirmed via direct read (not just trusting the 2026-07-21 update above, which was itself a research note, not a verified read):
snapverify_path(testsuite/snapverify.c) — confirmedhas_cfgrefis presence-only (header[16]), never decodes tag/sha; zero test assertions on it exist today. Needs extending to decode/assert tag+hash for Req 4.2/Req 2 coverage.snapdiff_paths(testsuite/snapdiff.c) — confirmed reusable as-is for Req 5.1's determinism check (any two paths, no transport-specific assumptions).walcompare/walcompare_report(testsuite/walcompare.c) — confirmed zero references tocfgref_tag/cfgref_sha/config bytes anywhere in the file; compares only(lsn, term, pcrc). Req 4.2 (active-config identity across members) has no assertion tool — needs either extendingwalcompareor a sibling tool.- Fault-injection precedent, exact shape confirmed:
ha_stream_body_read_fault_hook(config_repl.c:2104-2106, wired atconfig_repl.c:2183-2193) andxce_snapshot_chunk_hook(config_repl.c:2847, wired atconfig_repl.c:2937-2949) are both#ifdef XCE_TESTING-gated,NULL-by-default, file-scope function-pointer globals,extern-declared and set/reset around the driving call in the test file. This is the exact shape the two NEW deterministic hooks (Req 7.2's marker-apply→forced-checkpoint-durable window, Req 7.2/5.4's leadership-change-mid-distribution window) should mirror — a hook inside the newapply_committed_entryWAL_CONFIG_MARKERcase (or wherever the cross-member install-wait loop lives) taking identifying args (marker lsn/term) so a test can force-fail/force-delay at that exact boundary instead of relying on chaotic-soak timing luck. - Test fixture pattern, exact shape confirmed:
test_apply_committed_entry.c(bare in-memorycluster_t, no disk, directapply_committed_entry()calls, spy hooks countingHOOK_AFTER_EVENTfires) andtest_replay_apply_seam.c(real disk-backedpersist_t, two-phase write-then-recover, proving replay routes through the same seam) are the two patterns new host-frame-restore and marker-apply tests should extend — both already have atace_encode_worker/trr_encode_workerlocal-capnp-encode helper (mirroringpersist.c's file-staticcapnp_encode_worker) that a new host-admin-frame test can copy the idiom from.
Design Synthesis (per rules/design-synthesis.md)
1. Generalization: The host-admin-state frame (Req 1.6/1.9/1.10) and the queue-admin-state frame (existing, now migrated to capnp per Req 1.9) are the same underlying shape — (name, status) with independently-extensible fields. Rather than two hand-written near-duplicate collect/restore function pairs, design one generic collect_admin_state_frames(table, status_mask)/restore_admin_state_frame(table, name, status, create_if_missing_fn) pair parameterized over which table (workermgr/qmgr) and which create-if-missing behavior (hosts: makeWorker-based placeholder; queues: none, always pre-provisioned) applies — the two concrete restore functions (restore_host_state, restore_queue_state) become thin instantiations, not independent implementations. This directly follows Design Invariant 1 (classify config- vs join-provisioned) by construction: the generic function takes the classification as a parameter rather than assuming one.
2. Build vs. Adopt: Every major piece here is "adopt existing internal mechanism," not "build new" — the snapshot transport (snapshot_transfer_s/ha_handle_snapshot_chunk), the checkpoint/CAS machinery (persist_checkpoint/checkpoint_in_progress), the fence-and-retry pattern (config_serve_fence), the placeholder-entity pattern (lost_and_found's makeWorker() precedent), and the fault-injection hook shape (ha_stream_body_read_fault_hook) are all reused verbatim rather than reimplemented. The only genuinely NEW machinery is: the WAL_CONFIG_MARKER case in apply_committed_entry, the new fence field + verify function (structurally mirroring config_serve_fence but a distinct field), the config-bytes-embed in the snapshot header/entity payload, the HostAdminState/QueueAdminState capnp structs, and the two new test hooks. No external library or new dependency is involved anywhere in this spec.
3. Simplification: Considered whether the new fence needs to be a SEPARATE atomic from config_serve_fence — confirmed yes (the two conditions are logically independent: hash-mismatch vs materialization-incomplete, and Req 3.4 needs to distinguish "keep serving under last-verified config" from "no verified config exists yet," which requires knowing WHICH fence is active). Considered whether apply_wal_entry needs a matching WAL_CONFIG_MARKER case for symmetry with apply_committed_entry — confirmed no, it has zero live callers, touching it would be pure speculative-symmetry work with no runtime effect (Design Invariant would call this exactly the "convenient but unjustified" mistake to avoid).
Risks carried into design.md
- The narrow window between
schedule_checkpoint's CAS release (checkpoint_task_fn,persist.c:2222) andpersist_checkpoint's own re-acquire (persist.c:2265) allows a second concurrent trigger to slip in and start its ownpersist_checkpoint()call — pre-existing, not introduced by this spec, but the new marker-triggered call site adds a second caller into this same race window and should be noted. durable_lsnnot advancing on snapshot-install (above) — pre-existing, out of scope, flagged for a future spec.
Cross-Spec Gap Analysis: admin-config-tool × config-upgrade-snapshot × clusterconfig-schema-split (2026-07-28)
1. Current State Investigation
All three specs touch the same controller-side config surface (controller.capnp's ClusterConfig, config_repl.c's OP_upgradeConfig handler, cluster.c's dispatch gate) from different angles:
| Spec | Phase | Scope |
|---|---|---|
clusterconfig-schema-split |
implementation-complete |
Remove per-instance/dead fields (port, candidates, perf-tuning) from ClusterConfig |
admin-config-tool |
implementation-complete (blocked_on stale — see §2) |
xceadmin CLI: build/push cluster.bin against the controller |
config-upgrade-snapshot |
tasks-generated, 18/19 actionable tasks [x] |
Snapshot the config alongside cluster state at every upgrade boundary; activation fencing |
Verified current schema state (src/schema/controller.capnp:712-726): ClusterConfig has exactly 13 fields (@0–@12: clusterName … configGeneration), matching clusterconfig-schema-split's Requirement 3.1 target exactly. MasterCandidate/HashZArrayConfig/ResmgrConfig are gone (Requirements 1.6/2.5). config-upgrade-snapshot's own schema additions (HostAdminState, QueueAdminState, lines 769/774) are separate message types, not fields on ClusterConfig itself — no ordinal collision or renumbering conflict between the two specs' schema work.
Verified dispatch-gate state (src/controller/cluster.c:669-694): the OPCode_upgradeConfig polarity-inversion bug that admin-config-tool's spec.json.blocked_on and tasks.md (line 155) both cite as blocking real-cluster config push E2E validation is already fixed — commit 959b0cda (bug-1468, landed 2026-07-25 as part of config-upgrade-snapshot task 8.6). The fix's own comment in cluster.c explicitly cross-references admin-config-tool: "Independently diagnosed and documented as a blocking risk by the admin-config-tool spec (design.md 'Boundary Commitments' / 'Risks') before config-upgrade-snapshot task 8.6 needed a live OP_upgradeConfig sender and hit the same wall."
2. Gap Found: admin-config-tool's blocked_on is stale documentation, not a live blocker
admin-config-tool/spec.json's blocked_on array still lists:
"controller OP_upgradeConfig dispatch defect (cluster.c:667-679 vs ha.c:3926-3951) blocks real-cluster config push E2E validation"
This is the exact same defect as bug-1468, fixed 2026-07-25. Additionally, the same controller-side OP_upgradeConfig path this blocker describes has since been exercised live, repeatedly, and successfully by config-upgrade-snapshot's own --config-upgrade soak (.claude/skills/xce-test-ha/run-ha-soak.sh) — dozens of live 3-node runs today alone, all showing correct dispatch, config replication, majority-ack, and WAL_CONFIG_MARKER commit. The only difference is the client: the soak drives the RPC via xce_test's hand-rolled upgradeconfig command (built for config-upgrade-snapshot task 8.6, since no other live OP_upgradeConfig client existed at the time), not xceadmin's Python config push. But the controller-side code path is identical for both clients — the dispatch gate doesn't distinguish caller identity.
- Gap classification: Missing (stale tracking data, not missing capability).
admin-config-tool's actual E2E validation task (referenced in tasks.md line 155) can very likely be marked done now, or re-run directly withxceadmin config pushagainst a live cluster to close it out formally — the blocking condition itself no longer exists. - Recommendation for design/task phase: Clear
admin-config-tool/spec.json'sblocked_onentry (or mark it resolved with a dated note, matching this codebase's own convention — e.g.clusterconfig-schema-split's already-resolved entries andconfig-upgrade-snapshot's own dated Implementation Notes). If the E2E task was left unchecked pending this, runxceadmin config pushagainst a real deployed cluster (/xce-deploy-ha) once to get first-handxceadmin-specific confirmation (not just proof the shared controller path works) — the CLI's own TLS/redirect/framing code (Requirements 2–3) has never been proven against the real handler, only mock-tested (per tasks.md's own note).
3. Gap Found (residual, this session): config-upgrade-snapshot task 8.6 — new commit-timeout issue post-bug-1477
config-upgrade-snapshot's own remaining task (8.6, live chaotic-upgrade soak) was blocked on bug-1472 → then bug-1477 (both now fixed and verified this session — see .wolf/buglog.json). Re-running --config-upgrade after the bug-1477 fix shows the soak's core assertions (checkpoint-identity, config-identity, host-admin-status survival — the things bug-1471/1472/1477 blocked) all PASS for the first time. But the same run then fails later, in the force-elect-rotation step, with repeated commit timeouts (rc=-110) on the primary — a step this soak scenario has never previously reached (every prior attempt failed earlier). This is very likely a fourth pre-existing, newly-exposed defect in the same family as bug-1471/1472/1474/1477 (surfaced by clearing the earlier blockers, not caused by them) — not yet root-caused, flagged for the next investigation pass. Task 8.6 remains open pending this.
- Gap classification: Unknown (root cause not yet investigated — flagged, not analyzed, per this skill's "defer deep research to design phase" principle).
- Ownership: Likely
UNCLEARuntil investigated — could beha-commit-durability/ha-term-safety/raft-log-replication territory (commit-engine admission capacity or write-quorum under chaos), or aconfig-upgrade-snapshot-adjacent interaction. Follow this session's established routing discipline (bug-1471→1472→1474→1477 precedent): root-cause first, only fix in-place if narrowly scoped, otherwise route to the owning spec and keep task 8.6 blocked.
4. No gap found: clusterconfig-schema-split × admin-config-tool integration
admin-config-tool/requirements.md's own Adjacent Expectations section already documents this dependency as "RESOLVED, landed" (commit 519e11c), and the verified current schema state (§1 above) confirms it: port/candidates/perf-tuning fields are genuinely absent from ClusterConfig, so xceadmin's Requirements 1.8–1.10 (never source these fields) have nothing to accidentally violate — the fields don't exist to source. No further action needed here.
5. No gap found: clusterconfig-schema-split × config-upgrade-snapshot schema coexistence
Verified directly (§1): the two specs' schema additions/removals touch disjoint parts of controller.capnp (ClusterConfig's field list vs. new standalone HostAdminState/QueueAdminState structs). No ordinal renumbering conflict, no shared struct mutated by both specs in incompatible ways.
Summary Table
| # | Finding | Classification | Effort | Risk | Action |
|---|---|---|---|---|---|
| 1 | admin-config-tool blocked_on stale (bug-1468 already fixed) |
Missing (tracking, not capability) | S (update spec.json + one live xceadmin config push run) |
Low | Clear blocker, optionally run one live E2E confirmation |
| 2 | config-upgrade-snapshot task 8.6: new commit-timeout past force-elect step |
Unknown (not yet root-caused) | Unknown until investigated | Medium–High (commit/write-quorum path) | Dedicated debug pass before considering 8.6 closeable |
| 3 | clusterconfig-schema-split ↔ admin-config-tool |
No gap | — | — | None |
| 4 | clusterconfig-schema-split ↔ config-upgrade-snapshot schema |
No gap | — | — | None |
Research Needed (carried forward)
- Root cause of the new post-bug-1477 commit-timeout (finding #2) — not investigated in this pass, flagged only.
Resolved during this pass (no longer open): admin-config-tool/tasks.md was audited directly — all 23 tasks are [x], none gated on the stale blocker as an active checklist item. The E2E gap is purely spec.json.blocked_on + an informational tasks.md note (line 155), not an incomplete task. Confirms finding #1 is pure metadata cleanup, not a reopened implementation gap.