"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes#35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§4 requires the promise in onboarding, in Settings, and on a public privacy
page. It was made once, during onboarding, before the user had entered a single
date — which makes it a marketing line. Repeated above the controls that act on
that data, it is a statement somebody can hold the product to.
One copy, in strings.xml, read by both screens. A second literal is how two
versions of a promise come to exist, which is the failure DOC_TRUST_MAP.md
exists to prevent, here in code rather than in prose.
There is NO Privacy Policy row. §4 wants one and no hosted page exists, and a
policy link that 404s is worse than no link — which is also the convention
SettingsScreen already states: a row for something unbuilt is absent, not
disabled. The issue's verify line allows exactly this.
Three tests, and the second is the one that matters. The promise must say we
never SELL the data, and must NOT have been strengthened into claims the app
cannot keep — no third party, never shared, end-to-end — because Play Billing
and an ad SDK eventually will process something, and a promise the
implementation cannot keep is worse than a narrower one that holds. The third
scans Kotlin for a re-introduced literal, with comments stripped first per
GUARDS.md §2, or the KDoc explaining the rule would fail it.
Proved: replacing the resource lookup with the literal fails exactly one test.
## Two defects found on the way, both pre-existing
**No Robolectric test in :app could read a string resource.** core/database and
core/data have carried unitTests.isIncludeAndroidResources since they were
written; app never did. So the module owning almost all of the user-facing copy
was the one module whose copy could not be tested, and every getString() threw
NotFoundException with an id that had resolved perfectly well.
**checkPermissions read manifests that do not ship.** Turning the above on made
AGP write merged_manifest/debugUnitTest/, the guard walked the whole tree, and
the build failed on REORDER_TASKS — a test-runner permission no user ever sees.
The tempting fix is to allowlist it, which would then permit it in the real
manifest too and quietly undo the guard. It now reads only debug and release,
and refuses to pass unless it read BOTH: checking debug while release went
unread is the failure that matters, since the Play listing and the Data Safety
form describe the release manifest.
That is strictly stricter than before, and proved twice — a forbidden permission
in the app manifest still fails it, and a missing release manifest now fails it
where it used to pass.
GUARDS.md §8 gains a third prove-guard edge, found while proving the above: a
FAIL_PATTERN matching nothing gives the same "caught it, and only it" verdict as
one matching exactly once, because the script only refuses on more than one. The
empty "what failed" block is the tell.
closes#37
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
androidx.hilt.navigation.compose.hiltViewModel is deprecated — it moved to
androidx.hilt.lifecycle.viewmodel.compose, and every one of the ten files
calling it was importing the old path, so the whole app module compiled with a
deprecation warning on the single most-used composable helper in it.
hilt-lifecycle-viewmodel-compose was already resolving onto the classpath
transitively at the same 1.4.0. It is declared explicitly now rather than leaned
on, because it is called directly in ten files and a transitive that quietly
stops being pulled is a compile error with a confusing cause.
The app module now compiles with zero deprecation warnings.
Checked while auditing library currency: every artifact in the catalog, all four
plugins and the Gradle wrapper are already at their newest stable. The only
version that looks old is androidx.biometric 1.1.0, which IS the newest stable —
1.4.0 is alpha-only and biometric-ktx has never shipped a stable release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has
existed since Batch 01 with nothing outside its own module reading it; this
wires it, and adds the rest.
The recovery question was the reason #34 sat open, and it is decided: there is
no recovery. A backdoor into a period tracker's lock would be used by exactly
the person the lock exists to stop. Everything below follows from that.
## The gate
AppLockGate wraps the whole composition rather than being a screen inside it.
Today, Calendar and Insights each start collecting from CycleRepository the
moment they compose, so a lock implemented as a nav destination would already
have read the history before the user proved anything. content() is invoked
only in the unlocked branch.
Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings
and a permission dialog. Two guards on top: isChangingConfigurations, or
rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM
biometric overlay that stops the activity produces a lock that can never be
opened. No grace period: SECURITY.md leads with "someone who picks up an
unlocked phone", which is the window a grace period covers.
The unlock flag lives in a @Singleton, never in saved state. rememberSaveable
looks like the obvious home and would restore a background-killed app already
unlocked — the single most likely way to meet the lock screen would be the one
path that skipped it.
## What is stored is not the PIN
mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k))
Two layers because they defend different things. The Keystore MAC is what makes
a six-digit PIN safe at all — a million candidates is nothing to an attacker who
can compute the hash, and impossible for one who cannot get the key off the
device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a
domain-separation tag; the lockout counter is MACed under 0x02.
The key omits six builder calls and the KDoc names every one. setUserAuthenti-
cationRequired is the important absence: it would bind the key to the device
lock, so changing a passcode would destroy it — and under no-recovery that is
somebody's whole history gone for an unrelated reason. It would also be a
bypass, since SECURITY.md already names "someone who knows the unlock PIN" as
an adversary. The biometric key is separate and takes the opposite policy,
where invalidation correctly degrades to "use your PIN".
## Wrong PINs cost time, never data
Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and
no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a
pocket destroy a history while knowing nothing. Both clock bypasses are closed —
the wait is the longer of a wall-clock and a monotonic deadline, and a reboot
re-applies it in full, detected by elapsedRealtime going backwards.
## Two writes that had to move
Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on
the phone's own lock screen, reachable by anybody, so the action is now parked
in AppLockController and applied only after an unlock — dropped if the session
never unlocks. Behaviour is unchanged when the lock is off.
The erase behind "Forgot your PIN?" deletes health data, then the Keystore key,
then the lock store. Skipping the middle step leaves the user erased AND still
locked out; prove-guard mutates that line out and requires exactly one red.
## Found by testing, not by review
- A fresh install began in a 15-minute lockout: "no counter yet" and "counter
was tampered with" were the same value. They are now distinct.
- Setting a PIN locked you out of the session you set it in. Found on the
emulator, not in a test.
- Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice.
## Verified
244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and
PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice
here with no margin, and that the key is not auth-bound on either.
On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on
the lock screen, turning the lock off requires the current PIN, and
`adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real.
androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx
never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed
checkPermissions until they were allowed on purpose, and it drags fragment to
1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity.
closes#34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes#24closes#25closes#26closes#27
Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous
history, the privacy promise, notification privacy, first forecast. Verified end
to end on a device — the flow produces a forecast, the record persists, and a
relaunch goes straight to Today.
Three decisions with tests behind them:
- Nothing is written until the final step. Somebody who abandons onboarding
halfway has not asked this app to remember anything about them.
- Notification privacy is Discreet before the user touches anything (§28), and
Direct is last and never pre-selected. Checked on the device, not only in a
unit test.
- "Still going" and "I'm not sure" both mean no end date. §24: never invent
one. The date picker refuses future dates by not offering them rather than
by rejecting a tap it allowed.
DARK MODE WAS BROKEN FOR ALL OF BATCH 01
PeriodTheme never wrapped its content in a Surface, so every Text without an
explicit colour inherited Material's default — black — and the app background
never painted. In light mode that looked correct by accident, because dark text
on cream is what was wanted anyway. In dark mode the onboarding headings
rendered near-black on charcoal.
No test caught it and no test easily would have. It was found by opening the
app on a device and looking at it.
The Surface now lives in the theme, so a screen without a Scaffold cannot
forget, and every illustration has a light/dark preview pair. A preview is not
a test, but it is the cheapest thing that puts the failure in front of whoever
is editing the screen.
closes#16
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes#7
The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device:
launch, log a period, it is stored, the forecast recalculates, edit or delete it
and the forecast moves again. Hilt wiring, a TodayViewModel exposing one
immutable state, and a working surface that says "Batch 01 · working surface" at
the top so nobody mistakes it for the designed Today screen, which is Batch 03.
THE DEFECT THIS FOUND, ON A DEVICE
Tapping "Started today" twice on the same day killed the app:
FATAL EXCEPTION: main
android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed:
period_records.startDate
Not a hypothetical — the crash was reproduced on emulator-5580, the fix
applied, and the same two taps then produced "That day is already logged." with
the process still alive and zero FATAL lines in logcat.
The constraint is right: a duplicate must not overwrite the original row and
lose its createdAt and source. The API around it was wrong. Repeating a tap
when you are not sure the first one registered is an ordinary thing for a person
to do, not a fault, and it must never be an exception. So the period writes
return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound —
and only genuine faults still throw.
editPeriod had the same hole: moving a record onto a date another record holds.
That is refused rather than merged, because merging would delete a period the
user entered and only they can settle it.
The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what was just entered, and a
message somebody can read beats a process that vanished. The message carries the
exception type and never a record's contents (§45).
Four regression tests pin all of it, plus two instrumented tests on a real
file-backed database that close and reopen it — what a force-stop actually does,
and something an in-memory database cannot fail.
70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB.
closes#6
Period was a bare directory holding one 2,527-line specification, with no git
repository, no tracker and no documentation convention. This is the adoption
from Projects/Template/START-HERE-New-Project.md, plus a project that compiles
so the hooks and future guards have something real to run against.
Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to
docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the
capitalised Docs/ is gone. Every scaffolded document was filled in for Period.
docs/OPERATIONS.md deleted — an offline app is not a deployed service.
DOC_TRUST_MAP.md written last, describing what is actually here, including what
this project deliberately does not have.
Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm")
and cannot see the Android SDK, so the engine is testable without an emulator —
17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51.
BaselinePredictionEngine is a robust-median prototype and explicitly not the
product; it exists so Batch 02's replacement can be shown to be better rather
than merely different.
Versions verified against their official sources today rather than inherited
from the specification's own numbers, which that document asks for: Kotlin
2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt
2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no
longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk
stays 36, Play's floor from 2026-08-31, and the difference is deliberate.
Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md.
Three hooks in .githooks/, with pre-commit adapted to Gradle.
closes#1closes#2