# v1.3.0 Raft-Supported — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > Spec first: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`. **Goal:** Move raft from experimental to supported: proven failover under load, mandatory CI e2e, cold-node recovery via InstallSnapshot, raft-port TLS. **Architecture:** No changes to election/AppendEntries semantics. New InstallSnapshot message pair rides the existing framed TCP transport (backward-compatible trailing fields, `RaftProtoVersion` stays 1). TLS wraps the existing transport via `protocol/ssl.nim`. Snapshot payload reuses `core/backup.nim` tar.gz backup/restore. **Tech stack:** Nim 2.2.x, std/asyncnet + std/net SSL, existing `tests/raft_*_e2e_test.nim` process harness, GitHub Actions. ## Global Constraints - Spec: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`. - Do **not** change election safety, commit rules, or the DDL/DML classification from C3b/C3c. - Raft remains default-DB-only; snapshot payload covers the default DB only. - Compile all touched Nim with `-d:ssl --threads:on --path:src`. - Test baseline per task: `tests/test_all.nim` + `tests/bugfix_test.nim` must stay green; raft e2e suites green where the binary is built. - Branch: `main` (short-lived feature branches merged same day are fine). - Version bump to 1.3.0 happens only in the final task (T12). --- ## Phase map | Phase | Tasks | Outcome | |-------|-------|---------| | P1 Proof | T1 | Failover-under-load e2e | | P2 CI | T2 | Mandatory raft e2e CI gate | | P3 TLS | T3–T6 | Raft port TLS + mutual auth + TLS e2e | | P4 Cold node | T7–T11 | InstallSnapshot + compaction unpin + cold-node e2e | | P5 Release | T12 | Docs, limitations, version 1.3.0 | --- ### Task 1: Failover-under-load E2E **Files:** - Create: `tests/raft_failover_load_e2e_test.nim` - Modify: `baradadb.nimble` (task `test`, line ~30-34: add `raft_failover_load_e2e_test` after `raft_writes_e2e_test`) **Interfaces:** - Consumes: process harness conventions from `tests/raft_writes_e2e_test.nim` (`NodeProc`, `drainOutput`, `portOpen`, `openClient`, `waitForRow` pattern); client `adaptors/nim/baradb_sqlite`. - Produces: suite `Raft failover under load E2E`. **Scenario (spec D1):** - Port base `cbase = 50000 + (tstamp mod 4000)`, `rbase = cbase + 100` (distinct from 35000/41000/46000 bases already in use). - Boot 3 nodes with `BARADB_RAFT_*` env exactly as `raft_writes_e2e_test.nim:150-171`. - Wait for stable leader (same `maxLeader` logic). Leader DDL: `CREATE TABLE load_test (id INT PRIMARY KEY)`. - Load phase: spawn a Nim `Thread` that loops `n = 1, 2, ...`: `INSERT INTO load_test (id) VALUES (n)` against the current leader's client port; every **acknowledged** `n` appended to a `seq[int]` guarded by a `Lock`. On exception: reopen client against a survivor, continue (this models the documented client retry contract). - At ≥ 50 acked writes: `killNode(leader)`. - Assert A (availability): some survivor accepts an INSERT within 10 s of the kill. - Assert B (durability): after new leader is stable and the remaining follower caught up (poll `SELECT count(*)` equality or 10 s deadline), `SELECT id FROM load_test` on **both** survivors contains every acked id. - Stop the writer thread in `finally`; reuse the `dumpAll`-on-fail convention. - [x] **Step 1:** Write the suite skeleton: harness copied from `raft_writes_e2e_test.nim` (drain/kill/leader-discovery helpers), writer thread, kill at 50 acked, asserts A + B. - [x] **Step 2:** Build binary and run: `nim c -o:build/baradadb src/baradadb.nim && nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim` Expected: PASS. - [x] **Step 3:** Run 3 consecutive times (failover timing flakiness check). - [x] **Step 4:** Add suite to `nimble test` list in `baradadb.nimble`. - [x] **Step 5:** Commit `test(raft): failover under sustained write load e2e` --- ### Task 1a: Fix raft put/delete encoding for empty values (bug found in T1) **Bug:** `execInsert` (`src/barabadb/query/exec/dml.nim:60-90`) stores only non-PK columns in the value, so a PK-only table row gets `valStr = ""` and `kvPairs.add((fullKey, @[]))`. `appendWriteToRaft` (`src/barabadb/core/server.nim:309-330`) encodes an empty value as a `"delete"` log entry — but `execDelete` (`dml.nim:223-241`) uses the same `(fullKey, @[])` shape for real deletes. Result: INSERT into a PK-only table returns OK after majority commit, then every node (leader included, on apply) **deletes the row**. Verified live in T1: 30 acked inserts → 0 rows on all nodes. **Files:** - Modify: `src/barabadb/query/exec/types.nim:139` — `ExecResult.keyValuePairs` - Modify: `src/barabadb/query/exec/dml.nim` — 3 producer sites (insert ~90, delete ~241, update ~316) - Modify: `src/barabadb/core/server.nim` — `appendWriteToRaft` (~309) and its call site (~441-464) - Test: `tests/bugfix_test.nim` (new suite) **Interfaces:** - Change the pair type to carry the op explicitly: ```nim # exec/types.nim keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]] ``` - `execInsert`/`execUpdate` produce `deleted: false` (even when `value.len == 0`); `execDelete` produces `deleted: true`. - `appendWriteToRaft` encodes `deleted` → `"delete"`, else `"put"` (empty value stays a put). Apply side (`baradadb.nim:358-368`) already handles `put` with empty value correctly — no change needed there. - Check other `keyValuePairs` consumers compile clean (replication path in `server.nim`); keep `okResult(kvPairs=...)` call sites type-correct. **Steps:** - [x] **Step 1:** Write the failing test in `tests/bugfix_test.nim`: build `ExecResult` via the insert path for a PK-only table (or call `appendWriteToRaft` semantics directly): assert a PK-only insert yields a pair with `deleted == false` and encodes as `"put"`, while a delete yields `deleted == true` and encodes as `"delete"`. - [x] **Step 2:** Run, expect fail/compile error. - [x] **Step 3:** Implement the type + producer/consumer changes. - [x] **Step 4:** `bugfix_test` + `test_all` green; rebuild `build/baradadb` and re-run `tests/raft_failover_load_e2e_test.nim` — then **switch its table back** to the brief's original `load_test (id INT PRIMARY KEY)` / `VALUES (n)` shape (remove the two-column workaround and its header note) and re-run green. - [x] **Step 5:** Commit `fix(raft): distinguish put-with-empty-value from delete in write path` --- ### Task 2: Mandatory raft e2e CI gate **Files:** - Modify: `.github/workflows/ci.yml` - Modify: `tests/raft_e2e_test.nim`, `tests/raft_writes_e2e_test.nim`, `tests/raft_failover_load_e2e_test.nim` (skip→fail under CI) **Steps:** - [x] **Step 1:** In each suite's binary-missing branch, replace plain `skip()` with: ```nim if not fileExists(BinaryPath): if getEnv("CI").len > 0: echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?" fail() else: echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)" skip() ``` - [x] **Step 2:** Add a dedicated job to `.github/workflows/ci.yml` (after the `test` job), modeled on its setup steps: ```yaml raft-e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Nim uses: jiro4989/setup-nim-action@v1 with: nim-version: '2.2.10' - name: Install system dependencies run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates - name: Install Nim dependencies run: nimble install --depsOnly -y - name: Build server run: nim c -d:ssl -o:build/baradadb src/baradadb.nim - name: Raft e2e suites env: CI: "true" run: | nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim ``` - [x] **Step 3:** Push on a branch; confirm the `raft-e2e` job appears and is green; confirm deleting `build/` would fail (local run with `CI=true` and no binary → FAIL). - [x] **Step 4:** Commit `ci(raft): dedicated mandatory raft e2e job; no silent skips under CI` --- ### Task 3: Raft TLS config + fail-closed startup **Files:** - Modify: `src/barabadb/core/config.nim` (fields after `raftLogMaxEntries` at lines ~46/88; env parsing after line ~212) - Modify: `src/baradadb.nim` (raft wiring block, lines 337-377) - Test: `tests/bugfix_test.nim` (new suite) **Interfaces:** - Produces config fields (used by T4/T6): ```nim raftTlsEnabled*: bool # default false raftTlsCertFile*: string # default "" raftTlsKeyFile*: string # default "" raftTlsCaFile*: string # default "" raftTlsVerifyPeer*: bool # default false ``` - Env parsing (mirror lines 184-212 style): ```nim cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled) cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile) cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile) cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile) cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer) ``` - Fail-closed in `baradadb.nim` before `newRaftNetwork`: ```nim if config.raftTlsEnabled: if config.raftTlsCertFile.len == 0 or config.raftTlsKeyFile.len == 0 or not fileExists(config.raftTlsCertFile) or not fileExists(config.raftTlsKeyFile): raise newException(ValueError, "BARADB_RAFT_TLS_ENABLED=true but cert/key missing " & "(BARADB_RAFT_TLS_CERT_FILE / BARADB_RAFT_TLS_KEY_FILE)") ``` - [x] **Step 1:** Write failing test in `tests/bugfix_test.nim`: default config has `raftTlsEnabled == false`; env `BARADB_RAFT_TLS_ENABLED=true` + cert paths parse into config. - [x] **Step 2:** Run test, expect compile/fail (fields don't exist). - [x] **Step 3:** Implement fields + env parsing + startup check. - [x] **Step 4:** Test green; `test_all` still green. - [x] **Step 5:** Commit `feat(raft): TLS config surface with fail-closed startup` --- ### Task 4: TLS in RaftNetwork transport **Files:** - Modify: `src/barabadb/core/raft.nim` (`RaftNetwork` type ~line 731, `connectToPeer` ~748, `run` ~857) - Modify: `src/baradadb.nim` (construct TLSContext, pass to network) - Test: `tests/test_all.nim` (in-process TLS raft pair) **Interfaces:** - Consumes: `protocol/ssl.nim` — `newTLSConfig(certFile, keyFile, caFile, verifyPeer)`, `newTLSContext`, `wrapClient`, `wrapServer`. - Produces: `RaftNetwork.tls*: TLSContext` (nil = plaintext, unchanged default); `newRaftNetwork(node, tls = nil)`. **Implementation:** ```nim # raft.nim — in connectToPeer, after successful connect: if net.tls != nil: try: net.tls.wrapClient(sock) except CatchableError: try: sock.close() except CatchableError: discard return # raft.nim — in run, after accept, before receiveLoop: if net.tls != nil: try: net.tls.wrapServer(client) except CatchableError: client.close() continue ``` - `baradadb.nim`: build the context when enabled: ```nim var raftTls: TLSContext = nil if config.raftTlsEnabled: raftTls = newTLSContext(newTLSConfig( config.raftTlsCertFile, config.raftTlsKeyFile, caFile = config.raftTlsCaFile, verifyPeer = config.raftTlsVerifyPeer)) ... raftNet = newRaftNetwork(raftNode, raftTls) ``` - [x] **Step 1:** Write failing in-process test (`test_all.nim`): two `RaftNode`s over `RaftNetwork` with a self-signed cert from `generateSelfSignedCert` (`protocol/ssl.nim:79`) — election completes over TLS; plaintext dial to the TLS port produces no protocol effect (no state change, connection dropped). - [x] **Step 2:** Run, expect fail (no `tls` field). - [x] **Step 3:** Implement transport changes + wiring. - [x] **Step 4:** Test green; plaintext raft e2e suites still green (regression: nil-TLS path untouched). - [x] **Step 5:** Commit `feat(raft): optional TLS on raft transport (server + dialer)` --- ### Task 5: TLS for leader SQL forwarding **Files:** - Modify: `src/barabadb/core/server.nim` (`forwardQueryToLeader`, lines 210-289) **Interfaces:** - Consumes: `protocol/ssl.nim` `wrapClient`; server config `tlsEnabled`, `certFile`, `keyFile`. - Produces: `forwardQueryToLeader(host, port, query, tls: TLSContext = nil, ...)`. - [x] **Step 1:** When the server's client wire port has TLS on (`server.tls != nil`), wrap the forwarding socket with a client-side context before sending the wire header; on handshake failure return `(false, QueryResult(), "leader forward TLS handshake failed")`. - [x] **Step 2:** Manual check: TLS server + raft forwarding (follower INSERT forwarded over TLS) works; non-TLS setup unchanged. - [x] **Step 3:** Commit `feat(raft): TLS on follower→leader SQL forwarding` --- ### Task 6: Raft TLS E2E **Files:** - Create: `tests/raft_tls_e2e_test.nim` - Modify: `baradadb.nimble` (test list), `.github/workflows/ci.yml` (raft-e2e job: add this suite) **Interfaces:** - Consumes: T3/T4 implementation; harness from T1; openssl CLI for cert generation (already used by `protocol/ssl.nim`). **Scenario:** - Port base `54000 + (tstamp mod 4000)`. - Generate one self-signed cert per node into the temp data dirs (`openssl req -x509 ...`, or `generateSelfSignedCert`). - Boot 3 nodes with `BARADB_RAFT_TLS_ENABLED=true` + per-node cert/key; assert election + `CREATE TABLE` via raft DDL + one replicated INSERT visible on a follower. - Negative: start a 4th process with raft TLS **disabled** pointed at the same peers; assert the TLS cluster still elects/operates among its 3 members and the plaintext node never becomes leader (its frames are undecryptable). - Same `CI`-fail semantics as T2. - [x] **Step 1:** Write suite; Step 2: run green locally; Step 3: run 3× (timing); Step 4: wire into `nimble test` + CI job; Step 5: Commit `test(raft): 3-node TLS cluster e2e with plaintext rejection` --- ### Task 7: InstallSnapshot protocol **Files:** - Modify: `src/barabadb/core/raft.nim` (`RaftMessageKind` ~line 80, `RaftMessage` ~86, `serialize` ~679, `deserializeRaftMessage` ~702) - Test: `tests/test_all.nim` (serialize/deserialize round-trip) **Interfaces:** - Produces: ```nim # RaftMessageKind += rmkInstallSnapshot, rmkInstallSnapshotReply # RaftMessage new fields: snapId*: uint64 # snapshot generation, matches leader's base at build time snapOffset*: uint64 # byte offset of this chunk within the archive snapData*: seq[byte] # chunk payload (<= snapChunkBytes) snapDone*: bool # last chunk # Reused for this kind: prevLogIndex = snapshot base index, # prevLogTerm = snapshot base term. Reply uses success/matchIdx as usual. ``` - Serialization: append `snapId`, `snapOffset`, `snapData` (length-prefixed), `snapDone` **after** `matchIdx`; deserialize each with `if not s.atEnd` guards (pattern from `loadState`, raft.nim:163-167). Old binaries ignore trailing bytes; new binaries default missing fields to zero/false. `RaftProtoVersion` stays 1. - [x] **Step 1:** Write failing round-trip test: all new fields survive serialize→deserialize; a buffer serialized by the *old* layout (no trailing fields) deserializes with zero defaults. - [x] **Step 2:** Run, expect fail. - [x] **Step 3:** Implement. - [x] **Step 4:** Green; existing raft suites still green. - [x] **Step 5:** Commit `feat(raft): InstallSnapshot wire protocol (backward-compatible)` --- ### Task 8: Follower snapshot receive + restore **Files:** - Modify: `src/barabadb/core/raft.nim` (`RaftNode` — new callback + incoming-snapshot buffer; `processMessage` ~786) - Modify: `src/barabadb/core/config.nim` (`raftSnapChunkKb: int`, env `BARADB_RAFT_SNAP_CHUNK_KB`, default 256; parsing next to the other `BARADB_RAFT_*` env reads) - Modify: `src/baradadb.nim` (wire `restoreSnapshot` callback using `core/backup.nim` + `DatabaseRegistry`; pass chunk size to the node) - Test: `tests/test_all.nim` **Interfaces:** - Produces on `RaftNode`: ```nim snapChunkBytes*: int # from BARADB_RAFT_SNAP_CHUNK_KB, default 262144 restoreSnapshot*: proc(archivePath: string, baseIndex: uint64, baseTerm: uint64): bool {.gcsafe.} snapIncomingId*: uint64 snapIncomingFile*: string # temp path under dataDir/raft/snap_incoming/ ``` - `processMessage` case `rmkInstallSnapshot`: append `snapData` at `snapOffset` to the temp file (create/truncate when `snapId != snapIncomingId`); on `snapDone`: call `restoreSnapshot`; on success set `lastSnapshotIndex/Term = prevLogIndex/prevLogTerm`, `commitIndex = lastApplied = lastSnapshotIndex`, clear `log`, `saveState()`, reply success with `matchIdx = lastSnapshotIndex`; on failure reply `success = false` and delete the temp file. - `baradadb.nim` `restoreSnapshot` implementation: close default DB via registry, `restoreDataDir(archivePath, defaultDbDir)` (`backup.nim:263`), reopen, swap `ctx`. Return false on any exception. - [x] **Step 1:** Write failing test: feed a node two chunks + done with a real tar.gz fixture; assert callback received the assembled file, state fields updated, log cleared. - [x] **Step 2:** Run, expect fail. **Step 3:** Implement. - [x] **Step 4:** Green + regression suites. **Step 5:** Commit `feat(raft): follower InstallSnapshot receive and restore` --- ### Task 9: Leader snapshot send **Files:** - Modify: `src/barabadb/core/raft.nim` (`handleAppendReply` floor branch ~526-531, new `sendSnapshot` proc, per-peer reject counter) - Modify: `src/baradadb.nim` (wire `buildSnapshot` callback) - Test: `tests/test_all.nim` **Interfaces:** - Consumes: T7 protocol, `backupDataDir` (`backup.nim:225`). - Produces on `RaftNode`: ```nim buildSnapshot*: proc(destPath: string): bool {.gcsafe.} snapRejectStreak*: Table[string, int] # consecutive floor-level rejects per peer ``` - Logic: in `handleAppendReply`, when a reject arrives **and** `nextIndex[peerId] == lastSnapshotIndex + 1` (floor reached): increment streak; at streak ≥ 2 the leader knows the follower needs a snapshot → `asyncCheck sendSnapshot(peerId)`. Reset streak on any successful reply. - `sendSnapshot`: `buildSnapshot` into `dataDir/raft/snap_out_.tar.gz` (`snapId = lastSnapshotIndex`); stream chunks of `snapChunkBytes` as `rmkInstallSnapshot`; on final success reply set `matchIndex[peer] = lastSnapshotIndex`, `nextIndex[peer] = lastSnapshotIndex + 1`; delete the temp archive. - [x] **Step 1:** Write failing test: leader with compacted log (`lastSnapshotIndex = 100`) + peer at floor rejecting twice → snapshot messages emitted; success reply advances `matchIndex`/`nextIndex`. - [x] **Step 2:** Run, expect fail. **Step 3:** Implement. - [x] **Step 4:** Green + regression. **Step 5:** Commit `feat(raft): leader InstallSnapshot send on unrecoverable lag` --- ### Task 10: Unpin compaction from dead peers **Files:** - Modify: `src/barabadb/core/raft.nim` (`compactLog` ~244, `becomeLeader` ~327, `handleAppendReply` ~487) - Modify: `src/barabadb/core/config.nim` (`raftPeerStaleMs`, env `BARADB_RAFT_PEER_STALE_MS`, default 30000) - Test: `tests/test_all.nim` **Interfaces:** - Produces: `matchIndexSeenMs*: Table[string, int64]` on `RaftNode` — monotonic ms timestamp of the last successful reply per peer, updated in `handleAppendReply` success branch and initialized to "now" in `becomeLeader`. **Logic:** leader-side `compactLog` computes `minMatch` only over peers with `now - matchIndexSeenMs[peer] <= raftPeerStaleMs`; peers stale longer are excluded (they'll be snapshotted on return per T9). Follower path unchanged. Guard: never compact past `lastApplied`. - [x] **Step 1:** Write failing test: leader, one peer never replies, log > maxEntries → with default stale window, log compacts through lastApplied anyway; with the peer responsive, compaction still pins at its matchIndex (existing safety preserved). - [x] **Step 2:** Run, expect fail. **Step 3:** Implement. - [x] **Step 4:** Green + regression. **Step 5:** Commit `feat(raft): compaction unpinned from stale peers (snapshot fallback)` --- ### Task 11: Cold-node E2E **Files:** - Create: `tests/raft_coldnode_e2e_test.nim` - Modify: `baradadb.nimble`, `.github/workflows/ci.yml` (raft-e2e job) **Interfaces:** Consumes T7–T10; harness from T1. Port base `58000 + (tstamp mod 4000)`. Small `BARADB_RAFT_LOG_MAX_ENTRIES=16` and `BARADB_RAFT_PEER_STALE_MS=3000` to force compaction quickly. **Scenario A — node returns after compaction:** 1. 3-node cluster, create table, kill node n3. 2. Write 100 rows through the leader (forces compaction past n3's matchIndex once n3 is stale). 3. Assert via `/metrics` on the leader HTTP port (`baradb_raft_log_entries`) that the log stayed bounded. 4. Restart n3 with its intact data dir; assert it receives a snapshot (leader log line / `baradb_raft_snapshot_index` advances on n3) and within 15 s `SELECT count(*)` on n3 matches the leader. **Scenario B — wiped node joins:** 1. Stop n3, **delete its data dir**, restart with the same node id. 2. Assert it converges (snapshot → catch-up) and serves the full row set within 20 s. - [x] **Step 1:** Write suite. **Step 2:** Green locally. **Step 3:** 3× stability runs. **Step 4:** `nimble test` + CI wiring. **Step 5:** Commit `test(raft): cold-node rejoin and wiped-node join e2e` --- ### Task 12: Docs, limitations, version 1.3.0 **Files:** - Modify: `docs/en/distributed.md`, `docs/bg/distributed.md` — client failover contract (in-flight writes fail fast, retry; acked writes durable), TLS setup (`BARADB_RAFT_TLS_*`), snapshot behavior/tunables (`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`) - Modify: `docs/en/known-limitations.md`, `docs/bg/known-limitations.md` — raft 3-node moves from "Experimental" to "Supported" for the covered scope; remaining non-goals (multi-DB raft, membership changes, read consistency levels) stay listed - Modify: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md` — status → v1.3.0 supported - Modify: `CHANGELOG.md` — `## [1.3.0] — ` - Modify: `baradadb.nimble` → `version = "1.3.0"`; README status lines - Modify: `docs/en/release-checklist.md` — add raft TLS + cold-node suites - [x] **Step 1:** Doc edits. **Step 2:** Full `nimble test` green. - [x] **Step 3:** Commit `release: v1.3.0 raft-supported (failover load, CI gate, snapshot, TLS)` - [ ] **Step 4 (human/controller):** tag `v1.3.0` after review. --- ## Task dependency graph ``` T1 failover-load e2e ──→ T2 CI gate T3 TLS config ──→ T4 transport TLS ──→ T5 forward TLS ──→ T6 TLS e2e T7 snapshot protocol ──→ T8 follower restore ──→ T9 leader send ──→ T10 unpin ──→ T11 cold-node e2e all ──→ T12 docs/version ``` ## Explicit out-of-scope - Membership change (join/leave) protocol - Multi-database raft; `CREATE`/`DROP DATABASE` replication - Linearizable follower reads - Rolling-upgrade compat shims beyond the trailing-field guard (upgrade = restart all nodes) ## Definition of Done - [x] All P1–P5 tasks complete, `nimble test` green - [ ] `raft-e2e` CI job green and mandatory (no silent skip) - [x] Failover-under-load e2e: every acked write survives leader kill - [x] Cold-node e2e: returning node and wiped node converge automatically - [x] Raft TLS e2e: full-TLS cluster works; plaintext node excluded - [x] known-limitations updated: raft supported for the covered scope ## Estimated effort | Phase | Effort | |-------|--------| | P1–P2 | 0.5–1 day | | P3 | 1 day | | P4 | 2–3 days | | P5 | 0.5 day | | **Total** | **~4–5 focused days** |