The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6 |
||
|---|---|---|
| .. | ||
| githooks | ||
| GUARDS.md | ||
| README.md | ||
README.md
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
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
Seven 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 |
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 |
core/data |
Android library | CycleRepository, entity⇄domain mapping, accuracy — the only module that touches a DAO |
core/database, domain/cycle, 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.
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.
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.
Per 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; 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 |
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) 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.
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 |
.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.