8.9 KiB
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
Five today. core/datastore is Batch 01 issue #4 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 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 |
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/datastore | Android library | UserPreferences |
domain/cycle |
#4 |
| 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 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
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 — 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 |
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.