Privacy-Period-Tracker/docs/architecture/README.md

192 lines
9.8 KiB
Markdown
Raw Normal View History

chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
# 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
feat: DataStore-backed UserPreferences, separate from the cycle database core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification privacy, reminder time, the three reminder toggles, biometric lock, theme, the ads entitlement and whether onboarding finished. Two defaults are decisions, and each has a test whose job is to stop it being changed by accident: - notification privacy defaults to DISCREET (§28). A default of DIRECT would put menstrual detail on a lock screen before the user has been asked a single question, and a notification read over a shoulder is the likeliest real privacy breach in this product. - fertility reminders default to off. Most users are not tracking fertility and an unrequested ovulation notification is an unpleasant surprise. An unrecognised stored value falls back to the SAFE option rather than to whatever enum entry happens to be first — a rollback or a hand-edited file must not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an out-of-range reminder time. This is a separate store rather than two more Room tables, and the reason is a deletion semantic: Delete My Data removes the health history and must leave the settings alone. Handing a user back a weaker privacy setting at the exact moment they are exercising a privacy control is the worst possible time to do it, and separate stores make the correct behaviour the easy one. The repository takes a DataStore rather than a Context, so its 10 tests run on the JVM against a temporary file — no emulator, no Robolectric. The Android instance is supplied by DI at the app layer, the only place that should know where a file lives. 41 tests across the project, all passing. closes #4
2026-08-18 02:34:47 -05:00
Six today. core/data is Batch 01 issue #5 and **does not exist yet** — a module created before it has contents is a place
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
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 |
feat: Room database for cycle history, and the schema guard that actually works core/database holds the four entities from PRODUCT_PLAN.md §10 — period_records, spotting_records, prediction_records, not_yet_observations — with DAOs returning Flow, epoch-day/epoch-milli converters, and the schema exported to core/database/schemas and committed. Three constraints are structural rather than remembered: - startDate is UNIQUE and inserts ABORT rather than REPLACE. REPLACE would delete the original row with its createdAt and source; §14 says health history is never modified silently. - spotting has its own table, so no query for periods can reach it. §25: it must never start or reset a cycle. - a prediction snapshot can be scored but not rewritten — score() sets only actualStartDate and absoluteErrorDays. A snapshot editable after the fact can only ever report that the app was right, which would make §16's whole accuracy feature a lie. deleteEverything() is one transaction and the only bulk delete in the module: a partial wipe leaves the cycle reconstructible from the tables the user asked to be rid of. 14 tests, on the JVM under Robolectric — no emulator. THE SCHEMA GUARD, AND WHY IT IS A SCRIPT SchemaTest was written as a drift guard and proved not to be one. Room regenerates the schema export during compilation, so adding a column to PeriodRecordEntity without bumping VERSION leaves the suite green while the committed schema quietly changes underneath it. That was not reasoned about, it was run: the column was added, 1.json gained it, and every test passed. On a device that is "Room cannot verify the data integrity" — a crash on update, after shipping. scripts/schema-guard.sh asks git instead, which Room cannot overwrite. Proved both ways before being trusted: green on a clean tree, exit 1 on the injected drift. It runs in pre-commit when an entity or the schema directory is staged, and the hook treats its exit 2 as a refusal. SchemaTest keeps its four tests and now documents what it does not catch. Room's own MigrationTestHelper is not used: every constructor needs an Instrumentation and schema assets, and AGP 9's library source-set DSL throws DefaultAndroidLibrarySourceSet_Decorated cannot be cast to AndroidLibrarySourceSet when you add an asset directory. Recorded so the next person does not spend the afternoon on it. Docs updated in this commit, as their triggers required: the migration table now has its version 1 row and the trap that makes such tables go stale, TOOLS explains the seventh script, and the hooks README lists the new guard. closes #3
2026-08-18 02:32:07 -05:00
| `core/database` | Android library | Room entities, DAOs, converters, the schema export | `domain/cycle`, `domain/prediction` |
feat: DataStore-backed UserPreferences, separate from the cycle database core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification privacy, reminder time, the three reminder toggles, biometric lock, theme, the ads entitlement and whether onboarding finished. Two defaults are decisions, and each has a test whose job is to stop it being changed by accident: - notification privacy defaults to DISCREET (§28). A default of DIRECT would put menstrual detail on a lock screen before the user has been asked a single question, and a notification read over a shoulder is the likeliest real privacy breach in this product. - fertility reminders default to off. Most users are not tracking fertility and an unrequested ovulation notification is an unpleasant surprise. An unrecognised stored value falls back to the SAFE option rather than to whatever enum entry happens to be first — a rollback or a hand-edited file must not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an out-of-range reminder time. This is a separate store rather than two more Room tables, and the reason is a deletion semantic: Delete My Data removes the health history and must leave the settings alone. Handing a user back a weaker privacy setting at the exact moment they are exercising a privacy control is the worst possible time to do it, and separate stores make the correct behaviour the easy one. The repository takes a DataStore rather than a Context, so its 10 tests run on the JVM against a temporary file — no emulator, no Robolectric. The Android instance is supplied by DI at the app layer, the only place that should know where a file lives. 41 tests across the project, all passing. closes #4
2026-08-18 02:34:47 -05:00
| `core/datastore` | Android library | `UserPreferences` and the settings that are not health history | nothing in this project |
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
| `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:
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
| Module | Plugin | Owns | May depend on | Issue |
| --- | --- | --- | --- | --- |
feat: DataStore-backed UserPreferences, separate from the cycle database core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification privacy, reminder time, the three reminder toggles, biometric lock, theme, the ads entitlement and whether onboarding finished. Two defaults are decisions, and each has a test whose job is to stop it being changed by accident: - notification privacy defaults to DISCREET (§28). A default of DIRECT would put menstrual detail on a lock screen before the user has been asked a single question, and a notification read over a shoulder is the likeliest real privacy breach in this product. - fertility reminders default to off. Most users are not tracking fertility and an unrequested ovulation notification is an unpleasant surprise. An unrecognised stored value falls back to the SAFE option rather than to whatever enum entry happens to be first — a rollback or a hand-edited file must not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an out-of-range reminder time. This is a separate store rather than two more Room tables, and the reason is a deletion semantic: Delete My Data removes the health history and must leave the settings alone. Handing a user back a weaker privacy setting at the exact moment they are exercising a privacy control is the worst possible time to do it, and separate stores make the correct behaviour the easy one. The repository takes a DataStore rather than a Context, so its 10 tests run on the JVM against a temporary file — no emulator, no Robolectric. The Android instance is supplied by DI at the app layer, the only place that should know where a file lives. 41 tests across the project, all passing. closes #4
2026-08-18 02:34:47 -05:00
| 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 |
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
### 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
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
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
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
[`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 |
feat: DataStore-backed UserPreferences, separate from the cycle database core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification privacy, reminder time, the three reminder toggles, biometric lock, theme, the ads entitlement and whether onboarding finished. Two defaults are decisions, and each has a test whose job is to stop it being changed by accident: - notification privacy defaults to DISCREET (§28). A default of DIRECT would put menstrual detail on a lock screen before the user has been asked a single question, and a notification read over a shoulder is the likeliest real privacy breach in this product. - fertility reminders default to off. Most users are not tracking fertility and an unrequested ovulation notification is an unpleasant surprise. An unrecognised stored value falls back to the SAFE option rather than to whatever enum entry happens to be first — a rollback or a hand-edited file must not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an out-of-range reminder time. This is a separate store rather than two more Room tables, and the reason is a deletion semantic: Delete My Data removes the health history and must leave the settings alone. Handing a user back a weaker privacy setting at the exact moment they are exercising a privacy control is the worst possible time to do it, and separate stores make the correct behaviour the easy one. The repository takes a DataStore rather than a Context, so its 10 tests run on the JVM against a temporary file — no emulator, no Robolectric. The Android instance is supplied by DI at the app layer, the only place that should know where a file lives. 41 tests across the project, all passing. closes #4
2026-08-18 02:34:47 -05:00
| `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.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
**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.
feat: Room database for cycle history, and the schema guard that actually works core/database holds the four entities from PRODUCT_PLAN.md §10 — period_records, spotting_records, prediction_records, not_yet_observations — with DAOs returning Flow, epoch-day/epoch-milli converters, and the schema exported to core/database/schemas and committed. Three constraints are structural rather than remembered: - startDate is UNIQUE and inserts ABORT rather than REPLACE. REPLACE would delete the original row with its createdAt and source; §14 says health history is never modified silently. - spotting has its own table, so no query for periods can reach it. §25: it must never start or reset a cycle. - a prediction snapshot can be scored but not rewritten — score() sets only actualStartDate and absoluteErrorDays. A snapshot editable after the fact can only ever report that the app was right, which would make §16's whole accuracy feature a lie. deleteEverything() is one transaction and the only bulk delete in the module: a partial wipe leaves the cycle reconstructible from the tables the user asked to be rid of. 14 tests, on the JVM under Robolectric — no emulator. THE SCHEMA GUARD, AND WHY IT IS A SCRIPT SchemaTest was written as a drift guard and proved not to be one. Room regenerates the schema export during compilation, so adding a column to PeriodRecordEntity without bumping VERSION leaves the suite green while the committed schema quietly changes underneath it. That was not reasoned about, it was run: the column was added, 1.json gained it, and every test passed. On a device that is "Room cannot verify the data integrity" — a crash on update, after shipping. scripts/schema-guard.sh asks git instead, which Room cannot overwrite. Proved both ways before being trusted: green on a clean tree, exit 1 on the injected drift. It runs in pre-commit when an entity or the schema directory is staged, and the hook treats its exit 2 as a refusal. SchemaTest keeps its four tests and now documents what it does not catch. Room's own MigrationTestHelper is not used: every constructor needs an Instrumentation and schema assets, and AGP 9's library source-set DSL throws DefaultAndroidLibrarySourceSet_Decorated cannot be cast to AndroidLibrarySourceSet when you add an asset directory. Recorded so the next person does not spend the afternoon on it. Docs updated in this commit, as their triggers required: the migration table now has its version 1 row and the trap that makes such tables go stale, TOOLS explains the seventh script, and the hooks README lists the new guard. closes #3
2026-08-18 02:32:07 -05:00
| 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.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
feat: Room database for cycle history, and the schema guard that actually works core/database holds the four entities from PRODUCT_PLAN.md §10 — period_records, spotting_records, prediction_records, not_yet_observations — with DAOs returning Flow, epoch-day/epoch-milli converters, and the schema exported to core/database/schemas and committed. Three constraints are structural rather than remembered: - startDate is UNIQUE and inserts ABORT rather than REPLACE. REPLACE would delete the original row with its createdAt and source; §14 says health history is never modified silently. - spotting has its own table, so no query for periods can reach it. §25: it must never start or reset a cycle. - a prediction snapshot can be scored but not rewritten — score() sets only actualStartDate and absoluteErrorDays. A snapshot editable after the fact can only ever report that the app was right, which would make §16's whole accuracy feature a lie. deleteEverything() is one transaction and the only bulk delete in the module: a partial wipe leaves the cycle reconstructible from the tables the user asked to be rid of. 14 tests, on the JVM under Robolectric — no emulator. THE SCHEMA GUARD, AND WHY IT IS A SCRIPT SchemaTest was written as a drift guard and proved not to be one. Room regenerates the schema export during compilation, so adding a column to PeriodRecordEntity without bumping VERSION leaves the suite green while the committed schema quietly changes underneath it. That was not reasoned about, it was run: the column was added, 1.json gained it, and every test passed. On a device that is "Room cannot verify the data integrity" — a crash on update, after shipping. scripts/schema-guard.sh asks git instead, which Room cannot overwrite. Proved both ways before being trusted: green on a clean tree, exit 1 on the injected drift. It runs in pre-commit when an entity or the schema directory is staged, and the hook treats its exit 2 as a refusal. SchemaTest keeps its four tests and now documents what it does not catch. Room's own MigrationTestHelper is not used: every constructor needs an Instrumentation and schema assets, and AGP 9's library source-set DSL throws DefaultAndroidLibrarySourceSet_Decorated cannot be cast to AndroidLibrarySourceSet when you add an asset directory. Recorded so the next person does not spend the afternoon on it. Docs updated in this commit, as their triggers required: the migration table now has its version 1 row and the trap that makes such tables go stale, TOOLS explains the seventh script, and the hooks README lists the new guard. closes #3
2026-08-18 02:32:07 -05:00
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.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
## 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 |
feat: Room database for cycle history, and the schema guard that actually works core/database holds the four entities from PRODUCT_PLAN.md §10 — period_records, spotting_records, prediction_records, not_yet_observations — with DAOs returning Flow, epoch-day/epoch-milli converters, and the schema exported to core/database/schemas and committed. Three constraints are structural rather than remembered: - startDate is UNIQUE and inserts ABORT rather than REPLACE. REPLACE would delete the original row with its createdAt and source; §14 says health history is never modified silently. - spotting has its own table, so no query for periods can reach it. §25: it must never start or reset a cycle. - a prediction snapshot can be scored but not rewritten — score() sets only actualStartDate and absoluteErrorDays. A snapshot editable after the fact can only ever report that the app was right, which would make §16's whole accuracy feature a lie. deleteEverything() is one transaction and the only bulk delete in the module: a partial wipe leaves the cycle reconstructible from the tables the user asked to be rid of. 14 tests, on the JVM under Robolectric — no emulator. THE SCHEMA GUARD, AND WHY IT IS A SCRIPT SchemaTest was written as a drift guard and proved not to be one. Room regenerates the schema export during compilation, so adding a column to PeriodRecordEntity without bumping VERSION leaves the suite green while the committed schema quietly changes underneath it. That was not reasoned about, it was run: the column was added, 1.json gained it, and every test passed. On a device that is "Room cannot verify the data integrity" — a crash on update, after shipping. scripts/schema-guard.sh asks git instead, which Room cannot overwrite. Proved both ways before being trusted: green on a clean tree, exit 1 on the injected drift. It runs in pre-commit when an entity or the schema directory is staged, and the hook treats its exit 2 as a refusal. SchemaTest keeps its four tests and now documents what it does not catch. Room's own MigrationTestHelper is not used: every constructor needs an Instrumentation and schema assets, and AGP 9's library source-set DSL throws DefaultAndroidLibrarySourceSet_Decorated cannot be cast to AndroidLibrarySourceSet when you add an asset directory. Recorded so the next person does not spend the afternoon on it. Docs updated in this commit, as their triggers required: the migration table now has its version 1 row and the trap that makes such tables go stale, TOOLS explains the seventh script, and the hooks README lists the new guard. closes #3
2026-08-18 02:32:07 -05:00
| `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 |
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
| `.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.