Atomic writes
Every settings-file write in ClaudeScope funnels through a single
atomic_write_json chokepoint in src-tauri/src/io_atomic.rs. The
sequence:
- Serialize the in-memory
SettingsDocto bytes. - Re-parse the produced bytes via
serde_json::from_slice— if they don't round-trip, the write is refused before anything hits disk. Bad bytes never escape. - Tempfile in the same directory as the target.
- Stamp recheck — a
FileStamp(mtime + length) captured at load time is compared to the target's current state. If the file changed on disk between load and save, the write is refused with a "reload and retry" error rather than overwriting the external edit. The recheck has a microsecond-scale TOCTOU window between the comparison and thepersist— a writer that lands inside that window will still be overwritten. - Rename the tempfile over the target. The rename is filesystem-atomic.
- On Unix, the parent directory is also
fsync'd so the rename is crash-durable. On Windows, the parent-dir flush is skipped —std::fscannot open a directory handle for flushing without a small raw-winapi wrapper, and NTFS journals rename metadata inMoveFileExalready.
Backups
On the first write of a given file per session, the original is
copied to <file>.bak. Existing .bak files from previous runs are
preserved, not clobbered. This is a one-shot backup attempt, not a
best-effort write-through: if backup creation is required and the
copy fails, the error is surfaced and the write is not attempted.
The behavior is opt-out under Settings → Backups
(#88). Users
whose .claude/ is version-controlled and don't want extra files can
turn it off — at which point the audit log
(Undo and history) becomes the only
safety net.
Moves are two-file transactions
A scope-to-scope move writes the destination first, then removes the rule from the source. If the source write fails, the destination write is rolled back so the rule ends up in exactly one scope rather than being lost or duplicated. If the rollback itself fails the user sees an explicit error rather than a silent partial state.
The rollback path can't reuse apply_move_leaf_impl — it has to
write the pre-move destination directly, with no .bak (we already
created one), and with no stamp check (we are the canonical writer of
the destination at that point). This is why the apply impl threads
backups and stamp parameters down explicitly rather than reading
them from a static.
What's atomic vs. what's not
Atomic:
- The bytes hitting any single file. Either the new contents land intact or the old contents are preserved — never a partial write.
- The rename of tempfile-over-target on a single filesystem.
- The bytes-validate-as-JSON gate. The re-parse step runs before any filesystem operation; broken JSON never reaches disk.
Not atomic:
- The pair of writes that constitute a cross-scope move. They are
two filesystem operations; if the process is killed between them,
the rollback can't run. The destination's
.bakis the recovery path in that case. - The audit log append relative to the settings write. The audit entry lands after the settings write succeeds, so a process kill in that narrow window means a write happened but isn't logged. The log being authoritative-for-history-but-not-state (Architecture → Overview) absorbs that gap.