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> |
||
|---|---|---|
| .. | ||
| githooks | ||
| GUARDS.md | ||
| README.md | ||
| SCIENCE.md | ||
README.md
Architecture
Status: Current
Owner: _null
Last reviewed: 2026-08-20
Governs: docs/architecture/**, settings.gradle.kts, build.gradle.kts,
core/database/**, domain/** — the Gradle module graph and the data
shapes that outlive a function
Review trigger: Any new Gradle module, any change to a module boundary, any change
to a Room entity or a DAO, any new Room migration, any dependency
added to a domain/* module
The shape
Compose UI (app, feature/*)
↓
ViewModel — immutable StateFlow of screen state
↓
Use case / prediction engine (domain/*)
↓
Repository (core/data)
↓
Room + DataStore (core/database, core/datastore)
Unidirectional: state flows down as an immutable UiState, events flow up as
function calls. Nothing below the ViewModel knows Compose exists.
Modules
Ten today — a module created before it has contents is a place
for things to be put by accident. The wider layout sketched in
../planning/PRODUCT_PLAN.md §9 arrives the same
way, with the batch that needs it.
| Module | Plugin | Owns | May depend on |
|---|---|---|---|
app |
Android application | MainActivity, the four-tab navigation shell, DI wiring |
core/designsystem, core/data, core/datastore, core/notifications, domain/* — never core/database |
core/designsystem |
Android library | Material 3 theme, colour and type tokens | nothing in this project |
core/database |
Android library | Room entities, DAOs, converters, the schema export | domain/cycle, domain/prediction |
core/datastore |
Android library | UserPreferences and the settings that are not health history |
nothing in this project |
core/data |
Android library | CycleRepository, entity⇄domain mapping, accuracy — the only module that touches a DAO |
core/database, domain/cycle, domain/prediction |
core/notifications |
Android library | reminder copy and the action vocabulary, the privacy modes, WorkManager scheduling | core/data, core/datastore, domain/* |
core/security |
Android library | the app lock's PIN verifier, its Keystore key and the lockout policy | nothing in this project |
core/export |
Kotlin JVM | the export file format, and nothing else | domain/cycle — deliberately not domain/prediction |
domain/cycle |
Kotlin JVM | PeriodRecord, SpottingRecord, CycleRecord and the rules over them |
nothing |
domain/prediction |
Kotlin JVM | the forecast, the window, confidence, NotYetObservation |
domain/cycle |
Planned, with the issue that brings each one. Named without backticks on
purpose — doc-claims.sh reads a backticked path as a claim that the file is
there, and none of these are:
| Module | Plugin | Owns | May depend on | Issue |
|---|---|---|---|---|
| core/ads | Android library | the AdProvider implementation |
neither core/database nor domain/* |
Batch 07 |
Why domain/* is kotlin("jvm") and not an Android library
PRODUCT_PLAN.md §57.10 asks for the prediction
engine to be unit-testable without Android. A convention saying "do not import
android.* here" is a convention somebody breaks at 11pm; a module that
cannot see the Android SDK at all is a compile error instead.
It buys the thing §50 depends on: the acceptance tests in §51 — stable 35-day user, variable user, 45-day outlier, "not yet" — run on the JVM in under a second, so they run on every commit rather than on an emulator when someone remembers.
How the Room boundary is actually held
Three mechanisms, and it matters that none of them is "people remember":
core/datadepends oncore/databasewithimplementation, notapi, so Room never reaches the compile classpath of anything above it.CycleRepository's constructor isinternal— it names aPeriodDatabase, and a public constructor would force every caller to be able to name that type too. Callers useCycleData.repository(context).- Repository reads return domain types.
Mappers.ktis the one place an entity and a domain object meet.
Checkable, not merely intended: grep -rn "androidx.room" app/src domain is
empty, and ./gradlew :app:dependencies --configuration debugCompileClasspath
lists Room zero times.
No fallbackToDestructiveMigration. It turns a forgotten migration into
silent data loss on update — here, a user's entire cycle history gone with no
error and no way back. A missing migration must be a crash in testing rather
than a wipe in production.
Why core/export depends on domain/cycle and nothing else
The export must contain "only the user's own data — no derived analytics, no diagnostic payload, nothing about the device". That is a sentence in an issue, and sentences are not enforceable — so the module is shaped to make the wrong thing impossible rather than merely discouraged.
Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord all
live in domain/prediction. By depending only on domain/cycle, the export
module cannot name them: adding a forecast to the file is a compile error,
not something review has to notice. And being kotlin("jvm") rather than an
Android library puts android.os.Build off the classpath too, so a device fact
cannot be added either.
It also means the format is testable in milliseconds on the JVM, which is what lets it be pinned byte-for-byte against a committed golden file — the strongest protection available for a format that, as #35 puts it, "outlives this batch: whatever ships first is what people's archives will be in".
Reading the file back lives in the same module, and refuses more than it accepts
ExportDocument.render had no counterpart until #58, which made the archive a
one-way door: a user changing phone started the model from zero, and "your
history is yours" was a smaller promise than it sounded. ExportReader.read
mirrors it, in the same module, so the two halves of the format cannot drift
into separate places.
The JSON is parsed by Json.kt, hand-written and strict, for the same reason
the renderer is hand-written: core/export has two dependencies and their
narrowness is the point, kotlinx-serialization is not in the version catalog,
and the format is one function's output pinned by a golden file. Strict means it
refuses duplicate keys, trailing content, non-integer numbers and unsupported
escapes — 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.
What the reader refuses is the design:
| The file | The answer |
|---|---|
no format magic string, or the wrong one |
NotOurFile — and nothing is read |
a formatVersion above this build's |
NewerFormat — rule 2 lets a newer version add keys, but a bump means something was removed, retyped or given a new meaning |
| ours, and damaged or truncated | Damaged |
| a key this build has never heard of | ignored — that is rule 2's other half, and what lets the format grow |
a source this build cannot name |
IMPORTED, never a guess |
Ids are deliberately absent from the parsed result. An imported record is a new row on this phone, and pretending otherwise invites two devices to disagree about which row a number refers to.
Why core/security depends on nothing
It holds key material, and the rule that follows from that is the one worth
writing down: it must never be able to see a cycle date. So its allowed
dependency set is empty, and the erase that a forgotten PIN leads to is
orchestrated in app — LockEraseViewModel calls the cycle repository and the
lock repository in turn, rather than core/security reaching for either.
Adding it cost three rows in the root build.gradle.kts, and only two of them
fail loudly if forgotten:
| Row | What it does | What happens if forgotten |
|---|---|---|
":core:security" to emptySet() in allowedProjectDependencies |
declares its permitted edges | build fails — a module with no entry is reported as never checked |
":core:security" in the ":app" set |
lets app depend on it |
build fails on the dependency |
"core/security" in modulesSeeingHealthData |
puts it under checkNoHealthLogging |
nothing — it is silently never scanned |
The third is the one that matters most and warns least, which is exactly the
hazard app/proguard-rules.pro already describes: "Somebody adding a module and
forgetting to list it gets no warning, because absence of a finding looks exactly
like a clean result." In this module a stray println would print key material.
The count in the guard's own output is the check: it reports how many files it
scanned, and that number went from 52 to 62 when this module was added.
Ordinary outcomes are values; only faults are exceptions
period_records.startDate is UNIQUE and inserts ABORT rather than REPLACE, so a
duplicate cannot destroy the original row. Both are right. The API around them
was not: confirmPeriodStart let SQLiteConstraintException out, and
viewModelScope.launch has no handler, so tapping "Started today" twice
killed the app — found on a device, not in a test.
The mistake was treating already recorded as an error. It is a completely
reasonable thing for a person to do twice when they are unsure the first tap
registered. So the period writes return PeriodWriteResult — Added,
AlreadyRecorded, Updated, Conflict, NotFound — and a genuine fault (full
disk, corrupt database) still throws, because that is not something a caller can
carry on from.
Conflict is refused rather than resolved: moving a record onto a date another
record already holds is a question only the user can settle, and merging would
delete a period they entered.
An import is a write, and it is not a loop over confirmPeriodStart
CycleRepository.importHistory is its own path for three reasons, each of which
is something confirmPeriodStart does that would be wrong for an archive:
- it scores the standing forecast, and every record in an archive is
history the app never forecast — §16's "your predictions are getting better"
would fill with figures the app invented about itself. The backfill guard in
scoreOutstandingcatches most of that by date but not all of it: a file exported this morning carries a period starting today. So scoring is not dodged by accident of date here, it is not reached; - it clears the "not yet" observations a start resolves, which answer a question this phone asked;
- it snapshots a forecast per record, so a thousand-record archive would run the engine a thousand times over histories that never existed.
What it keeps is the part that matters: one transaction, through the same insert, behind the same UNIQUE start-date constraint, inside the repository — nothing above this module reaches a DAO. The forecast is recalculated exactly once, at the end, inheriting the standing lineage origin, because the question (when does the next period begin?) has not changed — only what is known about it.
Merge keeps what is on this phone: a start date already recorded here is left
exactly as it is, not merged field by field, because silently rewriting its end
date or its source from a file is the modification §14 forbids. Replace goes
through PeriodDatabase.replaceEverything, which empties the four tables inside
the same transaction as the writes — a failure between the wipe and the
inserts would leave the user with neither the history she had nor the one in her
file. That is why it is not deleteEverything: clearAllTables asserts it is
not inside a suspending transaction, runs its work in a blocking coroutine of
its own and finishes with a VACUUM that SQLite forbids inside a transaction.
Delete My Data still calls deleteEverything, and still gets the VACUUM —
replacing a history is not erasing one, and only one of them is a privacy
control.
The ViewModel also installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what the user just entered,
and a message they can read beats a process that vanished. The message carries
the exception type and never a record's contents — §45.
A failed wake-up says which kind of failure it was
Every throw in the reminder worker used to become Result.success(). A
repository failing every day produced no reminders, no retries, no logs — §45
forbids them here — and nothing a user or a developer could tell apart from a
quiet cycle.
resultFor splits the two kinds. A file or database that could not be read this
minute (IOException, SQLiteException) is worth asking again, three times,
with WorkManager's backoff; past that it gives up quietly, because a reminder
that finally posts after a day of retries is answering a question nobody is still
asking. Anything else — most likely a Prediction invariant — fails identically
on every retry, so retrying would spin and silence is the honest answer.
The catch is Exception, not Throwable, deliberately: an OOM or a
StackOverflowError is neither retryable nor a success, and WorkManager
recording it as a failure is right.
An answered reminder leaves the shade. setAutoCancel only covers the body
being tapped, so answering with a button left it sitting there to be answered
again, and confirming in the app left it asking a settled question. The handler
cancels on any verdict — including "too old to write down", where leaving it
would invite the same tap tomorrow — and the coordinator cancels when the history
changes underneath it. That chain drops its first emission: it runs in every
process, including the one WorkManager starts for the worker, and the first value
is the current state rather than an answer, so without the drop the worker's own
process would cancel the notification it was about to post.
An answer belongs to the day it was asked about
A notification waits in the shade until somebody deals with it. The handler used
LocalDate.now(), so a reminder posted on Friday and tapped on Monday recorded
Monday — and a period start is the single input the whole prediction engine is
built on. Being wrong by a weekend there is worse than never asking.
The day the question was about now travels in the PendingIntent, written when the
notification is built rather than read when it is tapped, and the parked
notification action carries it too. ReminderActionRules then decides whether the
answer is still worth writing: nothing dated in the future, nothing older than a
day, nothing already settled by a start she has logged since, and ENDED only
where something is actually open to close.
The bias is deliberately towards writing nothing. A stale tap still opens the app, which is where she can see what is recorded and change it — a better outcome than a confident write against the wrong day. Anything unrecognised, including the action strings used before buttons carried their own meaning, is treated the same way: a notification sitting in a shade across an upgrade opens the app and records nothing.
The Intent's extras are consumed once. MainActivity removes them after
parking, and only parks at all when savedInstanceState is null: after process
death Android redelivers the original Intent with its extras intact, so a
rotation or a restore would otherwise apply a days-old answer a second time.
Scheduling a reminder is not the same as saying when
ExistingPeriodicWorkPolicy.UPDATE carries the previous request's
lastEnqueueTime and periodCount forward, and a periodic request computes its
next run as `periodCount == 0 ? lastEnqueueTime + initialDelay : lastEnqueueTime
- interval`. Both halves of that bit us:
- After the first run, a new initial delay is ignored entirely. Changing the reminder from Morning to Evening did nothing at all.
- Before it, the delay is applied to the original enqueue time. The coordinator reschedules at every process start, so asking for 19:00 at 09:00 when the work was enqueued at 08:00 produced 18:00 — and every later period anchored off that.
So the scheduler reads the existing work first: KEEP when nothing is
scheduled, leave an overdue run alone (moving it skips today's reminder
entirely), leave a run already within five minutes of the target alone, and
otherwise UPDATE with an explicit setNextScheduleTimeOverride — the only way
to say when rather than how long from a moment WorkManager has its own opinion
about. CANCEL_AND_REENQUEUE is wrong here for a subtler reason: this runs at
every process start, including the one WorkManager itself started to run the
worker, and cancelling the unique work there cancels the running worker.
There is no flex window, and the copy was changed rather than the code. With
flex, the first run is placed at start + interval − flex, so a fifteen-minute
flex on a daily period aims the first reminder roughly 23h45m out and skips the
day the user set it. The screen used to promise delivery "a few minutes either
side"; a periodic request is deferred by the system, never brought forward, so it
now says after this time, never before.
Time-zone and clock changes need an app receiver; boot does not. The delay is
computed once, from the zone in force then, so flying east left the reminder
arriving at the old wall-clock time forever. WorkManager's own
RescheduleReceiver declares BOOT_COMPLETED and nothing else — which is
exactly why ClockChangeReceiver exists for TIMEZONE_CHANGED and TIME_SET,
and exactly why it does not duplicate boot.
The check-in count belongs to a period, not to the app
§30 says the app must stop asking "did your period start?" after a few
unanswered check-ins, and start again next cycle. That was a counter plus a
reset, and the reset ran in a flow watching the newest confirmed start —
distinctUntilChanged, whose first emission always passes, in a coordinator
PeriodApplication starts in every process. Including the one WorkManager
spawns to run the reminder, moments before the worker reads the count. The
stopping rule could never be reached.
The count is now stored with the row id of the period it was asked about
(check_in_period_id), and reads as zero for any other. A new period starts it
over by arithmetic instead of by an event that has to fire at the right moment
in the right process; there is no chain to race and nothing for a second process
to get wrong. Delete My Data leaves an id matching nothing, which is correctly
no count at all.
An id, never a date — the rule that keeps dates out of PeriodRecord.toString()
applies to anything at rest a backup or a crash reporter could pick up. An id
says a record exists; a date says when somebody bled.
One forecast stands at a time, and backfill is not a prediction
Two rules about prediction_records that are easy to get wrong and expensive to
notice, because both failure modes produce plausible accuracy figures rather
than obviously broken ones.
Exactly one unscored snapshot exists at any moment. Every recalculation replaces the standing forecast instead of appending. A forecast superseded before its outcome was known is not a wrong forecast — nobody was looking at it when the period arrived — and counting it as one lets a single cycle contribute several errors to §16's figures.
A confirmed start only scores a forecast made on or before it. Onboarding
asks for earlier periods and a user can add one at any time; scoring today's
forecast against a date in the past invents an error for a prediction nobody was
ever shown. This was a real defect, caught by a test expecting one scored
prediction and finding three, and it is pinned by
backfilled history does not fabricate accuracy figures.
generatedAt is when the forecast's lineage began, not when the row was
written. A "Not yet", an edit and a delete each revise the answer to one
standing question — when does the period after the latest confirmed start
begin? — so the replacement snapshot inherits the superseded one's date rather
than stamping today. Restamping it moved the goalposts of the backfill rule
above: a "Not yet" on the 29th, then a period logged on the 30th as having
started on the 28th, tripped the guard and the forecast the user was actually
shown was deleted unscored. The app learned nothing from precisely the cycle it
got wrong. Pinned by a period logged retroactively still scores the forecast the user was shown. For the same reason the standing snapshot is retired before
the engine is consulted, so a lineage dies with its history rather than waiting
to be scored against an unrelated one — deleting the last period retires the standing forecast.
A score follows the period it is a fact about. Editing a confirmed start
re-scores every snapshot recorded against it (predictedStartDate stays
immutable, so a correction worsens the figure as readily as it improves one),
unless the corrected start falls before the lineage began — the same rule that
refuses to score backfill refuses to keep that one, so an edit cannot smuggle in
a measurement the guard would have turned away. Deleting the period retracts its
score outright. Without this, §16's figures and the engine's own error window
kept learning from a period the user had rewritten or withdrawn. Pinned by
editing a period re-scores the forecast that was scored against it,
editing a period to before its forecast existed retracts the score and
deleting a period retracts the score recorded against it.
A resolving confirm clears every "not yet", not just the older ones. They all
censor the same question, so the newest confirmed start moots all of them at
once; a date-bounded clear left observations dated after a retroactively logged
start alive to penalise the next cycle's confidence for a question already
answered. A backfill into the middle of history still clears only what precedes
it, since it resolves nothing about the standing question — a not-yet dated after a retroactively logged start does not haunt the next cycle and a deep backfill does not clear the observations censoring the standing question.
The boundary that is not negotiable
The advertising subsystem must never receive menstrual dates, cycle length, period duration, fertility status, ovulation estimates, prediction confidence, prediction history, spotting records, or any other health-derived attribute. —
PRODUCT_PLAN.md§34
Expressed structurally rather than as a rule people remember: when core/ads
exists it will declare no dependency on core/database or domain/*, and a
Gradle check enforces the whole table above by enumerating each module's allowed
dependencies. Ads reach the UI through an AdProvider interface owned by app.
The guard, and the three proofs it survived
checkModuleBoundaries in the root build.gradle.kts holds the tables above as
a check. It runs as part of ./gradlew check, lists every violation rather
than the first, and refuses to report a pass when it examined no modules at all.
Per GUARDS.md §1 it is not evidence until it has been watched
failing. These three are repeatable, each restores the file from a trap, and
each was run:
The export is not optional. Gradle prints no test-style summary line for a
task like this one, so prove-guard.sh falls back to counting matching log
lines — and its default pattern also matches Gradle's own FAILURE: and
BUILD FAILED banners. One correctly-caught violation is then reported as
three failures and the script exits 3, telling you to narrow a guard that
was already narrow. Exit 3 is not a pass. Pointing the pattern at the guard's
own violation lines makes the count the guard's count, and all three below then
exit 0. See GUARDS.md §8.
export PROVE_GUARD_FAIL_PATTERN='^ - .* (does not permit|has no entry|applies an Android plugin)'
# 1. A domain module reaching upward — the leak that would end JVM-only tests
bash scripts/prove-guard.sh domain/prediction/build.gradle.kts \
'implementation(project(":domain:cycle"))' \
'implementation(project(":domain:cycle"))
implementation(project(":core:database"))' \
./gradlew checkModuleBoundaries
# 2. :app reaching past the repository straight to Room
bash scripts/prove-guard.sh app/build.gradle.kts \
'implementation(project(":core:data"))' \
'implementation(project(":core:data"))
implementation(project(":core:database"))' \
./gradlew checkModuleBoundaries
# 3. A module with no rule is reported as unmeasured, not assumed fine
bash scripts/prove-guard.sh build.gradle.kts \
'":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"),' \
'' \
./gradlew checkModuleBoundaries
Proof 1 failed the first time, and that is the point. The guard reported
"7 modules checked, no violations" with a forbidden dependency sitting in the
build file. The root project is configured before its subprojects, so reading
subprojects.configurations from the root script saw every configuration empty
— the check had been green over an empty map since the moment it was written.
Collection now happens in afterEvaluate, and the task throws rather than
passing if it ends up with no modules to examine.
Thirty seconds of proving caught a guard that would otherwise have been trusted for months.
Data shapes
Defined in domain/cycle and domain/prediction as plain Kotlin, plus
UserPreferences in core/datastore, and mirrored by Room entities in
core/database. The full field lists are
PRODUCT_PLAN.md §10; what matters here is why
each exists and what must not happen to it.
| Type | Why it exists | The rule that goes with it |
|---|---|---|
PeriodRecord |
a confirmed period, with its source and whether it is confirmed | a record's source is kept; edits are recorded, never silent. IMPORTED is the source every record read back from an archive gets, whatever the file claimed — the file's provenance is a fact about another phone |
SpottingRecord |
spotting, tracked separately | must not start a cycle or reset one |
CycleRecord |
derived interval between two confirmed starts | derived, never stored as truth — toCycles() recomputes from the period records on every read, so an edit cannot leave a stale interval behind it |
PredictionRecord |
a snapshot taken before the outcome is known | this is what makes accuracy measurable at all; never overwritten in place |
NotYetObservation |
the user said the period had not started by a date | a censoring observation — the forecast is re-conditioned on it, not shifted by +1 day |
FertilityEstimate |
estimated ovulation and the fertile window around it | derived from the forecast, never stored, and null when the forecast is too vague to locate ovulation |
Interval |
one gap between confirmed starts, with its recency weight and whether it looks questionable | derived per calculation, never stored. A questionable interval is down-weighted, never dropped — §12 step 2 |
UserPreferences |
notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, never in the cycle database — see below |
Why settings are not in the database
core/datastore could have been two more Room tables. It is not, and the reason
is a deletion semantic rather than a taste in storage.
Delete My Data removes the health history and must leave the settings alone. A user exercising that control has not asked to have notification privacy returned to a default they did not choose — handing back a weaker setting at the exact moment somebody is reaching for a privacy control is the worst possible time to do it. Separate stores make that the easy implementation rather than the one you have to remember.
UserPreferencesRepository takes a DataStore rather than a Context, which
is what lets its tests run on the JVM against a temporary file. The Android
instance is supplied by DI at the app layer — the only place that should know
where a file lives.
Permissions are a declared set, not whatever the build produces
The Play listing shows them, the Data Safety form describes them, and a
privacy-first period tracker is judged on them before anybody opens the app. It
is also the list most likely to grow without anyone deciding to: adding
WorkManager brought four permissions in one line — WAKE_LOCK,
ACCESS_NETWORK_STATE, RECEIVE_BOOT_COMPLETED, FOREGROUND_SERVICE — none
typed by anybody.
checkPermissions in the root build.gradle.kts holds the allowed set and a
forbidden set, checks the release manifest as well as debug, and fails on
anything outside either. §31's exact-alarm permissions are in the forbidden list
by name.
It failed its own first proof too: without a dependsOn on the manifest task it
read whatever was left from a previous build, so an injected
SCHEDULE_EXACT_ALARM went unnoticed. Both directions are proved now.
Notifications, and the two ways privacy leaks through Android
NotificationCopy is a pure function — mode plus kind plus day count in, two
versions of the text out — so every combination is tested without an emulator.
PeriodNotifier maps that onto Android, and the mapping is where the leaks are:
- A private notification with no public version does not blank the lock
screen, it shows the private text.
setPublicVersionis mandatory, which is whyNotificationTexthas no nullable title. - A channel is immutable after creation. Importance and lock-screen visibility cannot be changed, so one shared channel would keep whatever the user's first privacy mode set forever — the setting would appear to work and change nothing. There is one channel per mode. Found by an instrumented test, not by reading the docs.
The prediction engine
PersonalPredictionEngine (modelVersion personal-2) is what the app ships.
It keeps a discrete probability distribution over candidate start dates
rather than a date with a margin bolted on, and everything the product needs
falls out of that one structure: the most likely date is its mode, the window is
the narrowest span holding 80% of its mass, and a "Not yet" is the distribution
being conditioned on what the user just said. A date-plus-margin design cannot
express that last one, which is why §13 is the reason for the shape.
Projecting a year, without claiming to know one
CycleProjection reaches past the next period so the calendar can answer "will
I have my period the week of the wedding?". It is a separate type from
Prediction, deliberately: a forecast is something the app scores itself on —
the period arrives inside the window or it does not, and PredictionRecord
writes down which — while a projection eleven cycles out is never scored, never
learned from, and will have been replaced four times before its date arrives.
Separate types mean a projection cannot be snapshotted into the accuracy figures
by accident.
Three rules hold it honest, and each has a test named after it:
- Uncertainty grows as √n. Cycle lengths are near-independent draws, so eleven cycles out is about three times as uncertain as one, not eleven times. The independence is an approximation — real cycles correlate — which is why the square root is the optimistic edge and the assumption below is mandatory.
- It declines rather than stretching. Past ten days either side, a projection
stops being an answer, so the projection ends and reports that it ended. This
is
FertilityEstimate's precedent applied again: a window covering half a cycle says nothing while looking like it said something. - The assumption is stated, not implied. Any surface showing projected marks
carries
CycleProjection.PROJECTION_ASSUMPTION— "If your cycles continue as they have, this is the forecast" — with the confidence for that distance beside it. A year-ahead date drawn without it is the clearest overstatement this app could make (§27).
DayMark.PROJECTED_PERIOD is separate from PREDICTED_PERIOD for the same
reason the types are: the two make different promises, and drawing them
identically would be the calendar making the weaker claim in the stronger one's
voice. Cycle 1 of a projection is the engine's own forecast copied through
unchanged, so the calendar and the Today screen cannot disagree about the next
period.
Five decisions, each measured rather than assumed:
| Decision | Why |
|---|---|
| Weighted median, not mean | §51's outlier history moves a mean to 31.7 and leaves a median at 29 |
| Laplace, not normal | cycles have heavy tails; under a bell curve a period four days late is nearly impossible, so the model stays confidently wrong |
| Spread is the larger of MAD and mean-AD | a user alternating 25 and 37 has half her deviations at zero, so MAD alone reads a wildly variable cycle as perfectly consistent — a test caught exactly that |
| Recency trusted in proportion to consistency | recency weighting assumes the recent past predicts the near future, which is false for a variable user; weighting it equally cost three days on the §51 variable fixture |
| Trend damped, with a floor relative to the user's own spread | a fixed one-day floor fired on a 42-day-cycle history whose medians differed by one day and turned an exact forecast into a wrong one |
BaselinePredictionEngine stays in the tree as the control.
EngineComparisonTest scores both over the §51 fixtures on every build, so
"better" is a number. At the swap: mean absolute error 0.67 against 1.00,
and the window contained the actual start 9 times out of 9 against 7.
Coverage is the measure, not width. The personal engine's windows are wider, and where they are wider they are right to be — a history with a suspected missing period is genuinely less certain, and the baseline answers it with a two-day window and misses. What a window promises is that the period starts inside it; an engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The comparison test asserts coverage, with a ceiling so the trivial cheat of answering "some time this month" still fails.
Fertility declines rather than stretches
The fertile window is seven days wide before any uncertainty is added, so a forecast carrying ±5 produces a seventeen-day window — over half a cycle. That is honest arithmetic and useless information, and it was on screen before anybody noticed: a user one cycle into the app was shown 8 Aug – 24 Aug.
FertilityEstimate.from now returns null past
MAX_USEFUL_UNCERTAINTY_DAYS, and Today says so — "Not enough history to
estimate", with a reason to keep logging. Three stable cycles later the same
user gets 12 Aug – 20 Aug, which is worth reading.
The general rule this is an instance of: the app declines rather than
stretches. The same shape appears in PredictionAccuracy refusing figures
below three scored forecasts, and in CycleInsights withholding an average
until there are two intervals.
Unusual is relative to the user, never to a constant
IntervalAnalysis decides whether a gap is odd by comparing it to a robust
centre of this user's own intervals. A global rule — "over 40 days is
suspicious" — gets exactly one group wrong, and it is the group whose cycles are
already unusual: the person this product exists for, and the one most tired of
apps assuming she is average. 45 days is unremarkable at a usual of 43 and worth
questioning at a usual of 29.
Two flags, because they earn different responses. A gap near a whole multiple of the usual is a probable missed entry and produces §14's question. A gap merely far from usual is down-weighted and left alone — asking about it would be the over-questioning §25 warns against.
Never secretly modify health history. A gap that looks like a missing entry
(§14) produces a question, not a correction. That
is an architectural constraint as much as a UX one: nothing in the data layer
may write a PeriodRecord the user did not confirm.
Migrations
Room migrations are numbered, tested, and listed in this document — one row per migration, added in the same commit as the migration itself. The template this repository came from records why: a manual's migration table sat six behind, and every reader in between trusted it.
| Version | What changed | Migration | Guard |
|---|---|---|---|
| 1 | initial schema: period_records, spotting_records, prediction_records, not_yet_observations |
— (first version) | SchemaTest + scripts/schema-guard.sh |
Room's exported schemas live in core/database/schemas/ and are committed,
so a migration can be tested against the real previous schema rather than a
remembered one.
The trap in this table, and the guard that closes it
Room regenerates the schema export during compilation. Change an entity
without bumping PeriodDatabase.VERSION and Room silently overwrites
schemas/…/1.json to match — so every in-process check compares two copies of
the new truth and passes. This was not reasoned about; it was proved, by adding
a column and watching the whole unit suite stay green while the committed schema
quietly changed underneath it.
The failure that produces on a device is Room cannot verify the data integrity — a crash on update, in front of a user, after shipping.
scripts/schema-guard.sh is the guard, and it works by asking git, which is
the one party Room cannot overwrite: an already-committed schema file that now
differs means an entity changed under a shipped version. It runs in
.githooks/pre-commit whenever an entity or the schema directory is staged.
So: adding a row to this table is part of changing a schema, not tidying up afterwards. The version bump, the migration, the new schema file and this row belong in one commit.
Documents here
GUARDS.md— how to write a check that actually checks. Read it before adding a structural test or a probe.SCIENCE.md— which constants indomain/predictionare claims about menstrual physiology rather than tuning, and the published measurement behind each. Read it before changing a luteal phase, a fertile window or the population default; the rest of the engine's constants are calibration and belong toLearningCurveTest.
What ships in this folder
Nothing. This project took six scripts from the template into scripts/, and
../TOOLS.md explains why the rest are absent and where the
menu is.
| Path | What it is |
|---|---|
scripts/secrets.sh |
credential shapes in a staged diff — the one that stops a keystore reaching a commit |
scripts/doc-claims.sh |
every file a document names must exist; --covers asks the inverse |
scripts/doc-triggers.py |
which documents a pending change fires, read from the Governs: headers |
scripts/commit-mine.sh |
commits only the paths you name, by pathspec, after the secret scan |
scripts/forgejo-issue.py |
files and closes issues in the tracker convention, with every rule of it as a check |
scripts/prove-guard.sh |
breaks what a guard protects and requires the guard to go red |
scripts/schema-guard.sh |
a Room entity may not change without the version changing with it — asks git, because Room overwrites the export during the build |
checkNoHealthLogging (root build.gradle.kts) |
no logging call may exist in a module that can see a cycle date — §45. Strips comments and matches a call rather than the class, so the Log.WARN constant and the KDoc explaining the rule both stay legal |
checkThemedDrawables (root build.gradle.kts) |
every drawable has a -night twin of the same name, both directions. A missing night asset fails nothing at runtime — Android falls back to the light one and draws it on a dark screen — so the only other way to notice is to open that screen in that theme. Exemptions are a named map with reasons, not a narrowed scope; launcher-only exceptions are limited to system-tinted monochrome resources and the neutral activity-alias icon whose adaptive icon is resolved outside the app theme |
checkNoSharedStorageWrites (root build.gradle.kts) |
no module that can see a cycle date may name getExternalFilesDir, MediaStore, FileProvider or ACTION_SEND — §45's shared-storage ban, which checkPermissions structurally cannot see because it matches <uses-permission> and a <provider> merges green |
.githooks/ |
pre-commit, commit-msg, post-commit — see githooks/README.md |
What does not belong here
- Product intent — that is
../planning/PROJECT_PLAN.md - What it should feel like — that is
../design/README.md - What happened while building it — that is
../history/DEVELOPMENT_LOG.md
A note on drift
Architecture docs go stale faster than any other kind, because code changes under them silently. That is what the Review trigger above is for, and why it names a new Gradle module and a new Room migration specifically: those are the two changes here that make this document wrong without touching it.