Skip to content

docs(encryption): Stage 8a design proposal — snapshot header v2#877

Merged
bootjp merged 4 commits into
mainfrom
feat/encryption-8a-snapshot-header-v2-design
May 30, 2026
Merged

docs(encryption): Stage 8a design proposal — snapshot header v2#877
bootjp merged 4 commits into
mainfrom
feat/encryption-8a-snapshot-header-v2-design

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 29, 2026

Summary

Slices the parent design's Stage 8 — 'Snapshot header v2 + WAL coverage' — into 8a (header v2) and 8b (WAL coverage). 8a is the lower-risk slice: a versioned snapshot header that carries the raft_envelope_cutover_index alongside the existing HLC ceiling.

Why

Once Stage 6E lands the enable-raft-envelope cutover, every Phase-2-aware snapshot needs to carry the cutover index so a node restoring from snapshot can route subsequent Raft entries through the correct wrap/unwrap path. Without 8a, the cutover would have to be side-channeled out-of-band through the sidecar — forking the source of truth for FSM-level decisions away from the snapshot it was applied against.

Architecture (settled in parent §4.4)

Distinct 8-byte magic per version (EKVTHLC1 / EKVTHLC2). The rejected alternative (heuristic on shared magic) misclassifies early-epoch HLC ceilings as v2.

Properties preserved

  • Existing v1 snapshot files restore byte-for-byte under the new build.
  • Headerless legacy snapshots (TestFSMSnapshotRestoreOldFormat / TestFSMSnapshotRestoreSmallLegacy regression tests) still restore via the leave-bytes-in-stream fallback.
  • No migration step required.

Sequencing constraint

8a MUST ship before Stage 6E — 6E populates the sidecar's raft_envelope_cutover_index; 8a's v2 writer is the carrier for that value across snapshot restore.

7-test verification plan covers

  • All four read-path branches: v1, v2-with-forward-compat-trailing-bytes, unknown EKVTHLC*, headerless.
  • Write-path v1/v2 selector based on sidecar's cutover.
  • Restore-side cutover plumbing to the applier.

Test plan

  • Design doc reviewable on its own (no implementation in this PR).
  • @claude review for the architecture choice (already settled in parent §4.4 but worth confirming), the read-path branch coverage in §5, and the sequencing-constraint with 6E.
  • Implementation lands in a follow-on PR (per CLAUDE.md design-doc-first workflow).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added design specification for snapshot header enhancements, defining versioned format and forward-compatible parsing behavior.

Review Change Stack

…_envelope_cutover_index carriage)

Slices the parent design's Stage 8 — 'Snapshot header v2 + WAL coverage' — into
8a (header v2) and 8b (WAL coverage). 8a is the lower-risk slice: a versioned
snapshot header that carries the raft_envelope_cutover_index alongside the
existing HLC ceiling, so a node restoring a Phase-2 snapshot can route
subsequent Raft entries through the correct wrap/unwrap path without
side-channeling through the sidecar.

Architecture: distinct 8-byte magic per version (EKVTHLC1 / EKVTHLC2) — the
parent design (§4.4) already settled this choice; the rejected alternative
was a heuristic on shared magic that misclassifies early-epoch HLC ceilings
as v2. This slice is the implementation cut.

Properties preserved:
- Existing v1 snapshot files restore byte-for-byte under the new build.
- Headerless legacy snapshots (TestFSMSnapshotRestoreOldFormat /
  TestFSMSnapshotRestoreSmallLegacy regression tests) still restore via
  the leave-bytes-in-stream fallback.
- No migration step required; rollout is purely additive.

Sibling Stage 8b (WAL coverage / §4.5) is deferred to a separate design and
PR; the two are conceptually independent.

Sequencing constraint: 8a MUST ship before Stage 6E (the
enable-raft-envelope cutover) — 6E populates the sidecar's
raft_envelope_cutover_index, and 8a's v2 writer is the carrier for that
value across snapshot restore. The reverse order would leave 6E with no way
to thread the cutover through a snapshot restore.

7-test verification plan covers all four read-path branches (v1, v2 with
forward-compat trailing bytes, unknown EKVTHLC*, headerless), the
write-path v1/v2 selector, and the restore-side cutover plumbing.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

Warning

Review limit reached

@bootjp, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 53 minutes and 9 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 02372dcd-6799-4041-8c37-c9420abef358

📥 Commits

Reviewing files that changed from the base of the PR and between f2ad475 and 0266818.

📒 Files selected for processing (1)
  • docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md
📝 Walkthrough

Walkthrough

Introduces design specification for Stage 8a (Snapshot header v2), which defines a versioned snapshot header format using magic-based versioning (EKVTHLC1 for v1, EKVTHLC2 for v2) to carry raft_envelope_cutover_index alongside the existing HLC ceiling. Documents on-disk layouts, read/write path semantics, Restore integration, test action items, and rollout behavior with forward-compatibility and legacy snapshot handling.

Changes

Snapshot Header v2 Design (Stage 8a)

Layer / File(s) Summary
Problem statement and scope
docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md
Introduces Stage 8a design, explains motivation for versioned snapshot headers to carry cutover indices, scopes in-scope items (v2 magic/layout, read/write APIs, typed error, Restore integration), and scopes out WAL coverage, encryption, and Stage 6E migration assumptions.
Technical architecture and specification
docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md
Specifies magic-based versioning mechanism, on-disk header layouts (v1: ceiling only; v2: length field, ceiling, cutover), read-path branching for v2/v1/unknown magics and headerless legacy snapshots, write-path rules (emit v1 while cutover is zero, emit v2 once non-zero), and Restore integration plumbing to thread cutover through applier/engine pre-apply hooks.
Verification, testing, and rollout strategy
docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md
Enumerates test action items for all read/write code paths and end-to-end restore integration, explains why no on-disk migration is needed (v1 compatibility, lazy v2 emission), documents mixed-version compatibility expectations for pre-8a and post-8a binaries, and lists follow-on stages and dependencies.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • bootjp/elastickv#707: Introduces snapshot-header versioning carrying raft envelope cutover index with encryption-driven cutover semantics, directly connected at the snapshot-header format level.

Poem

🐰 A header split in two,
v1 and v2 both true,
With cutover indices bright,
Snapshots dance left and right,
Forward-compat sets us free! 📸

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing a Stage 8a design proposal for snapshot header v2 with the raft_envelope_cutover_index feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/encryption-8a-snapshot-header-v2-design

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 29, 2026

@claude review

Focus areas:

  1. Scope slicing (8a vs 8b) — parent design Stage 8 covered both snapshot header v2 (§4.4) and WAL coverage (§4.5). 8a takes only the header; 8b will own WAL coverage. Verify the two are genuinely independent and that 8a's value is not load-bearing on 8b.
  2. §3.2 read-path branch coverage — five cases: v1, v2, v2-with-trailing-bytes (forward compat), unknown EKVTHLC* magic, headerless legacy. The headerless-legacy case is load-bearing for backward compat (TestFSMSnapshotRestoreOldFormat / TestFSMSnapshotRestoreSmallLegacy must stay green). Verify the discriminator algorithm in §3.2 covers all five exhaustively.
  3. Sequencing constraint with 6E — 8a MUST ship before 6E. Verify the reverse (6E first) would leave the cutover unable to thread through a snapshot restore.
  4. No-downgrade write rule — §3.3 says once v2 has been written, subsequent snapshots stay on v2 unless cutover resets to 0. Is the no-silent-downgrade rule load-bearing for any specific failure mode? (If rotation never retracts cutover, this is academic but worth pinning explicitly.)
  5. Forward-compat hatch via len — v2 readers ignore trailing bytes past the fields they understand. Verify this is the right place for future-field extensibility (vs. minting a v3 magic for every new field).

@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Claude finished @bootjp's task in 4m 45s —— View job


Review: Stage 8a — Snapshot Header v2 Design

Todo list

  • Load tools
  • Read design doc (2026_05_29_proposed_8a_snapshot_header_v2.md)
  • Read parent/related design docs for context
  • Read existing snapshot/FSM code for context
  • Analyze focus area 1: Scope slicing (8a vs 8b independence)
  • Analyze focus area 2: §3.2 read-path branch coverage (5 cases)
  • Analyze focus area 3: Sequencing constraint with 6E
  • Analyze focus area 4: No-downgrade write rule
  • Analyze focus area 5: Forward-compat hatch via len
  • Post comprehensive review

Overall

The design is well-grounded in the parent (§4.4) and the architecture choice (distinct magic per version) is unambiguous and correct. The read-path algorithm is nearly exhaustive. Four issues need addressing before the implementation PR — one moderate, three minor-to-low.


Focus Area 1 — Scope slicing (8a vs 8b independence)

The split is genuine and correct. The two slices touch completely different I/O paths:

  • 8a: kv/snapshot.go + kv/fsm.go::Restore — FSM snapshot stream header
  • 8b: Raft log persistence on disk (WAL files written by etcd/raft's storage layer)

Neither depends on the other. 8a shipping first does not accelerate 8b; 8b can be designed and implemented independently. 8a's value is NOT load-bearing on 8b — a cluster running 8a-only (no WAL encryption) correctly carries the cutover index through snapshot restore and the Raft log files remain cleartext (which the parent §4.6 already notes is intentional: Raft metadata stays cleartext).

One cross-reference issue to fix (low, but worth a note):

docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md §1 Out of Scope says:

WAL coverage (§4.5) — encrypting the Raft log on disk.

But in the parent doc (2026_04_29_partial_data_at_rest_encryption.md), §4.5 is titled "Distribution catalog and HLC ceiling entries" — not WAL coverage. The parent stage table (row 8) does list "Snapshot header v2 + WAL coverage (§4.4, §4.5)" but that appears to be a summary label conflating the two columns, not a section reference. WAL coverage in the parent context means those catalog/HLC entries flowing through the Raft envelope for free via §4.2 — not Raft log file encryption.

If 8b is intended to cover Raft-log-on-disk encryption (a separate concern not detailed in any current §4.x), that should be stated directly without the §4.5 cross-reference, which misdirects readers. Fix this →


Focus Area 2 — §3.2 read-path branch coverage

The four branches (v2, v1, unknown-EKVTHLC*, headerless) are exhaustive. The "v2-with-trailing-bytes" forward-compat case falls naturally inside the v2 branch — no fifth branch is needed. The §5 test plan maps cleanly to the algorithm.

However, there are two gaps in the §3.2 algorithm spec that the implementation will have to solve without guidance:

Gap A — Short-stream case (moderate)

§3.2 step 1 says "Peek the 8 leading bytes." It does not define behavior when the stream has fewer than 8 bytes — i.e., the scenario covered by TestFSMSnapshotRestoreSmallLegacy. Today's fsm.go::Restore (kv/fsm.go:364-369) handles this by catching io.ErrUnexpectedEOF from io.ReadFull:

n, err := io.ReadFull(r, hdr[:])
if err != nil {
    if err == io.EOF || err == io.ErrUnexpectedEOF {
        return errors.WithStack(f.store.Restore(io.NopCloser(io.MultiReader(bytes.NewReader(hdr[:n]), r))))
    }
    ...
}

With a bufio.Reader.Peek(8), Peek on a stream shorter than 8 bytes returns the available bytes plus io.EOF. The algorithm must specify: if Peek returns fewer than 8 bytes (short or empty stream), treat as headerless legacy — the short peek result AND the remaining stream are passed to the inner store reader. This must be called out in §3.2 before the branch table, otherwise the implementation PR has to infer behavior from a test that doesn't exist in the current test plan. Fix this →

Gap B — len < 16 validation not in §3.2 (low)

The verification plan (§5) includes TestReadSnapshotHeader_V2WithLenMismatchFails: "write a v2 header with len = 0x08; expect a typed error, NOT an EOF / 'short payload' panic." This is the right test. But §3.2's v2 branch says nothing about this validation — the algorithm currently reads len bytes and then tries to index into payload[8:16] without checking len >= 16 first. The spec and the test disagree: the spec implies EOF/panic; the test forbids it.

Add to §3.2 step 2, after "Read 2 bytes → len": "If len < 16, return a typed error (suggested: ErrSnapshotHeaderPayloadTooShort or similar). Do not attempt to parse the payload." Fix this →

Side note on bufio.Reader scope

The design correctly specifies bufio.Reader.Peek for the headerless case, but doesn't say explicitly whether the bufio.Reader wrapper is created inside ReadSnapshotHeader or passed in. Since kvFSM.Restore currently takes io.Reader, the implementation will need to wrap in bufio.NewReader internally and then pass the bufio.Reader (not the original r) to the inner store restore, so the peeked-but-not-consumed bytes for the headerless case are replayed. This is a straightforward implementation detail but worth a one-liner in §3.2 step 1 to prevent an ambiguous hand-off.


Focus Area 3 — Sequencing constraint with 6E

The analysis is sound. The doc correctly identifies the failure mode in §6.0:

"The reverse (6E before 8a) would leave 6E with no way to carry the cutover through a snapshot restore."

Tracing through: if 6E ships first, enable-raft-envelope populates raft_envelope_cutover_index in the sidecar. A snapshot taken on a 6E-only node still writes a v1 header (raft_envelope_cutover_index field doesn't exist yet in the write path). A follower restoring that snapshot gets cutover = 0 — it will not set up AEAD-unwrap routing for post-cutover entries, causing either silent decryption failures or misrouted entries. This is a correctness break, not just a warning-level issue.

The assertion in §1 (8a can compile + run before 6E, with cutover always 0 → always v1) also holds: the v2 write path is a dead branch until 6E ships, so 8a has no observable effect until 6E follows. No false positives in Phase 0/1.

The constraint "8a MUST ship before 6E" is correctly stated and well-motivated. No changes needed here.


Focus Area 4 — No-downgrade write rule

The rule IS load-bearing — specifically for this scenario: if any future code path or sidecar mutation could set raft_envelope_cutover_index back to 0 after it was non-zero (e.g., a hypothetical reseed or factory-reset operation), a writer following cutover == 0 → write v1 would produce a v1 snapshot from a Phase-2 node. A follower restoring that snapshot would configure cutover = 0 and then fail to unwrap AEAD-wrapped log entries. The doc correctly notes that rotation does NOT retract cutover in current semantics and flags this as "out of scope," but stops there.

What's missing is the enforcement mechanism. The statement "never silently downgrades" reads as a documentation note. The implementation should either:

  • Assert (log.Fatal / panic) if raft_envelope_cutover_index transitions from non-zero to zero on a running node — this makes the invariant loud and uncrossable, or
  • At minimum, treat the cutover != 0 observation as a one-way latch on the writer (once seen non-zero, always write v2 regardless of subsequent sidecar reads).

Neither approach is specified. Without specifying one, the implementation PR author may implement a simple if cutover == 0 { write v1 } that silently violates the invariant if the sidecar field ever resets. Recommend adding a sentence to §3.3 specifying which of the two enforcement approaches is expected. Fix this →


Focus Area 5 — Forward-compat hatch via len

The len-based intra-v2 extension is the right mechanism for optional metadata fields. The parent §4.4 establishes this correctly and the 8a design carries it faithfully.

One gap: the design doesn't distinguish optional vs. required future fields. The len-skip approach is safe only when future fields are advisory — old v2 readers can silently ignore them without producing incorrect behavior. If a future field affects FSM correctness (e.g., a flag that changes how the restore initializes the applier), an old v2 reader would skip it and silently produce wrong behavior. In that case the right move is EKVTHLC3, not a v2 payload extension.

The design should add one line to the forward-compat discussion (§3.1 or §7) to the effect of: "The len hatch is valid only for advisory/optional fields. Any future field that an old v2 reader must know about to restore correctly requires a new magic (EKVTHLC3)." This establishes the extension boundary for authors of future stages. Fix this →


Summary

# Finding Severity
§1 §4.5 cross-reference in "Out of scope" doesn't match the parent doc Low
§3.2 Gap A Short-stream behavior not specified (bufio.Peek < 8 bytes) Moderate
§3.2 Gap B len < 16 validation missing from algorithm; implied only by §5 test Low
§3.3 No-downgrade enforcement mechanism not specified Low
§3.1/§7 Forward-compat len hatch scope not bounded (optional vs. required fields) Low

The core architecture (distinct magic, v2 layout, 4-branch discriminator, 6E sequencing) is sound and approved by the parent. Addressing the two §3.2 gaps in the doc before the implementation PR starts will prevent the implementer from having to infer behavior from ambiguous specs.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request proposes a design document for Stage 8a, introducing Snapshot Header v2 to carry the raft_envelope_cutover_index field. This enables nodes restoring from a snapshot to correctly identify whether subsequent Raft log entries are plaintext or AEAD-wrapped. The review feedback highlights three important improvements: handling short streams during the 8-byte peek to prevent failing valid small legacy snapshots, validating that the payload length is at least 16 bytes in the v2 reader to avoid out-of-bounds panics, and clarifying the routing logic when cutover == 0 to prevent incorrectly attempting to unwrap plaintext entries.

Comment on lines +131 to +134
1. Peek the 8 leading bytes from the snapshot stream (`bufio.Reader`
or equivalent — the bytes MUST be replayable into the
inner-store reader if the discriminator falls through to the
headerless branch).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When peeking the 8 leading bytes, if the stream is shorter than 8 bytes (e.g., legacy empty or very small snapshots), Peek(8) will return io.EOF or io.ErrUnexpectedEOF.

To prevent failing the restore on valid small legacy snapshots, the design should specify how to handle these Peek errors:

  1. If Peek(8) returns io.EOF or io.ErrUnexpectedEOF, check the bytes actually read.
  2. If the read bytes (even if fewer than 8) have "EKVTHLC" as a prefix, fail-closed with a truncated header error.
  3. Otherwise, safely fall back to the headerless legacy path (step 5).

Comment on lines +135 to +141
2. `bytes.Equal(peeked, hlcSnapshotMagicV2[:])`:
- Consume the 8 magic bytes.
- Read 2 bytes → `len` (big-endian uint16).
- Read `len` payload bytes.
- Parse `ceiling` from the first 8 of payload, `cutover` from
the next 8, ignore any trailing bytes (forward-compat).
- Return `(ceiling, cutover, nil)`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The design should explicitly specify that the reader must validate len >= 16 before parsing ceiling and cutover. If len < 16, it should return a typed error (e.g., ErrSnapshotHeaderInvalidLength or similar) to prevent out-of-bounds slice access or panics when attempting to parse the 8-byte ceiling and 8-byte cutover from a shorter payload.

Comment on lines +183 to +188
- `cutover` is plumbed to the applier / engine pre-apply hook so
subsequent Raft entries route through the correct wrap/unwrap
path:
- Entries with `raftIdx < cutover` are pre-Phase-2 plaintext;
pass through.
- Entries with `raftIdx >= cutover` are AEAD-wrapped; unwrap.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In §3.4, the routing logic states:

  • Entries with raftIdx < cutover are pre-Phase-2 plaintext; pass through.
  • Entries with raftIdx >= cutover are AEAD-wrapped; unwrap.

However, if cutover == 0 (the default/pre-cutover state), a naive implementation of raftIdx >= cutover would evaluate to true for all entries (since raftIdx >= 0 is always true), leading to attempts to unwrap plaintext entries as AEAD-wrapped.

The design should explicitly clarify that the routing logic must first check if cutover != 0 (or cutover > 0) before applying the index-based routing, and if cutover == 0, all entries should be treated as plaintext/pre-cutover.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md (2)

77-84: ⚡ Quick win

Clarify the 8a/6E sequencing constraint.

Lines 77-81 state that 8a "does NOT require 6E to ship first," but lines 282-284 later state "8a MUST ship before 6E." While technically consistent (8a can compile without 6E merged, but must be deployed before 6E is enabled), the phrasing creates confusion.

Consider revising lines 79-81 to: "8a does not require 6E to be merged first — the v2 writer can compile and run with the sidecar field defined but always-zero — but 8a MUST be fully deployed before 6E is enabled in production."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md` around lines 77 -
84, Update the ambiguous phrasing around the 8a/6E sequencing: change the
sentence in the "Stage 6E" paragraph that currently reads "8a does NOT require
6E to ship first" to explicitly state that 8a can be merged/compiled before 6E
but must be deployed before 6E is enabled; reference the same concepts (8a, 6E,
the v2 writer, and raft_envelope_cutover_index) and replace the line with: "8a
does not require 6E to be merged first — the v2 writer can compile and run with
the sidecar field defined but always-zero — but 8a MUST be fully deployed before
6E is enabled in production."

173-176: ⚡ Quick win

Rephrase to clarify the write path is stateless.

Lines 173-176 state "subsequent snapshots from this load stay on v2," which implies the writer maintains state. However, the write path is stateless and simply checks the sidecar's raft_envelope_cutover_index each time.

Suggest rephrasing: "Because the sidecar's raft_envelope_cutover_index, once set non-zero by enable-raft-envelope, is never cleared back to 0 by rotation (out of scope for current semantics), subsequent snapshots will continue to use the v2 layout without requiring writer-side state."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md` around lines 173 -
176, Reword the sentence to clarify that the writer is stateless and simply
reads the sidecar flag each time: replace the phrase asserting "subsequent
snapshots from this load stay on v2" with a statement explaining that because
the sidecar's raft_envelope_cutover_index (set non-zero by enable-raft-envelope)
is not cleared by rotation under current semantics, each snapshot writer will
observe that flag and continue producing v2-layout snapshots without any
writer-side state or special tracking; mention raft_envelope_cutover_index,
enable-raft-envelope, rotation and v2 explicitly to make the mechanism and
responsibilities clear.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md`:
- Around line 117-119: After reading the uint16 `len` in the snapshot v2 read
path (the function handling ReadSnapshotHeader_V2 / the coded reader for §3.2),
validate that len is within bounds 16 <= len <= MAX_HEADER_PAYLOAD (define
MAX_HEADER_PAYLOAD = 1024 or suitable constant) and if not return a typed error
ErrSnapshotHeaderLenOutOfBounds; add a unit test
TestReadSnapshotHeader_V2WithLenTooLarge that constructs a v2 header with len =
0xFFFF and asserts the reader returns ErrSnapshotHeaderLenOutOfBounds to prevent
excessive allocation/IO on malformed headers.
- Around line 126-161: Clarify ReadSnapshotHeader's behavior for short streams:
update the algorithm (or add a step 0) in the ReadSnapshotHeader description to
state "Peek up to 8 bytes; if the reader returns fewer than 8 bytes (EOF or
io.ErrUnexpectedEOF), treat the snapshot as headerless legacy, do not consume
those bytes, and pass the available bytes through to the inner-store restore."
Reference ReadSnapshotHeader and kv/fsm.go::Restore and ensure this behavior
aligns with existing tests TestFSMSnapshotRestoreOldFormat and
TestFSMSnapshotRestoreSmallLegacy.

---

Nitpick comments:
In `@docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md`:
- Around line 77-84: Update the ambiguous phrasing around the 8a/6E sequencing:
change the sentence in the "Stage 6E" paragraph that currently reads "8a does
NOT require 6E to ship first" to explicitly state that 8a can be merged/compiled
before 6E but must be deployed before 6E is enabled; reference the same concepts
(8a, 6E, the v2 writer, and raft_envelope_cutover_index) and replace the line
with: "8a does not require 6E to be merged first — the v2 writer can compile and
run with the sidecar field defined but always-zero — but 8a MUST be fully
deployed before 6E is enabled in production."
- Around line 173-176: Reword the sentence to clarify that the writer is
stateless and simply reads the sidecar flag each time: replace the phrase
asserting "subsequent snapshots from this load stay on v2" with a statement
explaining that because the sidecar's raft_envelope_cutover_index (set non-zero
by enable-raft-envelope) is not cleared by rotation under current semantics,
each snapshot writer will observe that flag and continue producing v2-layout
snapshots without any writer-side state or special tracking; mention
raft_envelope_cutover_index, enable-raft-envelope, rotation and v2 explicitly to
make the mechanism and responsibilities clear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4fe302dd-6c51-4206-b67d-ded700380f34

📥 Commits

Reviewing files that changed from the base of the PR and between 446a60f and f2ad475.

📒 Files selected for processing (1)
  • docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md

Comment thread docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md
Comment thread docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md
…claude moderate/low

Six findings from PR #877 round-1 reviews on the 8a design doc:

1. (coderabbit MAJOR / gemini medium / claude moderate) Short-stream Peek
   behavior. The §3.2 read-path algorithm didn't specify what happens when
   the input stream has fewer than 8 bytes; today's TestFSMSnapshotRestoreSmallLegacy
   exercises this case. Added step 0 to the algorithm: Peek(8) returning
   fewer than 8 bytes falls through to the headerless-legacy branch with
   partial bytes left in the bufio.Reader. Added
   TestReadSnapshotHeader_ShortStreamFallsBackToLegacy covering 0/4/7-byte
   sub-cases.

2. (coderabbit MAJOR) `len` upper-bound DoS hardening. A malformed snapshot
   could specify len=0xFFFF and force a 64 KiB allocation per restore.
   §3.2 step 2 now mandates 16 <= len <= maxSnapshotHeaderPayload (suggested
   1024); both bounds return ErrSnapshotHeaderInvalidLength. Added
   TestReadSnapshotHeader_V2WithLenTooLargeFails.

3. (gemini medium / claude low Gap B) `len >= 16` lower bound. Same typed
   error path; algorithm explicitly says "len < 16 → error" rather than
   the implicit panic-on-slice-indexing of the round-0 spec. The existing
   TestReadSnapshotHeader_V2WithLenMismatchFails was renamed to
   TestReadSnapshotHeader_V2WithLenTooShortFails for clarity.

4. (gemini medium) cutover==0 routing edge case in §3.4. A naive
   `raftIdx >= cutover` always true when cutover=0. Now a two-step
   routing logic with the `if cutover == 0 → all plaintext` precondition
   guard explicitly first.

5. (claude moderate / §3.3) No-downgrade enforcement was a documentation
   note, not a specified mechanism. Added a per-process-load latch: once
   the writer observes a non-zero raft_envelope_cutover_index, it stays
   on v2 even if a future sidecar read returns 0 (defends against silent
   data corruption from hypothetical reseed/factory-reset paths).
   log.Error (not Fatal — snapshot goroutine context) on the non-zero→0
   transition. Added TestWriteSnapshotHeader_NoDowngradeAfterCutoverSeen.

6. (claude low §1) Cross-reference correction. The round-0 doc said WAL
   coverage is parent §4.5, but parent §4.5 is "Distribution catalog and
   HLC ceiling entries"; WAL-file encryption is not yet detailed in any
   §4.x. Rewrote the Out-of-scope bullet to acknowledge the parent label
   discrepancy.

7. (claude low §3.1) Forward-compat boundary. Added §3.1.1 spelling out
   that the len-skip hatch is only safe for ADVISORY/OPTIONAL fields;
   any mandatory future field requires EKVTHLC3, not a v2 payload
   extension.

Architecture and scope slicing unchanged. The core read/write paths,
6E sequencing constraint, and rollout-without-migration property all
stand as round-0.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 29, 2026

Round-1 review feedback addressed in 8583991 (7 findings across coderabbit major / gemini medium / claude moderate/low):

  1. Short-stream Peek (coderabbit major Update dependency go to v1.21.4 #2 + gemini medium Configure Renovate #1 + claude moderate Gap A) — §3.2 now has explicit step 0 for Peek(8) returning fewer than 8 bytes → fall through to headerless-legacy with partial bytes preserved. New test TestReadSnapshotHeader_ShortStreamFallsBackToLegacy covering 0/4/7-byte sub-cases.
  2. len upper bound (coderabbit major Configure Renovate #1) — maxSnapshotHeaderPayload (suggested 1024) gates the read. Both bounds (len < 16 and len > max) return ErrSnapshotHeaderInvalidLength. New test TestReadSnapshotHeader_V2WithLenTooLargeFails.
  3. len >= 16 lower bound (gemini medium Update dependency go to v1.21.4 #2 + claude low Gap B) — same typed error path; algorithm explicitly says len < 16 → error.
  4. cutover == 0 routing edge (gemini medium Update docker/setup-qemu-action action to v3 #3) — §3.4 routing logic is now two-step with the cutover == 0 → all plaintext precondition explicitly first; the naive raftIdx >= cutover always-true edge is now impossible.
  5. No-downgrade enforcement (claude moderate §3.3) — added per-process-load latch: once the writer observes cutover != 0, stays on v2 even if a future sidecar read returns 0. log.Error (not Fatal — snapshot goroutine context) on the non-zero→0 transition. New test TestWriteSnapshotHeader_NoDowngradeAfterCutoverSeen.
  6. §4.5 cross-reference (claude low Configure Renovate #1) — Out-of-scope bullet rewritten to acknowledge the parent label discrepancy (parent §4.5 is 'Distribution catalog and HLC ceiling entries', not WAL coverage).
  7. Forward-compat boundary (claude low §3.1/§7) — new §3.1.1 spelling out that the len-skip hatch is only safe for advisory/optional fields; mandatory future fields require EKVTHLC3.

Architecture and scope slicing unchanged.

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Claude finished @bootjp's task in 3m 51s —— View job


Round-2 Review: Stage 8a — Snapshot Header v2 Design

Todo list

  • Load tools
  • Read updated design doc (commit 8583991)
  • Verify all 7 fixes
  • Post comprehensive round-2 review

Verification of the 7 claimed fixes

All 7 are correctly implemented. Details below.

Fix 1 — Short-stream Peek / step 0 + test

§3.2 step 0 correctly specifies the fall-through to step 5 when Peek(8) returns fewer than 8 bytes, with the partial bytes left in the buffer. Step 5's condition was updated to say "no EKVTHLC prefix at all, OR fewer than 8 bytes available per step 0" — the cross-reference between step 0 and step 5 is tight and unambiguous. The new test TestReadSnapshotHeader_ShortStreamFallsBackToLegacy covers 0/4/7-byte sub-cases correctly.

Fix 2 — len upper bound (maxSnapshotHeaderPayload) + test

§3.2 step 2 now requires len <= maxSnapshotHeaderPayload (suggested 1024) and returns ErrSnapshotHeaderInvalidLength on violation. The rationale (DoS hardening, preventing 64 KiB allocation) is cited. TestReadSnapshotHeader_V2WithLenTooLargeFails pins it.

Fix 3 — len >= 16 lower bound + typed error

Same step 2 now also requires len >= 16 and returns ErrSnapshotHeaderInvalidLength when violated, with an explicit "rather than a downstream io.EOF / panic on slice indexing" rationale. TestReadSnapshotHeader_V2WithLenTooShortFails covers len = 0x08.

Fix 4 — cutover == 0 routing edge (§3.4)

§3.4 now has the two-step precondition with cutover == 0 as the explicit first branch. The text calls out by name the "naive raftIdx >= cutover test would always be true at cutover == 0" failure mode. The guard is load-bearing and clearly labeled.

Fix 5 — No-downgrade enforcement latch + log.Error + test

§3.3 now specifies the per-process-load latch mechanism ("in-memory state on the writer remembers 'I have seen non-zero cutover'"), the log.Error (not Fatal) on the non-zero → zero sidecar transition, and the rationale for not using Fatal (snapshot goroutine context). TestWriteSnapshotHeader_NoDowngradeAfterCutoverSeen pins the latch by simulating an artificial sidecar reset mid-process.

One observation worth noting for the implementation: the per-process-load latch does not survive restart. The doc correctly acknowledges this ("Process restart re-reads the sidecar") — a legitimate pre-Phase-2 node that never saw a non-zero cutover never engages the latch. This is acceptable, and the scenario where a sidecar legitimately resets to 0 post-Phase-2 is out of scope.

Fix 6 — §4.5 cross-reference rewrite

The Out-of-scope WAL bullet now explains the discrepancy explicitly: "the parent doc's Stage 8 table row labels both slices as '… (§4.4, §4.5)', but §4.5 in the parent is actually 'Distribution catalog and HLC ceiling entries'; WAL-file encryption is a separate concern not yet detailed in any §4.x." The old misdirecting (§4.5) reference is gone.

Fix 7 — Forward-compat §3.1.1 advisory/mandatory boundary

New §3.1.1 clearly states: len-skip extension is only safe for advisory fields; fields that an old v2 reader must understand for correct restore behavior require a new magic (EKVTHLC3). The back-pointer to the discriminator step is also present.


Three remaining issues

Issue 1 — §3.1.1 has a forward reference labeled "above" (low)

§3.1.1 line 145 says:

"The discriminator in ReadSnapshotHeader is the gatekeeper for the 'I do not understand this version' fail-closed branch (step 4 above)."

§3.1.1 is a subsection of §3.1 and appears before §3.2, where step 4 is defined. This should be "(step 4 below)" or "(see §3.2, step 4)". A reader following the reference "step 4 above" will scroll backward and find nothing. Fix this →

Issue 2 — bufio.Reader ownership is ambiguous for v1/v2 branches (low, but implementation-critical)

§3.2 preamble (lines 149–154) says:

"The reader takes ownership of a bufio.Reader wrapping the input stream and MUST pass the same bufio.Reader to the inner-store restore on the headerless-legacy branch."

The phrase "takes ownership of" doesn't make it clear whether ReadSnapshotHeader creates the bufio.Reader internally or receives one as a parameter. If the function creates it internally and returns only (ceiling, cutover, err), the caller has no access to the bufio.Reader for the inner-store pass-through on any branch — including v1 and v2, where the header bytes are consumed from the bufio.Reader's internal buffer but the remaining inner-store bytes may still be sitting in that buffer (not yet read from the underlying io.Reader). If the caller then passes the original io.Reader to the inner store, those buffered bytes are silently lost.

The correct API shape is: ReadSnapshotHeader(r *bufio.Reader) (uint64, uint64, error) — the caller creates and owns the bufio.Reader, passes it to ReadSnapshotHeader, then passes the same r to the inner-store restore call regardless of which branch was taken (headerless, v1, or v2). Adding one sentence to the preamble stating this explicitly would prevent the "use original io.Reader for v1/v2 branches" implementation mistake.

Fix this →

Issue 3 — §6 rollout: pre-8a binary reading a v2 snapshot (very low, documentation precision)

Lines 360–365 describe a pre-8a binary reading a v2 snapshot and say it "would fall into the headerless-legacy branch (no EKVTHLC1 match)." This is technically correct for the specific pre-8a reader if it peeks and falls through on mismatch. However, the text skips over an important detail: EKVTHLC2 does have the EKVTHLC prefix, so a pre-8a reader that checks bytes.HasPrefix(peeked, []byte("EKVTHLC")) would take a different branch than a reader that only checks for EKVTHLC1. Since the pre-8a code does not have step 4's "unknown magic" branch, it would likely see "not EKVTHLC1" and fall to legacy — but the 8 magic bytes are in the stream and would confuse the inner store.

The net result is the same (loud failure, not silent corruption), and the doc's conclusion is correct. This is a precision note, not a behavioral gap.


Summary

# Finding Round Status
Fix 1 Short-stream step 0 + test R2 verify ✅ Correct
Fix 2 len upper bound + test R2 verify ✅ Correct
Fix 3 len >= 16 lower bound + typed error R2 verify ✅ Correct
Fix 4 cutover == 0 two-step precondition R2 verify ✅ Correct
Fix 5 No-downgrade latch + log.Error + test R2 verify ✅ Correct
Fix 6 §4.5 cross-reference R2 verify ✅ Correct
Fix 7 §3.1.1 advisory/mandatory boundary R2 verify ✅ Correct
New 1 §3.1.1 "step 4 above" → forward ref Low Open
New 2 bufio.Reader ownership ambiguous for v1/v2 Low Open
New 3 §6 pre-8a/v2 scenario precision Very low Open

The spec is substantially complete. New issues 1 and 2 are worth a follow-up edit before the implementation PR starts — issue 2 in particular can prevent a subtle "lost buffered bytes" bug if the implementer reads the preamble too quickly. Issue 1 is a trivial one-word fix.

… forward-ref + pre-8a precision

Three follow-ups from claude round-2 review on PR #877 (round-2 verdict: all 7
round-1 fixes verified correct; these are new low findings):

1. (Low — implementation-critical) §3.2 bufio.Reader ownership was ambiguous.
   'takes ownership of' didn't make clear whether ReadSnapshotHeader creates
   the bufio.Reader internally or accepts one as a parameter. Rewrote the
   preamble to specify the API signature (caller-owned bufio.Reader passed in)
   AND the load-bearing invariant: the caller MUST pass the SAME *bufio.Reader
   to the inner-store restore on ALL branches (v1, v2, headerless), not just
   headerless. Reason: bufio.Reader may have read more from the underlying
   io.Reader than it returned (opportunistic buffer fill); inner-store bytes
   can sit in the buffer between header parse and inner-store read; switching
   to the original io.Reader after header parsing silently drops those bytes.

2. (Low — trivial) §3.1.1 forward-reference. 'step 4 above' is wrong since
   §3.1.1 appears before §3.2 where step 4 is defined. Changed to '(see §3.2,
   step 4)'.

3. (Very low — precision) §6 pre-8a binary reading a v2 snapshot. Added a
   parenthetical noting that pre-8a code has no 'unknown EKVTHLC* magic'
   branch, so the EKVTHLC prefix in EKVTHLC2 does NOT trigger a typed error
   on pre-8a readers — the fail-closed guarantee in §3.2 step 4 is a property
   of the 8a reader only. Outcome unchanged (loud inner-store failure, not
   silent corruption); precision note for documentation accuracy.

Architecture and core spec unchanged.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 30, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 3m 58s —— View job


Round-3 Review: Stage 8a — Snapshot Header v2 Design

Todo list

  • Load tools
  • Read current design doc (post round-2 fixes, commit 4c94b1c)
  • Verify round-2 fixes: forward-ref, bufio.Reader ownership, §6 pre-8a precision
  • Full independent review pass for any remaining issues
  • Post comprehensive review

Round-2 fix verification

All three round-2 issues are correctly addressed.

Fix 1 — §3.1.1 forward ref
Line 145: "…fail-closed branch (see §3.2, step 4)." — the old "(step 4 above)" is gone.

Fix 2 — bufio.Reader ownership
Lines 152–169 are now explicit: the caller creates and passes the *bufio.Reader; the caller MUST pass the same r to the inner-store restore on all branches (v1, v2, and headerless), with the buffered-bytes-loss hazard explained by name. The function signature ReadSnapshotHeader(r *bufio.Reader) is stated inline. This is the right shape.

Fix 3 — §6 pre-8a/v2 precision
Lines 382–384 add the parenthetical: "(Note: pre-8a code has no 'unknown EKVTHLC magic' branch, so the EKVTHLC prefix in EKVTHLC2 does not trigger a typed error there. The fail-closed guarantee in §3.2 step 4 is a property of the 8a reader only.)"* — the loud-failure vs. typed-error distinction is now unambiguous.


Remaining findings

Finding 1 — §4.5 label fixed in §1 but not in two other locations (low)

The out-of-scope section (§1, lines 71–77) correctly explains that the parent's §4.5 is "Distribution catalog and HLC ceiling entries", not WAL coverage. However, the same mislabeling persists in two places that were not updated:

  • Metadata table, line 9: | Sibling slice | Stage 8b — WAL coverage (§4.5). Deferred to a separate design and PR. |
  • §7, line 400: - **Stage 8b** — §4.5 WAL coverage (Raft log encryption on disk).

A reader who lands on §7 directly (or skims the metadata block) sees §4.5 WAL coverage and looks up §4.5 in the parent to find "Distribution catalog and HLC ceiling entries" — the same confusion the §1 fix was intended to prevent. The out-of-scope correction is isolated to one section.

The simplest fix: drop the §4.x qualifier in both locations, since the out-of-scope section already establishes that 8b will introduce its own §4.x. Fix this →


Summary

# Finding Round Status
R1-Fix 1 Short-stream step 0 + test R3 verify ✅ Correct
R1-Fix 2 len upper bound + test R3 verify ✅ Correct
R1-Fix 3 len >= 16 lower bound + typed error R3 verify ✅ Correct
R1-Fix 4 cutover == 0 two-step precondition R3 verify ✅ Correct
R1-Fix 5 No-downgrade latch + log.Error + test R3 verify ✅ Correct
R1-Fix 6 §4.5 cross-reference in out-of-scope R3 verify ✅ Correct (but see R3-1)
R1-Fix 7 §3.1.1 advisory/mandatory boundary R3 verify ✅ Correct
R2-Fix 1 §3.1.1 forward ref "(see §3.2, step 4)" R3 verify ✅ Correct
R2-Fix 2 bufio.Reader ownership — caller creates, all-branches rule R3 verify ✅ Correct
R2-Fix 3 §6 pre-8a/v2 no-typed-error note R3 verify ✅ Correct
R3-1 §4.5 label persists in metadata table + §7 (only §1 was updated) Low Open

The spec is complete. The one remaining open item (R3-1) is a two-word fix — dropping §4.5 from the metadata table and §7 to match the correction already in §1. The architecture, read-path algorithm, write-path enforcement, and sequencing constraint are all sound and ready for the implementation PR.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 30, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 2m 37s —— View job


Round-3 Review: Stage 8a — Snapshot Header v2 Design

Todo list

  • Load tools
  • Read current design doc (post round-2 fixes, commit 4c94b1c)
  • Verify round-2 remaining issues: forward-ref, bufio.Reader ownership, §6 pre-8a precision
  • Full independent review pass for any remaining issues
  • Post comprehensive review

Verification of round-2 remaining issues

Issue 1 (round-2) — §3.1.1 "step 4 above" → forward ref ✅ Fixed

Line 144–145 now reads:

"The discriminator in ReadSnapshotHeader is the gatekeeper for the 'I do not understand this version' fail-closed branch (see §3.2, step 4)."

The forward reference "(step 4 above)" is gone. ✅

Issue 2 (round-2) — bufio.Reader ownership ambiguous for v1/v2 branches ✅ Fixed

Lines 149–170 now include a load-bearing preamble titled "API shape and bufio.Reader ownership" that:

  • Explicitly states the caller creates the bufio.Reader (not ReadSnapshotHeader)
  • Shows the exact signature: func ReadSnapshotHeader(r *bufio.Reader) (ceiling, cutover uint64, err error)
  • States the caller MUST pass the same r on all branches (v1, v2, and headerless-legacy — not just headerless)
  • Explains the mechanism: opportunistic buffer-fill means inner-store bytes can sit in the bufio.Reader between header consumption and inner-store read; switching to the original io.Reader silently loses them

This is the right place, phrased correctly, and will prevent the "use original io.Reader for v1/v2" implementation mistake. ✅

Issue 3 (round-2) — §6 pre-8a/v2 scenario precision ✅ Fixed

Lines 372–384 now include: "(Note: pre-8a code has no 'unknown EKVTHLC* magic' branch, so the EKVTHLC prefix in EKVTHLC2 does not trigger a typed error there. The fail-closed guarantee in §3.2 step 4 is a property of the 8a reader only.)"

This closes the precision gap — the pre-8a path is now accurately described (falls to legacy, passes EKVTHLC2 bytes into inner store, inner store fails to parse, loud failure not silent corruption). ✅


Fresh independent pass

One residual inconsistency found (low)

§1 Out-of-Scope correctly explains (lines 71–77) that the parent doc's §4.5 label does NOT actually describe WAL coverage:

"The parent doc's Stage 8 table row labels both slices as '… (§4.4, §4.5)', but §4.5 in the parent is actually 'Distribution catalog and HLC ceiling entries'; WAL-file encryption is a separate concern not yet detailed in any §4.x"

However, this fix was not propagated to the two shorthand references that still carry the stale §4.5 label:

  • Header table (line 9): Stage 8b — WAL coverage (§4.5). Deferred to a separate design and PR.
  • §7 (line 400): **Stage 8b** — §4.5 WAL coverage (Raft log encryption on disk).

A reader following either of those references to §4.5 in the parent doc finds "Distribution catalog and HLC ceiling entries" — which would be confusing without first reading §1's footnote. Since §1 already has the correct explanation, both shorthand occurrences should drop the (§4.5) label (or replace it with a note like (no §4.x yet — 8b will introduce one)). Fix this →


Summary

# Finding Round Status
R2 Issue 1 §3.1.1 forward ref "(step 4 above)" R2 open ✅ Fixed
R2 Issue 2 bufio.Reader ownership ambiguous R2 open ✅ Fixed
R2 Issue 3 §6 pre-8a/v2 EKVTHLC prefix precision R2 open ✅ Fixed
New Header table + §7 still say (§4.5) for 8b/WAL Low Open

The spec is substantively complete and correct. All moderate and implementation-critical issues from prior rounds are resolved. The single remaining item is a low-severity label inconsistency between §1's correct footnote and two shorthand references that weren't updated to match. Safe to approve once that is cleaned up (or defer to the implementation PR as a doc touch-up).

…and for Stage 8b WAL coverage

The §1 Out-of-Scope footnote correctly explains the parent doc's stale
"(§4.5)" label for 8b — but two shorthand references (header table line 9
and §7 line 400) still carried the bare "(§4.5)" label, sending a reader
who follows them to the parent's "Distribution catalog and HLC ceiling
entries" section. Drop the §4.5 label and cross-reference §1 instead so
the shorthand matches the footnote.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 30, 2026

Addressed round-3 LOW finding in 0266818:

  • Header table line 9: drop bare (§4.5) → cross-reference §1 Out-of-Scope.
  • §7 line 400: same shorthand → same cross-reference.

The §1 Out-of-Scope footnote already correctly explains that the parent doc's (§4.5) label for 8b/WAL coverage is stale (parent §4.5 is actually "Distribution catalog and HLC ceiling entries"). Both shorthand references now match.

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 1m 44s —— View job


Round-4 Review: Stage 8a — Snapshot Header v2 Design

Todo list

  • Load tools
  • Read current design doc (post round-3 fix, commit 0266818)
  • Verify R3-1: §4.5 label dropped from metadata table + §7, replaced with §1 cross-reference
  • Full independent review pass for any remaining issues
  • Post comprehensive review

Verification of R3-1

R3-1 — §4.5 label in metadata table + §7 ✅ Fixed

Metadata table (line 9):

Stage 8b — WAL coverage (no §4.x yet; see §1 Out-of-Scope). Deferred to a separate design and PR.

The stale (§4.5) label is gone. The cross-reference directs readers to §1 Out-of-Scope where the discrepancy is explained. ✅

§7 (line 400–401):

**Stage 8b** — WAL coverage (Raft log encryption on disk; no §4.x in the parent yet — see §1 Out-of-Scope). A separate design and PR; not blocked on 8a.

Same treatment — (§4.5) replaced with the parenthetical and §1 cross-reference. ✅

Both shorthand references now match §1's correct explanation.


Fresh independent pass

One minor observation — not a blocker.

§3.2 step 1 description uses bytes.Equal but step 4 uses bytes.HasPrefix (very low)

Step 1 (line 179–180) reads:

"With exactly 8 bytes peeked, discriminate via bytes.Equal on the 8 leading bytes:"

Steps 2 and 3 do use bytes.Equal. Step 4, however, uses bytes.HasPrefix, not bytes.Equal. A careful reader of step 1 who then lands on step 4 might briefly wonder why the branch doesn't use bytes.Equal as advertised. This is purely a documentation precision note — the algorithm is unambiguous and the bytes.HasPrefix usage in step 4 is correct and intentional (7-byte prefix, not 8-byte exact match). No fix required before the implementation PR; an implementer reading the full step 4 will understand without confusion.

No other issues found.


Summary

# Finding Round Status
R1–R2 all All prior moderate/implementation-critical issues R3 verify ✅ Resolved
R2 Fix 1 §3.1.1 forward ref "(see §3.2, step 4)" R3 verify ✅ Correct
R2 Fix 2 bufio.Reader ownership — caller creates, all-branches rule R3 verify ✅ Correct
R2 Fix 3 §6 pre-8a/v2 no-typed-error note R3 verify ✅ Correct
R3-1 §4.5 label — metadata table + §7 R4 verify ✅ Fixed
R4 obs. Step 1 says bytes.Equal; step 4 uses bytes.HasPrefix Very low Non-blocking

The design doc is complete and correct. All moderate and implementation-critical issues from prior rounds are resolved; both shorthand §4.5 references are updated to match §1's explanation. The architecture (distinct magic, v2 layout, 6-branch read-path, 6E sequencing, no-downgrade latch) is sound and ready for the implementation PR.

@bootjp bootjp merged commit bd14165 into main May 30, 2026
9 checks passed
@bootjp bootjp deleted the feat/encryption-8a-snapshot-header-v2-design branch May 30, 2026 07:10
bootjp added a commit that referenced this pull request May 30, 2026
…SnapshotHeader API + no-downgrade latch (#886)

## Summary

Implements Stage 8a — the snapshot header v2 carrier for
`raft_envelope_cutover_index`. Lands on top of the merged design doc
(`docs/design/2026_05_29_proposed_8a_snapshot_header_v2.md`, #877). No
on-disk migration; v1 snapshots restore byte-for-byte under the new
build, and only Phase-2 nodes ever emit v2.

### New API surface

- `ReadSnapshotHeader(r *bufio.Reader) (ceiling, cutover uint64, err
error)` — the 6-branch §3.2 reader. Caller-owned `bufio.Reader` is
passed unchanged into the inner store on every branch (the
bufio-buffering-loses-bytes failure mode flagged in PR #877 review).
- `CutoverSource` interface + `WithCutoverSource(...)` FSM option —
writer-side view of the sidecar's cutover index.
- `noDowngradeLatch` (process-load) — §3.3's "once non-zero observed →
always v2" invariant. Preserves the last non-zero cutover across
hypothetical sidecar resets and slogs an Error once on the non-zero→0
transition.
- `kvFSM.RestoredCutover()` — test seam for the §3.4 snapshot-to-applier
handoff. Stage 6E will consume this via the apply hook.

### Read-path branches (all covered by tests)

| Input | Result |
|---|---|
| v1 magic `EKVTHLC1` | `(ceiling, 0, nil)` |
| v2 magic `EKVTHLC2` + valid len | `(ceiling, cutover, nil)`, trailing
payload bytes skipped |
| `EKVTHLC*` w/ unknown version byte | `(0, 0,
ErrSnapshotHeaderUnknownMagic)` — fail-closed |
| v2 magic + `len < 16` or `len > 1024` | `(0, 0,
ErrSnapshotHeaderInvalidLength)` — fail-closed (DoS bound) |
| No `EKVTHLC` prefix | `(0, 0, nil)` — headerless legacy, bytes
preserved in `bufio.Reader` |
| Stream `< 8` bytes | `(0, 0, nil)` — short-stream fallback, bytes
preserved |

### Verification

- 11 new tests in `kv/snapshot_test.go` per design §5.
- 4 pre-8a regression tests (TestSnapshot,
TestFSMSnapshotPreservesCeiling, TestFSMSnapshotRestoreOldFormat,
TestFSMSnapshotRestoreSmallLegacy) stay green.
- `go test -race ./kv/...` clean.
- `golangci-lint --config=.golangci.yaml run ./kv/...` 0 issues.

### Self-review (5-lens)

1. **Data loss** — v1 + headerless restore byte-for-byte
(TestWriteSnapshotHeader_PreCutoverWritesV1 pins the v1 bytewise output;
TestFSMSnapshotRestoreOldFormat / TestFSMSnapshotRestoreSmallLegacy stay
green).
2. **Concurrency / distributed failures** — `noDowngradeLatch` uses
`atomic.Uint64`; v1/v2 selection captured at Snapshot() time so
concurrent sidecar mutation cannot flip the format mid-write.
3. **Performance** — header is a stack-allocated `[26]byte`; v2 payload
alloc bounded by `maxSnapshotHeaderPayload = 1024`. Reader uses
`bufio.Reader.Discard` / `io.ReadFull`; no per-byte copies.
4. **Data consistency** — no-downgrade latch enforces §3.3; cutover
plumbed through `kvFSM.restoredCutover` for Stage 6E. `cutover == 0`
from a v2 snapshot routes correctly via Stage 6E's precondition guard.
5. **Test coverage** — every read-path branch + write-path selector +
latch + restore-side handoff has a dedicated test. Bytewise pin on the
v1 output guards against any future regression.

## Test plan

- [x] `go test -race ./kv/...` clean
- [x] `golangci-lint --config=.golangci.yaml run ./kv/...` clean
- [x] All 11 new tests + 4 legacy regression tests pass
- [ ] @claude review for the read-path branch correctness, the
no-downgrade latch semantics, and the §3.4 cutover-handoff API
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant