docs: log the encryption decision, the crash it uncovered, and two false greens
Step 6 of WORK_CYCLE. Next action is #34, the app lock — the recovery decision is settled and the design is on the issue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
17cddb2a61
commit
42a2d543ad
|
|
@ -32,6 +32,83 @@ written and stay true. It is exempt from review for the same reason a receipt is
|
|||
|
||||
## Entries
|
||||
|
||||
### 2026-08-19 — Encryption, declined for a reason; and the crash it uncovered
|
||||
|
||||
The question was whether the cycle database could be encrypted at rest. It can.
|
||||
It is not, and the reasoning is now in `SECURITY.md` under *Deliberately out of
|
||||
scope* rather than in anybody's head.
|
||||
|
||||
**The architecture answers it.** `ReminderCoordinator` collects Room flows at
|
||||
every process start — including processes WorkManager starts after a reboot with
|
||||
no Activity — and `ReminderWorker` reads the forecast on a schedule. So a
|
||||
database key would have to be readable with no user present, which is the same
|
||||
availability condition Android's own file-based encryption key already has. A
|
||||
key anything running as this app can reach unattended defends a **file**, not a
|
||||
**process**. Against every adversary `SECURITY.md` lists — someone holding an
|
||||
unlocked phone, someone reading a lock screen, a rooted device — it adds
|
||||
nothing. What it would add is narrow and real: a file copied off the device and
|
||||
read elsewhere, a phone with no screen lock, and crypto-shred on delete.
|
||||
|
||||
Costed rather than argued: about +1 MB downloaded and +2 MB installed on a 2 MB
|
||||
app, and a new class of total loss, since a Keystore key can be lost in the
|
||||
field, platform backup is off, and no telemetry would tell us. The reversing
|
||||
condition is written down — a key bound to the app lock's secret, once export
|
||||
gives the user a copy they control.
|
||||
|
||||
**Looking for the encryption seam found a live defect instead.** The
|
||||
application scope was built with `SupervisorJob` and no
|
||||
`CoroutineExceptionHandler`, and two Room flows are collected on it.
|
||||
`SupervisorJob` stops a failing child cancelling its siblings; it does not stop
|
||||
the exception, which reaches the thread's default handler and ends the process —
|
||||
headless, at every boot, with nothing on screen to explain it. Both ViewModels
|
||||
already had a handler. The one scope that runs with nobody watching did not. The
|
||||
trigger is not hypothetical: `repository.forecast` runs the prediction engine
|
||||
inside the flow, and `Prediction`'s init block enforces its window invariants
|
||||
with `require`. Filed as #45, fixed in `424a513`, closed with its evidence.
|
||||
|
||||
**`prove-guard.sh` told me it had caught something it had not.** The first proof
|
||||
ran `./gradlew :app:test --tests …`. `:app:test` is AGP's lifecycle task and
|
||||
takes no `--tests` option, so Gradle failed in 544 ms with `Unknown
|
||||
command-line option`, the mutation was never compiled, no test ran — and the
|
||||
script reported *"the guard caught it"*. It decides from the exit code and
|
||||
cannot tell a broken test from a broken command.
|
||||
|
||||
Pulling that thread: **the three documented boundary-guard proofs in
|
||||
`architecture/README.md` have never exited 0.** Gradle prints no test-style
|
||||
summary for those tasks, so `prove-guard` counts log lines, and its default
|
||||
pattern also matches `FAILURE:` and `BUILD FAILED` — one caught violation reads
|
||||
as three, exit 3, "not a pass". They now carry a `PROVE_GUARD_FAIL_PATTERN` and
|
||||
were re-run at 0, 0, 0. Both lessons are `GUARDS.md` §8, which is exactly that
|
||||
document's review trigger.
|
||||
|
||||
**And a false claim that was one drawing away from shipping.**
|
||||
`BRAND_GUIDE.md` carried an artwork brief reading *"explaining encrypted local
|
||||
data storage"* — on the single subject where a padlock reads as a promise.
|
||||
Replaced with what is true and is the better story anyway: the data never
|
||||
leaves, so there is no server to breach and nothing to sell. `SECURITY_CHECKLIST`
|
||||
gained the row that would have caught it. Nothing shipped ever claimed it; grep
|
||||
over `app`, `core` and `domain` comes back empty.
|
||||
|
||||
Also registered the Forgejo webhook (#9), verified with a signed replay that
|
||||
returned `applied:true` — so a `P0` now raises an alert immediately instead of
|
||||
waiting for the next sweep.
|
||||
|
||||
**What this proved:** 207 tests, 0 failures, 0 skipped; `checkModuleBoundaries`,
|
||||
`checkNoHealthLogging` and `checkPermissions` all exit 0, the last against a
|
||||
freshly built release manifest; removing the new handler fails exactly one test,
|
||||
and removing either `catch` fails exactly its own.
|
||||
|
||||
**Next action:** #34, the biometric and PIN app lock. The recovery decision is
|
||||
settled — none — and the full design is on the issue. Start with `:core:security`
|
||||
and remember both rows it needs in the root `build.gradle.kts`, since
|
||||
`checkNoHealthLogging` reads a hardcoded module list and a missing row fails
|
||||
silently in the one module that holds key material.
|
||||
|
||||
**Blockers:** none. #34 needs `androidx.biometric`, which fails `checkPermissions`
|
||||
until `USE_BIOMETRIC` and `USE_FINGERPRINT` are added to the allowlist with their
|
||||
reasons, and needs `MainActivity` to become a `FragmentActivity` — both known,
|
||||
neither blocking.
|
||||
|
||||
### 2026-08-19 — Eleven screenshots, and the day the seeding chose a bad hero
|
||||
|
||||
Privacy: Period Tracker was the only Privacy LLC product without a page on
|
||||
|
|
|
|||
Loading…
Reference in New Issue