Skip to content

docs(encryption): Stage 6E design proposal — enable-raft-envelope admin RPC + Phase-2 cutover#893

Merged
bootjp merged 2 commits into
mainfrom
feat/encryption-6e-design
May 30, 2026
Merged

docs(encryption): Stage 6E design proposal — enable-raft-envelope admin RPC + Phase-2 cutover#893
bootjp merged 2 commits into
mainfrom
feat/encryption-6e-design

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 30, 2026

Summary

Stage 6E design proposal — lands enable-raft-envelope admin RPC + the §7.1 Phase-2 raft cutover end-to-end. Unblocked by Stage 8a (#886), which shipped the v2 snapshot reader that 6E depends on for mid-Phase-2 follower catch-up.

Why now

Every prerequisite has shipped:

  • 6D — Phase-1 storage envelope cutover
  • 7 — Writer registry + deterministic nonce
  • 8a — Snapshot header v2 cutover carriage

6E is the missing piece between sidecar-recorded cutover and on-the-wire wrap/unwrap.

Sliced into 3 implementation milestones

Milestone Scope Why this slice
6E-1 Admin RPC + sidecar plumbing (no behavior change) Records cutover index but no wrap/unwrap yet — safe to ship alone
6E-2 Engine unwrap + coord wrap + quiescence barrier The three pieces are interlocked at the cluster-halt boundary; cannot split
6E-3 6C-4 fail-closed guards (divergence, not-bootstrapped, epoch-out-of-range) Dead code without an active Phase 2; bundles after 6E-2

Load-bearing details documented

  • Why the barrier's source-tag exemption is required — a global Propose mutex would deadlock on the cutover's own proposal.
  • Why dispatch is strict-greater-than index > cutover — the cutover entry at index == cutover MUST NOT be unwrapped (chicken/egg with the sidecar update).
  • Why decodeProposalEnvelope MUST run before unwrap — wrapping entry.Data itself would clobber the proposal-ID handoff to resolveProposal; every coordinator write would time out forever.
  • Why 6E-2's three pieces cannot ship separately — either ordering produces cluster-wide halt-apply at cutover.

Verification action items

8 + 8 + 3 tests across the three milestones, with explicit 5-lens self-review focus per milestone (data loss for 6E-2's barrier window, data consistency for 6E-3's startup divergence refusal).

Test plan

  • Design doc reviewable on its own (no implementation in this PR).
  • @claude review for the milestone breakdown, the quiescence-barrier sequencing, and the strict-> index dispatch rule.
  • Implementation lands in three follow-on PRs (6E-1 → 6E-2 → 6E-3) per CLAUDE.md design-doc-first workflow.

…in RPC + Phase-2 cutover

Lands the design for the §7.1 Phase-2 raft cutover: admin RPC,
sidecar cutover-index recording, engine apply-hook unwrap,
coordinator wrap-on-propose, and the §7.1 6-step proposal-
quiescence barrier that prevents the unwrap path from seeing
a plaintext entry at index > cutover.

Sliced into three sequential implementation milestones:
  - 6E-1: Admin RPC + sidecar plumbing (no behavior change)
  - 6E-2: Engine unwrap + coord wrap + quiescence barrier
    (the atomic flip — pieces cannot ship separately)
  - 6E-3: 6C-4 Phase-2-specific fail-closed guards

Documents the load-bearing details from the parent design:
- Why the barrier's source-tag exemption is required (a global
  gate would deadlock on the cutover proposal itself).
- Why the dispatch is strict-greater-than (the cutover entry
  at index == cutover MUST NOT be unwrapped — chicken/egg).
- Why decodeProposalEnvelope MUST run before unwrap (clobbers
  the proposal-ID handoff otherwise; every coordinator write
  would time out forever).
- Why 6E-2's three pieces cannot ship separately (either
  ordering produces a cluster-wide halt-apply at cutover).
- Why 6C-4 guards bundle into 6E-3 not 6E-1 (dead code
  without an active Phase 2 to gate on).

Verification action items per milestone (8 + 8 + 3 tests
across 6E-1 / 6E-2 / 6E-3) and 5-lens self-review focus
per milestone.

Builds on Stage 8a (#886) — 8a's v2 snapshot reader already
carries the cutover across snapshot restore, so a fresh
follower joining mid-Phase-2 reconstructs the dispatch
boundary without operator intervention. Sequencing: 8a MUST
ship before 6E (already satisfied).
@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 30, 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 29 minutes and 22 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: 704be204-6044-4609-ae3a-009ca9091379

📥 Commits

Reviewing files that changed from the base of the PR and between d298cbb and dd0beb2.

📒 Files selected for processing (1)
  • docs/design/2026_05_31_proposed_6e_enable_raft_envelope.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/encryption-6e-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 30, 2026

@claude review

Specifically please verify:

  • The milestone breakdown is correct (6E-1 is safe to ship alone with no behavior change; 6E-2's three pieces truly cannot ship separately; 6E-3 guards bundle correctly).
  • The §7.1 6-step quiescence-barrier sequence — particularly the source-tag exemption rationale and the strict-greater-than index dispatch rule (index > cutover, not >=).
  • The decodeProposalEnvelope ordering invariant (must run BEFORE unwrap so the proposal-ID handoff to resolveProposal stays intact).
  • The sequencing claim that 8a (feat(encryption): Stage 8a implementation — snapshot header v2 + ReadSnapshotHeader API + no-downgrade latch #886) being already shipped is sufficient to unblock 6E (snapshot v2 reader carries cutover across mid-Phase-2 follower joins).

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 7m 23s —— View job


Stage 6E Design Review

Tasks:


Summary

The core design is sound. The quiescence-barrier logic, the strict-> dispatch rule, and the decodeProposalEnvelope ordering invariant are all correct and consistent with the parent doc (§4.2, §7.1) and the already-shipped engine code. Two issues need addressing before implementation starts — one critical, one important — plus several smaller gaps called out below.


Milestone Breakdown

6E-1 — safe to ship alone: ✅ correct

The reasoning holds: no coordinator wrap + no engine hook + no barrier means the cluster stays in plaintext-on-wire mode regardless of the cutover index now in the sidecar. 8a's v2 snapshot writer already reads RaftEnvelopeCutoverIndex (shipped code in kv/fsm.go:381-384), so a non-zero sidecar value after 6E-1 correctly triggers v2 snapshots — inert for apply, load-bearing for the 6E-2/8a handoff.

6E-2 — three pieces cannot ship separately: ✅ correct

The inseparability argument is airtight. Engine-unwrap-before-coordinator-wrap → followers see GCM failures on plaintext. Coordinator-wrap-before-engine-unwrap → FSM receives opaque raft-envelope bytes and produces protobuf decode errors. Both orderings produce a cluster-wide halt at the cutover boundary. The bundle is mandatory.

6E-3 guards bundle correctly: ⚠️ one guard is misplaced

ErrEnvelopeCutoverDivergence and ErrLocalEpochOutOfRange are legitimately Phase-2-specific (dead code without an active Phase 2). However, ErrEncryptionNotBootstrapped is misplaced in 6E-3. Its test (TestEnableRaftEnvelope_NotBootstrapped) is listed under §6.1 (6E-1 tests), and the note in §6.3 acknowledges it is "(already in 6E-1)". The guard must ship with the admin RPC in 6E-1, not 6E-3.

Why this matters for safety: If the guard ships in 6E-3 (not 6E-1), an operator on a 6E-1 cluster without a raft DEK bootstrap (active.raft == 0) could call enable-raft-envelope, record a non-zero cutover index in the sidecar, and continue. When 6E-2 ships and the engine hook activates, every entry at index > N would hit the e.raftCipher == nil path in applyNormalEntry:2271-2282 and halt apply cluster-wide. The guard prevents this by refusing the call at the RPC boundary in 6E-1. The §3.3 rationale ("dead code without 6E-2") does not hold for ErrEncryptionNotBootstrapped — it guards Phase-1 behavior, not Phase-2.

Fix: Move ErrEncryptionNotBootstrapped and its test into §3.1 (6E-1 scope) and remove the "(already in 6E-1)" note from §6.3.


§7.1 6-Step Quiescence-Barrier

Sequence correctness: ✅

The 6-step sequence in §2.2 matches the parent design's §7.1 Phase 2 exactly. Step ordering is sound: block → drain → propose → await-commit-and-apply → flip-wrap → unblock.

Source-tag exemption rationale: ✅ correct and complete

A global Propose mutex deadlocks because step 3's cutover proposal (from within the barrier) would block on itself. The per-source exemption ("encryption_admin" bypasses the gate) is the minimum necessary. The additional note that RegisterEncryptionWriter triggered by ConfChangeAddLearner mid-cutover also uses "encryption_admin" is correct (§4.1 fourth path). The current coordinator code has no source tag yet — 6E-2 must add this to the engine.Propose interface; the design should explicitly call this out as an interface change.

Strict-> index dispatch rule: ✅ correct

At step 3, the cutover entry is proposed un-wrapped. It commits at index == N. The FSM apply at every replica sets sidecar.RaftEnvelopeCutoverIndex = N. The engine hook's entry.Index > e.raftCutoverIndex() is false at exactly index == N (N > N is false), so the entry flows through unwrap-free. Every entry proposed after step 5 has index > N, is wrapped at the leader, and is correctly unwrapped at every replica. >= would attempt to GCM-unwrap the un-wrapped cutover entry → tag mismatch → halt-apply on every replica → cluster stuck in Phase 1 forever. The strict comparison is required.

Step 4 mechanization — flag for implementation: ⚠️

Step 4 says "wait for that entry to commit AND for the local FSM apply to set raft_envelope_cutover_index in the sidecar." Raft commit notification (majority ACK) precedes local FSM apply by at least one goroutine-schedule boundary. If step 5 fires on commit notification rather than on post-apply sidecar confirmation, wrapOnPropose is flipped before raftCutoverIndex() returns N for incoming entries, producing a race where a wrapped entry at N+1 reaches the apply loop before the cutover index is set. The 6E-2 implementation must await the FSM apply explicitly (e.g., via the existing resolveProposal response channel for the cutover entry), not just the Raft commit quorum. This should be spelled out in §3.2.


decodeProposalEnvelope Ordering Invariant

✅ Correct, and already correctly implemented

The invariant is correct: decodeProposalEnvelope(entry.Data) extracts (id, maybe_enc_payload), the raft DEK unwrap operates on maybe_enc_payload (not entry.Data), and resolveProposal receives the original entry.Data so the proposal-ID lookup is unaffected. The already-shipped code (engine.go:2226-2296) implements this exactly, with a comment block (lines 2234-2269) that pins the ordering and explains the aliasing implications.

The parent design's §4.2 is equally explicit: "wrapping entry.Data itself would clobber the leading 0x01 proposalEnvelopeVersion byte, making decodeProposalEnvelope return ok=false — every coordinator write times out forever." The 6E design doc's §2.4 restates this correctly.

No issues here; the invariant is sound and locked down in shipped code.


8a (#886) Sufficient to Unblock 6E

Claim: ✅ correct, with one implementation gap to document

8a ships: ReadSnapshotHeader (reads cutover from v2 snapshots), WriteSnapshotHeader (writes v2 when RaftEnvelopeCutoverIndex != 0), kvFSM.restoredCutover storage, and kvFSM.RestoredCutover() accessor. The §3.4 note in kv/fsm.go:421-426 already flags "Stage 6E will consume this from the apply hook." 6E does not need to change the snapshot format.

The mid-Phase-2 follower join path is:

  1. Leader snapshot → v2 header with cutover = N
  2. Follower Restore()f.restoredCutover = N
  3. 6E-2 wires RaftCutoverIndex callback to return max(sidecar.RaftEnvelopeCutoverIndex(), fsm.RestoredCutover()) [see gap below]
  4. Entries at index > N unwrap correctly

Gap: wrapOnPropose initialization on process restart — critical omission

The engine's RaftCutoverIndex callback reads from the sidecar (a live closure — correct). But the coordinator's wrapOnPropose atomic.Bool is set only by the barrier (step 5). After a process restart (or leader election), wrapOnPropose defaults to false. A new leader with a sidecar having RaftEnvelopeCutoverIndex = N would propose cleartext entries at index > N. Followers would try to unwrap them, GCM fails, halt-apply cluster-wide.

The StateCache.RefreshFromSidecar() pattern used for Phase 1's storageEnvelopeActive (applier.go:186-192) is the right analogy: on startup, if sidecar.RaftEnvelopeCutoverIndex != 0, the coordinator must initialize wrapOnPropose = true before accepting any proposals. The design doc must document this initialization requirement — it is absent from §3.2, the barrier description, and the §6.4 (6E-2) 5-lens review focus list.

Gap: RestoredCutover() wiring into RaftCutoverIndex callback

For a freshly-joined follower (no sidecar, restored from snapshot), sidecar.RaftEnvelopeCutoverIndex() == 0 but fsm.RestoredCutover() == N. The RaftCutoverIndex callback must consult both sources. Suggested callback: max(sidecar.RaftEnvelopeCutoverIndex(), fsm.RestoredCutover()). The 8a note acknowledges 6E will consume RestoredCutover(), but the wiring logic is not documented in the 6E design. Should be added to §3.2 or §4.


Minor Issues

Parent design doc milestone table is stale. 2026_04_29_partial_data_at_rest_encryption.md milestone table still shows 6D, 7, and 8 as "open". Per CLAUDE.md lifecycle conventions, the doc should be updated to reflect what has shipped. (Doesn't block 6E but creates confusion when cross-referencing.)

source tag is a new interface addition. The design mentions the per-call source tag on engine.Propose as if it already exists. It does not — the current coordinator code has no such parameter. 6E-2 must add it to the Propose interface (and update any callers/mocks). This scope should be called out explicitly in §3.2.


Verdict

Design is approvable with the following pre-implementation fixes:

  1. (Critical) Move ErrEncryptionNotBootstrapped guard + test from 6E-3 → 6E-1 scope. Fix this →

  2. (Critical) Document wrapOnPropose startup initialization in §3.2: "On process start (or on promotion to leader), if sidecar.RaftEnvelopeCutoverIndex() != 0, wrapOnPropose must be initialized to true before the coordinator accepts any proposals. Analogous to StateCache.RefreshFromSidecar() for Phase 1." Add a corresponding test: TestCoordinatorWrap_InitializedFromSidecarOnStartup. Fix this →

  3. (Important) Document RestoredCutover() wiring in §3.2 or §4: the RaftCutoverIndex callback must return max(sidecar.RaftEnvelopeCutoverIndex(), fsm.RestoredCutover()) so freshly-joined followers (no sidecar, restored from snapshot) correctly gate the unwrap path.

  4. (Important) Clarify step 4 of the barrier: "await local FSM apply" must use the post-apply sidecar confirmation, not the Raft commit quorum notification. Suggest using the resolveProposal response channel as the signal.

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 the design document for Stage 6E, detailing the enable-raft-envelope admin RPC and Phase-2 raft cutover. The feedback highlights a critical design gap regarding the initialization of the wrapOnPropose flag on node startup or restart to prevent accidental plaintext proposals. Additionally, the feedback suggests correcting the proposed applyNormalEntry signature to match the existing codebase and fixing a contradictory section header in the document.

Comment on lines +52 to +57
3. **Coordinator wrap-on-propose switch** —
`kv/coordinator.go` / `kv/sharded_coordinator.go` consult an
in-process `wrapOnPropose` flag (set by the cutover barrier,
reset never within a process lifetime) before calling
`engine.Propose`. Phase-2 leaders wrap; Phase-0/1 leaders
propose plaintext exactly as today.
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.

high

The design states that the wrapOnPropose flag is set by the cutover barrier and never reset within a process lifetime. However, it does not specify how wrapOnPropose is initialized on startup or restart.

If a node restarts after the cutover has already occurred (meaning sidecar.RaftEnvelopeCutoverIndex > 0), its in-process wrapOnPropose flag would default to false. If this restarted node becomes the leader, it will propose plaintext entries. Since these new entries will have index > cutover_index, all replicas' apply-hooks will attempt to unwrap them, fail GCM verification, and halt the cluster.

Suggestion:
Specify that on startup, the coordinator must initialize wrapOnPropose to true if the local sidecar has a non-zero RaftEnvelopeCutoverIndex.

Suggested change
3. **Coordinator wrap-on-propose switch**
`kv/coordinator.go` / `kv/sharded_coordinator.go` consult an
in-process `wrapOnPropose` flag (set by the cutover barrier,
reset never within a process lifetime) before calling
`engine.Propose`. Phase-2 leaders wrap; Phase-0/1 leaders
propose plaintext exactly as today.
3. **Coordinator wrap-on-propose switch**
kv/coordinator.go / kv/sharded_coordinator.go consult an
in-process wrapOnPropose flag (initialized to true on startup if
sidecar.RaftEnvelopeCutoverIndex > 0, and also set to true by the
cutover barrier) before calling engine.Propose. Phase-2 leaders wrap;
Phase-0/1 leaders propose plaintext exactly as today.
References
  1. When designing a production-grade system migration, the plan must consider and detail strategies for live or zero-downtime cutovers to avoid service interruption.

Comment on lines +115 to +143
// internal/raftengine/etcd/engine.go::applyNormalEntry
func (e *engine) applyNormalEntry(entry raftpb.Entry) error {
id, maybeEncPayload, ok := decodeProposalEnvelope(entry.Data)
if !ok {
return nil // pre-envelope entry, leave intact
}
payload := maybeEncPayload
// §6.3 hook: unwrap only at index strictly greater than
// the locally-recorded cutover. The local sidecar is the
// source of truth — every replica decides independently
// and deterministically because the sidecar value is
// itself replicated via the cutover entry's FSM apply.
if entry.Index > e.encryption.RaftEnvelopeCutoverIndex() {
var err error
payload, err = e.encryption.RaftDEK().Unwrap(maybeEncPayload)
if err != nil {
// GCM tag mismatch = sidecar/keystore divergence
// or on-disk corruption. Halt apply WITHOUT
// setApplied so the next restart replays this
// entry under a corrected keystore. Silent skip
// would diverge the FSM.
return errors.Wrap(err, "raft envelope: unwrap")
// engine treats this as ErrRaftUnwrapFailed → fatal
}
}
response := e.fsm.Apply(payload)
e.resolveProposal(entry.Index, entry.Data, response)
return 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 proposed signature and implementation of applyNormalEntry in the design document do not match the existing codebase in internal/raftengine/etcd/engine.go. Currently, applyNormalEntry returns (any, error) (where any is the FSM response), and the responsibilities of calling resolveProposal and setApplied belong to applyNormalCommitted.

If implemented as proposed, it would break the signature contract and result in duplicate proposal resolution or panics when applyNormalCommitted attempts to resolve the already-popped proposal.

Suggestion:
Update the code snippet to align with the existing applyNormalEntry signature and return values.

// internal/raftengine/etcd/engine.go::applyNormalEntry
func (e *Engine) applyNormalEntry(entry raftpb.Entry) (any, error) {
    id, maybeEncPayload, ok := decodeProposalEnvelope(entry.Data)
    if !ok {
        return nil, nil // pre-envelope entry, leave intact
    }
    payload := maybeEncPayload
    // §6.3 hook: unwrap only at index strictly greater than
    // the locally-recorded cutover. The local sidecar is the
    // source of truth — every replica decides independently
    // and deterministically because the sidecar value is
    // itself replicated via the cutover entry's FSM apply.
    if entry.Index > e.encryption.RaftEnvelopeCutoverIndex() {
        var err error
        payload, err = e.encryption.RaftDEK().Unwrap(maybeEncPayload)
        if err != nil {
            // GCM tag mismatch = sidecar/keystore divergence
            // or on-disk corruption. Halt apply WITHOUT
            // setApplied so the next restart replays this
            // entry under a corrected keystore. Silent skip
            // would diverge the FSM.
            return nil, errors.Wrap(err, "raft envelope: unwrap")
            // engine treats this as ErrRaftUnwrapFailed → fatal
        }
    }
    return e.fsm.Apply(payload), nil
}

field has been on disk since 6D shipped; only its
load-bearingness changes per milestone.

## 5. Why the cutover entry is unwrapped at index == cutover
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 section header contradicts the explanation below it. The text explains that the cutover entry itself flows through unwrap-free (i.e., it is not unwrapped) because the strict > comparison is false at entry.Index == N.

Suggestion:
Update the header to reflect that the entry is not unwrapped.

Suggested change
## 5. Why the cutover entry is unwrapped at index == cutover
## 5. Why the cutover entry is NOT unwrapped at index == cutover

…init) + 2 mediums

gemini HIGH on §2.1 (line 57)
  The original spec said wrapOnPropose is 'set by the cutover
  barrier and never reset within a process lifetime' but did
  not specify how the flag is initialized on startup. A node
  that restarts AFTER the cluster has cut over would default
  the flag to false, propose plaintext as a new leader, and
  the engine apply-hook (which uses the sidecar — NOT the
  in-process flag — as the source of truth) would attempt
  Unwrap on those plaintext entries, fail GCM, and halt apply
  cluster-wide.

  Fix: §2.1 now requires the flag to be initialized to true
  iff sidecar.RaftEnvelopeCutoverIndex != 0 at coordinator
  construction time. Two set-paths total (startup hydration,
  barrier step 5) and zero reset-paths within a process load.

  Added test TestCoordinatorWrap_StartupInitFromSidecar in
  §6.2 to pin the regression.

gemini MEDIUM on §2.4 (line 143)
  applyNormalEntry signature in the proposed code snippet did
  not match the actual codebase. Verified at
  internal/raftengine/etcd/engine.go:2226 that the real
  signature is (entry raftpb.Entry) (any, error); setApplied
  and resolveProposal live in applyNormalCommitted (line 2173,
  2186-2187) which calls applyNormalEntry. Updated the
  snippet + commentary to match: 6E-2 adds the unwrap shim
  inside applyNormalEntry without changing the caller
  contract; the error return is what makes
  applyNormalCommitted skip setApplied (the existing
  fail-closed shape).

gemini MEDIUM on §5 header (line 268)
  Header said 'is unwrapped' but the body says 'is NOT
  unwrapped'. Header was a typo. Now reads 'NOT unwrapped'.

Self-review (5-lens) on the doc change itself:
  1. Data loss — startup-init rule directly prevents the
     halt-apply-cluster-wide scenario that the original spec
     left open.
  2. Concurrency — the flag has two write paths (startup
     hydration on a single goroutine, barrier step 5 on a
     single goroutine), both serial; no race possible within
     the lifetime of one process load.
  3. Performance — startup-init is a single sidecar read at
     coordinator construction (already happens for 6C-2);
     zero hot-path cost.
  4. Data consistency — the engine apply-hook still reads
     the sidecar as the source of truth (per-replica
     deterministic); the in-process flag is the leader's
     write-side mirror, not a parallel decision oracle.
  5. Test coverage — new test pinned; without it the gemini
     HIGH scenario would regress silently.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 30, 2026

Round-1 fixes in dd0beb2:

  • gemini HIGH §2.1 (line 57) — startup-init rule for wrapOnPropose. The original spec left a gap where a post-cutover restart would default the flag to false, a new-leader plaintext propose would land at index > cutover, the engine apply-hook would attempt Unwrap, fail GCM, and halt apply cluster-wide. Now requires initialization to true iff sidecar.RaftEnvelopeCutoverIndex != 0 at coordinator construction time. Two write paths (startup hydration, barrier step 5), zero reset paths within a process load. Added test TestCoordinatorWrap_StartupInitFromSidecar in §6.2.
  • gemini MEDIUM §2.4 (line 143) — verified actual signature at internal/raftengine/etcd/engine.go:2226 is (entry raftpb.Entry) (any, error); setApplied and resolveProposal live in applyNormalCommitted (line 2173, 2186-2187). Updated the code snippet + commentary; 6E-2 adds the unwrap shim inside applyNormalEntry without changing the caller contract.
  • gemini MEDIUM §5 header — typo: was 'is unwrapped', now 'is NOT unwrapped' (matches the body).

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 5m 14s —— View job


Review of Stage 6E Design (Round-2: post dd0beb2 fixes)

Tasks:

  • Load tools
  • Read design document (current state after dd0beb2)
  • Read internal/raftengine/etcd/engine.go + encryption.go + kv/raft_payload_wrapper.go
  • Verify gemini HIGH fix: wrapOnPropose startup init from sidecar (§2.1)
  • Verify gemini MEDIUM fix: applyNormalEntry signature alignment (§2.4)
  • Verify gemini MEDIUM fix: §5 header typo
  • Review milestone breakdown (6E-1 safe alone, 6E-2 non-splittable, 6E-3 bundling)
  • Review §7.1 quiescence-barrier + source-tag exemption + strict-> index rule
  • Review decodeProposalEnvelope ordering invariant
  • Verify 8a unblocking claim
  • Post final review

Round-1 Fixes — all three correctly applied ✅

gemini HIGH (§2.1 startup init): The rule is present, correctly scoped, and its rationale is documented at doc lines 58–68. Two write paths (startup hydration, barrier step 5), zero reset paths, explicit call-site (coordinator construction, after sidecar hydrated per Stage 6C-2) — this closes the restart-becomes-leader plaintext-propose hole.

gemini MEDIUM (§2.4 applyNormalEntry signature): Verified against internal/raftengine/etcd/engine.go:2226:

func (e *Engine) applyNormalEntry(entry raftpb.Entry) (any, error)

applyNormalCommitted (line 2173) calls it, then does setApplied (2186) + resolveProposal (2187) after it returns. The doc's description at lines 133–141 matches the actual code exactly. ✅

gemini MEDIUM (§5 header): Line 297 now reads ## 5. Why the cutover entry is NOT unwrapped at index == cutover. ✅


Original review items

Milestone breakdown

6E-1 safe to ship alone ✅. The sidecar records the cutover index, but the engine's RaftCutoverIndex callback defaults to inertRaftCutoverIndex() (returns ^uint64(0)) until 6E-2 wires it. entry.Index > ^uint64(0) is always false, so the unwrap path stays truly dead code. The sidecar field being non-zero is load-bearing for 8a's snapshot writer and for the startup-init rule, but inert for the apply path. ✅

6E-2 three pieces cannot ship separately ✅. The interlocking argument in §3.2 is correct:

  • Engine unwrap first, coordinator still plaintext → GCM fail on every entry at index > cutover → cluster halt
  • Coordinator wrap first, engine not yet unwrapping → FSM receives ciphertext, proto decode fails → every apply errors

The design correctly identifies that the quiescence barrier is also needed even within the same PR, because the runtime window between "cutover entry applied" and "wrapOnPropose flipped" can still produce plaintext entries above the cutover index without the barrier.

6E-3 guards bundle correctly ✅. The ErrEnvelopeCutoverDivergence, ErrLocalEpochOutOfRange checks have nothing to gate on before 6E-2 ships — they are dead code without an active Phase 2. The bundling rationale is sound.

§7.1 quiescence-barrier + source-tag exemption + strict-> rule

Source-tag exemption ✅. A global Propose mutex would block step 3's cutover-entry proposal through the very gate being held — guaranteed deadlock. The per-source bypass for encryption_admin is the minimal safe exemption, and the doc correctly extends it to RegisterEncryptionWriter proposals triggered by ConfChangeAddLearner during the barrier window (§4.1 fourth path). No plaintext proposals from non-admin sources can slip through between barrier step 1 and step 6.

Strict-> dispatch ✅. Verified against the actual engine code at line 2270:

if entry.Index > e.raftCutoverIndex() {

The cutover entry at index == N is plaintext (the FSM's ApplyRotation sets sidecar.RaftEnvelopeCutoverIndex = N as a side-effect of applying it). At this point the sidecar just became N, so entry.Index > N is false, and the entry flows through unwrap-free. All subsequent entries (index > N) are wrapped because step 5 flips wrapOnPropose before step 6 unblocks intake. The boundary is exact. ✅

decodeProposalEnvelope ordering invariant

Verified against engine.go line 2230:

_, payload, ok := decodeProposalEnvelope(entry.Data)

entry.Data = [ proposalEnvelopeVersion (1B) | proposal_id (8B) | fsm_payload ]

The unwrap hook (line 2270) operates on payload (the inner bytes), NOT on entry.Data. resolveProposal (line 2299) also independently calls decodeProposalEnvelope(entry.Data) to recover the proposal ID. Since entry.Data is never modified (wrapping was applied to the FSM payload before engine.Propose, not to the full entry.Data), resolveProposal sees the intact proposal-envelope header. The design doc's invariant holds in the actual code. ✅

8a unblocking claim

Sufficient ✅. 8a's v2 snapshot writer emits RaftEnvelopeCutoverIndex from the sidecar into the snapshot header. A fresh follower joining mid-Phase-2:

  1. Receives the v2 snapshot — reads cutover_index = N from the header
  2. FSM snapshot restore calls WriteSidecar with the recovered cutover (8a's ReadSnapshotHeader covers this)
  3. After sidecar hydration, the engine's RaftCutoverIndex callback (wired in 6E-2 to read sidecar.RaftEnvelopeCutoverIndex) returns N
  4. Subsequent entries at index > N are correctly unwrapped at apply

The no-downgrade latch from 8a also prevents a v1-only node from joining a v2-snapshot cluster, so there's no window where a follower reconstructs an inert cutover. ✅


Findings

LOW — Design doc says 6E-2 "adds" the §6.3 hook; it was already scaffolded in-tree

The engine-side unwrap path — ErrRaftUnwrapFailed, RaftCutoverIndex type, inertRaftCutoverIndex(), unwrapRaftPayload(), and the if entry.Index > e.raftCutoverIndex() block in applyNormalEntry — is already present in the codebase (internal/raftengine/etcd/encryption.go and engine.go:2270). The default inertRaftCutoverIndex() returns ^uint64(0), so the hook is currently a strict no-op. Similarly, kv/raft_payload_wrapper.go provides the RaftPayloadWrapper infrastructure (wrappedProposer, applyRaftPayloadWrap) as Stage 3 scaffolding with a nil/identity default.

The doc's language in §3.2 — "Engine applyNormalEntry adds the §6.3 hook" — is technically inaccurate. What 6E-2 actually does is activate/wire the hook: replace inertRaftCutoverIndex with a live sidecar-backed callback, wire raftCipher, and install a non-nil RaftPayloadWrapper on the coordinator. This is a documentation precision issue, not a design flaw, but worth updating so the 6E-2 implementor doesn't re-add code that already exists.

Fix this →

MINOR — Test name inconsistency between §6.1 and §6.3

§6.1 lists TestEnableRaftEnvelope_NotBootstrapped; §6.3 references the same case as TestEnableRaftEnvelope_RequiresBootstrap. One of these names should be used consistently. (§6.3 explicitly parenthesizes (already in 6E-1), but the names differ.)

Fix this →


Overall verdict

The core logic is sound. The round-1 fixes are correctly applied. The three items the PR asked for specific verification on — quiescence-barrier sequencing (including source-tag exemption), strict-> dispatch rule, and decodeProposalEnvelope ordering invariant — are all correct and consistent with the actual engine code. The 8a prerequisite is sufficient to unblock mid-Phase-2 follower catch-up as claimed. The two findings above are documentation precision issues; neither affects the correctness of the milestone breakdown or the safety argument.

@bootjp bootjp merged commit 2bcb6e7 into main May 30, 2026
9 checks passed
@bootjp bootjp deleted the feat/encryption-6e-design branch May 30, 2026 15:30
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