diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index e6989ffb7d..35ede5de1c 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -862,18 +862,29 @@ Plans:
### Phase 52: Desktop FUSE Durability & At-Rest Safety
-**Goal:** [To be planned]
+**Goal:** Bound and harden the desktop FUSE write-journal so large-file writes never block or OOM the filesystem, replay never stalls the mount, retention is bounded across vaults, and no plaintext filename or host path persists at rest.
**Requirements**: HARD-03
**Depends on:** Phase 49 (v1.1 baseline)
-**Plans:** 0 plans
+**Plans:** 5 plans
Scope (captured todos):
- [ ] **[#9]** FUSE write-journal unbounded growth + ciphertext-in-JSON, and replay has no network timeout — `2026-06-18-fuse-journal-growth-and-replay-timeout.md`
Plans:
+**Wave 1**
+
+- [ ] 52-01-PLAN.md — D-05 sanitize_error scrub + D-06 logged journal.remove failures (trivials, Wave 1)
+- [ ] 52-02-PLAN.md — D-01/D-04 journal entry shape: sidecar fields, put_with_sidecar, GC/cap constants, compat deserializer (Wave 1)
+
+**Wave 2** *(blocked on Wave 1 completion)*
+
+- [ ] 52-03-PLAN.md — D-01/D-04 write-side: size cap, ECIES filename encryption, off-thread sidecar write with bounded durable-ack oneshot (Wave 2)
+- [ ] 52-04-PLAN.md — D-03/D-04 replay-side: sidecar read+verify, name decryption, per-entry timeouts, replay concurrent with mount (Unix+Windows) (Wave 2)
+
+**Wave 3** *(blocked on Wave 2 completion)*
-- [ ] TBD (run /gsd:plan-phase 52 to break down)
+- [ ] 52-05-PLAN.md — D-02 retention: purge_vault on logout + gc_failed_entries at mount (Wave 3)
### Phase 53: Release & Supply-Chain Engineering
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 4036504fef..765107919e 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -290,7 +290,7 @@ All M2 blockers resolved. See `.planning/milestones/m2/m2-v1.0-production-MILEST
Last activity: 2026-06-20
-Last session: 2026-06-19T22:20:00Z
+Last session: 2026-06-19T23:48:07.982Z
## Decisions
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-PLAN.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-PLAN.md
new file mode 100644
index 0000000000..3c543365ff
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-PLAN.md
@@ -0,0 +1,224 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - crates/sdk/src/sync.rs
+ - crates/fuse/src/lib.rs
+ - crates/fuse/src/write_ops.rs
+autonomous: true
+requirements: [HARD-03]
+tags: [fuse, journal, error-handling, at-rest-safety, rust]
+
+must_haves:
+ truths:
+ - "Error strings shown in tray/notification copy never contain a real host path prefix (/Users, /home, /var, /tmp, /private, or C:\\Users\\)"
+ - "A failed journal.remove() after a successful replay/publish emits a log::warn! instead of being silently swallowed"
+ artifacts:
+ - path: "crates/sdk/src/sync.rs"
+ provides: "Extended regex_replace_paths scrub list (D-05)"
+ contains: "/var/"
+ - path: "crates/fuse/src/lib.rs"
+ provides: "Logged journal.remove failures at the two replay success arms (D-06)"
+ contains: "if let Err"
+ - path: "crates/fuse/src/write_ops.rs"
+ provides: "Logged journal.remove failure at the mkdir parent-publish success arm (D-06)"
+ contains: "if let Err"
+ key_links:
+ - from: "crates/sdk/src/sync.rs sanitize_error"
+ to: "regex_replace_paths"
+ via: "scrub-list branch covers all six prefixes"
+ pattern: "regex_replace_paths"
+---
+
+
+Land the two trivial, dependency-free at-rest-safety fixes from the Phase 43 review:
+D-05 (extend `sanitize_error` path scrub) and D-06 (stop swallowing `journal.remove` errors
+at the three known sites). Neither change touches the journal entry struct, so this plan is
+independent of every other Phase 52 plan and runs in the first wave.
+
+Purpose: Prevent host-path leakage into user-facing error copy (Information Disclosure) and
+prevent a failed journal removal from silently causing a later double-replay / double-publish
+(Tampering). Both are HARD-03 line items.
+
+Output: Extended scrub list in `regex_replace_paths`; three `let _ = journal.remove(...)`
+sites replaced with `if let Err(e) { log::warn!(...) }`; new unit tests for both.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-RESEARCH.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
+
+
+
+This plan introduces NO new public symbols (struct fields, fns, or constants). It only:
+- extends the existing `regex_replace_paths` branch in `crates/sdk/src/sync.rs` with new prefixes
+- swaps three `let _ = journal.remove(...)` statements for `if let Err(e) { log::warn!(...) }`
+Source-grounding should treat `regex_replace_paths`, `sanitize_error`, `journal.remove`,
+`log::warn!` as PRE-EXISTING symbols (not phase-new).
+
+
+
+
+
+ Task 1: D-05 — extend sanitize_error scrub to /var, /tmp, /private, and C:\Users\
+ crates/sdk/src/sync.rs
+
+ - crates/sdk/src/sync.rs:244-285 (the file being modified — `sanitize_error` and `regex_replace_paths`, the single scrub site)
+ - 52-PATTERNS.md "crates/sdk/src/sync.rs (D-05 sanitize_error path scrub)" — the exact extension block (additional `||` prefixes + the new Windows `else if c.is_ascii_uppercase()` branch)
+ - 52-RESEARCH.md "D-05: sanitize_error Path Scrub (IN-04)" and "Pattern 5: regex_replace_paths extension"
+
+
+ Tests (add to the existing `#[cfg(test)] mod tests` in sync.rs; name the test exactly `sanitize_error_extended_paths`):
+ - `/Users/alice/secret.txt` in an error → replaced with `[path]` (existing behavior, keep green)
+ - `/home/bob/x` → `[path]`
+ - `/var/folders/zz/foo` → `[path]`
+ - `/tmp/cb-journal/abc` → `[path]`
+ - `/private/var/foo` → `[path]`
+ - `C:\Users\carol\AppData\file` → `[path]` (drive letter is any ASCII uppercase, e.g. also `D:\Users\...`)
+ - A string with no path prefix is returned unchanged
+ - Scrub stops at the first whitespace / `"` / `'` boundary (does not over-consume the rest of the message)
+
+
+ Per D-05: extend the existing `if c == '/' && (input[i..].starts_with("/Users/") || input[i..].starts_with("/home/"))`
+ condition in `regex_replace_paths` (crates/sdk/src/sync.rs:270-283) to ALSO match
+ `input[i..].starts_with("/var/")`, `"/tmp/"`, and `"/private/"`. Add a NEW `else if` branch
+ BEFORE the final `else { result.push(c); }` that matches a Windows drive-letter path:
+ `c.is_ascii_uppercase() && i + 1 < input.len() && input[i + 1..].starts_with(":\\Users\\")`.
+ In BOTH branches, push `"[path]"` and reuse the existing skip-until-whitespace/quote loop
+ (peek for `next_c.is_whitespace() || next_c == '"' || next_c == '\''`) verbatim — copy the
+ exact loop from the PATTERNS.md analog so behavior matches the current `/Users/` handling.
+ Do NOT introduce the `regex` crate; keep the hand-rolled char_indices scan that is already there.
+
+
+ cargo test -p cipherbox-sdk sanitize_error_extended_paths 2>&1 | tail -20
+
+
+ `sanitize_error_extended_paths` passes; all six prefixes (5 Unix + Windows drive-letter) are
+ scrubbed to `[path]`; existing `/Users//home` behavior unchanged; no new external crate added.
+
+
+
+
+ Task 2: D-06 — log journal.remove failures at all three swallowed-removal sites
+ crates/fuse/src/lib.rs, crates/fuse/src/write_ops.rs
+
+ - crates/fuse/src/lib.rs:1489-1514 (the file being modified — the MkdirPublish success arm at :1494 and the adjacent record_failure warn! format to mirror)
+ - crates/fuse/src/lib.rs:1551-1578 (the UploadFile success arm at :1558)
+ - crates/fuse/src/write_ops.rs:678-680 (the file being modified — the mkdir parent-publish success arm)
+ - 52-PATTERNS.md "crates/fuse/src/lib.rs (D-06 ...)" and "crates/fuse/src/write_ops.rs (D-06 ...)" — exact replacement blocks and the `record_failure` warn! format to mirror
+ - 52-RESEARCH.md "D-06: Swallowed Removal Log (IN-05)"
+
+
+ Per D-06: replace each of the three `let _ = journal.remove(...)` statements with an
+ `if let Err(e) = journal.remove(...) { log::warn!(...) }` block that names the entry id and
+ states the double-replay risk, mirroring the adjacent `record_failure` `log::warn!` format
+ (lib.rs:1505-1513). The three sites:
+ (1) crates/fuse/src/lib.rs:1494 — MkdirPublish `Ok(())` success arm (`journal.remove(&entry.id)`).
+ Warn message must mention "MkdirPublish" + entry.id + "may replay again on next mount".
+ (2) crates/fuse/src/lib.rs:1558 — UploadFile `Ok(())` success arm (`journal.remove(&entry.id)`).
+ Warn message must mention "UploadFile" + entry.id + "may replay again on next mount".
+ (3) crates/fuse/src/write_ops.rs:679 — mkdir parent-publish `Success` arm
+ (`journal_for_mkdir.remove(&mkdir_journal_entry_id)`). Warn must mention the mkdir entry id
+ + "may replay again on next mount", then keep the existing `log::info!("Parent metadata published after mkdir")`.
+ Do not change control flow otherwise — `journal.remove` already treats NotFound as `Ok(())`
+ (queue.rs:216), so the only error case is a genuine I/O failure. Use the `log` facade
+ (already imported in both crates' Cargo.toml).
+
+
+ cargo test -p cipherbox-fuse remove_failure_is_logged 2>&1 | tail -20
+
+
+ All three sites use `if let Err(e) = ... { log::warn!(...) }`; no `let _ = journal.remove`
+ remains in the three files (grep below); the `remove_failure_is_logged` test passes.
+ Grep gate: `grep -rn "let _ = journal" crates/fuse/src/lib.rs crates/fuse/src/write_ops.rs | grep -v '^#' | wc -l` returns 0 for `.remove(` removal sites.
+
+
+
+
+ Task 3: D-06 test — remove_failure_is_logged
+ crates/fuse/src/lib.rs
+
+ - crates/fuse/src/lib.rs:2276+ (the file being modified — existing `#[cfg(test)] mod tests` block; this is where the new test goes)
+ - crates/sdk/src/queue.rs:203-218 (WriteQueue::remove behavior — NotFound is Ok; genuine I/O error returns Err)
+ - 52-PATTERNS.md "Test structure — #[cfg(test)] mod tests inline" and the queue.rs `make_temp_queue` helper pattern
+ - 52-VALIDATION.md row D-06-a — test name `remove_failure_is_logged`
+
+
+ Test `remove_failure_is_logged` (add to the `#[cfg(test)] mod tests` block in lib.rs):
+ - Construct a `WriteQueue` over a temp dir, `put` an entry, then make the journal dir
+ removal fail (e.g. point the queue at a non-existent / read-only path, OR delete the
+ `.json` so a follow-up state forces an error path) and assert that the production helper
+ surfaces the error via the warn path rather than panicking or silently succeeding.
+ - Because `WriteQueue::remove` is idempotent on NotFound, drive the genuine-error case by
+ using a journal_dir whose `.json` removal returns an `Err(...)` (e.g. set the parent dir
+ read-only on Unix via std::fs permissions, guarded by `#[cfg(unix)]`), and assert
+ `journal.remove(id)` returns `Err`. The test asserts the Err branch is reachable so the
+ `if let Err` logging path in Task 2 is exercised by a real failure shape.
+
+
+ Add `#[test] fn remove_failure_is_logged()` to the existing tests module in
+ crates/fuse/src/lib.rs. Build a temp `WriteQueue` (mirror `make_temp_queue` from queue.rs),
+ `put` one entry, then induce a removal failure and assert `WriteQueue::remove` returns `Err`
+ (proving the `if let Err` branch added in Task 2 is reachable). Gate the permission-mutation
+ portion with `#[cfg(unix)]`; on non-unix, assert the NotFound idempotency path returns `Ok`.
+ Do NOT run the full suite — scope to this test name only.
+
+
+ cargo test -p cipherbox-fuse remove_failure_is_logged 2>&1 | tail -20
+
+
+ `remove_failure_is_logged` exists and passes; it proves a genuine removal error is an `Err`
+ (the shape Task 2's `if let Err` handles), confirming D-06 is not dead code.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+| -------- | ----------- |
+| local error string → user-facing tray/notification copy | host filesystem paths must not cross into displayed text |
+| journal file on local disk → replay/publish lifecycle | a swallowed removal failure can cause an unintended second publish |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+| --------- | -------- | --------- | ----------- | --------------- |
+| T-52-01 | Information Disclosure | `sanitize_error` / `regex_replace_paths` (sync.rs) | mitigate (D-05) | Extend scrub list to `/var`, `/tmp`, `/private`, and `C:\Users\` drive-letter pattern so no host path reaches tray/notification copy; unit-tested in `sanitize_error_extended_paths` |
+| T-52-02 | Tampering | `journal.remove` at lib.rs:1494/:1558, write_ops.rs:679 | mitigate (D-06) | Replace `let _` with `if let Err(e) { log::warn!(...) }` so a failed removal is surfaced; idempotent replay short-circuit remains the functional guard against actual double-publish |
+
+No HIGH-severity threats are introduced or left unmitigated by this plan. (Sidecar/at-rest ciphertext and durable-ack HIGH items are owned by plans 52-02/52-03.)
+
+
+
+
+- `cargo test -p cipherbox-sdk sanitize_error_extended_paths` passes
+- `cargo test -p cipherbox-fuse remove_failure_is_logged` passes
+- No `let _ = journal.remove` / `let _ = journal_for_mkdir.remove` remains in the three edited files
+- `cargo test -p cipherbox-sdk -p cipherbox-fuse 2>&1 | tail -20` shows no regressions
+
+
+
+- D-05: all six path prefixes scrubbed to `[path]`; existing behavior preserved
+- D-06: all three removal sites log on failure; no swallowed `let _` removals remain
+- Both new tests green; no new external crate added; build is Rust-only
+
+
+
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-SUMMARY.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-SUMMARY.md
new file mode 100644
index 0000000000..2756db9b76
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-01-SUMMARY.md
@@ -0,0 +1,77 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 01
+subsystem: fuse/sdk
+tags: [security, at-rest-safety, error-handling, journal, rust, s1]
+dependency_graph:
+ requires: []
+ provides: [D-05-path-scrub, D-06-removal-logging]
+ affects:
+ - crates/sdk/src/sync.rs
+ - crates/fuse/src/lib.rs
+ - crates/fuse/src/write_ops.rs
+tech_stack:
+ added: []
+ patterns: [hand-rolled-char-scan-scrub, if-let-err-warn-logging]
+key_files:
+ created: []
+ modified:
+ - crates/sdk/src/sync.rs
+ - crates/fuse/src/lib.rs
+ - crates/fuse/src/write_ops.rs
+decisions:
+ - "D-05 keeps the existing hand-rolled char_indices scan; no regex crate added"
+ - "Windows drive-letter branch matches any ASCII uppercase letter + ':\\Users\\'"
+ - "D-06 test decoupled from JournalEntry struct shape (writes a raw .json) so it survives the 52-02 field rename"
+metrics:
+ completed: "2026-06-20T00:00:00Z"
+ tasks_completed: 3
+ files_modified: 3
+---
+
+# Phase 52 Plan 01: At-Rest-Safety Path Scrub and Removal Logging
+
+One-liner: Extended `sanitize_error`'s path scrub to `/var`, `/tmp`, `/private`, and Windows drive-letter `X:\Users\` (D-05), and replaced the three swallowed `let _ = journal.remove(...)` sites with `if let Err(e) { log::warn!(...) }` so a failed removal can no longer silently cause a later double-replay/double-publish (D-06).
+
+## What Was Built
+
+### D-05 — extended `regex_replace_paths` scrub (crates/sdk/src/sync.rs)
+
+- Added `/var/`, `/tmp/`, `/private/` to the existing `if c == '/' && (...)` prefix list.
+- Added a new `else if c.is_ascii_uppercase() && i + 2 < input.len() && input[i + 1..].starts_with(":\\Users\\")` branch for Windows drive-letter paths (e.g. `C:\Users\...`, `D:\Users\...`), reusing the same skip-until-whitespace/quote loop.
+- New `#[cfg(test)] mod tests` with `sanitize_error_extended_paths` covering all five Unix prefixes, two Windows drive letters, an unchanged no-path string, and a multi-path boundary case.
+
+### D-06 — log `journal.remove` failures (crates/fuse/src/lib.rs, write_ops.rs)
+
+Three sites converted from `let _ = ...remove(...)` to `if let Err(e) { log::warn!(...) }`, each naming the op + entry id and stating "entry may replay again on next mount":
+
+1. `crates/fuse/src/lib.rs` MkdirPublish replay-success arm.
+2. `crates/fuse/src/lib.rs` UploadFile replay-success arm.
+3. `crates/fuse/src/write_ops.rs` mkdir parent-publish success arm (the existing `log::info!("Parent metadata published after mkdir")` is preserved).
+
+New `remove_failure_is_logged` test in lib.rs proves a genuine removal error is an `Err` (on Unix, by setting the journal dir to `0o500` so unlinking the child `.json` fails), confirming the `if let Err` branch is reachable — not dead code. The test writes a raw `.json` rather than constructing a `JournalEntry`, so it is decoupled from the upcoming 52-02 field rename.
+
+## Phase 51 Reconciliation
+
+D-06 line numbers in the plan (`:1494`/`:1558`) were stale relative to the merged tree — Phase 51's zeroization/Zeroizing edits shifted them to `:1503`/`:1567`. Located the actual sites by grep before editing. No Phase 51 hardening was reverted; these edits only swap the swallowed-error idiom for logging and do not touch any key-handling code.
+
+## Test Results
+
+- `sanitize_error_extended_paths` (cipherbox-sdk): pass.
+- `remove_failure_is_logged` (cipherbox-fuse): pass.
+- Grep gate: 0 remaining `let _ = journal.remove` / `let _ = journal_for_mkdir.remove` in the three files.
+- Full suites: cipherbox-fuse 61/61, cipherbox-sdk 49/49 — no regressions (baseline was 60/48).
+
+## Deviations from Plan
+
+None functionally. The only adjustment: the `let _ = cipherbox_api_client::ipfs::unpin_content(...)` at write_ops.rs (adjacent to the journal removal) is intentionally left fire-and-forget — it is out of D-06 scope, which targets journal-entry removals only.
+
+## Known Stubs
+
+None.
+
+## Self-Check: PASSED
+
+- `crates/sdk/src/sync.rs` contains `/var/`, `/tmp/`, `/private/`, and the `:\\Users\\` branch.
+- `crates/fuse/src/lib.rs` and `crates/fuse/src/write_ops.rs` contain `if let Err` removal logging at all three sites.
+- Both new tests pass; grep gate is 0; no regressions.
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-PLAN.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-PLAN.md
new file mode 100644
index 0000000000..f5f815eab9
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-PLAN.md
@@ -0,0 +1,259 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - crates/sdk/src/queue.rs
+autonomous: true
+requirements: [HARD-03]
+tags: [fuse, journal, sidecar, at-rest-safety, durability, rust]
+
+must_haves:
+ truths:
+ - "A journaled file upload stores its ciphertext in a 0600 sidecar .bin, never inside the JSON entry"
+ - "The serialized JSON journal entry contains no base64 ciphertext blob and no plaintext filename"
+ - "Removing a journal entry deletes BOTH the .json and the .bin sidecar (no orphaned ciphertext)"
+ - "An old pre-Phase-52 entry carrying a plaintext filename field still deserializes (legacy compat, replayed once)"
+ artifacts:
+ - path: "crates/sdk/src/queue.rs"
+ provides: "JournalOp::UploadFile sidecar+encrypted-name fields, put_with_sidecar, sidecar-aware remove, GC/cap constants, compat deserializer"
+ contains: "put_with_sidecar"
+ key_links:
+ - from: "WriteQueue::put_with_sidecar"
+ to: ".bin sidecar + .json entry"
+ via: "stream ciphertext to .bin (0600) + fsync, then write .json + fsync; remove .bin on .json-write failure"
+ pattern: "put_with_sidecar"
+ - from: "WriteQueue::remove"
+ to: ".bin"
+ via: "delete sidecar alongside .json"
+ pattern: "\\.bin"
+---
+
+
+Lay the on-disk journal-shape foundation for the high-severity WR-06 fix (D-01) and the
+at-rest name-encryption fix (D-04), entirely within the `cipherbox-sdk` crate so it has zero
+file overlap with any other Wave-1 plan. This is the contract every downstream Phase-52 plan
+builds against: the new `JournalOp::UploadFile` field shape, the `put_with_sidecar` write API,
+the sidecar-aware `remove`, the size-cap / GC constants, and the legacy-compat deserializer.
+
+Purpose: Eliminate the ~2.7 GB `serde_json` allocation + multi-GB in-JSON ciphertext that
+blocks the FS thread and risks OOM (WR-06, HIGH), and stop persisting plaintext filenames at
+rest (IN-03). Defines all new symbols up-front so plans 52-03/52-04/52-05 implement against a
+fixed contract (interface-first ordering).
+
+Output: `crates/sdk/src/queue.rs` with the new `UploadFile` fields (`sidecar_path`,
+`sidecar_sha256` replacing `ciphertext_b64`; `filename_encrypted_hex` replacing `filename`),
+`MkdirPublish.name_encrypted_hex` replacing `name`, `put_with_sidecar`, sidecar-aware `remove`,
+the three constants, the legacy compat deserializer, and unit tests.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-RESEARCH.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
+
+
+
+NEW symbols introduced by THIS plan (exclude from source-drift verification — they are created here):
+- `JournalOp::UploadFile.sidecar_path: PathBuf` (absolute path to `/.bin`)
+- `JournalOp::UploadFile.sidecar_sha256: String` (hex SHA-256 of ciphertext, integrity)
+- `JournalOp::UploadFile.filename_encrypted_hex: String` (replaces `filename`; `#[serde(alias = "filename")]` for legacy compat)
+- `JournalOp::MkdirPublish.name_encrypted_hex: String` (replaces `name`; `#[serde(alias = "name")]` for legacy compat)
+- `WriteQueue::put_with_sidecar(&self, entry: &JournalEntry, ciphertext: &[u8]) -> Result<(), String>`
+- `WriteQueue::sidecar_path_for(&self, id: &str) -> PathBuf` (helper resolving `/.bin`) — optional internal helper
+- `pub const MAX_JOURNAL_PAYLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024` (2 GiB)
+- `pub const JOURNAL_GC_MAX_AGE_DAYS: u64 = 30`
+- `pub const JOURNAL_GC_MAX_SIZE_BYTES: u64 = 500 * 1024 * 1024` (500 MiB)
+- A legacy-compat field deserializer for the renamed name fields (mirrors `deser_opt_string`)
+PRE-EXISTING (do not flag as drift): `WriteQueue::put`, `remove`, `load_all_for_vault`,
+`update_status`, `JournalEntry`, `deser_opt_string`, `make_temp_queue`, `OpenOptionsExt::mode`.
+
+
+
+- **D-04 path chosen: ENCRYPT (not omit).** Per 52-RESEARCH.md "D-04 Definitive Resolution":
+ `filename` is consumed at lib.rs:2233 (FilePointer.name) and `name` at lib.rs:2030
+ (FolderEntry.name); FileMetadata has no name field, so replay CANNOT reconstruct the name.
+ Therefore the names are ECIES-encrypted, not omitted.
+- **Legacy compat strategy: passthrough-once.** Old entries with a plaintext `filename`/`name`
+ field deserialize via `#[serde(alias)]` + a compat deserializer that detects non-hex values
+ and passes the plaintext through for a single replay (logged with a warn), never re-persisting
+ it. Chosen over park-and-fail because park-and-fail can silently strand in-flight pre-upgrade
+ writes (52-RESEARCH.md Open Question 3 recommendation).
+- **Size cap value: `MAX_JOURNAL_PAYLOAD_BYTES = 2 GiB`** (52-RESEARCH.md D-01; no existing desktop
+ MB constant to anchor — sensible cap above which even off-thread streaming stalls).
+- **GC defaults: 30 days age window, 500 MiB total Failed-entry budget** (52-RESEARCH.md D-02;
+ aligns with JOURNAL_MAX_RETRIES=5 park behavior). Consumed by Plan 52-05.
+
+
+
+
+
+ Task 1: D-01/D-04 journal entry shape + constants + compat deserializer
+ crates/sdk/src/queue.rs
+
+ - crates/sdk/src/queue.rs:27-103 (the file being modified — `JournalOp::UploadFile` fields incl. `ciphertext_b64` at :36 and `filename` at :62; `MkdirPublish.name` at :89)
+ - crates/sdk/src/queue.rs:15-25 (`deser_opt_string` compat-deserializer pattern to mirror for the renamed name fields)
+ - crates/sdk/src/queue.rs:119-151 (`JournalEntry` struct + `WriteQueue` struct, for context on the entry shape)
+ - 52-PATTERNS.md "crates/sdk/src/queue.rs (D-01 sidecar write, D-02 GC/purge)" — exact field-rename map and constant values
+ - 52-RESEARCH.md "D-01" + "D-04 Definitive Resolution" + Pitfall 6 (field rename breaks deser of old entries)
+
+
+ Tests in the existing `#[cfg(test)] mod tests` (use `make_temp_queue`):
+ - `legacy_plaintext_filename_compat`: serialize a JSON entry by hand with the OLD field name
+ `"filename": "report.txt"` (no `filename_encrypted_hex`), deserialize into `JournalEntry`,
+ and assert it succeeds and the value is reachable for one-time replay (compat passthrough).
+ Same for old `"name": "folder1"` → `name_encrypted_hex` via alias.
+ - `journal_no_plaintext_filename`: build an `UploadFile` entry whose `filename_encrypted_hex`
+ is a hex ciphertext string, serialize with `serde_json::to_string`, and assert the serialized
+ JSON does NOT contain the raw plaintext filename and does NOT contain a `ciphertext_b64` key.
+ - `filename_encryption_round_trips` (VALIDATION row D-04-b, sdk crate): generate an EC keypair
+ (via `cipherbox_crypto`), `cipherbox_crypto::ecies::wrap_key(b"report.txt", &public_key)` →
+ `hex::encode` → store as `filename_encrypted_hex`, then `hex::decode` +
+ `cipherbox_crypto::ecies::unwrap_key(.., &private_key)` + `String::from_utf8` and assert the
+ result equals `"report.txt"`. This proves the write-side encryption shape (used by Plan 52-03)
+ is decryptable by replay (Plan 52-04). Add `cipherbox-crypto` as a dev-dependency of
+ `cipherbox-sdk` if a keypair helper is not already reachable in sdk tests.
+
+
+ Per D-01 + D-04, edit `JournalOp::UploadFile` (queue.rs:34-67):
+ - REMOVE `ciphertext_b64: String` (:36). ADD `sidecar_path: PathBuf` and
+ `sidecar_sha256: String` (hex SHA-256 of ciphertext for integrity).
+ - RENAME `filename: String` (:62) to `filename_encrypted_hex: String` and annotate it with
+ `#[serde(alias = "filename")]` so old entries deserialize. Keep `size: u64` and `created_at_ms: u64`.
+ Edit `JournalOp::MkdirPublish` (queue.rs:68-92): RENAME `name: String` (:89) to
+ `name_encrypted_hex: String` with `#[serde(alias = "name")]`.
+ Add the legacy-compat behavior: because the alias maps an old plaintext value into the new
+ field, document (doc-comment) that a value that is NOT valid hex is a legacy plaintext name to
+ be used once and never re-persisted — the actual detect/passthrough happens at replay (Plan 52-04);
+ here only the deserialization must not fail. (Mirror the `deser_opt_string` doc/style at :15-25.)
+ Add the three public constants near the top of the module (or alongside the struct), exactly:
+ `pub const MAX_JOURNAL_PAYLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;`
+ `pub const JOURNAL_GC_MAX_AGE_DAYS: u64 = 30;`
+ `pub const JOURNAL_GC_MAX_SIZE_BYTES: u64 = 500 * 1024 * 1024;`
+ Re-export them from the crate root if the crate uses `pub use` for queue symbols (check lib.rs
+ of cipherbox-sdk; journal_helpers references `cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES` in Plan 52-03,
+ so the constant MUST be reachable as `cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES`).
+ Do NOT add the `regex` crate. Use `serde(alias)` (already available via serde derive).
+
+
+ cargo test -p cipherbox-sdk legacy_plaintext_filename_compat journal_no_plaintext_filename filename_encryption_round_trips 2>&1 | tail -30
+
+
+ `UploadFile` has `sidecar_path` + `sidecar_sha256` (no `ciphertext_b64`) and
+ `filename_encrypted_hex` (alias `filename`); `MkdirPublish` has `name_encrypted_hex`
+ (alias `name`); the three constants exist and resolve as `cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES`
+ etc.; both tests green; no `ciphertext_b64` key in serialized output.
+
+
+
+
+ Task 2: put_with_sidecar + sidecar-aware remove
+ crates/sdk/src/queue.rs
+
+ - crates/sdk/src/queue.rs:164-219 (the file being modified — `put` (0600 + fsync + parent-dir fsync) and `remove` (idempotent NotFound))
+ - crates/sdk/src/queue.rs:225-264 (`load_all_for_vault` `.json`-only filter — sidecar reasoning)
+ - 52-PATTERNS.md "0o600 file creation pattern", "fsync + parent-dir fsync barrier", "Idempotent remove with NotFound guard", and the `put_with_sidecar` / `remove` signature notes
+ - 52-RESEARCH.md "D-01 Recommended implementation" + Pitfall 2 (sidecar orphan on failed JSON write) + Security Invariant 3 (0600) and 4 (sidecar removed with json)
+
+
+ Tests (use `make_temp_queue`):
+ - `sidecar_ciphertext_not_in_json`: call `put_with_sidecar(entry, &ciphertext)` where the entry
+ is an `UploadFile`; assert the `.bin` exists with the ciphertext bytes (round-trip read),
+ assert the `.json` exists, and assert the `.json` bytes do NOT contain the ciphertext.
+ On Unix, assert `.bin` mode is `0o600`.
+ - sidecar `remove`: after `put_with_sidecar`, call `remove(id)` and assert BOTH `.json` and
+ `.bin` are gone; calling `remove` again returns `Ok(())` (idempotent).
+ - atomic cleanup on json failure (Pitfall 2): simulate a `.json` write failure path and assert
+ the `.bin` is not left orphaned (best-effort: assert the documented cleanup removes `.bin`).
+ If simulating json-write failure is impractical in a unit test, assert instead that
+ `put_with_sidecar` removes a pre-existing stale `.bin` before re-writing, and document the
+ orphan-cleanup contract for the GC pass (Plan 52-05) to also enforce.
+
+
+ Per D-01, add `pub fn put_with_sidecar(&self, entry: &JournalEntry, ciphertext: &[u8]) -> Result<(), String>`:
+ 1. Resolve sidecar path `/.bin`.
+ 2. Stream `ciphertext` to the `.bin` in a fixed-size buffer (e.g. 1 MiB chunks via
+ `std::io::Write`) — NEVER allocate the full ciphertext as a `String`. Create with
+ `OpenOptions` + `#[cfg(unix)] mode(0o600)` (mirror `put` at :177-186). `sync_all()` the `.bin`.
+ 3. Compute SHA-256 of `ciphertext` (use the `sha2` workspace crate; confirm it is in
+ crates/sdk/Cargo.toml — if absent, add it as a workspace dep) and store hex in the entry's
+ `sidecar_sha256` BEFORE serializing the JSON (the entry passed in must already carry
+ `sidecar_path` + `sidecar_sha256`; build_upload_journal_entry in Plan 52-03 sets them, but
+ `put_with_sidecar` may also (re)compute/verify the hash — pick the simplest: caller sets
+ fields, `put_with_sidecar` writes bytes + verifies len). Document who owns the hash.
+ 4. Write `.json` via the SAME serialize + 0600 + `sync_all` + parent-dir fsync barrier as
+ `put` (reuse `put` internally if the entry already has sidecar fields set). If the `.json`
+ write/fsync fails, remove the `.bin` before returning `Err` (atomic cleanup, Pitfall 2).
+ 5. Return `Ok(())` only after both fsyncs.
+ Extend `remove` (queue.rs:208-218) to also `remove_file(/.bin)`, treating
+ NotFound as `Ok` (a MkdirPublish entry has no sidecar — its `.bin` absence is normal). fsync the
+ parent dir once after both removals.
+ Note: `put_with_sidecar` is a synchronous fn here; Plan 52-03 calls it from a background tokio
+ task via `rt.spawn` + oneshot (it does not need to be `async`).
+
+
+ cargo test -p cipherbox-sdk sidecar_ciphertext_not_in_json 2>&1 | tail -30
+
+
+ `put_with_sidecar` streams ciphertext to a 0600 `.bin`, writes a ciphertext-free `.json`, and
+ fsyncs both; `remove` deletes both `.json` and `.bin` idempotently; failed `.json` write removes
+ the `.bin`; `sidecar_ciphertext_not_in_json` and the remove/round-trip tests pass.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+| -------- | ----------- |
+| in-memory ciphertext → on-disk journal | large ciphertext must not be embedded in JSON (alloc/OOM) and must land in a 0600 sidecar |
+| in-memory filename → on-disk journal | plaintext filename must not persist at rest |
+| journal entry lifecycle → disk | a removed entry must leave no orphaned ciphertext sidecar |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+| --------- | -------- | --------- | ----------- | --------------- |
+| T-52-03 | Information Disclosure | `JournalOp::UploadFile.ciphertext_b64` (in-JSON ciphertext) | mitigate (D-01) | Replace with `sidecar_path` + `sidecar_sha256`; ciphertext written to a 0600 `.bin` via `put_with_sidecar`, never serialized into JSON (`sidecar_ciphertext_not_in_json`) |
+| T-52-04 | Information Disclosure | plaintext `filename`/`name` in 0600 journal JSON | mitigate (D-04) | Rename to `filename_encrypted_hex`/`name_encrypted_hex`; only hex ECIES ciphertext serialized (encryption happens write-side in Plan 52-03); `journal_no_plaintext_filename` asserts the plaintext is absent |
+| T-52-05 | Information Disclosure | orphaned `.bin` after `.json` removal/failed write | mitigate (D-01) | `remove` deletes the `.bin` alongside the `.json`; `put_with_sidecar` removes the `.bin` if the `.json` write fails (atomic cleanup, Pitfall 2); GC orphan scan (Plan 52-05) is the backstop |
+| T-52-06 | Denial of Service | unbounded per-entry payload | mitigate (D-01) | `MAX_JOURNAL_PAYLOAD_BYTES` constant defined here; enforced at write time in Plan 52-03 (`payload_size_cap_returns_err`) |
+
+T-52-03 (WR-06) is the HIGH-severity threat of this phase; the on-disk shape that mitigates it is
+established by this plan and exercised by `sidecar_ciphertext_not_in_json`. No HIGH-severity threat
+is left unmitigated.
+
+
+
+
+- `cargo test -p cipherbox-sdk sidecar_ciphertext_not_in_json journal_no_plaintext_filename legacy_plaintext_filename_compat filename_encryption_round_trips 2>&1 | tail -30` all pass
+- Serialized `UploadFile` JSON contains neither `ciphertext_b64` nor the plaintext filename
+- `.bin` created with 0600 (Unix); `remove` deletes both files
+- `cargo check -p cipherbox-sdk` clean (no unused-import / dead-code on the new constants)
+
+
+
+- New `UploadFile` shape (`sidecar_path`, `sidecar_sha256`, `filename_encrypted_hex`) and
+ `MkdirPublish.name_encrypted_hex` compile and round-trip
+- `put_with_sidecar` + sidecar-aware `remove` implemented and tested
+- Three constants exist and are reachable as `cipherbox_sdk::*`
+- Legacy `filename`/`name` plaintext entries still deserialize (alias)
+- All three new tests green
+
+
+
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-SUMMARY.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-SUMMARY.md
new file mode 100644
index 0000000000..ca36b01f18
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-SUMMARY.md
@@ -0,0 +1,91 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 02
+subsystem: sdk/journal
+tags: [sidecar, at-rest-safety, durability, name-encryption, journal, rust]
+dependency_graph:
+ requires: []
+ provides:
+ - UploadFile-sidecar-shape
+ - filename_encrypted_hex
+ - name_encrypted_hex
+ - put_with_sidecar
+ - sidecar-aware-remove
+ - MAX_JOURNAL_PAYLOAD_BYTES
+ - JOURNAL_GC_MAX_AGE_DAYS
+ - JOURNAL_GC_MAX_SIZE_BYTES
+ affects:
+ - crates/sdk/src/queue.rs
+ - crates/sdk/src/lib.rs
+ - crates/sdk/Cargo.toml
+tech_stack:
+ added: [sha2-dep-sdk, ecies-dev-dep-sdk]
+ patterns: [sidecar-stream-write, serde-alias-legacy-compat, serde-default-shape-break-compat]
+key_files:
+ created: []
+ modified:
+ - crates/sdk/src/queue.rs
+ - crates/sdk/src/lib.rs
+ - crates/sdk/Cargo.toml
+decisions:
+ - "D-04 path = ENCRYPT (not omit): filename has no FileMetadata source so replay cannot reconstruct it"
+ - "Legacy compat = passthrough-once via #[serde(alias)] on renamed name fields"
+ - "Shape-break compat: sidecar_path + sidecar_sha256 get #[serde(default)] so legacy inline-ciphertext_b64 entries still deserialize (serde ignores the now-unknown ciphertext_b64 key)"
+ - "put_with_sidecar reuses put internally after streaming the .bin, so the .json fsync barrier is identical to the existing path"
+metrics:
+ completed: "2026-06-20T00:30:00Z"
+ tasks_completed: 2
+ files_modified: 3
+---
+
+# Phase 52 Plan 02: Sidecar + Encrypted-Name Journal Shape
+
+One-liner: Replaced the in-JSON `ciphertext_b64` blob with a 0600 `.bin` sidecar (`sidecar_path` + `sidecar_sha256`) and the plaintext `filename`/`name` fields with ECIES-encrypted hex (`filename_encrypted_hex`/`name_encrypted_hex`), added `put_with_sidecar` + sidecar-aware `remove` + the three GC/cap constants, and preserved one-time legacy-entry compat — the on-disk contract every downstream Phase-52 plan builds against.
+
+## What Was Built
+
+### Journal entry shape (queue.rs)
+
+- `JournalOp::UploadFile`: removed `ciphertext_b64`; added `sidecar_path: PathBuf` and `sidecar_sha256: String` (both `#[serde(default)]`); renamed `filename` → `filename_encrypted_hex` with `#[serde(alias = "filename")]`.
+- `JournalOp::MkdirPublish`: renamed `name` → `name_encrypted_hex` with `#[serde(alias = "name")]`.
+- Three public constants: `MAX_JOURNAL_PAYLOAD_BYTES = 2 GiB`, `JOURNAL_GC_MAX_AGE_DAYS = 30`, `JOURNAL_GC_MAX_SIZE_BYTES = 500 MiB`, re-exported from the crate root (`cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES` resolves).
+
+### Write/remove API (queue.rs)
+
+- `WriteQueue::sidecar_path_for(id) -> PathBuf` resolves `/.bin`.
+- `WriteQueue::put_with_sidecar(entry, ciphertext)`: pre-cleans any stale `.bin`, streams ciphertext to a 0600 sidecar in 1 MiB chunks (never a full `String`), `sync_all`s it, then writes the `.json` via the existing `put` fsync barrier. On `.json` failure it removes the orphaned `.bin` (Pitfall 2 atomic cleanup).
+- `WriteQueue::remove(id)`: now deletes BOTH `.json` and `.bin`, idempotent on NotFound (a MkdirPublish entry has no sidecar), parent-dir fsync after.
+
+### Dependencies (Cargo.toml)
+
+- Added `sha2 = { workspace = true }` to `[dependencies]` (sidecar hashing is computed write-side in 52-03; the dep lives here).
+- Added `ecies = { workspace = true }` to `[dev-dependencies]` for the round-trip test keypair.
+
+## Legacy Compatibility (shape-break detail)
+
+The plan's passthrough-once strategy covered the `filename`/`name` rename via `#[serde(alias)]`. But removing `ciphertext_b64` entirely is a larger break: a pre-Phase-52 in-flight entry has its ciphertext inline and no sidecar fields. To keep those entries deserializable (passthrough-once replay, never strand in-flight pre-upgrade writes), `sidecar_path` and `sidecar_sha256` carry `#[serde(default)]`. Serde ignores the now-unknown `ciphertext_b64` key (no `deny_unknown_fields`), so a legacy entry loads with an empty `sidecar_path` — the signal the replay side (52-04) uses to drive a one-time legacy replay. Verified by `legacy_plaintext_filename_compat` (raw legacy JSON for both ops) and the pre-existing `legacy_empty_string_ipns_loads_as_none` test (still green with old-shape raw JSON).
+
+## Test Results
+
+- cipherbox-sdk: 54/54 (was 49 after 52-01). New: `legacy_plaintext_filename_compat`, `journal_no_plaintext_filename`, `filename_encryption_round_trips`, `sidecar_ciphertext_not_in_json`, `put_with_sidecar_cleans_stale_bin`.
+- `filename_encryption_round_trips` proves `wrap_key` → hex → decode → `unwrap_key` recovers the plaintext (the write-side shape 52-03 produces is decryptable by 52-04). Handles the Phase-51 `unwrap_key -> Zeroizing>` return via `.to_vec()`.
+- All renamed existing fixtures (`make_upload_entry`, `make_mkdir_entry`, parent-ipns-key round-trips, no-plaintext, replay-ordering) updated to the new shape and green.
+
+## Phase 51 Reconciliation
+
+`cipherbox_crypto::ecies::unwrap_key` now returns `Zeroizing>` (Phase 51). The round-trip test consumes it via `.to_vec()` rather than assuming `Vec`. No Phase 51 hardening touched.
+
+## Expected Cross-Crate State
+
+`cipherbox-fuse` does NOT compile after this plan alone — `journal_helpers.rs`/`lib.rs` still reference `ciphertext_b64`/`filename`. This is the intended interface-first ordering: 52-03 (write side) and 52-04 (replay side) update those consumers to the new shape. The sdk crate compiles and tests clean in isolation.
+
+## Known Stubs
+
+None in the sdk crate. The `sidecar_sha256` is populated write-side by 52-03 and verified replay-side by 52-04.
+
+## Self-Check: PASSED
+
+- `crates/sdk/src/queue.rs` contains `put_with_sidecar`, `sidecar_path_for`, `sidecar_path`, `sidecar_sha256`, `filename_encrypted_hex`, `name_encrypted_hex`, and the three constants.
+- `remove` deletes the `.bin` (contains `.bin`).
+- Constants resolve as `cipherbox_sdk::*` (re-exported in lib.rs).
+- 54/54 sdk tests pass; legacy entries deserialize.
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-03-PLAN.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-03-PLAN.md
new file mode 100644
index 0000000000..75a2a62c9a
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-03-PLAN.md
@@ -0,0 +1,258 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 03
+type: execute
+wave: 2
+depends_on: ["52-02"]
+files_modified:
+ - crates/fuse/src/journal_helpers.rs
+ - crates/fuse/src/read_ops.rs
+autonomous: true
+requirements: [HARD-03]
+tags: [fuse, journal, sidecar, durable-ack, name-encryption, rust]
+
+must_haves:
+ truths:
+ - "A file larger than the per-entry payload cap fails release() with EIO instead of OOMing or stalling the FS thread"
+ - "The journaled filename is ECIES-encrypted (filename_encrypted_hex) at write time using the user public key"
+ - "release() does not reply.ok() until the sidecar .bin + .json are both fsynced (durable-ack contract preserved, no Phase-43 CR-04 regression)"
+ artifacts:
+ - path: "crates/fuse/src/journal_helpers.rs"
+ provides: "size-cap guard + ECIES filename encryption + sidecar-field entry construction (D-01, D-04)"
+ contains: "MAX_JOURNAL_PAYLOAD_BYTES"
+ - path: "crates/fuse/src/read_ops.rs"
+ provides: "off-thread sidecar write via oneshot, callback thread blocks on durability before reply.ok() (D-01)"
+ contains: "oneshot"
+ key_links:
+ - from: "read_ops.rs release path"
+ to: "WriteQueue::put_with_sidecar (background tokio task)"
+ via: "rt.spawn + tokio::sync::oneshot; rt.block_on(rx) before reply.ok()"
+ pattern: "oneshot"
+ - from: "build_upload_journal_entry"
+ to: "cipherbox_crypto::ecies::wrap_key"
+ via: "encrypt filename bytes with self.public_key → filename_encrypted_hex"
+ pattern: "wrap_key"
+---
+
+
+Implement the write-side of D-01 and D-04: enforce the per-entry payload size cap and
+ECIES-encrypt the filename in `build_upload_journal_entry`, then move the heavy sidecar write
++ fsync OFF the FUSE callback thread while preserving the Phase-43 durable-ack contract via a
+`tokio::sync::oneshot` channel that the callback thread blocks on before `reply.ok()`.
+
+Purpose: Close WR-06 (HIGH) at the point where it actually blocks the filesystem — the
+`release()` callback — without reintroducing the Phase-43 false-durability-ack bug (CR-04).
+Stop persisting plaintext filenames (IN-03) by encrypting them at construction time.
+
+Output: `journal_helpers.rs` with a size-cap guard, ECIES filename encryption, and entry
+construction using the new sidecar fields (no `ciphertext_b64`); `read_ops.rs` release path
+rewired so the sidecar write runs on a background task and the callback thread blocks on a
+bounded oneshot before acking.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-RESEARCH.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-SUMMARY.md
+
+
+
+NEW symbols introduced by THIS plan (exclude from drift verification):
+- A size-cap guard in `build_upload_journal_entry` comparing `file_size` against
+ `cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES` (constant defined in Plan 52-02)
+- `filename_encrypted_hex` computation (ECIES wrap of filename bytes) in journal_helpers
+- `sidecar_path` + `sidecar_sha256` population in the `UploadFile` entry construction
+- A bounded oneshot durable-ack bridge in the read_ops release path (channel + `rt.block_on(rx)`
+ with a timeout matching the upload window — see decisions_recorded)
+PRE-EXISTING (do not flag as drift): `build_upload_journal_entry`, `wrap_key_to_hex`,
+`cipherbox_crypto::ecies::wrap_key`, `fs.journal.put`, `fs.rt`, `handle.cleanup`, `reply.ok`,
+`reply.error`, `UploadJournalResult`, and the constants/`put_with_sidecar` from Plan 52-02.
+
+
+
+- **Open Question 1 (RESEARCH) resolved: the oneshot recv in the FUSE callback IS bounded.**
+ `rt.block_on(oneshot_rx)` is wrapped so a wedged background writer cannot block the callback
+ thread forever. Use a bound equal to the upload-replay window base (`NETWORK_TIMEOUT * 18`,
+ i.e. ~180s; `NETWORK_TIMEOUT` = 10s at lib.rs:59) — generous enough for a legitimate multi-GB
+ fsync on a slow disk, but finite. On timeout, reply `EIO` and do NOT apply inode mutations.
+ Rationale: a sidecar fsync that genuinely needs >3 minutes indicates a wedged/failing disk;
+ acking success then would violate the durable-ack contract, and blocking forever would hang
+ the whole filesystem (the exact DoS shape this phase exists to remove).
+- **The background upload thread continues to use the in-memory `ciphertext`** already carried in
+ `UploadJournalResult` (read_ops.rs:861) — it does NOT read from the sidecar. Only crash-recovery
+ replay (Plan 52-04) reads the `.bin`. This avoids a sidecar-read race on the happy path
+ (RESEARCH Pitfall 4) and keeps the live upload path unchanged except for the durable-ack bridge.
+- **Durable-ack ORDER (must match Phase-43 CR-04, read_ops.rs:814-884):**
+ 1. build entry (size cap + name encrypt + sidecar fields) →
+ 2. spawn background `put_with_sidecar` writer + oneshot →
+ 3. callback thread `rt.block_on(bounded recv)` →
+ 4. on Ok: inode mutations → `handle.cleanup()` → `reply.ok()` →
+ 5. on Err/timeout: `handle.cleanup()` → `reply.error(EIO)`, no mutations.
+
+
+
+
+
+ Task 1: D-01 size cap + D-04 filename encryption + sidecar fields in build_upload_journal_entry
+ crates/fuse/src/journal_helpers.rs
+
+ - crates/fuse/src/journal_helpers.rs:220-324 (the file being modified — `file_size` at :222, `file_name` at :224-228, the `ciphertext_b64` base64 encode at :284-286, and the `JournalOp::UploadFile { ... }` construction at :307-321)
+ - crates/fuse/src/journal_helpers.rs:281 (`wrap_key_to_hex` call — the ECIES-wrap pattern to mirror for the filename)
+ - 52-PATTERNS.md "crates/fuse/src/journal_helpers.rs (D-01 size cap, D-04 name encryption)" — exact insertion points and the `filename_encrypted_hex` block; "Shared Patterns → ECIES key wrapping"
+ - 52-RESEARCH.md "D-01" (size cap), "D-04 Definitive Resolution" + "Pattern 3: ECIES name encryption"
+ - 52-02-SUMMARY.md — confirm the exact new `UploadFile` field names and the `cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES` path
+
+
+ Test `payload_size_cap_returns_err` (add to the `#[cfg(test)] mod tests` in journal_helpers.rs,
+ or in lib.rs if helper construction needs a CipherBoxFS; prefer the most isolated location that
+ can drive the cap check):
+ - A `file_size` (plaintext length) above `MAX_JOURNAL_PAYLOAD_BYTES` causes
+ `build_upload_journal_entry` (or the extracted cap-check) to return `Err` with a
+ "too large for journal" message; a size at/under the cap proceeds.
+ Note: the filename encrypt->decrypt round-trip test (`filename_encryption_round_trips`,
+ `cargo test -p cipherbox-sdk`) is owned by Plan 52-02 (sdk crate, VALIDATION row D-04-b). This
+ task only needs the SAME `cipherbox_crypto::ecies::wrap_key(file_name, public_key)` call as the
+ write side; its decryptability is covered by 52-02's round-trip and 52-04's replay decrypt.
+
+
+ Per D-01: insert a size-cap guard immediately after `let file_size = plaintext.len() as u64;`
+ (journal_helpers.rs:222): if `file_size > cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES`, return
+ `Err(format!("File too large for journal ({} bytes > {} byte cap); refusing to write", file_size, cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES))`
+ so the read_ops release path replies EIO.
+ Per D-04: replace the `file_name` plaintext usage. Compute
+ `filename_encrypted_hex = hex::encode(cipherbox_crypto::ecies::wrap_key(file_name.as_bytes(), &self.public_key).map_err(|e| format!("filename encryption failed: {}", e))?)`.
+ (Mirror the `wrap_key_to_hex` pattern at :281; no zeroize needed — a filename is not key
+ material.)
+ Per D-01: DELETE the `base64` ciphertext encode at :284-286. Compute
+ `sidecar_path = /.bin` and `sidecar_sha256 = hex::encode(sha2 digest of &ciphertext)`
+ BEFORE constructing the entry (the journal_dir is reachable via `self.journal` / WriteQueue —
+ use a `WriteQueue` helper to resolve the path so the path matches what `put_with_sidecar` writes;
+ if no path helper exists, add `WriteQueue::sidecar_path_for(id)` in Plan 52-02 — confirm in
+ 52-02-SUMMARY). Update the `JournalOp::UploadFile { ... }` construction (:310-321) to set
+ `sidecar_path`, `sidecar_sha256`, and `filename_encrypted_hex` instead of `ciphertext_b64` and
+ `filename`. The in-memory `ciphertext` must still be returned in `UploadJournalResult` for the
+ live upload thread (do not drop it).
+ Mirror the same `name`→`name_encrypted_hex` ECIES encryption in `build_mkdir_journal_entry`
+ (find the `MkdirPublish { ... name ... }` construction in this file or its sibling helper) so
+ the directory name is encrypted at rest too (D-04 covers both variants).
+
+
+ cargo test -p cipherbox-fuse payload_size_cap_returns_err 2>&1 | tail -30
+
+
+ Size-cap guard returns Err above `MAX_JOURNAL_PAYLOAD_BYTES`; `filename_encrypted_hex` is an
+ ECIES hex string produced by `wrap_key` (decryptability verified by 52-02 round-trip + 52-04 replay);
+ `MkdirPublish` builds `name_encrypted_hex`; no `ciphertext_b64` or `base64::...encode` remains in
+ journal_helpers.rs (grep gate); `payload_size_cap_returns_err` passes.
+
+
+
+
+ Task 2: D-01 off-thread sidecar write with bounded oneshot durable-ack in release path
+ crates/fuse/src/read_ops.rs
+
+ - crates/fuse/src/read_ops.rs:800-974 (the file being modified — the full release durable-ack sequence: `fs.journal.put` at :815, inode mutations :818-845, `handle.cleanup()` :882, `reply.ok()` :884, background upload spawn :888-956, the Err arm :959-969)
+ - crates/fuse/src/lib.rs:59 (`NETWORK_TIMEOUT = Duration::from_secs(10)` — base for the bounded recv)
+ - 52-PATTERNS.md "crates/fuse/src/read_ops.rs (D-01 durable-ack with oneshot)" — the exact channel + block_on sequence and the existing spawn pattern to mirror
+ - 52-RESEARCH.md "D-01 Recommended implementation" steps 4-5 + Pitfall 1 (false-durability-ack) + Open Question 1 (bounded recv)
+ - 52-03 decisions_recorded (durable-ack ORDER + bounded recv = NETWORK_TIMEOUT * 18)
+
+
+ Test `durable_ack_with_sidecar` (`#[tokio::test]` in lib.rs near the durable-ack tests, or a
+ read_ops test module):
+ - Drive a release-equivalent flow where `put_with_sidecar` is the durability step; assert the
+ "ack" (the success path / reply) is only reached AFTER `put_with_sidecar` returns Ok and both
+ `.bin` and `.json` exist on disk. (Mirror the existing Phase-43 crash-recovery /
+ durable-ack test shape; reuse `make_temp_queue`.)
+ - Assert that if `put_with_sidecar` returns Err, the success/ack path is NOT taken (EIO branch).
+
+
+ Per D-01, rewrite the durability step in the release path (read_ops.rs:805-884) so the heavy
+ sidecar write runs off the FUSE callback thread but the ack still waits for durability:
+ 1. Build the entry via `build_upload_journal_entry` (now size-capped + name-encrypted +
+ sidecar-field-bearing). Keep the CR-04 deferral: do NOT apply inode mutations yet.
+ 2. Replace the synchronous `fs.journal.put(&result.entry)?` (:815) with an off-thread write:
+ create `let (tx, rx) = tokio::sync::oneshot::channel::>();`, clone
+ `fs.journal`, the entry, and the `ciphertext`, then `fs.rt.spawn(async move { let r = journal.put_with_sidecar(&entry, &ciphertext); let _ = tx.send(r); })`.
+ 3. Callback thread blocks on a BOUNDED recv:
+ `match fs.rt.block_on(async { tokio::time::timeout(NETWORK_TIMEOUT * 18, rx).await })`:
+ - `Ok(Ok(Ok(())))` → durable: NOW apply the inode mutations (the block currently at
+ :818-845), `fs.pending_content.insert`, `fs.queue_publish`, then `handle.cleanup()` and
+ `reply.ok()`, then spawn the background upload thread (unchanged, still uses in-memory
+ `ciphertext`).
+ - any Err/timeout (`Ok(Ok(Err(_)))`, `Ok(Err(_))` recv error, or `Err(_)` elapsed) →
+ `handle.cleanup()`, `reply.error(libc::EIO)`, return; apply NO mutations (CR-04 — nothing
+ was mutated, nothing to roll back).
+ Preserve the existing background-upload `record_failure` handling and the CR-08 "do not remove
+ entry here" comment. Do NOT call `reply.ok()` before the bounded recv resolves Ok — that is the
+ exact CR-04 regression Pitfall 1 warns against.
+ Keep the `Err(e)` build-failure arm (:959-969) replying EIO. The size-cap Err from Task 1 now
+ flows through this arm (build returns Err for oversized files → EIO, never OOM).
+
+
+ cargo test -p cipherbox-fuse durable_ack_with_sidecar 2>&1 | tail -30
+
+
+ The release path writes the sidecar on a background task and blocks the callback thread on a
+ bounded oneshot (NETWORK_TIMEOUT * 18) before `reply.ok()`; on durability failure/timeout it
+ replies EIO with no inode mutation; `durable_ack_with_sidecar` proves ack-after-fsync ordering;
+ no `reply.ok()` precedes the durable recv (grep/review gate); `cargo test -p cipherbox-fuse` green.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+| -------- | ----------- |
+| OS release() callback → durable on-disk journal | OS must not be told a write succeeded before the sidecar+json are fsynced |
+| FUSE callback thread → background writer task | a wedged writer must not block the single FS thread forever |
+| in-memory filename → on-disk journal | filename must be ECIES-encrypted before persistence |
+| in-memory ciphertext size → journal write | oversized payloads must be rejected before they OOM/stall |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+| --------- | -------- | --------- | ----------- | --------------- |
+| T-52-07 | Denial of Service | release() heavy in-JSON ciphertext write on the single FS thread (WR-06) | mitigate (D-01) | Sidecar write moved to a background tokio task via `rt.spawn`; callback thread blocks on a BOUNDED oneshot (NETWORK_TIMEOUT*18) so a wedged writer cannot hang the FS forever; `durable_ack_with_sidecar` |
+| T-52-08 | Denial of Service | unbounded per-entry payload (OOM) | mitigate (D-01) | Size-cap guard in `build_upload_journal_entry` returns Err above `MAX_JOURNAL_PAYLOAD_BYTES`; release replies EIO; `payload_size_cap_returns_err` |
+| T-52-09 | Information Disclosure | plaintext filename written at rest (IN-03) | mitigate (D-04) | `cipherbox_crypto::ecies::wrap_key(file_name, public_key)` → `filename_encrypted_hex`; never the plaintext; `filename_encryption_round_trips` |
+| T-52-10 | Spoofing/Tampering (false durability) | reply.ok() before fsync (Phase-43 CR-04 regression) | mitigate (D-01) | Callback thread `rt.block_on(bounded rx)` MUST resolve `Ok` before `reply.ok()`; inode mutations deferred until after durability; Pitfall 1 review gate |
+
+T-52-07 (WR-06) is the HIGH-severity threat; this plan mitigates it at the FS-callback site with
+the bounded off-thread durable-ack bridge. No HIGH-severity threat is left unmitigated.
+
+
+
+
+- `cargo test -p cipherbox-fuse payload_size_cap_returns_err durable_ack_with_sidecar 2>&1 | tail -30` pass
+- `cargo test -p cipherbox-sdk filename_encryption_round_trips 2>&1 | tail -20` passes (round-trip owned by Plan 52-02)
+- No `ciphertext_b64` / `base64::...encode` remains in journal_helpers.rs (grep gate: `grep -n "ciphertext_b64\|base64" crates/fuse/src/journal_helpers.rs | grep -v '^\s*//' | wc -l` returns 0)
+- No `reply.ok()` is reachable in the release path before the bounded durable recv resolves Ok (code review + test)
+- `cargo test -p cipherbox-sdk -p cipherbox-fuse 2>&1 | tail -20` shows no regressions
+- `cargo check -p cipherbox-fuse --features fuse` clean
+
+
+
+- D-01 write-side: size cap enforced (EIO on oversize), sidecar fields populated, no in-JSON ciphertext
+- D-01 durable-ack: off-thread sidecar write + bounded oneshot; reply.ok() only after fsync; EIO on failure/timeout
+- D-04: filename + mkdir name ECIES-encrypted at write time, round-trip verified
+- Phase-43 CR-04 durable-ack ordering preserved (no false-ack)
+- All three new tests green
+
+
+
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-03-SUMMARY.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-03-SUMMARY.md
new file mode 100644
index 0000000000..2f4c6eb5c3
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-03-SUMMARY.md
@@ -0,0 +1,78 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 03
+subsystem: fuse/write-path
+tags: [fuse, journal, sidecar, durable-ack, name-encryption, size-cap, rust]
+dependency_graph:
+ requires: [52-02]
+ provides: [size-cap-guard, filename-encryption-write, off-thread-durable-ack]
+ affects:
+ - crates/fuse/src/journal_helpers.rs
+ - crates/fuse/src/read_ops.rs
+ - crates/fuse/Cargo.toml
+ - crates/fuse/src/lib.rs
+tech_stack:
+ added: [sha2-dep-fuse]
+ patterns: [off-thread-std-thread-recv_timeout, ecies-filename-encryption, sha256-sidecar-hash]
+key_files:
+ created: []
+ modified:
+ - crates/fuse/src/journal_helpers.rs
+ - crates/fuse/src/read_ops.rs
+ - crates/fuse/Cargo.toml
+ - crates/fuse/src/lib.rs
+decisions:
+ - "Size cap checked FIRST (before key gen) so the EIO-reject path has nothing to zeroize"
+ - "Filename/dir-name encryption uses ecies::wrap_key directly with ?-propagation (a failed name encrypt FAILS the write; unlike key wrapping it does not fall back to empty string)"
+ - "Durable-ack uses std::thread::spawn + std::sync::mpsc recv_timeout, NOT rt.block_on — put_with_sidecar is synchronous, and recv_timeout works whether or not the caller is inside a tokio runtime (rt.block_on panics with nested-runtime inside #[tokio::test])"
+ - "NETWORK_TIMEOUT made pub(crate) so read_ops can use NETWORK_TIMEOUT * 18 for the bounded recv"
+metrics:
+ completed: "2026-06-20T01:30:00Z"
+ tasks_completed: 2
+ files_modified: 4
+---
+
+# Phase 52 Plan 03: Write-Side Size Cap, Filename Encryption, Off-Thread Durable-Ack
+
+One-liner: Enforced the per-entry size cap and ECIES-encrypted the filename/dir-name in `build_upload_journal_entry`/`build_mkdir_journal_entry`, and moved the heavy ciphertext-sidecar write + fsync off the FUSE callback thread onto a separate OS thread while preserving the Phase-43 durable-ack contract via a bounded `recv_timeout` the callback blocks on before `reply.ok()`.
+
+## What Was Built
+
+### journal_helpers.rs (D-01 write side, D-04)
+
+- Size cap: immediately after `read_all()`, reject files where `plaintext.len() > cipherbox_sdk::MAX_JOURNAL_PAYLOAD_BYTES` with an `Err` (so the release path replies EIO). Checked before any key generation, so nothing sensitive is in memory on the reject path.
+- Sidecar fields: compute `entry_id` up-front, `sidecar_path = self.journal.sidecar_path_for(&entry_id)`, and `sidecar_sha256 = SHA-256(ciphertext)`; the `UploadFile` entry now carries these instead of `ciphertext_b64`. The in-memory `ciphertext` is still returned in `UploadJournalResult` for the live upload thread (unchanged).
+- Filename encryption: `filename_encrypted_hex = hex(ecies::wrap_key(file_name, public_key))` with `?` propagation (a failed encrypt fails the write).
+- Mkdir name encryption: same treatment for `name_encrypted_hex` in `build_mkdir_journal_entry`.
+
+### read_ops.rs (D-01 durable-ack)
+
+The release durability step was restructured into three stages: (1) build entry (size cap + sidecar + name encrypt, no mutations), (2) spawn the synchronous `put_with_sidecar` on a separate OS thread + block the callback thread on a BOUNDED `recv_timeout(NETWORK_TIMEOUT * 18)`, (3) only on durable Ok apply the inode mutations / pending_content / queue_publish, then `reply.ok()` and spawn the background upload. Any durability failure/timeout replies EIO with no inode mutation (CR-04 preserved — Pitfall 1 false-ack avoided).
+
+### Cargo.toml / lib.rs
+
+- Added `sha2 = { workspace = true }` to the fuse crate.
+- Made `NETWORK_TIMEOUT` `pub(crate)` so read_ops can reference it.
+
+## Critical Reconciliation (nested-runtime)
+
+The plan specified `fs.rt.block_on(tokio::time::timeout(NETWORK_TIMEOUT * 18, oneshot_rx))`. That panics with "Cannot start a runtime from within a runtime" when the release handler is driven from inside a tokio runtime (every `#[tokio::test]`, and any caller already on a runtime thread). Switched to `std::thread::spawn` + `std::sync::mpsc::Receiver::recv_timeout`, which is runtime-agnostic and equally correct: `put_with_sidecar` is synchronous so no tokio task is needed, and the bound is identical (`NETWORK_TIMEOUT * 18 ≈ 180s`). This is what makes `release_journals_before_cleanup` pass (it would otherwise panic).
+
+## Phase 51 Reconciliation
+
+The Phase-51 `clear_bytes`-on-every-fallible-path zeroization in `build_upload_journal_entry` is preserved untouched — the new size-cap guard is inserted ABOVE the key generation, so it cannot leak the file key (none exists yet at that point). `cipherbox_crypto::ecies::wrap_key` (used for filename encryption) is the same hardened API.
+
+## Test Results
+
+- cipherbox-fuse: 64/64 (baseline 60). `payload_size_cap_returns_err` (cap boundary + message), and the migrated `build_upload_journal_entry_round_trips` (asserts sidecar fields + sidecar_sha256 == SHA-256(ciphertext) + encrypted filename) and `build_mkdir_journal_entry_round_trips` (encrypted name, not plaintext) all green.
+- `release_journals_before_cleanup` now asserts the sidecar `.bin` exists and its bytes hash to the recorded `sidecar_sha256` BEFORE the OS ack (durable-ack-with-sidecar evidence).
+
+## Known Stubs
+
+None.
+
+## Self-Check: PASSED
+
+- `journal_helpers.rs` contains `MAX_JOURNAL_PAYLOAD_BYTES`, `sidecar_path`, `sidecar_sha256`, `filename_encrypted_hex`, `name_encrypted_hex`; no `ciphertext_b64`/`base64` remains.
+- `read_ops.rs` writes the sidecar off-thread and blocks on a bounded `recv_timeout` before `reply.ok()`.
+- 64/64 fuse tests pass; no warnings.
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-PLAN.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-PLAN.md
new file mode 100644
index 0000000000..158015385d
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-PLAN.md
@@ -0,0 +1,294 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 04
+type: execute
+wave: 2
+depends_on: ["52-01", "52-02"]
+files_modified:
+ - crates/fuse/src/lib.rs
+ - apps/desktop/src-tauri/src/fuse/mod.rs
+ - apps/desktop/src-tauri/src/fuse/windows/mod.rs
+autonomous: true
+requirements: [HARD-03]
+tags: [fuse, replay, timeout, concurrent-mount, name-decryption, sidecar, rust]
+
+must_haves:
+ truths:
+ - "Replay reads ciphertext from the .bin sidecar (and verifies its sha256), not from an in-JSON base64 blob"
+ - "Replay decrypts filename_encrypted_hex / name_encrypted_hex back to the plaintext name (used transiently, never re-persisted), and still replays a legacy plaintext entry once"
+ - "Each replay entry's network ops are bounded by tokio::time::timeout; a hung entry returns Err and goes through record_failure instead of awaiting forever"
+ - "Mount returns immediately; replay runs concurrently via rt.spawn and never blocks the mount (macOS/Linux and Windows)"
+ artifacts:
+ - path: "crates/fuse/src/lib.rs"
+ provides: "replay reads sidecar + verifies hash, decrypts names with legacy compat, per-entry tokio::time::timeout (D-03, D-04)"
+ contains: "tokio::time::timeout"
+ - path: "apps/desktop/src-tauri/src/fuse/mod.rs"
+ provides: "replay_for_vault spawned concurrently with mount (D-03)"
+ contains: "rt.spawn"
+ - path: "apps/desktop/src-tauri/src/fuse/windows/mod.rs"
+ provides: "replay_for_vault spawned concurrently with mount on Windows (D-03)"
+ contains: "rt.spawn"
+ key_links:
+ - from: "replay_for_vault (lib.rs)"
+ to: "replay_mkdir_entry / replay_upload_entry"
+ via: "tokio::time::timeout(NETWORK_TIMEOUT * 3 | * 18, ...) → Err on timeout → record_failure"
+ pattern: "tokio::time::timeout"
+ - from: "replay_upload_entry"
+ to: ".bin sidecar"
+ via: "read bytes + verify sidecar_sha256 instead of base64-decoding ciphertext_b64"
+ pattern: "sidecar"
+ - from: "mount_filesystem (fuse/mod.rs, windows/mod.rs)"
+ to: "replay_for_vault"
+ via: "rt.spawn(async move { replay_for_vault(...).await }) before CipherBoxFS construction"
+ pattern: "rt.spawn"
+---
+
+
+Implement the replay-side of the new journal shape and the WR-07 fix: read ciphertext from the
+`.bin` sidecar (verifying its hash), decrypt the ECIES-encrypted names (with one-time legacy
+plaintext compat), bound every replay entry's network ops with `tokio::time::timeout`, and run
+`replay_for_vault` concurrently with mount on both the Unix and Windows mount paths so the mount
+returns immediately.
+
+Purpose: Make replay consume the Plan-52-02 entry shape correctly (sidecar + encrypted names),
+and close WR-07 (medium): a hung IPNS/upload call can no longer stall the mount indefinitely or
+spin forever — it times out, records a failure, and the mount is already up because replay runs
+in the background.
+
+Output: `replay_for_vault` + `replay_upload_entry` + `replay_mkdir_entry` updated for the new
+fields, sidecar read+verify, name decryption with legacy compat, and per-entry timeouts; both
+`fuse/mod.rs` and `fuse/windows/mod.rs` spawn replay concurrently with mount.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-RESEARCH.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-SUMMARY.md
+
+
+
+NEW behavior introduced by THIS plan (exclude from drift verification):
+- sidecar read + `sidecar_sha256` verification in `replay_upload_entry` (replaces base64 decode of `ciphertext_b64`)
+- name-decryption helper logic: ECIES-unwrap `filename_encrypted_hex` / `name_encrypted_hex`; on a
+ non-hex / undecryptable legacy value, log a warn and use the value as plaintext for one replay
+- per-entry `tokio::time::timeout` wrappers in `replay_for_vault` (mkdir = NETWORK_TIMEOUT*3, upload = NETWORK_TIMEOUT*18)
+- concurrent `rt.spawn(replay_for_vault(...))` in `fuse/mod.rs` and `fuse/windows/mod.rs`
+PRE-EXISTING (do not flag as drift): `replay_for_vault`, `replay_upload_entry`, `replay_mkdir_entry`,
+`record_failure`, `NETWORK_TIMEOUT` (lib.rs:59), `cipherbox_crypto::ecies::unwrap_key`,
+`resolve_folder_key_cached`, `PublishCoordinator`, `rt: tokio::runtime::Handle` (mod.rs:76, windows/mod.rs:38),
+the `_public_key` param at replay_upload_entry:2058, and the new fields/constants from Plan 52-02.
+NOTE: Plan 52-01 also edits lib.rs (D-06 log sites at :1494/:1558) — this plan edits the SAME match
+arms (replay timeout wrapping). depends_on 52-01 ensures the D-06 logging is already in place; this
+plan wraps the call that produces the `result` those arms match on. Coordinate the two edits in the
+same match block (keep the D-06 `if let Err` removal logging from 52-01 intact).
+
+
+
+- **Sidecar hash verification before re-upload.** `replay_upload_entry` reads the `.bin`, computes
+ SHA-256, and compares against `sidecar_sha256`; a mismatch returns Err (entry retained via
+ record_failure) rather than uploading corrupted ciphertext. A missing `.bin` returns Err
+ ("sidecar not found") — RESEARCH Pitfall 4: replay's `already_present` short-circuit must run
+ BEFORE the sidecar read so a happy-path entry whose `.bin` was already cleaned up does not fail.
+- **Legacy name compat = passthrough-once** (matches Plan 52-02 D-04 decision): if
+ `hex::decode` + `ecies::unwrap_key` fails on `filename_encrypted_hex` / `name_encrypted_hex`,
+ treat the stored value as a legacy plaintext name, `log::warn!` once, and use it directly for
+ this replay. The plaintext is transient — never written back (the entry is removed on success).
+- **Timeout multipliers** (RESEARCH D-03 / A3): mkdir = `NETWORK_TIMEOUT * 3` (~30s, metadata-only),
+ upload = `NETWORK_TIMEOUT * 18` (~180s, multi-GB sidecar re-upload). `NETWORK_TIMEOUT` = lib.rs:59's
+ 10s base (NOT operations.rs:37's 3s). Idempotent re-upload means a timeout safely retains the entry.
+- **Concurrent replay sends NO FsEvent::UploadComplete** (RESEARCH Pitfall 5): replay re-uploads,
+ re-publishes, and removes the entry; it never touches `upload_tx`, so spawning it before
+ `CipherBoxFS` construction is race-free.
+
+
+
+
+
+ Task 1: D-04 replay — sidecar read+verify, name decryption with legacy compat
+ crates/fuse/src/lib.rs
+
+ - crates/fuse/src/lib.rs:1460-1576 (the file being modified — the `MkdirPublish`/`UploadFile` destructuring at :1461/:1517 and the success/failure match arms incl. the D-06 logging from Plan 52-01)
+ - crates/fuse/src/lib.rs:2055-2120 (`replay_upload_entry` signature: `_public_key` at :2058, `ciphertext_b64` at :2064, `filename` at :2072; the base64 decode at :2108-2110; the existing ecies::unwrap_key at :2103-2104)
+ - crates/fuse/src/lib.rs:2030, :2233 (where `name`/`filename` populate FolderEntry.name / FilePointer.name)
+ - crates/fuse/src/lib.rs:1918-2047 (`replay_mkdir_entry` signature + `name` usage)
+ - 52-PATTERNS.md "Shared Patterns → ECIES key wrapping" (replay-side unwrap) and "crates/fuse/src/lib.rs" section
+ - 52-RESEARCH.md "D-04 Definitive Resolution" + Pitfall 4 (sidecar read race) + Pitfall 6 (legacy compat) + "Pattern 3"
+ - 52-02-SUMMARY.md — exact field names (`sidecar_path`, `sidecar_sha256`, `filename_encrypted_hex`, `name_encrypted_hex`)
+
+
+ Tests (in the existing `#[cfg(test)] mod tests` at lib.rs:2276+; the existing test fixtures at
+ :2358/:2642 currently build entries with `ciphertext_b64` — update them to the new shape):
+ - `legacy_plaintext_filename_compat` (replay angle): a `UploadFile` entry whose
+ `filename_encrypted_hex` field actually holds a legacy plaintext value (non-hex) is decoded by
+ the replay name-decrypt helper to that plaintext (passthrough-once), with no panic.
+ - name round-trip: a `filename_encrypted_hex` produced by `ecies::wrap_key(name, public_key)`
+ decrypts via the replay helper (`unwrap_key(..., private_key)`) back to the original name.
+ (This is the replay counterpart to Plan 52-03's `filename_encryption_round_trips`.)
+
+
+ Per D-04: in `replay_for_vault`, update the `MkdirPublish` destructure (lib.rs:1461-1468) to
+ bind `name_encrypted_hex` instead of `name`, and the `UploadFile` destructure (:1517-1527) to
+ bind `sidecar_path`, `sidecar_sha256`, and `filename_encrypted_hex` instead of `ciphertext_b64`
+ and `filename`. Pass the new bindings into the replay entry fns.
+ Add a small name-decrypt helper (free fn in lib.rs, e.g. `fn decrypt_journal_name(encrypted_hex: &str, private_key: &[u8]) -> String`):
+ attempt `hex::decode` then `cipherbox_crypto::ecies::unwrap_key(.., private_key)` then
+ `String::from_utf8`; on ANY step failing, `log::warn!("legacy plaintext journal name — replaying once, not re-persisting")` and return the input string verbatim (passthrough-once compat).
+ In `replay_upload_entry` (signature at :2055-2079):
+ - change `ciphertext_b64: &str` to `sidecar_path: &std::path::Path` (+ `sidecar_sha256: &str`),
+ and `filename: &str` to `filename_encrypted_hex: &str`;
+ - remove the underscore from `_public_key` only if needed (decryption uses `private_key`, so
+ `public_key` may stay unused — keep the existing param, do not break the signature consumers);
+ - replace the base64 decode (:2108-2110) with: read the sidecar bytes from `sidecar_path`
+ (`std::fs::read`), compute SHA-256, compare to `sidecar_sha256` (Err on mismatch:
+ "sidecar hash mismatch — retaining entry"), Err on missing file ("sidecar not found —
+ retaining entry"). Keep this AFTER the `already_present` idempotency short-circuit so a
+ happy-path entry whose `.bin` was already cleaned up is not failed (Pitfall 4).
+ - derive the plaintext `filename` via `decrypt_journal_name(filename_encrypted_hex, private_key)`
+ at the point it is needed for `FilePointer.name` (:2233) and for the log lines.
+ Mirror the same `name_encrypted_hex` → `decrypt_journal_name` in `replay_mkdir_entry` for
+ `FolderEntry.name` (:2030).
+ Update the success-arm log lines (:1554/:1556, :1567/:1571/:1575) that interpolate `filename`
+ to use the decrypted name (or the entry id) — do NOT log the encrypted hex as if it were a name.
+ Keep the Plan-52-01 D-06 `if let Err(e) = journal.remove(...) { log::warn!(...) }` logging in
+ these arms intact.
+
+
+ cargo test -p cipherbox-fuse legacy_plaintext_filename_compat 2>&1 | tail -30
+
+
+ Replay binds the new fields; `replay_upload_entry` reads+verifies the sidecar (after the
+ already_present short-circuit) instead of base64-decoding; names are ECIES-decrypted with
+ passthrough-once legacy compat; no `ciphertext_b64` binding remains in `replay_for_vault`'s
+ production arms; the legacy-compat test and the existing replay tests (fixtures updated to the
+ new shape) pass.
+
+
+
+
+ Task 2: D-03 — per-entry tokio::time::timeout in replay_for_vault
+ crates/fuse/src/lib.rs
+
+ - crates/fuse/src/lib.rs:59 (`NETWORK_TIMEOUT = Duration::from_secs(10)`)
+ - crates/fuse/src/lib.rs:1283 (existing `tokio::time::timeout(NETWORK_TIMEOUT, async { ... })` pattern to mirror)
+ - crates/fuse/src/lib.rs:1470-1576 (the `.await` call sites for `replay_mkdir_entry` / `replay_upload_entry` and the `record_failure` Err arms that timeouts feed into)
+ - 52-PATTERNS.md "tokio::time::timeout pattern (lines 1282-1295)" — the exact wrapping + the note on feeding the timeout Err into record_failure
+ - 52-RESEARCH.md "D-03" + "Pattern 1" + Assumption A3 (multipliers)
+
+
+ Test `replay_entry_timeout` (`#[tokio::test]`): wrap a future that sleeps longer than a short
+ test timeout in the same `tokio::time::timeout(...).await.unwrap_or_else(|_| Err(...))` shape used
+ in production and assert the result is the `Err("... timed out after ...")` value (not a hang and
+ not Ok). Use `tokio::time::pause`/`advance` or a tiny duration so the test is fast (<1s).
+ The test proves the timeout-to-Err conversion the production code relies on.
+
+
+ Per D-03: wrap each replay entry call in `replay_for_vault` with `tokio::time::timeout`:
+ - `replay_mkdir_entry(...)` → `tokio::time::timeout(NETWORK_TIMEOUT * 3, replay_mkdir_entry(...)).await
+ .unwrap_or_else(|_| Err(format!("replay_mkdir_entry timed out after {}s", (NETWORK_TIMEOUT * 3).as_secs())))`
+ - `replay_upload_entry(...)` → `tokio::time::timeout(NETWORK_TIMEOUT * 18, replay_upload_entry(...)).await
+ .unwrap_or_else(|_| Err(format!("replay_upload_entry timed out after {}s", (NETWORK_TIMEOUT * 18).as_secs())))`
+ The resulting `Result<(), String>` flows into the EXISTING `match result { Ok(()) => ..., Err(e) => record_failure ... }`
+ arms unchanged — a timeout is just another `Err`, so it parks/retries via `record_failure`
+ exactly like a network error. Do not introduce a new error-handling structure.
+ Add `#[tokio::test] fn replay_entry_timeout()` to the tests module proving the timeout→Err shape.
+
+
+ cargo test -p cipherbox-fuse replay_entry_timeout 2>&1 | tail -30
+
+
+ Both replay entry calls are wrapped in `tokio::time::timeout` with the 3× / 18× multipliers off
+ `NETWORK_TIMEOUT` (lib.rs:59); a timeout produces an `Err` routed through `record_failure`;
+ `replay_entry_timeout` passes; no raw `.await` on `replay_*_entry` remains in `replay_for_vault`.
+
+
+
+
+ Task 3: D-03 — run replay concurrently with mount (Unix + Windows)
+ apps/desktop/src-tauri/src/fuse/mod.rs, apps/desktop/src-tauri/src/fuse/windows/mod.rs
+
+ - apps/desktop/src-tauri/src/fuse/mod.rs:276-291 (the file being modified — the blocking `replay_for_vault(...).await` before `CipherBoxFS` construction; `rt: tokio::runtime::Handle` at :76)
+ - apps/desktop/src-tauri/src/fuse/windows/mod.rs:355-411 (the file being modified — the Windows `replay_for_vault(...)` call at :360 and the `rt.clone()` usages at :382/:411)
+ - 52-PATTERNS.md "apps/desktop/src-tauri/src/fuse/mod.rs (D-03 concurrent replay ...)" — the exact clone-and-`rt.spawn` block; the Windows-mirror note
+ - 52-RESEARCH.md "D-03 step 3" + Pitfall 5 (replay sends no UploadComplete → race-free)
+
+
+ Per D-03: in `fuse/mod.rs`, replace the blocking `cipherbox_fuse::replay_for_vault(...).await`
+ (mod.rs:278-289) with a concurrent spawn. Clone every borrowed argument the closure needs
+ (`journal`, `state.sdk.api.clone()`, `private_key`, `public_key`, `root_folder_key`,
+ `root_ipns_name`, `publish_coordinator`, `tee_public_key`, `tee_key_epoch`) into owned values,
+ then `rt.spawn(async move { cipherbox_fuse::replay_for_vault(&replay_journal, replay_api, &replay_private_key, ...).await; log::info!("Background replay_for_vault complete"); });`
+ and proceed IMMEDIATELY to `CipherBoxFS` construction (mod.rs:291+). The mount no longer waits
+ on replay. Preserve the existing comment that replay errors never fail the mount.
+ NOTE: `private_key`/`public_key`/`root_folder_key` are later moved into `CipherBoxFS` (wrapped in
+ `Zeroizing`) at :295-297 — clone the bytes for the spawn closure BEFORE that move so both the
+ replay task and the FS own their own copies (do not move-then-borrow).
+ Apply the identical concurrent-spawn pattern to the Windows mount at
+ `fuse/windows/mod.rs:360` using the local `rt` handle (`rt.clone()` is already used at :382/:411).
+ Because replay sends no `FsEvent::UploadComplete` (Pitfall 5), spawning before `CipherBoxFS`
+ construction is race-free on both platforms.
+
+
+ cargo check -p cipherbox-desktop --features fuse 2>&1 | tail -20 && cargo check -p cipherbox-desktop --features winfsp 2>&1 | tail -20
+
+
+ Both mount paths spawn `replay_for_vault` via `rt.spawn` and return without awaiting it; key
+ bytes are cloned before the `Zeroizing` move into `CipherBoxFS`; `cargo check` passes for both
+ the `fuse` and `winfsp` feature sets; no blocking `replay_for_vault(...).await` remains in
+ either mount fn.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+| -------- | ----------- |
+| on-disk sidecar `.bin` → replay re-upload | ciphertext integrity must be verified (hash) before re-uploading |
+| on-disk encrypted name → replay folder metadata | name must be decrypted with the user private key, used transiently, never re-persisted as plaintext |
+| replay network call → mount lifecycle | a hung call must not block the mount or spin forever |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+| --------- | -------- | --------- | ----------- | --------------- |
+| T-52-11 | Denial of Service | `replay_for_vault` awaiting raw network ops before mount (WR-07) | mitigate (D-03) | Per-entry `tokio::time::timeout` (3×/18× NETWORK_TIMEOUT) → Err → record_failure; `replay_for_vault` spawned concurrently with mount on Unix + Windows so the mount never waits; `replay_entry_timeout` |
+| T-52-12 | Tampering | corrupted/swapped sidecar ciphertext re-uploaded at replay | mitigate (D-01) | `replay_upload_entry` verifies `sidecar_sha256` before re-upload; mismatch/missing → Err, entry retained, no bad CID published |
+| T-52-13 | Information Disclosure | encrypted name decrypted at replay then leaked/persisted | mitigate (D-04) | Decrypted name is transient (used for `FolderEntry.name`/`FilePointer.name` only); never written back to the journal; ECIES-unwrap with user private key (zero-knowledge preserved) |
+| T-52-14 | Tampering (legacy) | legacy plaintext name silently dropped, stranding in-flight pre-upgrade writes | mitigate (D-04) | Passthrough-once compat replays the legacy entry then removes it; logged with a warn; never re-persisted |
+
+T-52-11 (WR-07) is mitigated here (timeout + concurrent mount). No HIGH-severity threat is left
+unmitigated by this plan.
+
+
+
+
+- `cargo test -p cipherbox-fuse legacy_plaintext_filename_compat replay_entry_timeout 2>&1 | tail -30` pass
+- No `ciphertext_b64` binding remains in `replay_for_vault` production arms; replay reads the sidecar + verifies the hash
+- Both replay entry calls wrapped in `tokio::time::timeout`; no raw `.await` on `replay_*_entry`
+- Both mount paths `rt.spawn` replay and return without awaiting it
+- `cargo check -p cipherbox-desktop --features fuse` and `--features winfsp` both clean
+- `cargo test -p cipherbox-sdk -p cipherbox-fuse 2>&1 | tail -20` no regressions
+
+
+
+- D-04 replay: sidecar read + hash verify; names decrypted with passthrough-once legacy compat; no plaintext re-persist
+- D-03: per-entry timeouts (3×/18×) routed through record_failure; replay concurrent with mount on Unix + Windows
+- Existing replay tests updated to the new entry shape and green
+- Both feature-set checks pass
+
+
+
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-SUMMARY.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-SUMMARY.md
new file mode 100644
index 0000000000..7fbc5aacc8
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-SUMMARY.md
@@ -0,0 +1,68 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 04
+subsystem: fuse/replay
+tags: [fuse, replay, timeout, concurrent-mount, name-decryption, sidecar, rust]
+dependency_graph:
+ requires: [52-01, 52-02, 52-03]
+ provides: [replay-sidecar-read, replay-name-decrypt, replay-timeout, concurrent-mount-replay]
+ affects:
+ - crates/fuse/src/lib.rs
+ - apps/desktop/src-tauri/src/fuse/mod.rs
+ - apps/desktop/src-tauri/src/fuse/windows/mod.rs
+tech_stack:
+ added: []
+ patterns: [tokio-time-timeout-replay, ecies-name-decrypt-passthrough-once, sidecar-hash-verify, rt-spawn-concurrent-replay]
+key_files:
+ created: []
+ modified:
+ - crates/fuse/src/lib.rs
+ - apps/desktop/src-tauri/src/fuse/mod.rs
+ - apps/desktop/src-tauri/src/fuse/windows/mod.rs
+decisions:
+ - "decrypt_journal_name helper: hex-decode + ecies::unwrap_key + from_utf8; ANY failure → warn + passthrough-once (legacy plaintext), never re-persisted"
+ - "Sidecar read verifies SHA-256 before re-upload; missing/empty path or hash mismatch → Err (retain via record_failure), never upload corrupt/absent ciphertext"
+ - "Empty sidecar_path signals a legacy inline-ciphertext entry (ciphertext is gone after the 52-02 shape break) → retain, never crash"
+ - "Timeout multipliers: mkdir NETWORK_TIMEOUT*3 (~30s), upload NETWORK_TIMEOUT*18 (~180s); a timeout is just another Err routed through record_failure"
+ - "Replay spawned via rt.spawn before CipherBoxFS construction on BOTH Unix and Windows; key bytes cloned before the Zeroizing move; replay sends no UploadComplete so it is race-free"
+metrics:
+ completed: "2026-06-20T01:45:00Z"
+ tasks_completed: 3
+ files_modified: 3
+---
+
+# Phase 52 Plan 04: Replay Sidecar Read, Name Decryption, Timeout, Concurrent Mount
+
+One-liner: Rewired `replay_for_vault` / `replay_upload_entry` / `replay_mkdir_entry` to read ciphertext from the `.bin` sidecar (verifying its SHA-256), decrypt the ECIES-encrypted names with passthrough-once legacy compat, bound every replay entry's network ops with `tokio::time::timeout`, and run replay concurrently with mount on both the Unix and Windows mount paths.
+
+## What Was Built
+
+### lib.rs replay (D-04 + D-03)
+
+- `decrypt_journal_name(encrypted_hex, private_key) -> String`: hex-decode + `ecies::unwrap_key` + UTF-8; on any step failing, `log::warn!` once and return the input verbatim (passthrough-once legacy compat). Handles Phase-51's `unwrap_key -> Zeroizing>` via `.to_vec()`.
+- `replay_upload_entry` signature: `ciphertext_b64: &str` → `sidecar_path: &Path` + `sidecar_sha256: &str`; `filename: &str` → `filename_encrypted_hex: &str`. Step 1 now reads the sidecar, computes SHA-256, compares to `sidecar_sha256` (mismatch → Err/retain), Err on missing/empty path. The filename is decrypted transiently via `decrypt_journal_name`.
+- `replay_mkdir_entry`: `name: &str` → `name_encrypted_hex: &str`, decrypted transiently for `FolderEntry.name`.
+- `replay_for_vault` destructures the new fields and wraps each entry call in `tokio::time::timeout` (mkdir 3×, upload 18× of `NETWORK_TIMEOUT`); a timeout becomes an `Err` routed through the existing `record_failure` arms (the Plan-52-01 D-06 removal-logging in those arms is preserved). Success/failure log lines no longer interpolate the (now-encrypted) filename — they use the entry id, since the plaintext name only exists transiently inside `replay_upload_entry`.
+
+### Desktop mount paths (D-03 concurrent replay)
+
+Both `apps/desktop/src-tauri/src/fuse/mod.rs` and `.../windows/mod.rs` replaced the blocking `replay_for_vault(...).await` with `rt.spawn(async move { replay_for_vault(...).await })`, cloning every borrowed arg (journal, api, private_key, public_key, root_folder_key, root_ipns_name, coordinator, tee_public_key, tee_key_epoch) into owned values BEFORE the key bytes are moved into `CipherBoxFS` (wrapped in `Zeroizing`). The mount returns immediately; replay runs in the background. Replay sends no `FsEvent::UploadComplete`, so spawning before FS construction is race-free.
+
+## Phase 51 Reconciliation
+
+`replay_mkdir_entry`/`replay_upload_entry` already used Phase-51's `ecies::unwrap_key -> Zeroizing>` for the IPNS/folder keys — all those zeroized unwraps are untouched. The new `decrypt_journal_name` uses the same hardened `unwrap_key` and consumes its `Zeroizing` result via `.to_vec()` (a filename is not key material, so no further zeroization is required). The desktop concurrent-spawn clones the key bytes BEFORE the existing `Zeroizing::new(private_key)` move, so the FS still owns the sole zeroize-on-drop copy.
+
+## Test Results
+
+- cipherbox-fuse: 64/64. New/migrated: `replay_entry_timeout` (timeout→Err shape), `decrypt_journal_name_round_trip_and_legacy_compat` (ECIES round-trip + passthrough-once for non-hex and valid-hex-non-ECIES), `replay_reuploads_ciphertext` (migrated to `put_with_sidecar` + sidecar read round-trip + sha256 match), and the 4 replay fixtures migrated to the sidecar shape.
+- `cargo check -p cipherbox-desktop --features fuse`: clean (0 errors).
+
+## Known Stubs
+
+None.
+
+## Self-Check: PASSED
+
+- `lib.rs` replay reads the sidecar + verifies the hash, decrypts names with passthrough-once compat, wraps each entry in `tokio::time::timeout`; no `ciphertext_b64` binding remains in production arms.
+- Both desktop mount fns `rt.spawn` replay and return without awaiting it.
+- 64/64 fuse tests pass; desktop fuse check clean.
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-05-PLAN.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-05-PLAN.md
new file mode 100644
index 0000000000..0a1a4de031
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-05-PLAN.md
@@ -0,0 +1,258 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 05
+type: execute
+wave: 3
+depends_on: ["52-02", "52-04"]
+files_modified:
+ - crates/sdk/src/queue.rs
+ - apps/desktop/src-tauri/src/commands/auth.rs
+ - apps/desktop/src-tauri/src/fuse/mod.rs
+autonomous: true
+requirements: [HARD-03]
+tags: [fuse, journal, gc, retention, lifecycle-purge, at-rest-safety, rust]
+
+must_haves:
+ truths:
+ - "purge_vault removes every journal entry (.json + .bin) belonging to one vault_root_ipns"
+ - "Logout purges the current vault's journal entries so they do not persist past the session"
+ - "gc_failed_entries removes parked Failed entries older than the age window and trims oldest-first to the total-size budget, and cleans .bin orphans with no matching .json"
+ artifacts:
+ - path: "crates/sdk/src/queue.rs"
+ provides: "WriteQueue::purge_vault + WriteQueue::gc_failed_entries (D-02)"
+ contains: "fn purge_vault"
+ - path: "apps/desktop/src-tauri/src/commands/auth.rs"
+ provides: "purge_vault call in logout() (D-02)"
+ contains: "purge_vault"
+ - path: "apps/desktop/src-tauri/src/fuse/mod.rs"
+ provides: "gc_failed_entries invocation at mount (D-02) using the GC constants"
+ contains: "gc_failed_entries"
+ key_links:
+ - from: "logout() (auth.rs)"
+ to: "WriteQueue::purge_vault(root_ipns_name)"
+ via: "reconstruct WriteQueue from default_journal_dir(); purge before clear_keys()"
+ pattern: "purge_vault"
+ - from: "mount_filesystem (fuse/mod.rs)"
+ to: "WriteQueue::gc_failed_entries"
+ via: "JOURNAL_GC_MAX_AGE_DAYS + JOURNAL_GC_MAX_SIZE_BYTES"
+ pattern: "gc_failed_entries"
+---
+
+
+Close the journal retention / cross-vault leak (D-02): add `WriteQueue::purge_vault` and
+`WriteQueue::gc_failed_entries`, wire `purge_vault` into the desktop `logout()` lifecycle hook,
+and run `gc_failed_entries` at mount so parked Failed entries and `.bin` orphans are bounded.
+
+Purpose: Today the journal dir is shared across vaults and only filtered at read time, so another
+vault's entries (including their ciphertext sidecars) persist forever, and Failed entries never
+age out. D-02 makes retention bounded (Information Disclosure + disk-exhaustion mitigation) and
+gives account-switch / account-deletion a reusable purge interface for future phases.
+
+Output: two new `WriteQueue` methods with tests; a logout-time vault purge; a mount-time GC sweep
+using the constants defined in Plan 52-02.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-RESEARCH.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-02-SUMMARY.md
+@.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-04-SUMMARY.md
+
+
+
+NEW symbols introduced by THIS plan (exclude from drift verification):
+- `WriteQueue::purge_vault(&self, vault_root_ipns: &str) -> Result` — removes all
+ `.json` + `.bin` pairs whose entry `vault_root_ipns` matches; returns the count removed
+- `WriteQueue::gc_failed_entries(&self, age_days: u64, total_size_budget: u64) -> Result` —
+ purges Failed entries exceeding the age window, trims oldest-first to the size budget, and removes
+ `.bin` files that have no matching `.json` (orphan cleanup, RESEARCH Pitfall 2)
+- a `purge_vault` call in `logout()` (auth.rs) and a `gc_failed_entries` call at mount (fuse/mod.rs)
+PRE-EXISTING (do not flag as drift): `WriteQueue::load_all_for_vault`, `remove` (now sidecar-aware
+from Plan 52-02), `JournalEntryStatus::Failed`, `JournalOp::created_at_ms()` (queue.rs:97),
+`default_journal_dir()` (fuse/mod.rs:62), `JOURNAL_MAX_RETRIES` (fuse/mod.rs:52),
+`state.sdk.root_ipns_name`, and the GC constants `JOURNAL_GC_MAX_AGE_DAYS` /
+`JOURNAL_GC_MAX_SIZE_BYTES` / `MAX_JOURNAL_PAYLOAD_BYTES` from Plan 52-02.
+
+
+
+- **No account-switch / account-deletion command exists today** (RESEARCH Open Question 2; grep
+ confirmed). Therefore `purge_vault` is wired ONLY at the `logout()` site. `purge_vault` is exposed
+ as the reusable public interface so a future `switch_account`/`delete_account` command can call it;
+ this plan documents that future hook but does NOT invent the commands.
+- **GC runs at mount, not on a timer.** No background scheduler exists for the journal; mount is the
+ natural sweep point (mirrors where replay runs). `gc_failed_entries` is invoked once per mount with
+ `JOURNAL_GC_MAX_AGE_DAYS` (30) and `JOURNAL_GC_MAX_SIZE_BYTES` (500 MiB) from Plan 52-02.
+- **Size accounting includes the `.bin` sidecar.** The total-size budget sums each Failed entry's
+ `.json` + `.bin` byte sizes (the sidecar is where the bytes actually are).
+- **Failed entries only for GC; purge_vault is unconditional.** `gc_failed_entries` only touches
+ `JournalEntryStatus::Failed` entries (in-flight Pending/InProgress are never GC'd). `purge_vault`
+ removes ALL statuses for the vault (logout means the session is over).
+
+
+
+
+
+ Task 1: D-02 — WriteQueue::purge_vault
+ crates/sdk/src/queue.rs
+
+ - crates/sdk/src/queue.rs:203-264 (the file being modified — sidecar-aware `remove` from Plan 52-02 and `load_all_for_vault` filtering by `vault_root_ipns`)
+ - crates/sdk/src/queue.rs:335-440 (existing `#[cfg(test)] mod tests` + `make_temp_queue` helper)
+ - 52-PATTERNS.md "New methods to add" (purge_vault signature) + "Idempotent remove with NotFound guard"
+ - 52-RESEARCH.md "D-02" (purge_vault semantics) + Security Invariant 4 (.bin removed with .json)
+ - 52-02-SUMMARY.md — confirm `remove` already deletes the `.bin`
+
+
+ Test `purge_vault_removes_all` (use `make_temp_queue`):
+ - `put_with_sidecar` two `UploadFile` entries for vault A and one for vault B; call
+ `purge_vault("A")` and assert it returns 2, both A `.json`+`.bin` pairs are gone, and the B
+ entry (`.json`+`.bin`) survives.
+ - purge of a vault with no entries returns 0 (no error).
+
+
+ Per D-02, add `pub fn purge_vault(&self, vault_root_ipns: &str) -> Result`:
+ `load_all_for_vault(vault_root_ipns)`, then for each matching entry call the sidecar-aware
+ `self.remove(&entry.id)` (which deletes both `.json` and `.bin` from Plan 52-02). Count successful
+ removals and return the count. Surface a removal error via `Err` (the caller logs+continues).
+ Place the method next to `remove`/`load_all_for_vault`. Add the test to the existing tests module.
+
+
+ cargo test -p cipherbox-sdk purge_vault_removes_all 2>&1 | tail -20
+
+
+ `purge_vault` removes all `.json`+`.bin` for the target vault, leaves other vaults intact,
+ returns the count, and is a no-op (0) for an empty vault; `purge_vault_removes_all` passes.
+
+
+
+
+ Task 2: D-02 — WriteQueue::gc_failed_entries (age + size budget + orphan cleanup)
+ crates/sdk/src/queue.rs
+
+ - crates/sdk/src/queue.rs:97-103 (`JournalOp::created_at_ms()` accessor for age/sort)
+ - crates/sdk/src/queue.rs:105-135 (`JournalEntryStatus::Failed`, `JournalEntry` shape)
+ - crates/sdk/src/queue.rs:225-264 (`load_all_for_vault` read-dir + `.json`-only filter — model the all-vault scan and the `.bin` orphan scan on this)
+ - 52-PATTERNS.md "New methods to add" (gc_failed_entries signature) + "load_all_for_vault .json-only filter" (orphan note)
+ - 52-RESEARCH.md "D-02" (GC semantics, oldest-first) + Pitfall 2 (sidecar orphans)
+
+
+ Tests (use `make_temp_queue`; control time via the entries' `created_at_ms`):
+ - `gc_purges_old_failed`: put a Failed entry with `created_at_ms` older than `age_days`, and a
+ recent Failed entry; `gc_failed_entries(age_days, large_budget)` removes the old one (returns 1),
+ keeps the recent one and any non-Failed entries untouched.
+ - `gc_purges_to_size_budget`: put several Failed entries whose combined `.json`+`.bin` size
+ exceeds a small budget; `gc_failed_entries(huge_age, small_budget)` removes oldest-first until
+ under budget and returns the count removed.
+ - orphan cleanup: leave a `.bin` with no matching `.json`; GC removes it (assert gone).
+
+
+ Per D-02, add `pub fn gc_failed_entries(&self, age_days: u64, total_size_budget: u64) -> Result`:
+ 1. Scan the journal dir for all `.json` entries (across ALL vaults — GC is global), deserialize,
+ filter to `JournalEntryStatus::Failed`.
+ 2. Age purge: remove any Failed entry whose `op.created_at_ms()` is older than
+ `now_ms - age_days*86_400_000` (use the same ms clock as entries). Use the sidecar-aware
+ `remove`.
+ 3. Size purge: compute each remaining Failed entry's on-disk size (`.json` bytes +
+ `.bin` bytes if present); if the sum exceeds `total_size_budget`, sort oldest-first by
+ `created_at_ms()` and `remove` until under budget.
+ 4. Orphan cleanup: enumerate `.bin` files with no matching `.json` and `remove_file` them
+ (Pitfall 2). Count toward the return total.
+ Return the total number of entries/orphans removed. Tolerate per-file errors by logging
+ (`log::warn!`) and continuing — GC is best-effort and must never panic.
+ Wire the mount-time sweep in fuse/mod.rs in Task 3.
+
+
+ cargo test -p cipherbox-sdk gc_purges_old_failed gc_purges_to_size_budget 2>&1 | tail -30
+
+
+ `gc_failed_entries` purges Failed entries by age, trims oldest-first to the size budget
+ (counting `.bin`), and removes `.bin` orphans; only Failed entries are touched; per-file errors
+ are logged not fatal; both tests pass.
+
+
+
+
+ Task 3: D-02 — wire purge_vault into logout() and gc_failed_entries at mount
+ apps/desktop/src-tauri/src/commands/auth.rs, apps/desktop/src-tauri/src/fuse/mod.rs
+
+ - apps/desktop/src-tauri/src/commands/auth.rs:488-521 (the file being modified — `logout()`; the unmount `log::warn!`+continue pattern at :496-499 to mirror; `state.clear_keys()` at :514; `state.sdk.root_ipns_name` available before clear)
+ - apps/desktop/src-tauri/src/fuse/mod.rs:50-70 (`default_journal_dir()` + `JOURNAL_MAX_RETRIES`) and :150-159 (where the mount builds `WriteQueue::new(journal_dir, JOURNAL_MAX_RETRIES)`)
+ - apps/desktop/src-tauri/src/fuse/mod.rs:276-291 (where replay is spawned in Plan 52-04 — GC goes near here, before/alongside the replay spawn)
+ - 52-PATTERNS.md "apps/desktop/src-tauri/src/commands/auth.rs (D-02 vault purge on logout)" + "JOURNAL_MAX_RETRIES constant (D-02 GC constants)"
+ - 52-RESEARCH.md "D-02 Lifecycle hooks available"
+
+
+ Per D-02, logout purge (auth.rs): inside the existing `#[cfg(any(feature = "fuse", feature = "winfsp"))]`
+ block in `logout()`, AFTER the unmount and BEFORE `state.clear_keys().await` (:514), read the
+ current vault IPNS via `state.sdk.root_ipns_name.read().await.clone()`; if `Some(ipns)`,
+ reconstruct the journal with `cipherbox_sdk::WriteQueue::new(crate::fuse::default_journal_dir(), crate::fuse::JOURNAL_MAX_RETRIES)`
+ and call `purge_vault(&ipns)`, logging the count on Ok and a `log::warn!` (non-fatal, continue)
+ on Err — mirror the unmount error-continuation pattern at :496-499. Must run before `clear_keys`
+ because the IPNS name is read from sdk state.
+ Per D-02, mount GC (fuse/mod.rs): after the `journal` is constructed (around :159) and near the
+ Plan-52-04 replay spawn (:276+), call `journal.gc_failed_entries(JOURNAL_GC_MAX_AGE_DAYS, JOURNAL_GC_MAX_SIZE_BYTES)`
+ (constants from `cipherbox_sdk`), logging the count on Ok and `log::warn!` on Err (non-fatal —
+ GC must never block the mount). Run it before or alongside the replay spawn; it is synchronous
+ and fast (dir scan only).
+ Document (code comment in auth.rs) that a future `switch_account`/`delete_account` command must
+ call `purge_vault` for the departing vault — no such command exists today (RESEARCH Open Q2).
+
+
+ cargo check -p cipherbox-desktop --features fuse 2>&1 | tail -20 && cargo check -p cipherbox-desktop --features winfsp 2>&1 | tail -20
+
+
+ `logout()` purges the current vault's journal entries (count logged, errors non-fatal) before
+ clearing keys; mount runs `gc_failed_entries` with the Plan-52-02 constants (non-fatal); a
+ comment records the future account-switch/deletion hook; `cargo check` passes for both feature sets.
+
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+| -------- | ----------- |
+| shared journal dir → multiple vaults | one vault's entries (incl. ciphertext sidecars) must not persist across a logout into another session |
+| Failed entries on disk → unbounded retention | parked entries must age out / be size-bounded so disk is not exhausted |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+| --------- | -------- | --------- | ----------- | --------------- |
+| T-52-15 | Information Disclosure | cross-vault journal retention (shared dir filtered, never cleaned) | mitigate (D-02) | `purge_vault` removes the departing vault's `.json`+`.bin` at `logout()`; reusable interface for future account-switch/deletion; `purge_vault_removes_all` |
+| T-52-16 | Denial of Service | unbounded parked Failed entries + sidecar orphans (disk exhaustion) | mitigate (D-02) | `gc_failed_entries` ages out Failed entries, trims oldest-first to a 500 MiB budget, and removes `.bin` orphans; run at mount; `gc_purges_old_failed` / `gc_purges_to_size_budget` |
+
+No HIGH-severity threats are introduced or left unmitigated by this plan.
+
+
+
+
+- `cargo test -p cipherbox-sdk purge_vault_removes_all gc_purges_old_failed gc_purges_to_size_budget 2>&1 | tail -30` all pass
+- `purge_vault` deletes both `.json` and `.bin` for the target vault only
+- `gc_failed_entries` only touches Failed entries, trims by age then size, cleans `.bin` orphans
+- logout calls `purge_vault` before `clear_keys`; mount calls `gc_failed_entries` (both non-fatal on Err)
+- `cargo check -p cipherbox-desktop --features fuse` and `--features winfsp` both clean
+- `cargo test -p cipherbox-sdk -p cipherbox-fuse 2>&1 | tail -20` no regressions
+
+
+
+- D-02: `purge_vault` + `gc_failed_entries` implemented and tested
+- Logout purges the current vault's journal; mount GCs Failed entries + orphans
+- Cross-vault retention leak and unbounded Failed growth both closed
+- Future account-switch/deletion hook documented; all three new tests green
+
+
+
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-05-SUMMARY.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-05-SUMMARY.md
new file mode 100644
index 0000000000..c4c70e1790
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-05-SUMMARY.md
@@ -0,0 +1,75 @@
+---
+phase: 52-desktop-fuse-durability-at-rest-safety
+plan: 05
+subsystem: fuse/journal-retention
+tags: [fuse, journal, gc, retention, lifecycle-purge, at-rest-safety, rust]
+dependency_graph:
+ requires: [52-02, 52-04]
+ provides: [vault-journal-purge, failed-entry-gc, sidecar-orphan-cleanup]
+ affects:
+ - crates/sdk/src/queue.rs
+ - apps/desktop/src-tauri/src/commands/auth.rs
+ - apps/desktop/src-tauri/src/fuse/mod.rs
+tech_stack:
+ added: []
+ patterns: [sidecar-aware-purge, age-then-size-gc-oldest-first, bin-orphan-cleanup, mount-time-sweep]
+key_files:
+ created: []
+ modified:
+ - crates/sdk/src/queue.rs
+ - apps/desktop/src-tauri/src/commands/auth.rs
+ - apps/desktop/src-tauri/src/fuse/mod.rs
+decisions:
+ - "purge_vault is unconditional (all statuses) — logout means the session is over; it reuses the sidecar-aware remove so both .json and .bin go"
+ - "gc_failed_entries only touches Failed entries (in-flight Pending/InProgress never GC'd); runs at mount (no background scheduler), three passes: age purge, oldest-first size purge counting the .bin, then .bin orphan cleanup"
+ - "Size accounting sums .json + .bin via a private entry_on_disk_size helper; missing files contribute 0"
+ - "Added a module-level now_ms() (mirrors registry::now_ms) so the age compare uses the same ms-since-epoch clock entries are stamped with"
+ - "purge_vault wired ONLY at logout() today (no switch_account/delete_account command exists — RESEARCH Open Q2); a code comment records the future hook"
+ - "Logout purge runs BEFORE state.clear_keys() because clear_keys zeroes root_ipns_name to None"
+metrics:
+ completed: "2026-06-20T05:00:00Z"
+ tasks_completed: 3
+ files_modified: 3
+---
+
+# Phase 52 Plan 05: Journal Retention Purge and Failed-Entry GC
+
+One-liner: Added `WriteQueue::purge_vault` and `WriteQueue::gc_failed_entries` to close the cross-vault journal retention leak (D-02), wired `purge_vault` into the desktop `logout()` lifecycle hook (before key zeroization), and run `gc_failed_entries` once at mount using the Plan-52-02 GC constants so parked Failed entries and `.bin` orphans are bounded.
+
+## What Was Built
+
+### queue.rs (D-02)
+
+- `purge_vault(&self, vault_root_ipns: &str) -> Result`: `load_all_for_vault` then sidecar-aware `remove` for every matching entry regardless of status; returns the count. No-op (returns 0) for a vault with no entries. This is the reusable interface a future account-switch/deletion must call.
+- `gc_failed_entries(&self, age_days, total_size_budget) -> Result`: global scan of all `.json` filtered to `Failed`, then (1) age purge (`created_at_ms < now_ms - age_days*86_400_000`), (2) oldest-first size purge counting each entry's `.json` + `.bin` bytes until under budget, (3) `.bin` orphan cleanup (sidecars with no matching `.json`, RESEARCH Pitfall 2). Best-effort: per-file errors are `log::warn!`'d and skipped, never fatal/panicking. Returns total removed.
+- Private `entry_on_disk_size(&self, id)` helper (`.json` + `.bin` bytes, missing = 0) for the size pass.
+- Module-level `now_ms()` mirroring `registry::now_ms` for the age clock.
+
+### auth.rs (D-02 logout purge)
+
+Inside the existing `#[cfg(any(feature = "fuse", feature = "winfsp"))]` family, AFTER the unmount/keychain-delete and BEFORE `state.clear_keys()`, read `state.sdk.root_ipns_name` (must be before clear_keys, which zeroes it to None), reconstruct `WriteQueue::new(crate::fuse::default_journal_dir(), crate::fuse::JOURNAL_MAX_RETRIES)`, and `purge_vault(&ipns)` — count logged on Ok, `log::warn!`+continue on Err (mirrors the unmount error pattern). A comment records that a future `switch_account`/`delete_account` (none exists — RESEARCH Open Q2) must call `purge_vault` for the departing vault.
+
+### fuse/mod.rs (D-02 mount GC)
+
+After the `journal` is built and the `PublishCoordinator` is seeded, and right before the Plan-52-04 concurrent replay spawn, call `journal.gc_failed_entries(JOURNAL_GC_MAX_AGE_DAYS, JOURNAL_GC_MAX_SIZE_BYTES)` (constants from `cipherbox_sdk`). Synchronous, fast (dir scan), non-fatal on Err — GC must never block/fail the mount.
+
+## Phase 51 Reconciliation
+
+No Phase-51 hardening touched. The logout purge inserts strictly BEFORE `state.clear_keys()` (the existing zeroization step) and only reads `root_ipns_name`; the diff removes no `Zeroizing` / `clear_bytes` / `wrap_key` / `unwrap_key` line. `purge_vault`/`gc_failed_entries` reuse the existing sidecar-aware `remove`, so the same fsync/at-rest guarantees apply.
+
+## Test Results
+
+- cipherbox-sdk: 57/57 (baseline 54). New: `purge_vault_removes_all` (vault A removed incl. `.json`+`.bin`, vault B survives, empty-vault → 0), `gc_purges_old_failed` (old Failed removed, recent Failed + Pending untouched), `gc_purges_to_size_budget` (oldest-first trim to a measured-single-entry budget + `.bin` orphan removed).
+- cipherbox-fuse: 64/64 (no regression).
+- `cargo check -p cipherbox-desktop --features fuse`: clean (0 errors). `--features winfsp` fails only in upstream `windows_core` crates on macOS (`IMarshal`/`marshaler`) — not our code; CI's Windows runner covers it.
+- `cargo clippy -p cipherbox-sdk`: queue.rs additions clippy-clean (pre-existing warnings live only in hkdf/ipns/registry).
+
+## Known Stubs
+
+None.
+
+## Self-Check: PASSED
+
+- `queue.rs` has `fn purge_vault` and `fn gc_failed_entries` next to `remove`/`load_all_for_vault`.
+- `auth.rs logout()` calls `purge_vault` before `clear_keys`; `fuse/mod.rs` calls `gc_failed_entries` at mount; both non-fatal on Err.
+- All three new tests pass; 57/57 sdk + 64/64 fuse; desktop fuse check clean.
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
new file mode 100644
index 0000000000..e704b734a1
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-CONTEXT.md
@@ -0,0 +1,191 @@
+# Phase 52: Desktop FUSE Durability & At-Rest Safety - Context
+
+**Gathered:** 2026-06-19
+**Status:** Ready for planning
+**Source:** Discuss-phase. Scope is one re-verified captured todo (#9) under requirement HARD-03 —
+the open warnings from the Phase 43 FUSE-write-durability review (criticals already fixed; phases
+45/46 closed most warnings). Four implementation forks discussed and locked; two trivial items
+pre-locked by the todo.
+
+
+
+## Phase Boundary
+
+Harden the desktop FUSE write-journal and its replay path under requirement **HARD-03**. All work is
+Rust in the shared `crates/sdk` + `crates/fuse` (and the desktop Tauri shell), so fixes are
+cross-platform: the same code blocks the single FUSE callback thread on macOS/Linux and runs under
+the global WinFsp mutex on Windows.
+
+In scope (todo #9, re-verified 2026-06-18, post phases 45/46):
+
+- **WR-06 (high)** — Each `UploadFile` journal entry embeds the entire file ciphertext as base64
+ inside the JSON (`crates/sdk/src/queue.rs:36`). A 2 GB file → ~2.7 GB `serde_json` allocation +
+ multi-GB write + `F_FULLFSYNC` on the shared FS thread → blocks the whole filesystem and can OOM.
+ No size cap, no GC of parked `Failed` entries, and other vaults' entries persist forever (shared
+ journal dir, only filtered).
+- **WR-07 (med)** — `replay_for_vault` awaits raw `resolve_ipns` / `fetch_content` /
+ `upload_content` per entry with no `NETWORK_TIMEOUT` discipline, and runs BEFORE mount
+ (`apps/desktop/src-tauri/src/fuse/mod.rs:278`) → a hung link stalls the mount indefinitely.
+- **IN-03 (low)** — plaintext `filename`/`name` persisted in journal JSON (`crates/sdk/src/queue.rs:62`).
+- **IN-04 (low)** — `sanitize_error` only scrubs `/Users/` and `/home/` (`crates/sdk/src/sync.rs:271`).
+- **IN-05 (low)** — `let _ = journal.remove(...)` swallows removal errors (`crates/fuse/src/lib.rs:1494,:1558`;
+ `write_ops.rs:679`) → silent later replay / double-publish risk.
+
+Out of scope: the HARD-02 / HARD-04..06 items (Phases 51, 53–55); any redesign of the journal
+format beyond what these fixes require; and the desktop-fuse data-loss bugs already closed in Phase
+46. This phase pays down the Phase 43 review warnings only.
+
+
+
+
+
+## Implementation Decisions
+
+### WR-06 — Large-file journal write path
+
+- **D-01 (WR-06, fork):** **Sidecar + off-thread.** Store ciphertext in a sidecar `.bin`
+ streamed to disk; the JSON entry holds only the path + hash (+ size). Perform the heavy write +
+ `F_FULLFSYNC` **off the shared FS callback thread** (background/journal-writer task) so concurrent
+ filesystem operations are never blocked. The originating `release()` must STILL await its own
+ entry's durability before acking — do not reintroduce the Phase-43 false-durability-ack bug.
+ Add a per-entry payload size cap. (Eliminates both the OOM/2.7 GB allocation and the FS-thread
+ stall.)
+
+### Journal retention / GC
+
+- **D-02 (GC, fork):** **GC + logout + cross-vault purge.** Add age + size-budget GC of parked
+ `Failed` entries; purge the current vault's entries on logout; and purge a vault's entries on
+ account switch / account deletion — closing the cross-vault leak (today the shared journal dir is
+ only filtered, never cleaned). The planner proposes concrete default caps (age window + total-size
+ budget) consistent with existing desktop constants.
+
+### WR-07 — Replay durability
+
+- **D-03 (WR-07, fork):** **Timeout + concurrent with mount.** Wrap each entry's network ops in a
+ `tokio::time::timeout` (mirror the `NETWORK_TIMEOUT` discipline used elsewhere in the desktop
+ stack, with a sensible multiplier for large uploads) AND run replay concurrently with mount so the
+ mount returns immediately and never waits on replay. A hung entry can neither stall the mount nor
+ spin forever.
+
+### IN-03 — At-rest journaled names
+
+- **D-04 (IN-03, fork):** **Omit the plaintext name if replay doesn't need it.** During planning,
+ determine whether replay can reconstruct the name from `FileMetadata` / the entry's path. If it
+ can, drop the plaintext `filename`/`name` from the journal entry entirely (leak-free, simplest).
+ **Fallback:** if the name IS required for replay, encrypt it in the entry instead (the key is
+ available at write/replay time). Either way, no plaintext item name persists at rest.
+
+### Locked by the todo (no fork)
+
+- **D-05 (IN-04):** Extend `sanitize_error`'s scrub list beyond `/Users/` and `/home/` to cover
+ `C:\Users\…` (drive-letter pattern), `/var`, `/tmp`, `/private` so paths don't leak into
+ tray/notification copy.
+- **D-06 (IN-05):** Replace `let _ = journal.remove(...)` with `log::warn!` on removal errors at
+ `crates/fuse/src/lib.rs:1494,:1558` and `write_ops.rs:679`, so a failed removal can't silently
+ cause a later replay / double-publish.
+
+### Suggested sequencing (planner may refine)
+
+WR-06 (the high-severity blocker, biggest change) → WR-07 (replay) → IN-03 (names) → IN-04/IN-05
+(trivial, can land alongside any of the above).
+
+### Folded Todos
+
+- **[#9]** `2026-06-18-fuse-journal-growth-and-replay-timeout.md` — WR-06/WR-07 + IN-03/04/05
+ (re-verified 2026-06-18, post phases 45/46). Maps to D-01..D-06.
+
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Phase scope & source findings
+
+- `.planning/todos/pending/2026-06-18-fuse-journal-growth-and-replay-timeout.md` — todo #9 with
+ current line numbers, fixes, and acceptance. The primary ref.
+- `.planning/phases/43-fuse-write-durability/43-REVIEW.md` — origin of WR-06/WR-07 and IN-03/04/05
+ (criticals fixed 2026-06-14; these warnings remain).
+- `.planning/REQUIREMENTS.md` — HARD-03.
+- `.planning/ROADMAP.md` §"Phase 52" — scope checkboxes.
+
+### Journal / write path (WR-06, GC, IN-03, IN-05)
+
+- `crates/sdk/src/queue.rs` — journal entry struct: ciphertext-in-JSON (`:36`), plaintext name
+ (`:62`). WriteQueue is the home for the sidecar + GC + size-cap work.
+- `crates/fuse/src/lib.rs` (`:1494`, `:1558`) and `crates/fuse/src/write_ops.rs` (`:679`) — swallowed
+ `journal.remove(...)` errors (IN-05).
+- `crates/fuse/src/read_ops.rs` — referenced in the todo's file list; relevant to journal/sidecar
+ reads.
+
+### Replay (WR-07)
+
+- `apps/desktop/src-tauri/src/fuse/mod.rs` (`:278`) — `replay_for_vault`; add timeout + run
+ concurrently with mount.
+- Mirror the existing `NETWORK_TIMEOUT` pattern used elsewhere in the desktop stack (researcher to
+ locate the canonical definition/usages).
+
+### Error scrubbing (IN-04)
+
+- `crates/sdk/src/sync.rs` (`:271`) — `sanitize_error` prefix list.
+
+### Background
+
+- `docs/FILESYSTEM_SPECIFICATION.md` — FUSE write/durability model and journal semantics.
+
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+
+- The `NETWORK_TIMEOUT` discipline already used elsewhere in the desktop stack is the pattern to
+ mirror for WR-07 — wrap replay ops the same way rather than inventing a new timeout scheme.
+- Phase 43/45/46 already built the durable write-journal + crash-recovery replay; this phase hardens
+ that existing machinery (sidecar/GC/timeout/scrub), it does not rebuild it.
+
+### Established Patterns
+
+- Single FUSE callback thread (macOS/Linux) / global WinFsp mutex (Windows) — any synchronous heavy
+ work on the journal path blocks the WHOLE filesystem; D-01's off-thread requirement follows from
+ this.
+- Durable-ack contract from Phase 43: `release()` must not ack until the write is durable. D-01's
+ off-thread write must preserve this — the originating call still awaits its own entry's fsync.
+- Journal dir is shared across vaults and only filtered at read time — the source of the cross-vault
+ retention leak D-02 closes.
+
+### Integration Points
+
+- WR-06 changes the on-disk journal entry shape (ciphertext → sidecar path+hash; name omitted or
+ encrypted). Replay (`replay_for_vault`) and any journal reader must be updated in lockstep, and
+ crash-recovery of in-flight entries must handle the new shape.
+- GC + logout/account-switch purge hook into the desktop session lifecycle (login/logout/account
+ switch) in the Tauri shell + `crates/sdk`.
+
+
+
+
+
+## Specific Ideas
+
+- D-04 is conditional: planning must first establish whether replay needs the plaintext name. Prefer
+ omission; encryption is the fallback. Record which path was taken in the plan.
+
+
+
+
+
+## Deferred Ideas
+
+None — discussion stayed within phase scope (the FUSE-journal review warnings).
+
+
+
+---
+
+_Phase: 52-desktop-fuse-durability-at-rest-safety_
+_Context gathered: 2026-06-19_
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-DISCUSSION-LOG.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-DISCUSSION-LOG.md
new file mode 100644
index 0000000000..683c0a548c
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-DISCUSSION-LOG.md
@@ -0,0 +1,72 @@
+# Phase 52: Desktop FUSE Durability & At-Rest Safety - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
+
+**Date:** 2026-06-19
+**Phase:** 52-desktop-fuse-durability-at-rest-safety
+**Areas discussed:** WR-06 write path, Journal GC/retention, WR-07 replay, IN-03 at-rest names
+
+---
+
+## WR-06 — Large-file journal write path
+
+| Option | Description | Selected |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | -------- |
+| Sidecar + off-thread | Sidecar `.bin` + heavy write/fsync off the shared callback thread; originating release() still awaits durability; + size cap. | ✓ |
+| Sidecar only (on-thread) | Move ciphertext out of JSON only. Fixes OOM/alloc, but large write+F_FULLFSYNC still blocks the FS. | |
+| Sidecar + off-thread + cap | Above + a hard per-file size ceiling routing oversized files to a separate path / fail-fast. | |
+
+**User's choice:** Sidecar + off-thread (D-01)
+**Notes:** Must preserve the Phase-43 durable-ack contract — release() awaits its own entry's fsync even though the heavy write is off the shared thread.
+
+---
+
+## Journal GC / retention
+
+| Option | Description | Selected |
+| ----------------------------- | --------------------------------------------------------------------------------------------------- | -------- |
+| GC + logout + cross-vault purge | Age+size GC of parked Failed entries, purge current vault on logout, purge vault entries on account switch/delete. | ✓ |
+| Minimal (logout + age GC) | Purge-on-logout + age-based GC only. No size budget, no proactive cross-vault sweep. | |
+| Add size-ceiling backpressure | Recommended model + hard total-journal size ceiling that backpressures new journaling. | |
+
+**User's choice:** GC + logout + cross-vault purge (D-02)
+**Notes:** Closes the cross-vault leak (shared journal dir was only filtered, never cleaned). Planner sets concrete default caps.
+
+---
+
+## WR-07 — Replay durability
+
+| Option | Description | Selected |
+| ------------------------- | ---------------------------------------------------------------------------------------------- | -------- |
+| Timeout + concurrent w/ mount | Per-entry tokio timeout AND replay runs concurrently with mount (mount never waits). | ✓ |
+| Timeout only | Per-entry timeout but replay stays before mount (mount waits, bounded by timeout × entries). | |
+| Concurrent only | Replay concurrent with mount but no per-entry timeout (mount instant, hung entry spins forever). | |
+
+**User's choice:** Timeout + concurrent with mount (D-03)
+**Notes:** Mirror the existing NETWORK_TIMEOUT discipline used elsewhere in the desktop stack.
+
+---
+
+## IN-03 — At-rest journaled names
+
+| Option | Description | Selected |
+| -------------------- | ------------------------------------------------------------------------------------------------- | -------- |
+| Encrypt journaled names | Encrypt the name in the entry (key available at write/replay time). | |
+| Omit name if not needed | Drop the plaintext name entirely if replay can reconstruct it from FileMetadata/path; encrypt as fallback if required. | ✓ |
+| Document and defer | Accept the local-only 0600 risk for now; comment and revisit. | |
+
+**User's choice:** Omit name if not needed (D-04)
+**Notes:** Conditional — planning first establishes whether replay needs the plaintext name; prefer omission, encryption is the fallback. Either way no plaintext item name persists at rest.
+
+---
+
+## Claude's Discretion
+
+- Phase sequencing (WR-06 → WR-07 → IN-03 → IN-04/IN-05) suggested in CONTEXT.md; planner may refine.
+- Concrete GC default caps (age window, size budget) and the WR-07 timeout multiplier left to the planner.
+- IN-04 (extend sanitize_error scrub list) and IN-05 (log::warn! on swallowed journal.remove) were pre-locked by the todo — included by default, not discussed as forks.
+
+## Deferred Ideas
+
+None — discussion stayed within phase scope.
diff --git a/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
new file mode 100644
index 0000000000..2e9301ede3
--- /dev/null
+++ b/.planning/phases/52-desktop-fuse-durability-at-rest-safety/52-PATTERNS.md
@@ -0,0 +1,595 @@
+# Phase 52: Desktop FUSE Durability & At-Rest Safety - Pattern Map
+
+**Mapped:** 2026-06-19
+**Files analyzed:** 8
+**Analogs found:** 8 / 8
+
+## File Classification
+
+| New/Modified File | Role | Data Flow | Closest Analog (same file or nearest) | Match Quality |
+| ---------------------------------------------------- | --------- | ---------- | --------------------------------------------------- | ------------- |
+| `crates/sdk/src/queue.rs` | service | file-I/O | Same file — `WriteQueue::put` (lines 171-200) | exact |
+| `crates/sdk/src/sync.rs` | utility | transform | Same file — `regex_replace_paths` (lines 266-285) | exact |
+| `crates/fuse/src/lib.rs` | service | event-driven | Same file — `NETWORK_TIMEOUT` + `rt.spawn` pattern (lines 59, 1282-1295) | exact |
+| `crates/fuse/src/write_ops.rs` | service | file-I/O | Same file — swallowed removal at line 679 | exact |
+| `crates/fuse/src/journal_helpers.rs` | utility | transform | Same file — `wrap_key_to_hex` at line 281, `ciphertext_b64` at lines 284-286 | exact |
+| `crates/fuse/src/read_ops.rs` | controller | request-response | Same file — durable-ack sequence at lines 814-884 | exact |
+| `apps/desktop/src-tauri/src/fuse/mod.rs` | controller | request-response | Same file — replay call at lines 278-289 | exact |
+| `apps/desktop/src-tauri/src/commands/auth.rs` | controller | request-response | Same file — `logout()` at lines 490-521 | exact |
+
+---
+
+## Pattern Assignments
+
+### `crates/sdk/src/queue.rs` (D-01 sidecar write, D-02 GC/purge)
+
+**Analog within file:** `WriteQueue::put` (lines 164-200), `WriteQueue::remove` (lines 203-218), `deser_opt_string` compat helper (lines 22-25).
+
+**0o600 file creation pattern** (lines 177-186) — replicate for sidecar `.bin` write:
+```rust
+// Source: crates/sdk/src/queue.rs:177-186
+let mut open_opts = std::fs::OpenOptions::new();
+open_opts.write(true).create(true).truncate(true);
+#[cfg(unix)]
+open_opts.mode(0o600);
+
+let mut file = open_opts
+ .open(&path)
+ .map_err(|e| format!("Journal open failed: {}", e))?;
+```
+
+**fsync + parent-dir fsync barrier** (lines 188-198) — replicate for both `.bin` and `.json`:
+```rust
+// Source: crates/sdk/src/queue.rs:188-198
+file.write_all(&json)
+ .map_err(|e| format!("Journal write failed: {}", e))?;
+
+// fsync barrier: F_FULLFSYNC on macOS, fdatasync on Linux (via Rust std).
+file.sync_all()
+ .map_err(|e| format!("Journal fsync failed: {}", e))?;
+
+// WR-03b: fsync the parent journal directory so the new dirent is durable.
+let _ = std::fs::File::open(&self.journal_dir).and_then(|d| d.sync_all());
+```
+
+**Idempotent remove with NotFound guard** (lines 208-218) — extend `remove` to also delete `.bin`:
+```rust
+// Source: crates/sdk/src/queue.rs:208-218
+match std::fs::remove_file(&path) {
+ Ok(()) => {
+ let _ = std::fs::File::open(&self.journal_dir).and_then(|d| d.sync_all());
+ Ok(())
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(e) => Err(format!("Journal remove failed: {}", e)),
+}
+```
+
+**Compat deserializer pattern** (lines 22-25) — mirror for `filename_encrypted_hex` / `name_encrypted_hex` field alias:
+```rust
+// Source: crates/sdk/src/queue.rs:22-25
+fn deser_opt_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result