Privacy-Period-Tracker/docs/architecture/README.md

491 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```text
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](../planning/PRODUCT_PLAN.md) 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, 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](../planning/PRODUCT_PLAN.md) 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":
1. `core/data` depends on `core/database` with **`implementation`**, not `api`,
so Room never reaches the compile classpath of anything above it.
2. `CycleRepository`'s constructor is **`internal`** — it names a
`PeriodDatabase`, and a public constructor would force every caller to be
able to name that type too. Callers use `CycleData.repository(context)`.
3. Repository reads return domain types. `Mappers.kt` is 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".
### 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.
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.
### 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](../planning/PRODUCT_PLAN.md)
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`](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`](GUARDS.md) §8.
```bash
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](../planning/PRODUCT_PLAN.md); 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 |
| `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. `setPublicVersion` is mandatory, which is
why `NotificationText` has 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-1`) 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.
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](../planning/PRODUCT_PLAN.md)) 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`](GUARDS.md)** — how to write a check that actually checks. Read
it before adding a structural test or a probe.
## What ships in this folder
Nothing. This project took six scripts from the template into `scripts/`, and
[`../TOOLS.md`](../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](githooks/README.md) |
## What does not belong here
- Product intent — that is [`../planning/PROJECT_PLAN.md`](../planning/PROJECT_PLAN.md)
- What it should feel like — that is [`../design/README.md`](../design/README.md)
- What happened while building it — that is [`../history/DEVELOPMENT_LOG.md`](../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.