# 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 `` and a `` 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.