151 lines
7.3 KiB
Markdown
151 lines
7.3 KiB
Markdown
# 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
|
|
|
|
Four today. `core/database` and `core/datastore` are Batch 01 issues #3 and #4
|
|
and **do 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 |
|
|
| `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:
|
|
|
|
| Module | Plugin | Owns | May depend on | Issue |
|
|
| --- | --- | --- | --- | --- |
|
|
| `core/database` | Android library | Room entities, DAOs, migrations | `domain/cycle` | #3 |
|
|
| `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](../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, 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 — 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](../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 test |
|
|
| --- | --- | --- |
|
|
| 1 | initial schema | — |
|
|
|
|
Room's exported schemas are committed, so a migration can be tested against the
|
|
real previous schema rather than a remembered one.
|
|
|
|
## 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 |
|
|
| `.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.
|