The bug (#479, P0 — verified by the 2026-07-09 whole-repo review)
SettingsViewModel.setEncryptCache only writes the DataStore setting; the on-disk conversion (decrypt-to-plaintext) is deferred to the next cold start (DatabaseProvisioner). That creates a transitional window — encryptCache == false, DB still SQLCipher-encrypted, SEALED_AUTH still present — in which three components answered from the setting instead of the actual seal/on-disk state:
AppLockViewModel.onForeground → KeyInvalidationPolicy (critical): removing the device PIN inside the window produced decide(appLock=true, encryptCache=false, deviceSecure=false) → DISABLE_APP_LOCK: app-lock silently off, no wipe, SEALED_AUTH orphaned with its auth-bound key permanently invalidated. The next cold start hits DatabaseKeyStore.resolvePassphrase → SealState.AUTH → session.await() that nothing can ever complete (app-lock is off, so no auth flow runs again) — the app hangs forever behind the CacheEncryptionGate until the user clears app data.
EncryptedCacheGuard.isCacheLocked() (high): derived "locked" from appLock && encryptCache, wrong in both transitional states — workers parked forever inside provideDatabase when the setting was off but the DB still auth-sealed (case A), and sync/push/send stalled needlessly while the seal was still MASTER (case B).
The DISABLE_APP_LOCK arm did only setAppLock(false) — no setClearPending, no reseal — which is what orphaned SEALED_AUTH.
The fix — gate on the seal, not the setting
AppLockViewModel.onForeground now derives the policy input as settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase() — the same gate-on-the-seal guard SettingsViewModel.setAppLock already carries. Lock removal in the window now routes to CLEAR_AND_DISABLE (wipe scheduled + restart); key invalidation routes to CLEAR_AND_REQUIRE_AUTH. DISABLE_APP_LOCK is only reachable seal-free, so it can no longer orphan a seal. The KeyInvalidationPolicy.decide parameter is renamed to encryptedCacheProtected to make the contract explicit; every existing row of the 16-row policy table is unchanged.
EncryptedCacheGuard now mirrors resolvePassphrase's blocking branches: keyed off DatabaseKeyStore.sealState() (MASTER → never locked; NONE → locked only while both settings ask for a not-yet-armed cache; AUTH → locked while the session is), plus — for the AUTH-seal-with-setting-off window — a raw 16-byte header read of the cache file, so a lingering orphaned seal over an already-plaintext cache does not stall background work. Still never touches Room.
DatabaseProvisioner closes the window at its source: after the decrypt-on-disable conversion it releases the orphaned auth seal (best-effort reseal under the master key, dropping SEALED_AUTH and its Keystore key), so the passphrase stays recoverable and a later re-enable reuses it.
All new decision/fallback paths breadcrumb through AppLog (PII-free enums/booleans only).
Tests
Unit:KeyInvalidationPolicyTest (renamed column + explicit transitional-window row; 16-row exhaustive table preserved), new AppLockViewModelSealStateTest driving the REAL policy through the ViewModel for the seal-present/setting-off matrix (clear+disable / disable-only / clear+require-auth / require-auth), EncryptedCacheGuardTest rewritten for the seal-based truth table incl. both transitional states against real fixture files, DatabaseProvisionerTest reseal-ordering + non-fatal-failure cases.
Instrumented:WorkerCacheLockDeferralInstrumentedTest — PruneWorker defers (Result.retry(), Lazy never resolved) during the case-A window with the real guard; MASTER-seal and orphan-seal-over-plaintext contrast cases. DatabaseProvisionerInstrumentedTest — real-Keystore end-to-end: transitional-window cold start decrypts AND releases the auth seal (seal becomes MASTER, passphrase round-trips). FetchGateReceiverInstrumentedTest updated for the new guard wiring.
## The bug (#479, P0 — verified by the 2026-07-09 whole-repo review)
`SettingsViewModel.setEncryptCache` only writes the DataStore setting; the on-disk conversion (decrypt-to-plaintext) is deferred to the next cold start (`DatabaseProvisioner`). That creates a **transitional window** — `encryptCache == false`, DB still SQLCipher-encrypted, `SEALED_AUTH` still present — in which three components answered from the *setting* instead of the *actual seal/on-disk state*:
1. **`AppLockViewModel.onForeground` → `KeyInvalidationPolicy` (critical):** removing the device PIN inside the window produced `decide(appLock=true, encryptCache=false, deviceSecure=false)` → `DISABLE_APP_LOCK`: app-lock silently off, **no wipe**, `SEALED_AUTH` orphaned with its auth-bound key permanently invalidated. The next cold start hits `DatabaseKeyStore.resolvePassphrase` → `SealState.AUTH` → `session.await()` that nothing can ever complete (app-lock is off, so no auth flow runs again) — the app hangs forever behind the `CacheEncryptionGate` until the user clears app data.
2. **`EncryptedCacheGuard.isCacheLocked()` (high):** derived "locked" from `appLock && encryptCache`, wrong in both transitional states — workers parked forever inside `provideDatabase` when the setting was off but the DB still auth-sealed (case A), and sync/push/send stalled needlessly while the seal was still `MASTER` (case B).
3. **The `DISABLE_APP_LOCK` arm** did only `setAppLock(false)` — no `setClearPending`, no reseal — which is what orphaned `SEALED_AUTH`.
## The fix — gate on the seal, not the setting
* **`AppLockViewModel.onForeground`** now derives the policy input as `settings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase()` — the same gate-on-the-seal guard `SettingsViewModel.setAppLock` already carries. Lock removal in the window now routes to `CLEAR_AND_DISABLE` (wipe scheduled + restart); key invalidation routes to `CLEAR_AND_REQUIRE_AUTH`. `DISABLE_APP_LOCK` is only reachable seal-free, so it can no longer orphan a seal. The `KeyInvalidationPolicy.decide` parameter is renamed to `encryptedCacheProtected` to make the contract explicit; **every existing row of the 16-row policy table is unchanged**.
* **`EncryptedCacheGuard`** now mirrors `resolvePassphrase`'s blocking branches: keyed off `DatabaseKeyStore.sealState()` (`MASTER` → never locked; `NONE` → locked only while both settings ask for a not-yet-armed cache; `AUTH` → locked while the session is), plus — for the `AUTH`-seal-with-setting-off window — a raw 16-byte header read of the cache file, so a lingering orphaned seal over an already-plaintext cache does not stall background work. Still never touches Room.
* **`DatabaseProvisioner`** closes the window at its source: after the decrypt-on-disable conversion it releases the orphaned auth seal (best-effort reseal under the master key, dropping `SEALED_AUTH` and its Keystore key), so the passphrase stays recoverable and a later re-enable reuses it.
* All new decision/fallback paths breadcrumb through `AppLog` (PII-free enums/booleans only).
## Tests
* **Unit:** `KeyInvalidationPolicyTest` (renamed column + explicit transitional-window row; 16-row exhaustive table preserved), new `AppLockViewModelSealStateTest` driving the REAL policy through the ViewModel for the seal-present/setting-off matrix (clear+disable / disable-only / clear+require-auth / require-auth), `EncryptedCacheGuardTest` rewritten for the seal-based truth table incl. both transitional states against real fixture files, `DatabaseProvisionerTest` reseal-ordering + non-fatal-failure cases.
* **Instrumented:** `WorkerCacheLockDeferralInstrumentedTest` — PruneWorker defers (`Result.retry()`, `Lazy` never resolved) during the case-A window with the real guard; MASTER-seal and orphan-seal-over-plaintext contrast cases. `DatabaseProvisionerInstrumentedTest` — real-Keystore end-to-end: transitional-window cold start decrypts AND releases the auth seal (seal becomes `MASTER`, passphrase round-trips). `FetchGateReceiverInstrumentedTest` updated for the new guard wiring.
## Validation (all green locally)
* `assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt` — BUILD SUCCESSFUL
* Local emulator E2E (`local_instrumented.py`, API 36): 17/17 pass across the three changed instrumented classes
* API 37 preview E2E (`api37_e2e.py`): full suite 309/309 pass
Closes #479
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The bug (#479, P0 — verified by the 2026-07-09 whole-repo review)
SettingsViewModel.setEncryptCacheonly writes the DataStore setting; the on-disk conversion (decrypt-to-plaintext) is deferred to the next cold start (DatabaseProvisioner). That creates a transitional window —encryptCache == false, DB still SQLCipher-encrypted,SEALED_AUTHstill present — in which three components answered from the setting instead of the actual seal/on-disk state:AppLockViewModel.onForeground→KeyInvalidationPolicy(critical): removing the device PIN inside the window produceddecide(appLock=true, encryptCache=false, deviceSecure=false)→DISABLE_APP_LOCK: app-lock silently off, no wipe,SEALED_AUTHorphaned with its auth-bound key permanently invalidated. The next cold start hitsDatabaseKeyStore.resolvePassphrase→SealState.AUTH→session.await()that nothing can ever complete (app-lock is off, so no auth flow runs again) — the app hangs forever behind theCacheEncryptionGateuntil the user clears app data.EncryptedCacheGuard.isCacheLocked()(high): derived "locked" fromappLock && encryptCache, wrong in both transitional states — workers parked forever insideprovideDatabasewhen the setting was off but the DB still auth-sealed (case A), and sync/push/send stalled needlessly while the seal was stillMASTER(case B).DISABLE_APP_LOCKarm did onlysetAppLock(false)— nosetClearPending, no reseal — which is what orphanedSEALED_AUTH.The fix — gate on the seal, not the setting
AppLockViewModel.onForegroundnow derives the policy input assettings.encryptCache || databaseKeyStore.hasAuthSealedPassphrase()— the same gate-on-the-seal guardSettingsViewModel.setAppLockalready carries. Lock removal in the window now routes toCLEAR_AND_DISABLE(wipe scheduled + restart); key invalidation routes toCLEAR_AND_REQUIRE_AUTH.DISABLE_APP_LOCKis only reachable seal-free, so it can no longer orphan a seal. TheKeyInvalidationPolicy.decideparameter is renamed toencryptedCacheProtectedto make the contract explicit; every existing row of the 16-row policy table is unchanged.EncryptedCacheGuardnow mirrorsresolvePassphrase's blocking branches: keyed offDatabaseKeyStore.sealState()(MASTER→ never locked;NONE→ locked only while both settings ask for a not-yet-armed cache;AUTH→ locked while the session is), plus — for theAUTH-seal-with-setting-off window — a raw 16-byte header read of the cache file, so a lingering orphaned seal over an already-plaintext cache does not stall background work. Still never touches Room.DatabaseProvisionercloses the window at its source: after the decrypt-on-disable conversion it releases the orphaned auth seal (best-effort reseal under the master key, droppingSEALED_AUTHand its Keystore key), so the passphrase stays recoverable and a later re-enable reuses it.AppLog(PII-free enums/booleans only).Tests
KeyInvalidationPolicyTest(renamed column + explicit transitional-window row; 16-row exhaustive table preserved), newAppLockViewModelSealStateTestdriving the REAL policy through the ViewModel for the seal-present/setting-off matrix (clear+disable / disable-only / clear+require-auth / require-auth),EncryptedCacheGuardTestrewritten for the seal-based truth table incl. both transitional states against real fixture files,DatabaseProvisionerTestreseal-ordering + non-fatal-failure cases.WorkerCacheLockDeferralInstrumentedTest— PruneWorker defers (Result.retry(),Lazynever resolved) during the case-A window with the real guard; MASTER-seal and orphan-seal-over-plaintext contrast cases.DatabaseProvisionerInstrumentedTest— real-Keystore end-to-end: transitional-window cold start decrypts AND releases the auth seal (seal becomesMASTER, passphrase round-trips).FetchGateReceiverInstrumentedTestupdated for the new guard wiring.Validation (all green locally)
assembleDebug+testDebugUnitTest+jacocoTestCoverageVerification+compileDebugAndroidTestKotlin+lintDebug+ktlintCheck+detekt— BUILD SUCCESSFULlocal_instrumented.py, API 36): 17/17 pass across the three changed instrumented classesapi37_e2e.py): full suite 309/309 passCloses #479
Merge Queue Status
2026-07-10 20:34 UTC· Rule:default· triggered by merge protections2026-07-10 20:40 UTC· at46ca019aa878e364b44480fd6e915639eaa9c690· mergeThis pull request spent 5 minutes 43 seconds in the queue, including 2 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed