Skip to content

encryption: add the §5.2 writes-per-DEK rotation budget - #1233

Open
bootjp wants to merge 5 commits into
mainfrom
design/encryption-9c4-write-budget
Open

bootjp wants to merge 5 commits into
mainfrom
design/encryption-9c4-write-budget

Conversation

@bootjp

@bootjp bootjp commented Sep 10, 2026

Copy link
Copy Markdown
Owner

I was wrong that this was blocked

I have been reporting encryption 9C+ as blocked on open key-lifecycle questions. Re-reading §5.2, that is only true of the rewrap/retire/rewrite half. The rotation budget is specified completely:

a hard ceiling of 2³² writes per (DEK, process-load) pair, in line with NIST SP 800-38D §8.3 … admission control refuses new writes once 90% of the ceiling is reached and the cluster auto-proposes a rotate-dek entry

And it builds directly on elastickv_encryption_writes_per_dek, which shipped in 9C-1 (#1221). Nothing about it needed a decision from you.

Three properties, each revert-checked

A refused write is not counted. This is the one that keeps the ceiling meaningful — if refusals consumed budget, a caller retrying on refusal would walk the counter straight past the ceiling, which is precisely what it exists to prevent.

Refusal is at 90%, not at the ceiling. Rotation takes a Raft round trip. With no headroom you get one of two bad outcomes: block writes while it commits, or keep issuing writes past the ceiling.

The budget is per DEK. A freshly rotated key must start full — a shared counter would have the new key inherit the old key's spent budget and wedge the cluster the instant it rotated.

Scope note

Per-process-load, matching §5.2's (DEK, process-load) pair and the §4.1 nonce construction whose local_epoch bumps on every process start. A restart begins a fresh budget because it also begins a fresh nonce epoch, so the (key, nonce) space the ceiling protects is itself fresh. That is the reasoning for what would otherwise look like a counter that forgets too easily.

Behavior change / risk

New type; nothing constructs it yet, so there is no runtime change. Wiring it into admission control and the auto-propose path is the execution half and stays open — deliberately, since refusing writes is a change I would not fold into the same PR as the accounting.

Hot-path shape: steady state is an uncontended RLock plus one atomic add. A nil budget allows everything, so an unwired node is unaffected.

Test evidence

  • go test ./internal/encryption/... -race -count=1 — pass
  • golangci-lint run (full repo) — 0 issues, no //nolint
  • Revert-checked, restores byte-exact:
    • refused writes consume budget → 4 failures
    • no 90% headroom → 4 failures
    • budget shared across DEKs → 3 failures

10 tests including the concurrency case (this sits on every encrypted write, so a torn counter would be a live production race) and nil-receiver.

Self-review (five passes)

  1. Data loss — none; nothing is written or deleted. The budget prevents the nonce-reuse class the ceiling guards.
  2. Concurrency / distributed failures — per-key atomic counters behind an RWMutex-guarded map; the post-increment re-check bounds threshold overshoot to the number of in-flight writers and can never reach the ceiling from below the threshold. Race-clean.
  3. Performance — RLock + atomic add per write. The counter is memoized per key_id so the write path never allocates.
  4. Data consistency — no MVCC/OCC/HLC interaction; the budget is process-local accounting, and §5.2 scopes it that way on purpose.
  5. Test coverage — 10 tests, 3 revert-checks. Not covered because not yet wired: the admission-control refusal and the auto-propose, which are the execution half.

https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE

The second of §5.2's two rotation triggers: a hard ceiling of 2^32
writes per (DEK, process-load) pair, per NIST SP 800-38D §8.3, with
admission control refusing new writes at 90% and the cluster
auto-proposing a rotate-dek entry at that point.

I had been reporting this whole stage as blocked on open key-lifecycle
questions. That was wrong — §5.2 specifies the budget trigger
completely, and it builds directly on the
elastickv_encryption_writes_per_dek counter shipped in 9C-1. Only the
rewrap/retire/rewrite half carries open questions.

Three properties the tests pin, each revert-checked:

  - a refused write is NOT counted, so a caller that retries on refusal
    cannot walk the counter past the ceiling — the one thing the
    ceiling exists to prevent;
  - refusal happens at 90%, not at the ceiling: rotation needs a Raft
    round trip, so no headroom would mean either blocking writes while
    it commits or issuing writes past the ceiling;
  - the budget is per DEK, so a freshly rotated key starts full. A
    shared counter would have the new key inherit the old key's spent
    budget and wedge the cluster the moment it rotated.

Scope is per-process-load, matching §5.2's "(DEK, process-load)" pair
and the §4.1 nonce construction whose local_epoch bumps on every
process start: a restart begins a fresh budget because it also begins a
fresh nonce epoch, so the (key, nonce) space the ceiling protects is
itself fresh.

Pure and injectable — no store, no clock — so the accounting is
testable without a cluster. Wiring it into admission control and the
auto-propose path is the execution half and stays open.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

bootjp commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@bootjp

bootjp commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T08:39:13.901866Z c0a6faf New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 58 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 2550e3bf-c8bc-47f2-a11d-aae0409fed56

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca7e90 and c0a6faf.

📒 Files selected for processing (3)
  • docs/design/2026_04_29_partial_data_at_rest_encryption.md
  • internal/encryption/write_budget.go
  • internal/encryption/write_budget_test.go

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.

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown

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


Review: §5.2 writes-per-DEK rotation budget

  • Read repo guidelines (CLAUDE.md)
  • Read changed files (write_budget.go, write_budget_test.go, design doc diff)
  • Review for correctness, concurrency, and test coverage per repo self-review lenses
  • Post review feedback

Could not execute go test/golangci-lint myself in this sandboxed review session (Bash requires interactive approval that wasn't granted here), so this is a static read of the diff against origin/main, not a re-run of the reported test evidence.

The design is sound and the write path (Record, counterFor) is genuinely lock-light and race-safe as documented. Two things worth fixing before this gets built on in the admission-control PR:

1. WriteBudgetExhausted is unreachable through Record() as written.
internal/encryption/write_budget.go:117-134 — once counter.Load() >= b.threshold, every call takes the case used >= b.threshold branch and returns WriteBudgetRotate without touching the counter (that's the "refused writes aren't counted" property, working as intended). But that means the counter freezes at ~90% forever and can never climb the remaining 10% of the ceiling to satisfy case used >= b.ceiling. The godoc on WriteBudgetExhausted (lines 48-52) says it's "Reachable only if a caller ignored WriteBudgetRotate" — but an ignorant caller that keeps calling Record() gets the exact same frozen-counter behavior, so it still never fires. This isn't just theoretical: TestWriteBudgetFailsClosedPastTheCeiling (write_budget_test.go:63-76) is the only test aimed at that verdict, and it never actually produces one — it only checks Allowed() == false, which WriteBudgetRotate alone already satisfies. Grepping the test file confirms WriteBudgetExhausted is asserted only via its .String() (line 167), never via an actual Record() return value. Worth deciding now, before admission control is wired: either let Record keep advancing past the threshold so Exhausted is a real, reachable state, or drop/re-scope the verdict and stop documenting it as reachable.
Fix this →

2. Threshold truncation in NewWriteBudget.
internal/encryption/write_budget.go:94-98 computes threshold: ceiling / 100 * writeBudgetRefusePercent, with a comment claiming this order "cannot round to zero for a small one." That's backwards: ceiling/100 truncates first, so any ceiling < 100 collapses the threshold to exactly 0, and any ceiling that isn't a clean multiple of 100 loses up to ~90 units of precision (e.g. ceiling=150 → threshold 90, not the correct 135). At the real DefaultWriteBudgetCeiling (2³²) this is negligible, but write_budget_test.go:66-70 (TestWriteBudgetFailsClosedPastTheCeiling) calls NewWriteBudget(10) with a comment stating "Threshold is 9" — the actual computed threshold is 0, which is also why finding #1's test never reaches Exhausted (every call is refused from the first one, counter never moves). Worth fixing the comment/test and, if this constructor is ever exercised with a small ceiling in production config or the admission-control tests, tightening the rounding.
Fix this →

Minor / non-blocking:

  • docs/design/2026_04_29_partial_data_at_rest_encryption.md:37 — the new 9C-4 row's link column is while every other "shipped" row cites either an implemented doc or a PR reference (e.g. row 5E says this PR). Worth filling in once merged, for consistency with the table's own convention.
  • Record's post-increment re-check (write_budget.go:126-131) can, in the narrow race window right at the threshold, increment the counter for a call that still returns WriteBudgetRotate — a small, already-documented and bounded exception to the unconditional "a refused write is not counted" claim in the exported doc comment (lines 106-108) and the PR description. It's safe (only ever pushes rotation earlier, never reaches the ceiling), just slightly overclaimed in the docs.
  • I couldn't find elastickv_encryption_writes_per_dek (or any promauto/prometheus registration) anywhere under internal/encryption/ in this tree — worth double-checking the PR's claim that it "shipped in 9C-1 (encryption: add storage-envelope metrics (Stage 9C-1, §9.2) #1221)" still holds, though this sandbox's shallow clone couldn't confirm or refute that history either way.

Otherwise: key_id typing matches uint32 used elsewhere in the package (envelope.go:72), the nil-receiver/Forget/Used/Remaining behavior all read correctly, and the double-checked-locking counterFor is a standard, correct pattern. Scope (per-process-load, no wiring yet) matches what the PR claims.
· branch design/encryption-9c4-write-budget

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 146e42e785

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/encryption/write_budget.go Outdated
// Integer arithmetic in this order so the threshold cannot
// overflow for a large ceiling and cannot round to zero for a
// small one.
threshold: ceiling / 100 * writeBudgetRefusePercent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compute the 90% threshold without discarding the remainder

For ceilings that are not multiples of 100, dividing before multiplying can make the refusal point far lower than 90%; for example, NewWriteBudget(199) sets the threshold to 90 (about 45%), and any ceiling below 100 sets it to zero and refuses the first write. Preserve the remainder while using overflow-safe arithmetic so supported custom ceilings receive an actual 90% budget.

Useful? React with 👍 / 👎.

Comment thread internal/encryption/write_budget.go Outdated
Comment on lines +129 to +130
if counter.Add(1) > b.threshold {
return WriteBudgetRotate

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reserve budget atomically before allowing a write

When several writers load a count just below the threshold concurrently, they can all enter this branch; only the writer whose add lands on the threshold is allowed, while later writers return WriteBudgetRotate but leave their increments recorded. With a ceiling of 100 and the count at 89, eleven simultaneous calls can therefore raise Used to 100 even though ten writes were refused, causing subsequent calls to report WriteBudgetExhausted. Use a CAS loop so calls returning a refusal do not consume budget.

Useful? React with 👍 / 👎.

| 9B | AWS KMS, GCP KMS, Vault Transit, and test/CI env KEK providers; mutually-exclusive source loader and loaded-provider mutator gate (§5.1, §6.1, §6.5) | shipped | `2026_07_18_implemented_9b_kek_providers.md` |
| 9C+ | Rotation budget/rewrap/retire/rewrite, metrics, remaining benchmarks and encrypted Jepsen (§5.2, §5.4, §6.5, §8, §9.2) | open | — |
| 9C-4 | §5.2 writes-per-DEK rotation budget: the 2^32 per-(DEK, process-load) ceiling, the 90% refuse-and-rotate threshold, and per-DEK accounting | shipped | — |
| 9C+ | Rotation rewrap/retire/rewrite, admission-control and auto-propose wiring for the budget above, remaining benchmarks (§5.2, §5.4, §6.5, §8) | open | — |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the write-counter metric in the open milestone

Removing metrics from this open row makes the required elastickv_encryption_writes_per_dek{key_id} observable disappear from the implementation plan even though it has not shipped: a repository-wide search for that exact metric finds only the design document, and the new WriteBudget neither registers nor updates a Prometheus collector. Until the metric is implemented, retain it in the open scope so deployments do not silently miss the §9.2 counter.

Useful? React with 👍 / 👎.

Three review findings on the §5.2 budget.

1. Refused writes consumed budget. Record loaded the counter, decided,
   then incremented — so every writer that read a count below the
   threshold incremented it, including the ones then refused. With a
   ceiling of 100 and the count at 89, concurrent callers left the
   counter at 100 having permitted one write, and the next call
   reported Exhausted for a DEK that had issued 90 writes. That
   contradicted this type's own documented contract ("A refused write
   is not counted") and the comment claiming the overshoot "never
   reaches the ceiling from below the threshold". Record now reserves
   the slot with CompareAndSwap, so the counter records permitted
   writes and nothing else.

   The existing TestWriteBudgetDoesNotCountRefusedWrites could not
   catch this: sequentially the Load sees the threshold and returns
   before incrementing, so the bug is only reachable under contention.

2. The threshold discarded the remainder. ceiling/100*90 loses up to 99
   ceiling-units: a ceiling of 199 refused at 90 (about 45%) and any
   ceiling below 100 refused at 0, i.e. refused the first write and
   wedged the DEK. Splitting quotient and remainder keeps the exact
   floor without overflow, and a ceiling whose 90% point floors to zero
   now allows one write rather than none.

   TestWriteBudgetDefaultCeilingMatchesTheDesign asserted
   `1<<32/100*90` — the same expression as the implementation — so it
   could not catch a division-order error either. It now states the
   specification: 90% of 2^32 is 3865470566, not 3865470480.

3. The open 9C+ milestone row had dropped the §9.2 metrics and
   encrypted Jepsen. elastickv_encryption_writes_per_dek{key_id} exists
   only in this design document; this budget does not export it. Put
   the metrics and Jepsen scope back in the open row, naming that gap.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@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, add credits to your account and enable them for code reviews in your settings.

@bootjp

bootjp commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

All three findings are correct and fixed in 372c052. Details, including two cases where the existing tests could not have caught the defect:

P2 — Reserve budget atomically before allowing a write. Confirmed. Traced with ceiling 100, threshold 90, count 89, 11 concurrent callers: all load 89, all pass the check, all Add, results 90…100. The one that lands on 90 returns Allow; the other ten return Rotate having already incremented. The counter reaches 100 — the ceiling — for a DEK that issued one write, and the next call reports Exhausted. This contradicted both the WriteBudgetRotate doc ("The write is NOT counted, so a caller that retries cannot drive the counter past the ceiling") and my own inline claim that the overshoot "never reaches the ceiling from below the threshold". Record now reserves the slot with CompareAndSwap.

Worth flagging: TestWriteBudgetDoesNotCountRefusedWrites already existed and passed, because it is sequential — the Load sees the threshold and returns before incrementing, so the bug is unreachable without contention. My first replacement test (a staged burst of 16 racers released by closing a channel) also passed against the buggy code — the channel wake-ups serialise enough that late racers observe the incremented count. It only became a real detector once the racers hammer Record until refused, giving sustained contention at the boundary; it then fails on trial 1.

P2 — Compute the 90% threshold without discarding the remainder. Confirmed, and worse than a rounding issue at small ceilings: NewWriteBudget(199) refused at 90 (45%), and every ceiling below 100 refused at 0 — the first write refused, rotation proposed forever, no write ever issued under the new DEK either. Now computed as ceiling/100*90 + ceiling%100*90/100, the exact floor (with ceiling = 100q + r this is 90q + floor(9r/10), and r < 100 bounds the remainder term at 8910, so neither term overflows). A ceiling whose 90% point floors to zero allows exactly one write instead of none.

Two existing tests were complicit:

  • TestWriteBudgetDefaultCeilingMatchesTheDesign asserted uint64(1)<<32/100*90 — the implementation's own expression — so it could never catch a division-order error. It asserted the lossy 3865470480; the exact value is 3865470566. Now stated as the specification (1<<32*9/10, plus the literal).
  • TestWriteBudgetFailsClosedPastTheCeiling carried the comment "Threshold is 9" for NewWriteBudget(10). Under the old arithmetic that threshold was 0, so every write in that test was refused and the assertion !Allowed() held vacuously. The fix makes the comment true; the test now also asserts Used == 9.

P2 — Keep the write-counter metric in the open milestone. Confirmed. elastickv_encryption_writes_per_dek{key_id} appears only in this design document (lines 1185 and 2534) — no Go file references it, and WriteBudget registers no collector. The 9C+ row now names the metrics and encrypted Jepsen scope again, and calls out that this budget does not export the counter yet.

I applied the same correction to the 9C-5 row on #1234 and the 9C-6 row on #1235, which had narrowed the open row the same way.

Verification

  • go test ./internal/encryption/ -race -count=1 — ok, 25.3s; golangci-lint — 0 issues
  • Revert-checks, each confirming the named test fails with the fix reverted and the file restores byte-exact:
    • Load-then-Add → TestWriteBudgetRefusedWritesDoNotConsumeBudgetUnderConcurrency FAILS ("the counter passed the refusal threshold, so refused writes consumed budget")
    • divide-before-multiply → TestWriteBudgetThresholdIsNinetyPercent, TestWriteBudgetDefaultCeilingMatchesTheDesign, TestWriteBudgetFailsClosedPastTheCeiling FAIL
    • drop the anti-wedge guard → TestWriteBudgetTinyCeilingDoesNotWedgeTheDEK, TestWriteBudgetReachesExhaustedAtADegenerateCeiling, TestWriteBudgetThresholdIsNinetyPercent FAIL

One consequence to note: with atomic reservation the counter stops at the threshold, so WriteBudgetExhausted is unreachable through Record for any ceiling above its threshold. I kept it as the fail-closed guard and documented that precisely, rather than leaving a doc comment that overstates when it fires. TestWriteBudgetReachesExhaustedAtADegenerateCeiling covers the one configuration that does reach it.

@codex review
@claude review

@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, add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

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


Review: fixes in 372c052

  • Read repo guidelines (CLAUDE.md)
  • Review the fixes in 372c052 against the three findings raised by Codex/CodeRabbit
  • Verify the CAS-based reservation fix in write_budget.go
  • Verify the threshold rounding fix
  • Verify the design doc scope-note fix
  • Run tests / lint if possible
  • Post final review

Could not execute go test / golangci-lint in this sandboxed review session — every go/shell invocation here (even go version) required interactive approval that wasn't available — so this is a static verification of the diff, not a re-run of the reported evidence. Same limitation the earlier review hit.

All three fixes check out.

1. CAS reservation (internal/encryption/write_budget.go:141-166). The new loop reserves the slot before allowing:

for {
    used := counter.Load()
    if used >= b.ceiling { return WriteBudgetExhausted }
    if used >= b.threshold { return WriteBudgetRotate }
    if counter.CompareAndSwap(used, used+1) { return WriteBudgetAllow }
}

This is correct: Allow only ever fires on a winning CAS from used < threshold, so the counter can never advance past threshold through Record. That in turn makes WriteBudgetExhausted unreachable via Record except when threshold == ceiling (the degenerate small-ceiling case) — and the doc comment on WriteBudgetExhausted (lines 52-62) and TestWriteBudgetReachesExhaustedAtADegenerateCeiling both say so honestly now, instead of the earlier comment claiming a reachability that never actually happened. TestWriteBudgetRefusedWritesDoNotConsumeBudgetUnderConcurrency is a real detector this time — sustained contention (racers loop until refused, not one staged burst) rather than the original one-shot burst that the PR notes "also passed against the buggy code."

2. Threshold arithmetic (refusalThreshold, lines 122-133). Checked the decomposition by hand: for ceiling = 100q + r, 90q + floor(9r/10) == floor(9·ceiling/10) exactly (no approximation), and r < 100 bounds the remainder term at 8910, so there's no overflow at any uint64 ceiling — I verified the large-ceiling side too: ceiling/100*90 is at most 90% of ceiling, which always fits in uint64 since it's less than the input. Spot-checked the table cases by hand:

  • 199 → 179 (matches floor(199*9/10)=179.1)
  • 2^32 → 3865470566 (matches floor(2^32*9/10))
  • degenerate ceiling=1 → exact=0 → clamped to 1, so the DEK gets one write instead of being wedged forever.

All correct.

3. Design doc (docs/design/2026_04_29_partial_data_at_rest_encryption.md:37). The open 9C+ row now explicitly re-lists the §9.2 metrics (naming elastickv_encryption_writes_per_dek{key_id} and stating it isn't exported yet) and encrypted Jepsen, addressing CodeRabbit's point that narrowing the row had made that gap invisible.

Minor, non-blocking: the CAS loop is unbounded under pathological contention (a Record call can in principle retry indefinitely if it keeps losing the race right at the threshold boundary), but that's inherent to lock-free reservation and bounded in practice by the number of concurrent writers on one key — not worth a backoff for this hot path.

Nothing else to flag. Scope, doc comments, and tests are now mutually consistent, and the two new regression tests (TestWriteBudgetRefusedWritesDoNotConsumeBudgetUnderConcurrency, TestWriteBudgetThresholdIsNinetyPercent) are the kind that would have caught these on the first pass — good revert-check discipline per the PR description.
· branch design/encryption-9c4-write-budget

bootjp added a commit that referenced this pull request Sep 11, 2026
The 9C-6 row had narrowed the open 9C+ scope to "remaining benchmarks
and encrypted Jepsen", dropping the explicit mention of the §9.2
metrics. Neither elastickv_encryption_writes_per_dek{key_id} nor
elastickv_encryption_last_proposed_index_per_raft_dek{key_id} exists
outside this design document, so naming them keeps the gap visible
rather than leaving it implied by the section reference alone.

Same correction applied on #1233 and #1234, which narrowed the row the
same way.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
…-write-budget

# Conflicts:
#	docs/design/2026_04_29_partial_data_at_rest_encryption.md
@bootjp

bootjp commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Correction to my earlier comment on this PR.

I wrote that elastickv_encryption_writes_per_dek{key_id} "appears only in this design document — no Go file references it". That was true of this branch's base, but main has since merged #1221 (Stage 9C-1), which does export it from the storage-envelope path (monitoring/encryption.go:58). So the claim is now wrong, and the reviewer's original point — keep the metric visible in the open scope — is satisfied differently than I described: the counter exists; what remains is wiring this budget's accounting to it.

Fixed while merging main to clear the conflict. The milestone table now carries main's 9C-1 row alongside this stage's, and the open 9C+ row lists the metrics that genuinely remain unexported (active_dek_id, last_proposed_index_per_raft_dek, kek_unwrap_seconds, sidecar_raft_index) rather than one that has shipped.

No code change: the three fixes in this PR and their revert-checks are unaffected.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c49cca66a0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +185 to +187
if b == nil {
return 0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep nil budgets from reporting rotation due

When the budget is intentionally unwired, Record treats a nil receiver as unlimited and allows writes, but Remaining returns zero, which its contract defines as “rotation is due.” Any admission or observability code that checks Remaining(keyID) == 0 will therefore trigger rotation immediately on an unconfigured budget even though the corresponding write verdict is Allow. Return a value representing an unlimited/disabled budget, or make the nil state explicit so these APIs agree.

Useful? React with 👍 / 👎.

Comment on lines +226 to +227
counter = &atomic.Uint64{}
b.counters[keyID] = counter

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Initialize the counter map for zero-value budgets

When WriteBudget is embedded or declared without calling NewWriteBudget, its zero value reaches this assignment with a nil counters map and Record panics. This is particularly surprising because the exported type otherwise handles nil receivers and its other methods tolerate a zero value. Lazily initialize the map and apply the default ceiling before recording, or explicitly prevent zero-value use.

Useful? React with 👍 / 👎.

…stently

Two findings from the second review round, both about a budget that was
not built by NewWriteBudget.

P2 — the nil receiver disagreed with itself. Record returns Allow on a nil
budget, but Remaining returned 0, which this type documents as "rotation
is due". Any admission check or dashboard keyed on Remaining(k) == 0 would
therefore demand rotation on every unconfigured node forever while the
write verdict said Allow. Remaining now returns RemainingUnlimited for
that state, so the two APIs agree about the same receiver, and zero keeps
its single meaning.

P2 — the zero value panicked. A WriteBudget declared or embedded rather
than constructed reached counterFor with a nil map, and assigning into one
panics -- surprising precisely because this type tolerates a nil receiver
and its other methods tolerate a zero value. The map is initialised on
first use, and the ceiling and threshold fall back to the §5.2 default
rather than staying zero, which would have refused the very first write.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

bootjp commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Second round: both fixed in the commit above.

P2 — Keep nil budgets from reporting rotation due. Confirmed, and the disagreement was in the dangerous direction: Record on a nil budget returns Allow, so the write path said "proceed" while Remaining said "rotation due" about the same receiver — and any admission check or dashboard keyed on Remaining(k) == 0 would demand rotation on every unconfigured node forever.

Remaining now returns RemainingUnlimited (^uint64(0)) for the unwired state. I chose an explicit sentinel over, say, returning the ceiling, so that zero keeps exactly one meaning — rotation is due — and a caller that cares can distinguish "no budget" without inferring it.

P2 — Initialize the counter map for zero-value budgets. Confirmed; the revert-check reproduces the exact panic: assignment to entry in nil map. Your framing of why it matters is the right one: this type tolerates a nil receiver and its other methods tolerate a zero value, so panicking on a construction detail is the inconsistency.

The map is now initialised on first use, and — the part worth calling out — the ceiling and threshold fall back to the §5.2 default rather than staying zero. Lazily initialising only the map would have left a zero ceiling, and refusalThreshold(0) refuses the very first write, so the panic would have become a wedged DEK. A test asserts the zero value agrees with NewWriteBudget(0).

Verification

  • go test ./internal/encryption/ -race -count=1 — ok 28.8s; golangci-lint — 0 issues
  • Revert-checks, each confirming the named test fails with the fix reverted and the file restores byte-exact:
    • nil Remaining returns 0 → TestWriteBudgetNilReceiverAgreesAcrossItsAPIs FAILS
    • no lazy map init → TestWriteBudgetZeroValueIsUsable panics with assignment to entry in nil map
    • zero value keeps a zero ceiling → TestWriteBudgetZeroValueIsUsable FAILS on the default-ceiling and embedded cases

@codex review
@claude review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

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


Review: second-round fixes in 0992fc1

  • Read repo guidelines (CLAUDE.md)
  • Read current internal/encryption/write_budget.go and write_budget_test.go
  • Verify the two fixes claimed in the trigger comment (nil Remaining sentinel, zero-value map init)
  • Check for regressions or new issues introduced by these fixes
  • Post final review

Could not execute go test / golangci-lint in this sandboxed review session — every go/shell invocation here required interactive approval that wasn't available, same limitation the two earlier reviews on this PR hit. This is a static verification of the diff (c49cca6..0992fc1) against the two findings from the 2026-09-12 Codex review, not a re-run of the reported test evidence.

Both fixes check out.

1. Nil-budget Remaining sentinel (write_budget.go:184-207). RemainingUnlimited = ^uint64(0) is exported with a doc comment explaining why zero was unsafe to overload, and Remaining now returns it only on a nil receiver. I checked whether the sentinel could collide with a legitimate value: Remaining can never exceed threshold, and threshold is capped at refusalThreshold(ceiling) ≤ 0.9·ceiling, so even at ceiling = math.MaxUint64 the largest real Remaining is ~10% below MaxUint64 — the sentinel is unreachable via normal computation, so there's no ambiguity between "real remaining count" and "unwired." ^uint64(0) also matches existing usage elsewhere in this repo (main_raft_envelope_wiring.go:16, store/mvcc_store.go:284), so it's idiomatic here, not a one-off.

2. Zero-value map init (counterFor, write_budget.go:260-270). The lazy make(map[uint32]*atomic.Uint64, 1) sits inside the existing write-lock double-check, after the nil-map-safe existence probe (b.counters[keyID] on a nil map is legal and returns ok=false), so the fix doesn't disturb the double-checked-locking shape reviewed earlier — it just closes the one path (first insert on an unconstructed budget) that used to panic.

Consistency check I specifically went looking for a hole in: ceilingOrDefault/refusalThresholdOrDefault treat a field as "unconfigured" via == 0. That's only sound if a properly-constructed budget can never legitimately end up with ceiling == 0 or threshold == 0. Confirmed: NewWriteBudget always substitutes the §5.2 default before storing ceiling (never stores 0), and refusalThreshold always clamps to at least 1 (never returns 0) — so the two fields are either both zero (true zero-value struct) or both non-zero (constructed), with no partial state. Record and Remaining both go through the same two helper methods, so they can't disagree with each other the way Record/Remaining disagreed before this fix.

The two new tests (TestWriteBudgetNilReceiverAgreesAcrossItsAPIs, TestWriteBudgetZeroValueIsUsable, including the embedded-struct case) are the right shape to pin this — they check cross-API agreement rather than just "doesn't panic," which is exactly what broke last time.

Nothing else to flag. No other call sites exist yet (WriteBudget/RemainingUnlimited are referenced only from the package's own files, confirming the PR's "nothing constructs it yet" scope claim still holds), and the design-doc row from the first round is untouched by this commit.
· branch design/encryption-9c4-write-budget

…-write-budget

# Conflicts:
#	docs/design/2026_04_29_partial_data_at_rest_encryption.md
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