myxce/.kiro/specs/config-upgrade-snapshot/research.md
Rongsong Shen aecd8766f4 docs(admin-config-tool): clear stale OP_upgradeConfig dispatch blocker (bug-1468 already fixed)
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.
2026-07-28 14:13:06 +00:00

422 lines
45 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 — 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 until `entity-bearing-snapshots` lands 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`) and `load_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 on `load_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.c` snapshot codec and the already-large `config_repl.c` upgrade 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.c` from 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)
1. **Foundation (in/with `entity-bearing-snapshots`):** wire COW into kvmgr and add
the generic entity (de)serialize to `write_snapshot`/`load_snapshot` — *Option A
style*, shared by recovery + state transfer.
2. **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:**
1. **Spec sequencing** — co-scope `entity-bearing-snapshots` into this spec's
delivery, or hard-gate this spec behind it? (Recommend gate; promote to design
only once the foundation has a tasks plan — matches `roadmap.md`.)
2. **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.
3. **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.
4. **Activation gate** — bind "new config active" to "upgrade snapshot for this
`(term,lsn)` installed + hash-verified vs `config_committed_marker`"; mismatch ⇒
loud serve-fence + retry (never exit — `controller/CLAUDE.md`).
5. **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 by `ha-entity-snapshots`
(21/21 tasks, fully approved). It shipped `write_snapshot`/`load_snapshot`
(`persist.c`) as a direct Cap'n-Proto entity-encode/decode of job and queue
records at checkpoint time — **not** the COW `xzarray_freeze`/`thaw` path this
research doc's Option A/C assumed (that machinery is still dormant,
`kvmgr.c:297`). Design must build on the real `write_snapshot`/`load_snapshot`
interface, 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_on` should 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). Reports `has_cfgref` as 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-
snapshots` task 7.1 / `raft-ha-core-safety` task 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 existing
`scan_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 existing
`checkpoint_in_progress` CAS, 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_snapshot` advance `durable_lsn` (not just
`checkpoint_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:
1. **No cross-member config comparator (new gap).** `walcompare` diffs WAL only.
Req 4.2 ("every member's active configuration identical") has no assertion
tool. Extend `walcompare` (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 extend `snapverify` to decode/assert the
cfgref tag+hash value, not just report its presence.
2. **No deterministic fault-injection at the marker-apply → forced-checkpoint-
durable window (new gap, needed for Req 5.2/6.3).** `/xce-test-ha --chaotic`
kills 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 in `config_repl.c`: `ha_stream_body_read_fault_hook`,
`xce_snapshot_chunk_hook` (both `#ifdef XCE_TESTING` seams). Add an
equivalent hook at the marker-apply/forced-checkpoint boundary.
3. **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.
4. **Reusable, no new work needed:** `snapdiff_paths` for the Req 5.1
determinism check; `/xce-test-ha --force-snapshot` for 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 wires `WAL_CONFIG_MARKER` into 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`) — confirmed `has_cfgref` is 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 to `cfgref_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 extending `walcompare` or a sibling tool.
- **Fault-injection precedent, exact shape confirmed**: `ha_stream_body_read_fault_hook` (`config_repl.c:2104-2106`, wired at `config_repl.c:2183-2193`) and `xce_snapshot_chunk_hook` (`config_repl.c:2847`, wired at `config_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 new `apply_committed_entry` `WAL_CONFIG_MARKER` case (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-memory `cluster_t`, no disk, direct `apply_committed_entry()` calls, spy hooks counting `HOOK_AFTER_EVENT` fires) and `test_replay_apply_seam.c` (real disk-backed `persist_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 a `tace_encode_worker`/`trr_encode_worker` local-capnp-encode helper (mirroring `persist.c`'s file-static `capnp_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`) and `persist_checkpoint`'s own re-acquire (`persist.c:2265`) allows a second concurrent trigger to slip in and start its own `persist_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_lsn` not 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 with `xceadmin config push` against 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`'s `blocked_on` entry (or mark it resolved with a dated note, matching this codebase's own convention — e.g. `clusterconfig-schema-split`'s already-resolved entries and `config-upgrade-snapshot`'s own dated Implementation Notes). If the E2E task was left unchecked pending this, run `xceadmin config push` against a real deployed cluster (`/xce-deploy-ha`) once to get first-hand `xceadmin`-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 `UNCLEAR` until investigated — could be `ha-commit-durability`/`ha-term-safety`/raft-log-replication territory (commit-engine admission capacity or write-quorum under chaos), or a `config-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.