Skip to content

fix: harden rotation soundness under concurrency and crash-resume#596

Merged
FSM1 merged 43 commits into
mainfrom
feat/rotation-soundness-deep-merge-fresh-record-resume-and-durabl
Jul 8, 2026
Merged

fix: harden rotation soundness under concurrency and crash-resume#596
FSM1 merged 43 commits into
mainfrom
feat/rotation-soundness-deep-merge-fresh-record-resume-and-durabl

Conversation

@FSM1

@FSM1 FSM1 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Phase 70 — Rotation Soundness: Deep Merge, Fresh-Record Resume, Durable Floor Concurrency

Closes the read-key rotation-soundness debt deferred across Phases 64/68/69. All six Success Criteria delivered and verified.

What changed

  • SC#1 — local-wins merge (packages/sdk-core/src/rotation/merge.ts, engine.ts): a concurrent-add CAS-409 re-merge no longer downgrades a rotated child's readKeySealed. Wired at both merge sites (mergeConcurrentChildren + updateFolderMetadataAndPublish's inline D-09 merge).
  • SC#2 — deep verifySubtreeClean: now recurses the full subtree (not just immediate children); missing root ⇒ dirty; key-bearing frontier at any depth.
  • SC#3 — fresh-record resume: entry gate no longer branches on completedNodeIds.size; a fresh RotationJobRecord + current root key converges via safe double-rotation. Distinct RootKeyStaleError surfaced (client falls back to top-down re-nav). No key recovery is attempted — a genuinely-lost root key has no cryptographic recovery by design (documented residual).
  • SC#4 — grant threading: grantCallbacks/innerGrants reach both rotateOne call sites so inner-grant re-mint fires.
  • SC#5 — atomic floor store (crates/sdk/src/floor_store.rs): mutex held across spawn_blocking fs I/O, max-preserving, fail-closed on corrupt sidecar, atomic temp-rename, 0600 perms. TS/Rust parity asserted.
  • SC#6 — terminal-owner zeroization + per-root badge (packages/sdk/src/client.ts): activeRootNodeIds: Set<string>, cached IDB connection.

Bug caught by the phase gate and fixed in this PR

The live sdk-e2e gate caught a real over-reach in the initial SC#1 implementation: enqueuing a concurrently-added child for its own rotateOne pass required that child's IPNS write key (which the rotating party may not hold) and orphaned the parent pointer. Fixed to merge + re-seal the concurrent child's SealedChildRef under the parent's new readKey instead of rotating it — matching design section 4.5 ("picked up, full re-key is a follow-on"). See commit reseal not rotate.

Gates

  • Verification: 6/6 (70-VERIFICATION.md, status passed).
  • Live SDK-E2E rotation-crash-safety: 4/4 against the docker + API stack (the SC#1/SC#3 round-trip proof).
  • Unit: sdk-core rotation 86/86, sdk client-rotation 29/29, Rust floor_store 9/9 + high_water 10/10.

Notes

  • This branch also carries the Phase 69 roadmap completion checkbox (.planning bookkeeping swept in during parallel planning) — harmless docs, Phase 69 code already shipped in #594.
  • Source todos remain in .planning/todos/pending/ for triage — the two "followups/hardening" bundles may hold residual items and were intentionally not auto-closed.

Post-review disposition (PR review resolved)

Two reviewers (greptile + CodeRabbit) raised 10 threads, all triaged and resolved:

Fixed in 98a6265b7:

  • high_water.rs — CRITICAL: bump_lock now wraps the whole read/check/bump (was bump-only) — closes a concurrent-resolve anti-rollback TOCTOU.
  • floor_store.rs — fail closed on unreadable sidecars (only NotFound -> empty; other read errors no longer become cold-start).
  • engine.ts — zero the clean-edge childReadKey in a finally.
  • test hygiene — fresh mock key buffers; try/finally around e2e key captures.

Deferred (tracked todos, noted here for transparency):

  • Depth>=2 crash-resume soundness gap (greptile P1 + CodeRabbit): the dirty-frontier consumption path is depth-1-only and reuses a stale child key at dirty edges. As shipped, SC#2/SC#3 (full-subtree verify + fresh-record resume) are proven only for depth-1 / childless-root trees — the e2e gate deliberately used a childless root (the repo's known Pitfall-4 unrecoverable-key window). Large/structural + needs new depth>=2 e2e; tracked in .planning/todos/pending/2026-07-08-rotation-crash-resume-depth2-soundness-gap.md. Reviewer note before merge.
  • Floor-write-failure propagation (CodeRabbit): needs a HighWaterStore::put signature change; folded into the existing rotation-hardening-followups todo.

🤖 Generated with Claude Code

FSM1 and others added 30 commits July 7, 2026 20:29
Entire-Checkpoint: 83cbbccad208
Entire-Checkpoint: 086bedc26077
Author RED-phase unit tests for the rotation-only three-way merge
covering local-wins-on-conflict, remote-only-add-included,
base-only-omission-dropped, and the documented concurrent-delete
resurrection residual accepted in T-70-02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 589efbb59e0f
Add the rotation-only three-way merge as an isolated pure function
so the local-wins conflict policy is syntactically impossible to
invoke from a non-rotation call site, closing the merge-downgrade
Elevation-of-Privilege gap (T-70-01). Re-export from the rotation
barrel; folder/merge.ts's remote-wins mergeChildren is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 3adad415a36f
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: f88550437dd6
Entire-Checkpoint: f30e4b1b3738
…decarFloorStore

Adds three tokio tests against the current unsynchronized floor store: two
concurrency tests spawning N tasks against a shared Arc-wrapped store to
prove no lost updates on same and different node_ids, and a corrupt-sidecar
test proving load_map currently degrades a present-but-corrupt sidecar to a
silent empty map instead of failing closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 1041d54f87c6
…p_floor

JsonSidecarFloorStore::get/put now hold a tokio::sync::Mutex around the
whole load-modify-write critical section, with all blocking filesystem I/O
moved into tokio::task::spawn_blocking so the executor never blocks while
the lock is held. put computes max(existing, candidate) inside that locked
section (mirroring the already-correct TS idbPut pattern), so concurrent
puts on the same or different node_ids can never lost-update each other.

A present-but-unparseable sidecar now fails closed: get reports every node
under that store as maximally floored (a bounded i64::MAX sentinel, never
u64::MAX which would wrap negative and defeat the regression check) instead
of silently degrading to an empty cold-start map, and put refuses to write
over the corrupt file rather than clobbering other nodes' floors. This
required no HighWaterStore trait signature change, so listing.rs/adapter.rs
consumers are unaffected.

high_water.rs's bump_floor call sites (bump_generation, bump_seq,
seed_from_grant, and enforce_resolved's step 4) are now guarded by a
per-instance tokio::sync::Mutex so this orchestration layer's own
read-compare-write window cannot interleave against a concurrent bump,
on top of the store's own authoritative atomicity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 74943bdeb759
…igh-water

Documents that this orchestration layer's bumpFloor is intentionally
non-atomic on both the TS and Rust twins, and that atomicity is owned by
the concrete HighWaterStore adapter -- idbPut already reads the existing
value back inside the same IndexedDB readwrite transaction and writes
Math.max(existing, value), matching the just-hardened
JsonSidecarFloorStore::put (SC#5). No functional TS change was needed;
idbPut was already correct.

Also records the cross-store bump sequencing in enforceResolved
(generationStore then seqStore, awaited sequentially) as an explicit
doc-only residual: both floors only ever rise, so a partial failure between
the two writes is under-protective, never rollback-accepting, and does not
need an atomic multi-store transaction to satisfy this phase's SC#5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: d734ed785a7c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: d1d9629f3efe
Replaces the module-global activeRootNodeId scalar with a Set so
concurrent rotations on different root node ids no longer clobber
each other's badge state — the badge now resets only when the
per-root tracking set drains. Also caches the job-checkpoint IndexedDB
connection instead of opening a fresh one on every persistJob call,
invalidating the cache on onversionchange/close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: da28f60842d9
Adds a web-e2e case driving the real rotation-driver.service.ts
persistJob callback for two distinct root node ids overlapping in
time, proving the badge stays active until BOTH roots finish rather
than resetting when the first root completes. Static-typechecked only
per SC#6; full Playwright run is deferred to the stack-gated wave/phase
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 57b9f3ee670b
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 858ee460f2ca
… sites

Extends the CAS-409 concurrent-add merge describe block with three
assertions that fail against the current remote-wins mergeChildren
implementation:
- local wins on conflict (rotated child new-key seal survives a merge
  against a remote still holding the pre-rotation seal)
- rotateOne returns the CAS-merged children (including a remote-added
  child), not the pre-merge node.children snapshot
- mergeConcurrentChildren exposes { published, mergedChildren }

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 4d392e4bf0c7
mergeConcurrentChildren now delegates to mergeRotatedChildren (local
wins on conflict) instead of the generic remote-wins mergeChildren,
and returns { published, mergedChildren } so rotateOne can capture the
plaintext merged set. rotateOne's CAS-409 merge closure captures the
result into an outer-scope mergedChildrenForReturn (mirroring
registration.ts's currentWriteChildren idiom), and the final return
now hands back the merged children instead of the pre-merge
node.children snapshot, so a concurrently-added child is available to
the BFS caller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 90bfe0dbcf23
updateFolderMetadataAndPublish gains an optional mergeChildrenFn param
(defaults to the existing remote-wins mergeChildren so every
non-rotation caller is byte-unchanged). ParentTrackingState in
engine.ts gains a baseChildrenSnapshot field, captured at
parentTracking.set time before any child-driven mutation. Both D-09
batched-republish call sites in rotateReadFromNode now pass
mergeChildrenFn: mergeRotatedChildren and the captured snapshot as
baseChildren, and diff the republish's returned publishedChildren
against the pre-call snapshot to enqueue any concurrently-added child
onto the BFS frontier for its own rotateOne pass.

folder/merge.ts's mergeChildren remote-wins default is unchanged
(verified via git diff --stat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c5f8debd4990
Adds a depth-2 fixture (root -> subfolder -> grandchild) asserting a
dirty grandchild edge is detected with a usable engine-derived
nodeReadKey, a missing-root record surfaces as dirty rather than
silently clean, and a fully-clean multi-level tree returns no dirty
frontier. Fails against the current depth-1-only implementation.

Also adjusts two pre-existing depth-1 verifySubtreeClean fixtures
(their clean child edges default to kind folder) to mark the child as
kind file, since the upcoming recursive implementation will otherwise
attempt to recurse into them against a static unsealNode mock that
returns the same root node for every call, hanging the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 52b95cfc040a
verifySubtreeClean now walks the FULL subtree instead of only the
root's immediate children: it recurses into every clean folder edge to
find dirty edges at any depth, and treats a missing root IPNS record
as dirty rather than silently returning clean. Recursion stops below a
dirty edge since the derived key there is the child's stale
pre-rotation key and cannot unseal its current body — there is no
cryptographic recovery path for a key genuinely lost to an
interrupted prior run.

The returned frontier item shape is now { ipnsName, nodeId,
parentIpnsName, nodeReadKey, childPubKind, enqueuedGeneration } so a
dirty node discovered at any depth carries everything a BFS consumer
needs to seed its queue directly, without re-deriving keys under a
depth-1 assumption.

Extracts two shared key-chain-walk helpers (resolveAndFetchNode,
resolveChildKeyAndEnvelope) used by both verifySubtreeClean and the
main BFS in rotateReadFromNode, replacing four previously-duplicated
resolve+unseal call sites with a single implementation. The stale
docstring claiming fresh-record resume depends on an unwired Phase-68
durable floor is corrected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c3c478c03bea
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 4717cc9b3472
…, grant reachability, fail-closed accounting, and fresh-copy dirty-resume

- Extends engine.test.ts with 5 new behaviors for rotateReadFromNode's entry gate
- Rewrites the stale 64-07 no-double-bump convergence-guard test into a safe
  double-rotation test (design 4.5) since the old skip semantics are being replaced
- Adjusts two pre-existing dirty-resume fixtures whose static unsealNode mocks
  collide node identity once the child is genuinely re-entered via rotateOne

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 99f52da47a53
…leError, grant threading, and fresh-copy dirty-resume result

- rotateReadFromNode's entry gate now probes root-unseal viability with the
  supplied rootReadKey BEFORE deciding fresh rotateOne(root) vs dirty-tail
  recovery, regardless of completedNodeIds.size; a stale key throws a distinct
  RootKeyStaleError (re-exported via rotation/index.ts and sdk-core index.ts)
  instead of a generic AEAD failure, documenting the genuinely-lost-root-key
  window as an accepted residual with no cryptographic recovery
- verifySubtreeClean-driven dirty-tail detection now runs unconditionally
  (not gated on completedNodeIds.size) using the pre-rotation rootReadKey,
  consuming plan 70-05's key-bearing DirtyFrontierItem frontier to seed the
  BFS queue directly, even below a would-be convergence-skipped ancestor
- Removed the ROT-06 no-double-bump convergence guard from the BFS dequeue
  loop; every queued item is rotated via the normal rotateOne call (design
  4.5 safe double-rotation) instead of being skipped when already ahead of
  its enqueued baseline generation
- Added grantCallbacks/innerGrants to RotationParams, threaded to every
  rotateOne call site (root and BFS loop) so reMintGrantsRootedAt is
  reachable from the real walk, not only via direct rotateOne injection
- A missing IPNS/envelope record on the frontier (root children enqueue,
  dirty-resume seeding, grandchildren enqueue) is now fail-closed accounted
  via a shared decrementPendingAndMaybeRepublish helper instead of a silent
  continue that desynced pendingChildCount and stalled the parent's D-09
  batched republish forever
- The dirty-resume-republish path returns a truthy RotateReadResult whose
  readKey is a fresh Uint8Array copy of the caller-supplied rootReadKey,
  never an alias of the caller-owned buffer
- collectDirtyFrontier zeroes a clean edge's derived readKey once fully
  processed (D-09 terminal-owner hygiene); a dirty edge's key is left
  untouched since it survives into the returned frontier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 016a3d7afcc0
Entire-Checkpoint: a86120c1152e
…ScopeExitRotation

- performScopeExitRotation now zeroes rotationResult.readKey after the
  folderTree defensive copy is taken, closing the SC#6 gap where the
  engine-returned readKey was never zeroed by its caller
- safe because plan 70-06 guarantees rotateReadFromNode always returns a
  fresh copy, never an alias of the caller-owned rootReadKey
- extends client-rotation.test.ts with paired zeroization assertions
  (owner-zeroed, folderTree-copy-unchanged, caller-rootReadKey-unchanged)
  and fixes the pre-existing folderTree-refresh test to snapshot expected
  bytes before the call, since rotatedReadKey is now zeroed in place

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c129af71e03c
…igation

- performScopeExitRotation's rotate() now catches RootKeyStaleError from
  rotateReadFromNode specifically (typed via the sdk-core barrel, not a
  string match) instead of letting it fail the already-published mutation
- on catch, drops the stale folderTree entry and re-navigates top-down
  from the vault root via ensureFolderLoaded to rediscover the current key
  via the parent chain (Open Question 1's recommended recovery path) --
  no cryptographic key recovery is claimed, only re-derivation via the
  chain
- when re-navigation also fails to recover the root (Open Question 2's
  pure-revoke ancestor-mirror residual can block this one hop earlier),
  surfaces a clear, actionable error instead of a generic AEAD failure
- adds client-rotation.test.ts coverage for the recovered, unrecoverable,
  and non-RootKeyStaleError-passthrough cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 8a7fbdffdfde
FSM1 and others added 6 commits July 7, 2026 23:56
…d child

Test 3 previously only checked child IPNS names survived the CAS-409
merge, which masks the merge-downgrade bug where remote-wins would
leave subfolder3's SealedChildRef pointing at its stale pre-rotation
key. Now derives subfolder3's key via unsealChildReadKey against the
new root key and unseals its actual published body, plus asserts the
concurrent-write injection callback genuinely ran so the test cannot
silently pass without exercising the CAS-409 merge path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: bc0cc5a3cdc4
Adds a new e2e case proving SC#3's fresh-record resume: rotation
crashes immediately after the root's own commit (earlier than the
existing final-persist crash), then resumes with a brand-new
RotationJobRecord (empty completedNodeIds, not seeded from crash-time
state) and the current valid rootReadKey. The root is a single file
with no children, since any node whose own D-09 batched
parent-republish has not fired leaves its children's SealedChildRef
entries wrapped under a pre-rotation key that cannot be re-derived
from the node's post-rotation key - a childless root avoids that
unrecoverable window while still proving the entry gate converges via
safe double-rotation and cuts the pre-rotation grant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: b3bcd4718723
Plan 70-04's enqueueConcurrentlyAddedChildren over-reached ROT-05 by pushing
a concurrently-added child onto the BFS queue for its own rotateOne pass,
which required an IPNS write key the rotating party may not hold, and ran
after parentTracking teardown so the parent's SealedChildRef readKeySealed
was never repointed at the new key.

Replace it with createConcurrentAddResealingMerge, an async mergeChildrenFn
wrapper invoked inside the D-09 batched republish's CAS-409 merge. It merges
the concurrent child via mergeRotatedChildren and re-seals only its
readKeySealed wrapper under the parent's new readKey, trying both the old
and already-current key since a concurrent writer may have used either
depending on race timing. publishWithCas and updateFolderMetadataAndPublish
already support an async merge callback; widen mergeChildrenFn's type to
match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 6fb006865be5
Documents the 70-04 over-reach diagnosis and correction (commit
7faa0e82835d56368ea87f969d57b083d43ea9a3), plus the live sdk-e2e
rotation-crash-safety gate result (4/4) and sdk-core unit result (355/355).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 40313205f74b
Phase 70 rotation soundness verified 6/6 (VERIFICATION.md). Live
sdk-e2e rotation-crash-safety gate 4/4; unit sdk-core rotation 86/86,
sdk client-rotation 29/29, Rust floor_store 9/9 + high_water 10/10.
VALIDATION.md status flipped to passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: e6ebdea39e2a
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR implements Phase 70 "rotation soundness" fixes: a rotation-only local-wins merge policy for concurrent CAS-409 conflicts, a fully recursive dirty-subtree detection with richer resume frontier items, RootKeyStaleError fail-closed handling with client fallback, Rust floor-store mutex/atomicity and fail-closed corruption handling, per-root badge tracking and cached IndexedDB connections in the web rotation driver, and terminal-owner zeroization in the SDK client. Extensive phase planning/verification documentation and tests accompany the changes.

Changes

Rotation Soundness (sdk-core, Rust, web, sdk client)

Layer / File(s) Summary
Rotation-only local-wins merge
packages/sdk-core/src/rotation/merge.ts, packages/sdk-core/src/rotation/index.ts, packages/sdk-core/src/index.ts, packages/sdk-core/src/__tests__/rotation/merge.test.ts
Adds mergeRotatedChildren with local-wins conflict resolution, remote-only add inclusion, base-only delete dropping, with barrel re-exports and unit tests including a documented resurrection residual.
Injectable merge in folder publish
packages/sdk-core/src/folder/registration.ts
updateFolderMetadataAndPublish gains an optional async mergeChildrenFn override for CAS-409 conflict resolution, defaulting to existing mergeChildren.
Rotation engine dirty-resume rework
packages/sdk-core/src/rotation/engine.ts, packages/sdk-core/src/__tests__/rotation/engine.test.ts
Introduces RootKeyStaleError, restructures verifySubtreeClean into full recursive traversal with richer DirtyFrontierItems, captures CAS-merged children in rotateOne, removes the no-double-bump guard for safe double-rotation, and threads grant callbacks through the BFS walk.
Client zeroization and stale-root fallback
packages/sdk/src/client.ts, packages/sdk/src/__tests__/client-rotation.test.ts
performScopeExitRotation zeroizes the terminal-owned read key and catches RootKeyStaleError to trigger top-down folderTree re-navigation instead of failing the mutation.
Rust floor store atomicity and fail-closed corruption
crates/sdk/src/floor_store.rs, crates/sdk/src/rotation/high_water.rs
Adds a mutex-guarded critical section with spawn_blocking I/O, atomic max-preserving writes, fail-closed sentinel handling for corrupt sidecars, and a bump_lock serializing floor bumps within a RotationHighWater instance.
Web rotation driver multi-root tracking
apps/web/src/services/rotation-driver.service.ts, tests/web-e2e/tests/rotation-ux.spec.ts
Replaces single active-root scalar with a Set for concurrent multi-root badge tracking, caches the IndexedDB connection with new closeJobDB() export, and adds an e2e test for overlapping rotations.
SDK e2e crash-safety strengthening
tests/sdk-e2e/src/suites/rotation-crash-safety.test.ts
Strengthens the concurrent-add scenario with unseal verification and fail-fast injection guards, and adds a fresh-record mid-walk crash resume scenario.
Phase 70 planning/verification docs
.planning/REQUIREMENTS.md, .planning/ROADMAP.md, .planning/STATE.md, .planning/phases/70-rotation-soundness-.../*
Adds complete Phase 70 plan/summary/research/patterns/security/validation/verification/learnings documents and marks Phase 70 complete in roadmap/state tracking.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • FSM1/cipher-box#488: Both directly modify updateFolderMetadataAndPublish's CAS-409 merge flow; this PR extends it with an injectable mergeChildrenFn.
  • FSM1/cipher-box#579: Both directly modify packages/sdk-core/src/rotation/engine.ts, with this PR reworking the dirty-resume/CAS-merge logic introduced there.
  • FSM1/cipher-box#587: Both touch apps/web/src/services/rotation-driver.service.ts and rotation badge lifecycle tests, extending existing concurrent-rotation behavior.

Suggested labels: release:api:feat, release:sdk-core:fix

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: hardening rotation behavior for concurrency and crash-resume across the Phase 70 work.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rotation-soundness-deep-merge-fresh-record-resume-and-durabl

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.

@github-actions github-actions Bot added release:sdk-core:feat Minor version bump (new feature) for sdk-core release:cipherbox-sdk:feat Minor version bump (new feature) for cipherbox-sdk release:web:fix Patch version bump (bug fix) for web release:sdk:feat Minor version bump (new feature) for sdk release:tee-worker:fix Patch version bump (bug fix) for tee-worker release:desktop:fix Patch version bump (bug fix) for desktop labels Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Release Preview

Package Bump Label Source
cipherbox-sdk minor release:cipherbox-sdk:feat Direct (feat commit)
desktop minor release:desktop:fix Cascade (cipherbox-sdk minor)
sdk minor release:sdk:feat Direct (feat commit)
sdk-core minor release:sdk-core:feat Direct (feat commit)
tee-worker patch release:tee-worker:fix Cascade (sdk-core minor)
web minor release:web:fix Direct (fix commit)

Cascade Details

  • sdk-core minor -> tee-worker patch (direct dependency)
  • cipherbox-sdk minor -> desktop patch (direct dependency)

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.29515% with 94 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.81%. Comparing base (4b96aa9) to head (f9a9d8e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
packages/sdk-core/src/rotation/engine.ts 73.64% 72 Missing and 1 partial ⚠️
crates/sdk/src/floor_store.rs 90.67% 11 Missing ⚠️
packages/sdk/src/client.ts 72.22% 10 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #596      +/-   ##
==========================================
+ Coverage   65.89%   73.81%   +7.91%     
==========================================
  Files         153      193      +40     
  Lines       14551    28412   +13861     
  Branches     1700     1731      +31     
==========================================
+ Hits         9589    20971   +11382     
- Misses       4717     7196    +2479     
  Partials      245      245              
Flag Coverage Δ
api 79.83% <74.92%> (-0.13%) ⬇️
api-client 79.83% <74.92%> (-0.13%) ⬇️
core 79.83% <74.92%> (-0.13%) ⬇️
crypto 79.83% <74.92%> (-0.13%) ⬇️
desktop 21.82% <ø> (-9.62%) ⬇️
rust 76.13% <91.05%> (?)
sdk 79.83% <74.92%> (-0.13%) ⬇️
sdk-core 79.83% <74.92%> (-0.13%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/sdk/src/rotation/high_water.rs 99.62% <100.00%> (ø)
packages/sdk-core/src/folder/registration.ts 90.44% <100.00%> (+0.06%) ⬆️
packages/sdk-core/src/rotation/merge.ts 100.00% <100.00%> (ø)
packages/sdk/src/state/rotation-high-water.ts 100.00% <ø> (ø)
packages/sdk/src/client.ts 70.13% <72.22%> (-0.06%) ⬇️
crates/sdk/src/floor_store.rs 94.73% <90.67%> (ø)
packages/sdk-core/src/rotation/engine.ts 80.02% <73.64%> (-2.97%) ⬇️

... and 95 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

FSM1 and others added 2 commits July 8, 2026 00:52
…covery error

CodeRabbit findings on Phase 70 branch. engine.ts leaked the nodeReadKey
buffer for a dirty-frontier item that got deduped in
enqueueDirtyFrontierItem, and left the dirty-resume loop's
frontierItem.nodeReadKey unzeroed on every exit path since that buffer
is never adopted into queue (a freshly re-derived childReadKey is
adopted instead). client.ts swallowed the real ensureFolderLoaded
recovery error behind .catch(() => null), so every RootKeyStaleError
recovery failure was misreported as the known stale-key residual
instead of surfacing the actual underlying error as cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 2004e720d51b
Crypto security audit: SECURED (0 critical/high/medium, 5 low residuals).
Concurrent-add re-seal fix verified sound; local-wins confirmed
load-bearing for revocation. Adds 70-LEARNINGS.md; reconciles STATE.md
current-focus drift flagged by review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 6a37b1a18c0c
@FSM1 FSM1 marked this pull request as ready for review July 8, 2026 11:16
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR hardens read-key rotation across merge, resume, and durable floor handling. The main changes are:

  • Rotation-only local-wins child merge and concurrent-add resealing.
  • Recursive dirty-subtree checks and stale-root recovery handling.
  • Grant callback threading through the rotation walk.
  • Atomic Rust floor-store updates with corrupt-sidecar fail-closed behavior.
  • Per-root rotation badge tracking and client-side key zeroization.

Confidence Score: 4/5

This is close, but the nested resume path should be fixed before merging.

  • A dirty descendant below the root can be skipped during resume.
  • The affected parent mirror is not republished with the rotated child key.
  • The subtree can remain unreadable or incompletely rotated after the job reports recovery.

packages/sdk-core/src/rotation/engine.ts

Security Review

A nested crash-resume path can still leave a descendant read-key rotation incomplete, which can weaken revocation soundness for that subtree.

Important Files Changed

Filename Overview
packages/sdk-core/src/rotation/engine.ts Expands rotation resume and merge handling, but the skipped-root resume branch can still drop dirty descendants below depth one.
packages/sdk-core/src/folder/registration.ts Adds an async merge-policy hook for rotation-specific CAS conflict handling.
packages/sdk-core/src/rotation/merge.ts Adds a rotation-only local-wins merge helper for child references.
crates/sdk/src/floor_store.rs Serializes sidecar floor updates and fails closed on corrupt floor files.
crates/sdk/src/rotation/high_water.rs Serializes high-water read, check, and bump operations.
packages/sdk/src/client.ts Handles stale rotation keys with folder-tree recovery and zeroizes returned rotation keys.
apps/web/src/services/rotation-driver.service.ts Tracks active rotation roots with a set and caches the job IndexedDB connection.

Reviews (2): Last reviewed commit: "chore(70): defer depth>=2 crash-resume s..." | Re-trigger Greptile

Comment thread packages/sdk-core/src/rotation/engine.ts
Comment thread packages/sdk-core/src/rotation/engine.ts

@coderabbitai coderabbitai 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.

Actionable comments posted: 7

🧹 Nitpick comments (2)
.planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-RESEARCH.md (1)

428-455: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Refresh the gap table or mark this note historical.

The test map and Wave 0 gap list still read as pre-implementation (, Wave 0) even though the roadmap/state now mark Phase 70 complete. That leaves the same work described as both done and pending. Please either update the status or archive the note as historical research.

🤖 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
@.planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-RESEARCH.md
around lines 428 - 455, The Phase 70 research note still lists several tests and
gaps as pending with Wave 0 markers even though the phase is now marked
complete. Update the test map and Wave 0 gap list in 70-RESEARCH.md to reflect
the current completed status, or move/archive the note as historical so it no
longer conflicts with the roadmap; focus on the status markers and gap items
rather than the test descriptions themselves.
.planning/STATE.md (1)

5-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use one completion scope per progress block.

total_plans/completed_plans and percent look like they come from different denominators, so this state snapshot now mixes plan counts with a phase-completion percentage. Please split phase-level and plan-level progress, or derive both renderings from the same source field.

🤖 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 @.planning/STATE.md around lines 5 - 17, The progress snapshot in STATE.md is
mixing phase-level and plan-level tracking, so update the progress block to use
a single completion scope per block. Adjust the state representation around
current_phase/current_phase_name and progress so total_plans/completed_plans and
percent are derived from the same source field, or split them into separate
phase-progress and plan-progress fields to avoid mismatched denominators.
🤖 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 `@crates/sdk/src/floor_store.rs`:
- Around line 62-75: The `load_map_blocking` fallback is too broad: it currently
treats every `std::fs::read` failure as `LoadOutcome::Empty`, which lets
unreadable existing sidecars bypass the fail-closed behavior. Update the `match
std::fs::read(path)` branch in `load_map_blocking` so only
`std::io::ErrorKind::NotFound` returns `LoadOutcome::Empty`; for other read
errors such as permission or I/O failures, log an error with the path and error
details and return `LoadOutcome::Corrupt` (or the appropriate fail-closed
outcome used by `load_map_blocking`/`LoadOutcome`).
- Around line 212-223: JsonSidecarFloorStore::put currently only logs failures
from the blocking write path, so callers like HighWaterStore::put and bump_floor
can still succeed even when the floor never reaches disk. Update put to
propagate the persistence error instead of swallowing it (including both the
Ok(Err(e)) and panic cases), and make enforce_resolved/floor update paths fail
closed if the write fails so the caller can observe the error.

In `@crates/sdk/src/rotation/high_water.rs`:
- Around line 266-272: The `enforce_resolved` flow in `high_water.rs` only locks
`bump_lock` around the final `bump_floor` calls, which still allows concurrent
reads of stale floors and can accept a rollback. Move the `bump_lock`
acquisition to cover the floor checks and the validation path for the
`generation_store` and `seq_store`, so the read-compare-write sequence is
serialized within `enforce_resolved`. Keep the existing final monotonic bumps
under the same guard so the whole check-and-bump operation stays atomic for a
given instance.

In `@packages/sdk-core/src/__tests__/rotation/engine.test.ts`:
- Around line 1798-1800: The mocks in `collectDirtyFrontier`-related tests are
returning shared module-level key buffers, which can be zeroized by the
implementation and corrupt the expected fixture. Update the `unsealChildReadKey`
mock in the `engine.test.ts` dirty-frontier and clean-tree cases to return fresh
copies for each call, and keep assertions against an immutable snapshot of the
expected key values. Reference the `unsealChildReadKey` mock setup and the
affected `collectDirtyFrontier` test cases to apply the same pattern
consistently.

In `@packages/sdk-core/src/rotation/engine.ts`:
- Around line 1395-1411: The dirty-frontier handling in verifySubtreeClean is
only looking up frontierItem.ipnsName against rootNode.children, which lets
depth-2+ dirty items be treated as missing and can finish without repairing the
real stale parent mirror. Update the dirty-resume flow around
verifySubtreeClean, frontier processing, and decrementPendingAndMaybeRepublish
so items are queued/released by parentIpnsName and only processed once the
matching parent tracking state exists; for non-root parents, carry or
reconstruct the needed ParentTrackingState instead of assuming root-level
children. Also adjust the normal frontier ordering so dirty items are not
rotated before their parent’s parentTracking entry has been created.
- Around line 655-697: The clean-edge childReadKey cleanup in
collectDirtyFrontier can be skipped if unsealNode or the recursive
collectDirtyFrontier call throws, leaving the derived key unzeroed. Move the
zeroing logic for childReadKey into a finally block around the clean-edge
processing in collectDirtyFrontier so it always runs after
resolveChildKeyAndEnvelope, regardless of whether the folder unseal or recursion
succeeds. Keep the existing dirty-edge early continue behavior intact, and
preserve the defensive Uint8Array check when zeroing the key.

In `@tests/sdk-e2e/src/suites/rotation-crash-safety.test.ts`:
- Around line 779-789: Clear all captured key material in the rotation
crash-safety test paths, including on assertion failures. Update the
unseal/assertion flow around unsealChildReadKey, unsealNode, and the
fresh-resume logic so owned buffers like sub3ReadKey, readKeyPrimeRoot4,
resumeResult4.readKey, and any entries in capturedReadKeys are zeroed in
try/finally blocks after use. Keep the existing assertions, but ensure every
buffer that stores sensitive read keys is always wiped even if a test fails.

---

Nitpick comments:
In
@.planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-RESEARCH.md:
- Around line 428-455: The Phase 70 research note still lists several tests and
gaps as pending with Wave 0 markers even though the phase is now marked
complete. Update the test map and Wave 0 gap list in 70-RESEARCH.md to reflect
the current completed status, or move/archive the note as historical so it no
longer conflicts with the roadmap; focus on the status markers and gap items
rather than the test descriptions themselves.

In @.planning/STATE.md:
- Around line 5-17: The progress snapshot in STATE.md is mixing phase-level and
plan-level tracking, so update the progress block to use a single completion
scope per block. Adjust the state representation around
current_phase/current_phase_name and progress so total_plans/completed_plans and
percent are derived from the same source field, or split them into separate
phase-progress and plan-progress fields to avoid mismatched denominators.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 337ad58a-2e1a-4359-a670-7d6ee9caf9ea

📥 Commits

Reviewing files that changed from the base of the PR and between 14944f4 and f851691.

📒 Files selected for processing (40)
  • .planning/REQUIREMENTS.md
  • .planning/ROADMAP.md
  • .planning/STATE.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-01-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-01-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-02-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-02-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-03-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-03-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-04-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-04-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-05-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-05-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-06-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-06-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-07-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-07-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-08-PLAN.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-08-SUMMARY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-LEARNINGS.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-PATTERNS.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-RESEARCH.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-SECURITY.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-VALIDATION.md
  • .planning/phases/70-rotation-soundness-deep-merge-fresh-record-resume-and-durabl/70-VERIFICATION.md
  • apps/web/src/services/rotation-driver.service.ts
  • crates/sdk/src/floor_store.rs
  • crates/sdk/src/rotation/high_water.rs
  • packages/sdk-core/src/__tests__/rotation/engine.test.ts
  • packages/sdk-core/src/__tests__/rotation/merge.test.ts
  • packages/sdk-core/src/folder/registration.ts
  • packages/sdk-core/src/index.ts
  • packages/sdk-core/src/rotation/engine.ts
  • packages/sdk-core/src/rotation/index.ts
  • packages/sdk-core/src/rotation/merge.ts
  • packages/sdk/src/__tests__/client-rotation.test.ts
  • packages/sdk/src/client.ts
  • packages/sdk/src/state/rotation-high-water.ts
  • tests/sdk-e2e/src/suites/rotation-crash-safety.test.ts
  • tests/web-e2e/tests/rotation-ux.spec.ts

Comment thread crates/sdk/src/floor_store.rs Outdated
Comment thread crates/sdk/src/floor_store.rs
Comment thread crates/sdk/src/rotation/high_water.rs Outdated
Comment thread packages/sdk-core/src/__tests__/rotation/engine.test.ts Outdated
Comment thread packages/sdk-core/src/rotation/engine.ts
Comment thread packages/sdk-core/src/rotation/engine.ts
Comment thread tests/sdk-e2e/src/suites/rotation-crash-safety.test.ts Outdated
FSM1 and others added 3 commits July 8, 2026 13:35
RR-01 (concurrent-add downgrade), RR-02 (fresh-record resume/SC#4),
floor-store atomicity, and the coderabbit rotation-soundness followups
are closed by Phase 70. hardening-followups kept in pending with items
1 (cross-store atomicity) and 5 (reconcile cached generation) still open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 75291945dd52
- widen enforce_resolved's bump_lock to cover the whole floor
  read-check-bump sequence so two concurrent calls cannot both read a
  stale floor and accept a rollback under concurrency
- fail closed on non-NotFound floor sidecar read errors instead of
  silently treating a present-but-unreadable file as cold-start,
  which bypassed the anti-rollback regression check
- zero collectDirtyFrontier's locally-derived clean-edge read key in a
  finally block so a throw from unsealNode or the recursive call no
  longer leaves it live
- return fresh copies from the engine.test.ts unsealChildReadKey mock
  so collectDirtyFrontier zeroing a derived key cannot mutate the
  shared fixture and mask a regression via a self-referential toEqual
- wrap the sdk-e2e rotation-crash-safety key-material assertions in
  try/finally so an assertion failure still zeroes owned key buffers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: cf513607cd76
PR #596 review (greptile P1 + CodeRabbit) found the dirty-frontier
consumption path is depth-1-only and reuses a stale child key at dirty
edges. Real but large/structural (needs new depth>=2 e2e + a key-source
design decision entangled with the known Pitfall-4 limitation). SC#2/SC#3
are proven for depth-1/childless-root as shipped; tracked for a follow-on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: f759f48de47c
Comment thread packages/sdk-core/src/rotation/engine.ts
@FSM1 FSM1 merged commit faa781e into main Jul 8, 2026
31 checks passed
@FSM1 FSM1 deleted the feat/rotation-soundness-deep-merge-fresh-record-resume-and-durabl branch July 8, 2026 12:15
FSM1 added a commit that referenced this pull request Jul 8, 2026
docs(roadmap): insert Phase 70.1 for rotation read-plane durability

Deferred rotation read-plane debt from Phase 70 ship (PR #596 review):
depth>=2 crash-resume soundness gap, floor-store write-failure
propagation + cross-store atomicity, and the reconcile cached-generation
gate. Retargets the depth-gap todo (resolves_phase 70.1) and folds
hardening-followups items 1 + 5 into the phase. Depends on Phase 70.


Entire-Checkpoint: b65e7c88bc74

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:cipherbox-sdk:feat Minor version bump (new feature) for cipherbox-sdk release:desktop:fix Patch version bump (bug fix) for desktop release:sdk:feat Minor version bump (new feature) for sdk release:sdk-core:feat Minor version bump (new feature) for sdk-core release:tee-worker:fix Patch version bump (bug fix) for tee-worker release:web:fix Patch version bump (bug fix) for web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant