# Architecture ``` Status: Current Owner: _null Last reviewed: 2026-08-18 Governs: docs/architecture/**, 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 Six today. core/data is Batch 01 issue #5 and **does not exist yet** — 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 | everything below | | `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 | | `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/data | Android library | the repositories — the only things that touch a DAO | `core/database`, `core/datastore`, `domain/cycle` | #5 | | 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. ### 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`. Per [`GUARDS.md`](GUARDS.md) §1, that check is proved to fail — a deliberate forbidden dependency added, the guard watched going red, the file restored — before it is treated as evidence. `scripts/prove-guard.sh` performs it. ## Data shapes Defined in `domain/cycle` as plain Kotlin, and mirrored by Room entities in core/database once issue #3 creates it. 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 — recomputed from period records | | `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 | | `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. **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 | | `.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.