530 lines
30 KiB
Markdown
530 lines
30 KiB
Markdown
# 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-2`) 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.
|
||
|
||
### Projecting a year, without claiming to know one
|
||
|
||
`CycleProjection` reaches past the next period so the calendar can answer *"will
|
||
I have my period the week of the wedding?"*. It is a **separate type from
|
||
`Prediction`, deliberately**: a forecast is something the app scores itself on —
|
||
the period arrives inside the window or it does not, and `PredictionRecord`
|
||
writes down which — while a projection eleven cycles out is never scored, never
|
||
learned from, and will have been replaced four times before its date arrives.
|
||
Separate types mean a projection cannot be snapshotted into the accuracy figures
|
||
by accident.
|
||
|
||
Three rules hold it honest, and each has a test named after it:
|
||
|
||
- **Uncertainty grows as √n.** Cycle lengths are near-independent draws, so
|
||
eleven cycles out is about three times as uncertain as one, not eleven times.
|
||
The independence is an approximation — real cycles correlate — which is why the
|
||
square root is the *optimistic* edge and the assumption below is mandatory.
|
||
- **It declines rather than stretching.** Past ten days either side, a projection
|
||
stops being an answer, so the projection ends and reports that it ended. This
|
||
is `FertilityEstimate`'s precedent applied again: a window covering half a
|
||
cycle says nothing while looking like it said something.
|
||
- **The assumption is stated, not implied.** Any surface showing projected marks
|
||
carries `CycleProjection.PROJECTION_ASSUMPTION` — *"If your cycles continue as
|
||
they have, this is the forecast"* — with the confidence for that distance
|
||
beside it. A year-ahead date drawn without it is the clearest overstatement
|
||
this app could make (§27).
|
||
|
||
`DayMark.PROJECTED_PERIOD` is separate from `PREDICTED_PERIOD` for the same
|
||
reason the types are: the two make different promises, and drawing them
|
||
identically would be the calendar making the weaker claim in the stronger one's
|
||
voice. Cycle 1 of a projection is the engine's own forecast copied through
|
||
unchanged, so the calendar and the Today screen cannot disagree about the next
|
||
period.
|
||
|
||
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.
|
||
- **[`SCIENCE.md`](SCIENCE.md)** — which constants in `domain/prediction` are
|
||
claims about menstrual physiology rather than tuning, and the published
|
||
measurement behind each. Read it before changing a luteal phase, a fertile
|
||
window or the population default; the rest of the engine's constants are
|
||
calibration and belong to `LearningCurveTest`.
|
||
|
||
## 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 `<uses-permission>` and a `<provider>` 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.
|