The export was a one-way door. A user changing phone started the prediction
model from zero, which made "your history is yours" a smaller promise than it
sounded — and the archive says, in its own prose, "keep this file, a future
version of the app will be able to read it back". Nothing checked that was true.
`ExportReader` mirrors `ExportDocument.render` in the same module, over a
hand-written strict JSON reader. Strict is the point: it refuses duplicate keys,
trailing content, non-integer numbers and unsupported escapes, because a parser
that quietly accepts a trailing comma is a parser that will one day accept
somebody else's file and import it as a cycle history. A missing magic string is
`NotOurFile`; a version above this build's is `NewerFormat` rather than a
half-read; unknown *keys* are ignored, which is what lets the format grow; an
unknown `source` becomes `IMPORTED` rather than a guess about the user.
The write is `CycleRepository.importHistory`, not a loop over
`confirmPeriodStart`, because that would score the standing forecast against
history the app never predicted — and the backfill guard does not catch all of
it, since a file exported this morning carries this morning's period. So the
scoring is not dodged by accident of date; it is not reached. The engine runs
exactly once, at the end, over the whole history. Merge keeps what is on this
phone untouched (§14 — a file does not get to rewrite a record she made here),
and replace goes through the new `PeriodDatabase.replaceEverything`, which
empties the tables inside the same transaction as the writes: a failure between
the wipe and the inserts would leave her with neither her own history nor the
file's. Delete My Data still calls `deleteEverything` and still gets its VACUUM —
replacing a history is not erasing one.
The screen asks the one question the app must not answer for her, before the
picker rather than after, because the screen that asked is the screen a re-lock
destroys. Add is the filled button and needs no confirmation; replace is
confirmed by a dialog that names the deletion and points back at the safe
option. The result lands in the banner above the tabs, beside the export's, as a
live region — it arrives with no focus change.
The archive cannot touch the app lock. It carries `biometricUnlock` and the
importer does not apply it: there is no PIN in the file and there cannot be.
Also raises the lock ViewModel test's hang budget to three minutes. 60s passed
alone and failed when four modules ran in one invocation — three real PBKDF2
derivations at 210k iterations. The budget is for catching a stuck coroutine,
not a slow one.
Verified: `:core:export` round trip against the committed golden file (17);
`CycleRepositoryImportTest` — one recalculation for a four-record archive,
nothing scored, one standing forecast, and a failed replace leaving the history
intact (prove-guard: remove `withTransaction` from `replaceEverything`, exactly
one red); `DataImporterTest`, `ImportScreenTest`, `ImportControllerTest`,
`ImportCopyTest`. On PeriodQA: Settings → Restore → Add → Downloads →
records-2026-08-19.json gives "Restored. 4 periods and 2 spotting days added.",
Insights then shows three cycles and accuracy still unscored, and the same file
a second time gives "Everything in that file was already recorded here, so
nothing changed."
closes#58
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§27's purpose is one sentence — show the user what the app has learned — which
means the screen has to be honest about how little that sometimes is. Every
figure is absent rather than approximated below the history that supports it,
because the easiest way to overstate accuracy is to average two numbers and
print a decimal place.
- one interval is an anecdote, not an average: no "average cycle" until two
- accuracy figures wait for three scored forecasts, and say why they are
waiting rather than showing a mean of one
- §27's learning copy is chosen by what the data supports, never by mood.
"Personalized to your cycle" is a claim, and it appears only when there are
enough confirmed cycles for the forecast to genuinely be hers
A BUG THE OUTLIER TEST CAUGHT
The typical-range quartiles were indexed off `size` instead of `size - 1`, which
on an even-length list puts the upper index on the largest value. A history of
29, 28, 30, 29, 61, 29 reported a "typical range" of 29–61 — describing a
regular cycle as wildly erratic, on the one screen whose whole job is to say
what has been learned about her. Now 29–30.
Nothing on this screen leaves the device. §46 names prediction_error= among the
values that must never become an analytics event, and this is exactly the screen
that would tempt somebody to send one. There is no network call in these files
and there must never be.
153 tests, all passing. ./gradlew check green.
closes#20
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