Summary
services/fs/fsCore.ts's protectTextValue()/unprotectTextValue() (and, transitively, every fs-store save/load) resolve the active key and encrypt/decrypt purely via resolveProtectedWriteKey() — they never take the same admission lock the IndexedDB path uses (withProtectedWriteAdmission() / assertNoActiveEncryptionMigration(), see docs/IDB-ENCRYPTION.md). Concretely:
- Writes during a disable/rotate migration.
services/fs/fsEncryptionMigration.ts#migrateAllProtectedFsData re-keys/decrypts every fs-backed file directly, with no lock held against ordinary saves. A normal autosave (project/settings) that resolves its key concurrently with the migration can write a file encrypted under the old key (or plaintext, for disable) after the bridge has already converged that same file, and after clearIdbPassphrase()/rotateIdbPassphrase() swaps or discards the old key — leaving that one file stranded under a key nothing can derive anymore.
- Reads during a migration. Similarly,
unprotectTextValue() doesn't check assertNoActiveEncryptionMigration() before decrypting, so a read racing a migration isn't guaranteed to see a consistent state.
- Legacy plaintext content ignores the lock policy. When encryption is configured but the session is currently locked,
unprotectTextValue() only fails closed for values that parse as a protected-v1 envelope — a value that's still in its pre-migration plaintext form (opportunistic/lazy migration by design, see fsCore.ts's "Protected text files" section) is returned as-is, bypassing the lock gate entirely for any file that hasn't been touched by a save (or the bridge) since encryption was enabled.
Why this wasn't fixed inline
The services/fs/fsEncryptionMigration.ts bridge (built to fix the more severe "disable/rotate strands fs data outright" bug) already substantially narrows the window this issue describes, and per-file read/write failures during migration are now safely skipped (non-strict) or aborted (strict) rather than silently succeeding into a bad state. But it does not prevent the race — it just makes the failure mode of hitting it less catastrophic (a stranded file becomes detectable as "temporarily unreadable" rather than crashing or silently corrupting).
Fixing this properly means giving the fs path the same Web Locks–based admission control the IDB path has (services/storage/storageEncryptionService.ts#withProtectedWriteAdmission), or coordinating both under one shared admission mechanism — a real architectural addition, not a one-line fix, and one best done alongside (or as part of) the resumable-migration work already tracked in #359, since both are about making the fs migration bridge a first-class citizen of the same lifecycle machinery the IDB path already has.
Real fix
protectTextValue() should call the fs equivalent of assertIdbProtectedWriteAllowed() (or take a shared-mode admission) before resolving a key and encrypting.
unprotectTextValue() should call assertSecureStorageReadable() (or equivalent) before returning any content — including a value that isn't (yet) a protected envelope — whenever encryption is configured, so a locked session fails closed uniformly regardless of a given file's migration status.
migrateAllProtectedFsData() should hold the exclusive-mode admission across its whole run, matching how the IDB migration orchestrator already holds it.
Found via
Surfaced during the PR #356 (fix/desktop-project-data-encryption) review-correction loop — codeant-ai, qodo-code-review, and chatgpt-codex-connector independently flagged variants of this same gap (2026-08-13).
Related: #359 (fs migration bridge crash-resumability — same root cause, different symptom).
Summary
services/fs/fsCore.ts'sprotectTextValue()/unprotectTextValue()(and, transitively, every fs-store save/load) resolve the active key and encrypt/decrypt purely viaresolveProtectedWriteKey()— they never take the same admission lock the IndexedDB path uses (withProtectedWriteAdmission()/assertNoActiveEncryptionMigration(), seedocs/IDB-ENCRYPTION.md). Concretely:services/fs/fsEncryptionMigration.ts#migrateAllProtectedFsDatare-keys/decrypts every fs-backed file directly, with no lock held against ordinary saves. A normal autosave (project/settings) that resolves its key concurrently with the migration can write a file encrypted under the old key (or plaintext, for disable) after the bridge has already converged that same file, and afterclearIdbPassphrase()/rotateIdbPassphrase()swaps or discards the old key — leaving that one file stranded under a key nothing can derive anymore.unprotectTextValue()doesn't checkassertNoActiveEncryptionMigration()before decrypting, so a read racing a migration isn't guaranteed to see a consistent state.unprotectTextValue()only fails closed for values that parse as aprotected-v1envelope — a value that's still in its pre-migration plaintext form (opportunistic/lazy migration by design, seefsCore.ts's "Protected text files" section) is returned as-is, bypassing the lock gate entirely for any file that hasn't been touched by a save (or the bridge) since encryption was enabled.Why this wasn't fixed inline
The
services/fs/fsEncryptionMigration.tsbridge (built to fix the more severe "disable/rotate strands fs data outright" bug) already substantially narrows the window this issue describes, and per-file read/write failures during migration are now safely skipped (non-strict) or aborted (strict) rather than silently succeeding into a bad state. But it does not prevent the race — it just makes the failure mode of hitting it less catastrophic (a stranded file becomes detectable as "temporarily unreadable" rather than crashing or silently corrupting).Fixing this properly means giving the fs path the same Web Locks–based admission control the IDB path has (
services/storage/storageEncryptionService.ts#withProtectedWriteAdmission), or coordinating both under one shared admission mechanism — a real architectural addition, not a one-line fix, and one best done alongside (or as part of) the resumable-migration work already tracked in #359, since both are about making the fs migration bridge a first-class citizen of the same lifecycle machinery the IDB path already has.Real fix
protectTextValue()should call the fs equivalent ofassertIdbProtectedWriteAllowed()(or take a shared-mode admission) before resolving a key and encrypting.unprotectTextValue()should callassertSecureStorageReadable()(or equivalent) before returning any content — including a value that isn't (yet) a protected envelope — whenever encryption is configured, so a locked session fails closed uniformly regardless of a given file's migration status.migrateAllProtectedFsData()should hold the exclusive-mode admission across its whole run, matching how the IDB migration orchestrator already holds it.Found via
Surfaced during the PR #356 (
fix/desktop-project-data-encryption) review-correction loop — codeant-ai, qodo-code-review, and chatgpt-codex-connector independently flagged variants of this same gap (2026-08-13).Related: #359 (fs migration bridge crash-resumability — same root cause, different symptom).