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

491 lines
28 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-20
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
Governs: docs/architecture/**, settings.gradle.kts, build.gradle.kts,
core/database/**, domain/** — the Gradle module graph and the data
shapes that outlive a function
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
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: export my data, as one plaintext file the user places "Your cycle belongs to you" was a promise the app could not keep: there was no way to get the data out. ## The format, because it outlives the batch One pretty-printed JSON file. The issue asks for "human-readable" and means it — this is what somebody's archive will be in for years, so it is a contract with eight rules written down beside it, not an implementation detail. Pinned byte-for-byte against a committed golden file, which doubles as the documented example so SECURITY.md links at it rather than keeping a second copy that would drift. A reformat, a reordered key or a changed date rendering all fail in a test rather than in an archive. Dates are ISO calendar dates with no timezone and no conversion, ever. Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident", and a zone-aware formatter here would shift every date for users east or west of whoever wrote it — a cycle tracker off by one day is wrong in the way that matters. There is a test that renders the same fixture in UTC, +14 and -12 and requires identical bytes, because that bug would never fire where it was written. ## Plaintext, and that is the decision rather than the default An earlier note said this would be encrypted. It should not be, and SECURITY.md now says why: the export is the copy that makes a lost Keystore key survivable instead of final — the exact condition recorded for ever revisiting database encryption. Putting it behind a passphrase reproduces the failure that decision was taken to avoid: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to backup, which this is not. ## Only the user's own data, as a compile error :core:export is pure JVM and depends on :domain:cycle alone. Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord live in :domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps android.os.Build off it too — so a forecast or a device fact cannot be added by accident. The key set is asserted with assertEquals rather than contains, so a new field is a failing test rather than a silent addition. Row ids are out because they are monotonic and would disclose how many records the user DELETED. The Play entitlement is out because a purchase one file-edit away from being granted is a purchase that will be. ## No second copy, ever The Storage Access Framework writes straight into the document the user picked. The alternative — write to cacheDir, share by FileProvider, delete after — creates the temporary file the issue warns about and races the receiving app still reading it. A test walks cacheDir after a successful export and requires it empty; it fails the moment anybody reintroduces that pattern. The destination is parked until the session is unlocked. Returning from the picker can re-lock, and writing while locked would hand the whole history to whoever took the phone during the save dialog. ## Two new guards, both proved to fail checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody having typed it. checkPermissions structurally cannot see it — it matches <uses-permission>, and a <provider> declaring FileProvider merges green. checkNoHealthLogging gains a completeness check. A module missing from modulesSeeingHealthData was silently exempt with a green build, which app/proguard-rules.pro has described as a hazard since before :core:security and :core:export existed. Every module must now be in that list or in an explicit modulesWithNoHealthData with its reason; being in neither is a violation rather than an exemption. 261 JVM tests, none skipped. Five guards green. closes #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
Ten today — 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 |
| --- | --- | --- | --- |
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
| `app` | Android application | `MainActivity`, the four-tab navigation shell, DI wiring | `core/designsystem`, `core/data`, `core/datastore`, `core/notifications`, `domain/*` — never `core/database` |
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
| `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 |
feat: repository layer, and stop backfilled history fabricating accuracy figures core/data is the seam between storage and everything else. Reads return domain types, cycles are derived rather than stored, and the forecast is a function of the data instead of a field somebody has to remember to refresh — so §11's "recalculate after a confirmed start, after an edit, after a Not yet" is automatic rather than three call sites. Confirming a period is four writes in one transaction, because a partial result is a corrupt history rather than a failed action: write the record, score the forecast that was standing, clear the "not yet" observations it resolved, and snapshot a fresh forecast. THE DEFECT THIS FOUND A test expecting one scored prediction found three. The cause was not the test: every historical period entered during onboarding was scoring the current forecast against a date in the past, inventing an error for a prediction nobody had ever been shown. §16's "your predictions are getting better" would have been populated with figures the app made up about itself — plausible ones, which is what makes it expensive to notice. Two rules now, both pinned by tests: - exactly one unscored snapshot exists at a time. A forecast superseded before its outcome was known is not a wrong forecast, and counting it lets one cycle contribute several errors. - a confirmed start only scores a forecast made on or before it. Anything earlier is backfill and leaves the standing forecast alone. Accuracy also stays quiet below three scored predictions. One lucky forecast reading "average error: 0 days" is an overstatement, not a measurement. THE ROOM BOUNDARY, HELD THREE WAYS implementation rather than api on core:database; CycleRepository's constructor internal because it names a PeriodDatabase; reads mapped to domain types in Mappers.kt. Callers use CycleData.repository(context) and never learn Room exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain is empty, and Room appears zero times in :app's debugCompileClasspath. No fallbackToDestructiveMigration: it turns a forgotten migration into a silent wipe of the user's entire cycle history on update. Also fixed: `domain/*` inside a KDoc silently opened a nested block comment — Kotlin block comments nest — which broke compilation in a way the error message pointed nowhere near. 58 tests across the project, all passing. closes #5
2026-08-18 02:41:05 -05:00
| `core/data` | Android library | `CycleRepository`, entity⇄domain mapping, accuracy — the only module that touches a DAO | `core/database`, `domain/cycle`, `domain/prediction` |
feat: reminders that stay quiet on a lock screen §28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus kind plus day count in, two versions of the text out — so every combination is tested exhaustively without an emulator. This is the one surface whose mistakes are visible to somebody who is not the user, so the tests are exhaustive rather than representative: every kind × every mode asserts that no health word reaches a lock screen outside Direct, and that includes the ACTION LABELS, which §31 points out are visible text too. A perfectly discreet body under a button reading "Started my period" leaks anyway. TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS A private notification with no public version does not blank the lock screen — it shows the private text. NotificationText therefore has no nullable title and an instrumented test asserts every kind attaches one. And a notification channel is IMMUTABLE after creation: importance and lock-screen visibility cannot be changed. One shared channel would have kept whatever the user's first privacy mode set, forever — switching from Direct to Maximum privacy would have appeared to work and changed nothing. There is now one channel per mode. Found by an instrumented test on a device; nothing in the unit tests could have seen it. §30's stopping rule is a test of its own: the app asks a bounded number of times, says "We'll stop checking for now. Log your period whenever it begins.", and then says nothing more — while the engine keeps learning, which is the sentence §30 puts right after it. WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions task fails the build if one ever appears in the merged manifest — from here or from a dependency. That guard also failed its own first proof, reading a stale manifest because it did not depend on the task that writes one. ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for and is the requirement most likely to be missed: a "Not yet" moves the forecast, so work queued against the old one is aimed at a day that no longer means anything. 188 unit tests and 6 instrumented, all passing. ./gradlew check green. closes #24 closes #25 closes #26 closes #27
2026-08-18 15:26:59 -05:00
| `core/notifications` | Android library | reminder copy, the privacy modes, WorkManager scheduling | `core/data`, `core/datastore`, `domain/*` |
feat: app lock, with no way to reset a forgotten PIN §45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has existed since Batch 01 with nothing outside its own module reading it; this wires it, and adds the rest. The recovery question was the reason #34 sat open, and it is decided: there is no recovery. A backdoor into a period tracker's lock would be used by exactly the person the lock exists to stop. Everything below follows from that. ## The gate AppLockGate wraps the whole composition rather than being a screen inside it. Today, Calendar and Insights each start collecting from CycleRepository the moment they compose, so a lock implemented as a nav destination would already have read the history before the user proved anything. content() is invoked only in the unlocked branch. Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings and a permission dialog. Two guards on top: isChangingConfigurations, or rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM biometric overlay that stops the activity produces a lock that can never be opened. No grace period: SECURITY.md leads with "someone who picks up an unlocked phone", which is the window a grace period covers. The unlock flag lives in a @Singleton, never in saved state. rememberSaveable looks like the obvious home and would restore a background-killed app already unlocked — the single most likely way to meet the lock screen would be the one path that skipped it. ## What is stored is not the PIN mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k)) Two layers because they defend different things. The Keystore MAC is what makes a six-digit PIN safe at all — a million candidates is nothing to an attacker who can compute the hash, and impossible for one who cannot get the key off the device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a domain-separation tag; the lockout counter is MACed under 0x02. The key omits six builder calls and the KDoc names every one. setUserAuthenti- cationRequired is the important absence: it would bind the key to the device lock, so changing a passcode would destroy it — and under no-recovery that is somebody's whole history gone for an unrelated reason. It would also be a bypass, since SECURITY.md already names "someone who knows the unlock PIN" as an adversary. The biometric key is separate and takes the opposite policy, where invalidation correctly degrades to "use your PIN". ## Wrong PINs cost time, never data Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a pocket destroy a history while knowing nothing. Both clock bypasses are closed — the wait is the longer of a wall-clock and a monotonic deadline, and a reboot re-applies it in full, detected by elapsedRealtime going backwards. ## Two writes that had to move Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on the phone's own lock screen, reachable by anybody, so the action is now parked in AppLockController and applied only after an unlock — dropped if the session never unlocks. Behaviour is unchanged when the lock is off. The erase behind "Forgot your PIN?" deletes health data, then the Keystore key, then the lock store. Skipping the middle step leaves the user erased AND still locked out; prove-guard mutates that line out and requires exactly one red. ## Found by testing, not by review - A fresh install began in a 15-minute lockout: "no counter yet" and "counter was tampered with" were the same value. They are now distinct. - Setting a PIN locked you out of the session you set it in. Found on the emulator, not in a test. - Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice. ## Verified 244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice here with no margin, and that the key is not auth-bound on either. On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on the lock screen, turning the lock off requires the current PIN, and `adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real. androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed checkPermissions until they were allowed on purpose, and it drags fragment to 1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity. closes #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
| `core/security` | Android library | the app lock's PIN verifier, its Keystore key and the lockout policy | **nothing in this project** |
feat: export my data, as one plaintext file the user places "Your cycle belongs to you" was a promise the app could not keep: there was no way to get the data out. ## The format, because it outlives the batch One pretty-printed JSON file. The issue asks for "human-readable" and means it — this is what somebody's archive will be in for years, so it is a contract with eight rules written down beside it, not an implementation detail. Pinned byte-for-byte against a committed golden file, which doubles as the documented example so SECURITY.md links at it rather than keeping a second copy that would drift. A reformat, a reordered key or a changed date rendering all fail in a test rather than in an archive. Dates are ISO calendar dates with no timezone and no conversion, ever. Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident", and a zone-aware formatter here would shift every date for users east or west of whoever wrote it — a cycle tracker off by one day is wrong in the way that matters. There is a test that renders the same fixture in UTC, +14 and -12 and requires identical bytes, because that bug would never fire where it was written. ## Plaintext, and that is the decision rather than the default An earlier note said this would be encrypted. It should not be, and SECURITY.md now says why: the export is the copy that makes a lost Keystore key survivable instead of final — the exact condition recorded for ever revisiting database encryption. Putting it behind a passphrase reproduces the failure that decision was taken to avoid: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to backup, which this is not. ## Only the user's own data, as a compile error :core:export is pure JVM and depends on :domain:cycle alone. Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord live in :domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps android.os.Build off it too — so a forecast or a device fact cannot be added by accident. The key set is asserted with assertEquals rather than contains, so a new field is a failing test rather than a silent addition. Row ids are out because they are monotonic and would disclose how many records the user DELETED. The Play entitlement is out because a purchase one file-edit away from being granted is a purchase that will be. ## No second copy, ever The Storage Access Framework writes straight into the document the user picked. The alternative — write to cacheDir, share by FileProvider, delete after — creates the temporary file the issue warns about and races the receiving app still reading it. A test walks cacheDir after a successful export and requires it empty; it fails the moment anybody reintroduces that pattern. The destination is parked until the session is unlocked. Returning from the picker can re-lock, and writing while locked would hand the whole history to whoever took the phone during the save dialog. ## Two new guards, both proved to fail checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody having typed it. checkPermissions structurally cannot see it — it matches <uses-permission>, and a <provider> declaring FileProvider merges green. checkNoHealthLogging gains a completeness check. A module missing from modulesSeeingHealthData was silently exempt with a green build, which app/proguard-rules.pro has described as a hazard since before :core:security and :core:export existed. Every module must now be in that list or in an explicit modulesWithNoHealthData with its reason; being in neither is a violation rather than an exemption. 261 JVM tests, none skipped. Five guards green. closes #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
| `core/export` | **Kotlin JVM** | the export file format, and nothing else | `domain/cycle` — deliberately **not** `domain/prediction` |
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 |
| --- | --- | --- | --- | --- |
| 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.
feat: repository layer, and stop backfilled history fabricating accuracy figures core/data is the seam between storage and everything else. Reads return domain types, cycles are derived rather than stored, and the forecast is a function of the data instead of a field somebody has to remember to refresh — so §11's "recalculate after a confirmed start, after an edit, after a Not yet" is automatic rather than three call sites. Confirming a period is four writes in one transaction, because a partial result is a corrupt history rather than a failed action: write the record, score the forecast that was standing, clear the "not yet" observations it resolved, and snapshot a fresh forecast. THE DEFECT THIS FOUND A test expecting one scored prediction found three. The cause was not the test: every historical period entered during onboarding was scoring the current forecast against a date in the past, inventing an error for a prediction nobody had ever been shown. §16's "your predictions are getting better" would have been populated with figures the app made up about itself — plausible ones, which is what makes it expensive to notice. Two rules now, both pinned by tests: - exactly one unscored snapshot exists at a time. A forecast superseded before its outcome was known is not a wrong forecast, and counting it lets one cycle contribute several errors. - a confirmed start only scores a forecast made on or before it. Anything earlier is backfill and leaves the standing forecast alone. Accuracy also stays quiet below three scored predictions. One lucky forecast reading "average error: 0 days" is an overstatement, not a measurement. THE ROOM BOUNDARY, HELD THREE WAYS implementation rather than api on core:database; CycleRepository's constructor internal because it names a PeriodDatabase; reads mapped to domain types in Mappers.kt. Callers use CycleData.repository(context) and never learn Room exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain is empty, and Room appears zero times in :app's debugCompileClasspath. No fallbackToDestructiveMigration: it turns a forgotten migration into a silent wipe of the user's entire cycle history on update. Also fixed: `domain/*` inside a KDoc silently opened a nested block comment — Kotlin block comments nest — which broke compilation in a way the error message pointed nowhere near. 58 tests across the project, all passing. closes #5
2026-08-18 02:41:05 -05:00
### 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.
feat: export my data, as one plaintext file the user places "Your cycle belongs to you" was a promise the app could not keep: there was no way to get the data out. ## The format, because it outlives the batch One pretty-printed JSON file. The issue asks for "human-readable" and means it — this is what somebody's archive will be in for years, so it is a contract with eight rules written down beside it, not an implementation detail. Pinned byte-for-byte against a committed golden file, which doubles as the documented example so SECURITY.md links at it rather than keeping a second copy that would drift. A reformat, a reordered key or a changed date rendering all fail in a test rather than in an archive. Dates are ISO calendar dates with no timezone and no conversion, ever. Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident", and a zone-aware formatter here would shift every date for users east or west of whoever wrote it — a cycle tracker off by one day is wrong in the way that matters. There is a test that renders the same fixture in UTC, +14 and -12 and requires identical bytes, because that bug would never fire where it was written. ## Plaintext, and that is the decision rather than the default An earlier note said this would be encrypted. It should not be, and SECURITY.md now says why: the export is the copy that makes a lost Keystore key survivable instead of final — the exact condition recorded for ever revisiting database encryption. Putting it behind a passphrase reproduces the failure that decision was taken to avoid: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to backup, which this is not. ## Only the user's own data, as a compile error :core:export is pure JVM and depends on :domain:cycle alone. Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord live in :domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps android.os.Build off it too — so a forecast or a device fact cannot be added by accident. The key set is asserted with assertEquals rather than contains, so a new field is a failing test rather than a silent addition. Row ids are out because they are monotonic and would disclose how many records the user DELETED. The Play entitlement is out because a purchase one file-edit away from being granted is a purchase that will be. ## No second copy, ever The Storage Access Framework writes straight into the document the user picked. The alternative — write to cacheDir, share by FileProvider, delete after — creates the temporary file the issue warns about and races the receiving app still reading it. A test walks cacheDir after a successful export and requires it empty; it fails the moment anybody reintroduces that pattern. The destination is parked until the session is unlocked. Returning from the picker can re-lock, and writing while locked would hand the whole history to whoever took the phone during the save dialog. ## Two new guards, both proved to fail checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody having typed it. checkPermissions structurally cannot see it — it matches <uses-permission>, and a <provider> declaring FileProvider merges green. checkNoHealthLogging gains a completeness check. A module missing from modulesSeeingHealthData was silently exempt with a green build, which app/proguard-rules.pro has described as a hazard since before :core:security and :core:export existed. Every module must now be in that list or in an explicit modulesWithNoHealthData with its reason; being in neither is a violation rather than an exemption. 261 JVM tests, none skipped. Five guards green. closes #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
### 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".
feat: app lock, with no way to reset a forgotten PIN §45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has existed since Batch 01 with nothing outside its own module reading it; this wires it, and adds the rest. The recovery question was the reason #34 sat open, and it is decided: there is no recovery. A backdoor into a period tracker's lock would be used by exactly the person the lock exists to stop. Everything below follows from that. ## The gate AppLockGate wraps the whole composition rather than being a screen inside it. Today, Calendar and Insights each start collecting from CycleRepository the moment they compose, so a lock implemented as a nav destination would already have read the history before the user proved anything. content() is invoked only in the unlocked branch. Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings and a permission dialog. Two guards on top: isChangingConfigurations, or rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM biometric overlay that stops the activity produces a lock that can never be opened. No grace period: SECURITY.md leads with "someone who picks up an unlocked phone", which is the window a grace period covers. The unlock flag lives in a @Singleton, never in saved state. rememberSaveable looks like the obvious home and would restore a background-killed app already unlocked — the single most likely way to meet the lock screen would be the one path that skipped it. ## What is stored is not the PIN mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k)) Two layers because they defend different things. The Keystore MAC is what makes a six-digit PIN safe at all — a million candidates is nothing to an attacker who can compute the hash, and impossible for one who cannot get the key off the device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a domain-separation tag; the lockout counter is MACed under 0x02. The key omits six builder calls and the KDoc names every one. setUserAuthenti- cationRequired is the important absence: it would bind the key to the device lock, so changing a passcode would destroy it — and under no-recovery that is somebody's whole history gone for an unrelated reason. It would also be a bypass, since SECURITY.md already names "someone who knows the unlock PIN" as an adversary. The biometric key is separate and takes the opposite policy, where invalidation correctly degrades to "use your PIN". ## Wrong PINs cost time, never data Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a pocket destroy a history while knowing nothing. Both clock bypasses are closed — the wait is the longer of a wall-clock and a monotonic deadline, and a reboot re-applies it in full, detected by elapsedRealtime going backwards. ## Two writes that had to move Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on the phone's own lock screen, reachable by anybody, so the action is now parked in AppLockController and applied only after an unlock — dropped if the session never unlocks. Behaviour is unchanged when the lock is off. The erase behind "Forgot your PIN?" deletes health data, then the Keystore key, then the lock store. Skipping the middle step leaves the user erased AND still locked out; prove-guard mutates that line out and requires exactly one red. ## Found by testing, not by review - A fresh install began in a 15-minute lockout: "no counter yet" and "counter was tampered with" were the same value. They are now distinct. - Setting a PIN locked you out of the session you set it in. Found on the emulator, not in a test. - Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice. ## Verified 244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice here with no margin, and that the key is not auth-bound on either. On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on the lock screen, turning the lock off requires the current PIN, and `adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real. androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed checkPermissions until they were allowed on purpose, and it drags fragment to 1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity. closes #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
### 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.
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
### 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.
feat: repository layer, and stop backfilled history fabricating accuracy figures core/data is the seam between storage and everything else. Reads return domain types, cycles are derived rather than stored, and the forecast is a function of the data instead of a field somebody has to remember to refresh — so §11's "recalculate after a confirmed start, after an edit, after a Not yet" is automatic rather than three call sites. Confirming a period is four writes in one transaction, because a partial result is a corrupt history rather than a failed action: write the record, score the forecast that was standing, clear the "not yet" observations it resolved, and snapshot a fresh forecast. THE DEFECT THIS FOUND A test expecting one scored prediction found three. The cause was not the test: every historical period entered during onboarding was scoring the current forecast against a date in the past, inventing an error for a prediction nobody had ever been shown. §16's "your predictions are getting better" would have been populated with figures the app made up about itself — plausible ones, which is what makes it expensive to notice. Two rules now, both pinned by tests: - exactly one unscored snapshot exists at a time. A forecast superseded before its outcome was known is not a wrong forecast, and counting it lets one cycle contribute several errors. - a confirmed start only scores a forecast made on or before it. Anything earlier is backfill and leaves the standing forecast alone. Accuracy also stays quiet below three scored predictions. One lucky forecast reading "average error: 0 days" is an overstatement, not a measurement. THE ROOM BOUNDARY, HELD THREE WAYS implementation rather than api on core:database; CycleRepository's constructor internal because it names a PeriodDatabase; reads mapped to domain types in Mappers.kt. Callers use CycleData.repository(context) and never learn Room exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain is empty, and Room appears zero times in :app's debugCompileClasspath. No fallbackToDestructiveMigration: it turns a forgotten migration into a silent wipe of the user's entire cycle history on update. Also fixed: `domain/*` inside a KDoc silently opened a nested block comment — Kotlin block comments nest — which broke compilation in a way the error message pointed nowhere near. 58 tests across the project, all passing. closes #5
2026-08-18 02:41:05 -05:00
### 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`.
fix: make the learning loop keep the samples it earns Tracing the confirm -> score -> feed-back loop end to end turned up four ways it lost or falsified its own evidence. None of them looked broken: each produced plausible accuracy figures with something missing. A forecast's generatedAt is now the date its LINEAGE began, not the date its row was written. Every "Not yet", edit and delete revises the answer to one standing question, so the replacement inherits the origin instead of restamping today. Restamping moved the goalposts of the backfill guard: 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. The standing snapshot is retired before the engine is consulted rather than after, so a lineage dies with its history instead of waiting to be scored against an unrelated one. Scores now follow the period they are facts about. Editing a start re-scores every snapshot recorded against it -- predictedStartDate stays immutable, so a correction worsens the figure as readily as it improves one -- and the same rule that refuses to score backfill retracts a score whose start has moved behind its lineage, so an edit cannot smuggle in a measurement the guard would have turned away. Deleting the period retracts outright. A confirm that becomes the newest start clears every "not yet", not just the older ones: they all censor the same question. A date-bounded clear left observations dated after a retroactively logged start alive to depress the next cycle's confidence for a question already answered. Also removes CycleData.repository's engine default. Nobody relied on it, which is the point -- a caller who omitted the argument would compile cleanly and ship the baseline prototype §11 calls unacceptable. No schema change: all four fixes are queries and call order. Six of the seven new tests were observed red against the unfixed source. The seventh -- a deep backfill does not clear the observations censoring the standing question -- passes both sides deliberately, pinning against overshooting the not-yet fix. closes #46 closes #47 closes #48 closes #49 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:13:53 -05:00
**`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`.
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
### 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`.
feat: module boundary guard, and two API-level bugs it uncovered checkModuleBoundaries holds the dependency tables in docs/architecture/README.md as a check: every module's permitted project dependencies, plus the rule that domain:cycle and domain:prediction must never apply an Android plugin. core:ads is already in the map with an empty permitted set, before the module exists — PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it constrains is one shaped around whatever exception somebody wanted at the time. It lists every violation rather than the first, and refuses to report a pass when it examined no modules at all. THE GUARD FAILED ITS OWN FIRST PROOF prove-guard.sh injected a forbidden dependency into :domain:prediction and the guard reported "7 modules checked, no violations". The root project is configured before its subprojects, so reading subprojects.configurations from the root script saw every configuration empty — it had been green over an empty map since the moment it was written, and would have been trusted for months. Collection moved into afterEvaluate, and the task now throws rather than passing if it ends up with no modules. Three proofs recorded in the architecture doc, all re-run and all red: a domain module reaching upward, :app reaching past the repository straight to Room, and a module with no rule being reported as unmeasured rather than assumed fine. TWO REAL BUGS FROM WIRING IT INTO `check` Running the whole check for the first time turned up Android lint errors that would have shipped: NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26) NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26) Both are on the recalculation path. On any device below Android 14 — most of the install base this app targets — that is a crash. Neither the unit tests nor the API 36 emulator could see it; lint is the only thing that could. Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26. Also cleared the lint warnings that were real: a redundant activity label, and a round launcher icon declared but never referenced. The two that remain are deliberate and now say so where the warning is read — targetSdk 36 is Play's floor and raising it opts into untested runtime behaviour, and the -v26 mipmap qualifier stays because removing it makes AAPT fail to resolve the icon at all. ./gradlew check now passes with 0 lint errors across all seven modules. 70 unit tests, all passing. closes #7
2026-08-18 03:00:11 -05:00
### 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:
fix: a throw in a background flow no longer kills the process The application scope in PeriodApplication was built with SupervisorJob and no CoroutineExceptionHandler, and ReminderCoordinator launchIns two Room flows on it. SupervisorJob stops a failing child cancelling its siblings; it does not stop the exception, which reaches the thread's default handler and ends the process. That scope is the one that runs with nobody watching. Application.onCreate runs in every process, including the ones WorkManager starts after a reboot and at the daily reminder — no Activity, no screen, nothing to show an error. Both ViewModels already install a handler; the one place a crash is invisible did not. The trigger is real rather than theoretical: repository.forecast runs the prediction engine inside the flow, and Prediction's init block enforces its window invariants with require. Three layers, outermost last: - ReminderCoordinator catches per chain, so one failing collection cannot take the other down. Doing nothing on failure is deliberate — cancelling the schedule would turn a failed read into reminders silently switched off until the user next touched a notification setting. - ReminderWorker returns success and posts nothing when it cannot read what it needs, which is already its behaviour with no history. Cancellation is rethrown rather than swallowed. - The scope handler is a backstop whose only job is that the process lives. It cannot log: checkNoHealthLogging covers this module, and an exception message here can carry a date derived from a cycle. The chains moved into internal functions taking flows so the catch is reachable from a test. CycleRepository is final with an internal constructor, which is right for a data boundary and wrong for faking, and adding a mocking library to reach one catch would have been the worse trade. Proved to fail, per GUARDS.md §1: removing the handler fails exactly one test (ApplicationScopeTest.kt:69), and removing either catch fails exactly its own. GUARDS.md gains §8. prove-guard.sh decides a guard caught the mutation from the runner's exit code, and cannot tell a broken test from a malformed command. Its first use here reported a clean catch when Gradle had actually rejected `:app:test --tests` as an unknown option and run nothing. The same tool's line-counting fallback also means the three documented boundary proofs in architecture/README.md have been exiting 3 rather than 0 since they were written; they now carry the fail pattern that makes them exit 0. closes #45 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 03:06:10 -05:00
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.
feat: module boundary guard, and two API-level bugs it uncovered checkModuleBoundaries holds the dependency tables in docs/architecture/README.md as a check: every module's permitted project dependencies, plus the rule that domain:cycle and domain:prediction must never apply an Android plugin. core:ads is already in the map with an empty permitted set, before the module exists — PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it constrains is one shaped around whatever exception somebody wanted at the time. It lists every violation rather than the first, and refuses to report a pass when it examined no modules at all. THE GUARD FAILED ITS OWN FIRST PROOF prove-guard.sh injected a forbidden dependency into :domain:prediction and the guard reported "7 modules checked, no violations". The root project is configured before its subprojects, so reading subprojects.configurations from the root script saw every configuration empty — it had been green over an empty map since the moment it was written, and would have been trusted for months. Collection moved into afterEvaluate, and the task now throws rather than passing if it ends up with no modules. Three proofs recorded in the architecture doc, all re-run and all red: a domain module reaching upward, :app reaching past the repository straight to Room, and a module with no rule being reported as unmeasured rather than assumed fine. TWO REAL BUGS FROM WIRING IT INTO `check` Running the whole check for the first time turned up Android lint errors that would have shipped: NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26) NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26) Both are on the recalculation path. On any device below Android 14 — most of the install base this app targets — that is a crash. Neither the unit tests nor the API 36 emulator could see it; lint is the only thing that could. Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26. Also cleared the lint warnings that were real: a redundant activity label, and a round launcher icon declared but never referenced. The two that remain are deliberate and now say so where the warning is read — targetSdk 36 is Play's floor and raising it opts into untested runtime behaviour, and the -v26 mipmap qualifier stays because removing it makes AAPT fail to resolve the icon at all. ./gradlew check now passes with 0 lint errors across all seven modules. 70 unit tests, all passing. closes #7
2026-08-18 03:00:11 -05:00
```bash
fix: a throw in a background flow no longer kills the process The application scope in PeriodApplication was built with SupervisorJob and no CoroutineExceptionHandler, and ReminderCoordinator launchIns two Room flows on it. SupervisorJob stops a failing child cancelling its siblings; it does not stop the exception, which reaches the thread's default handler and ends the process. That scope is the one that runs with nobody watching. Application.onCreate runs in every process, including the ones WorkManager starts after a reboot and at the daily reminder — no Activity, no screen, nothing to show an error. Both ViewModels already install a handler; the one place a crash is invisible did not. The trigger is real rather than theoretical: repository.forecast runs the prediction engine inside the flow, and Prediction's init block enforces its window invariants with require. Three layers, outermost last: - ReminderCoordinator catches per chain, so one failing collection cannot take the other down. Doing nothing on failure is deliberate — cancelling the schedule would turn a failed read into reminders silently switched off until the user next touched a notification setting. - ReminderWorker returns success and posts nothing when it cannot read what it needs, which is already its behaviour with no history. Cancellation is rethrown rather than swallowed. - The scope handler is a backstop whose only job is that the process lives. It cannot log: checkNoHealthLogging covers this module, and an exception message here can carry a date derived from a cycle. The chains moved into internal functions taking flows so the catch is reachable from a test. CycleRepository is final with an internal constructor, which is right for a data boundary and wrong for faking, and adding a mocking library to reach one catch would have been the worse trade. Proved to fail, per GUARDS.md §1: removing the handler fails exactly one test (ApplicationScopeTest.kt:69), and removing either catch fails exactly its own. GUARDS.md gains §8. prove-guard.sh decides a guard caught the mutation from the runner's exit code, and cannot tell a broken test from a malformed command. Its first use here reported a clean catch when Gradle had actually rejected `:app:test --tests` as an unknown option and run nothing. The same tool's line-counting fallback also means the three documented boundary proofs in architecture/README.md have been exiting 3 rather than 0 since they were written; they now carry the fail pattern that makes them exit 0. closes #45 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 03:06:10 -05:00
export PROVE_GUARD_FAIL_PATTERN='^ - .* (does not permit|has no entry|applies an Android plugin)'
feat: module boundary guard, and two API-level bugs it uncovered checkModuleBoundaries holds the dependency tables in docs/architecture/README.md as a check: every module's permitted project dependencies, plus the rule that domain:cycle and domain:prediction must never apply an Android plugin. core:ads is already in the map with an empty permitted set, before the module exists — PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it constrains is one shaped around whatever exception somebody wanted at the time. It lists every violation rather than the first, and refuses to report a pass when it examined no modules at all. THE GUARD FAILED ITS OWN FIRST PROOF prove-guard.sh injected a forbidden dependency into :domain:prediction and the guard reported "7 modules checked, no violations". The root project is configured before its subprojects, so reading subprojects.configurations from the root script saw every configuration empty — it had been green over an empty map since the moment it was written, and would have been trusted for months. Collection moved into afterEvaluate, and the task now throws rather than passing if it ends up with no modules. Three proofs recorded in the architecture doc, all re-run and all red: a domain module reaching upward, :app reaching past the repository straight to Room, and a module with no rule being reported as unmeasured rather than assumed fine. TWO REAL BUGS FROM WIRING IT INTO `check` Running the whole check for the first time turned up Android lint errors that would have shipped: NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26) NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26) Both are on the recalculation path. On any device below Android 14 — most of the install base this app targets — that is a crash. Neither the unit tests nor the API 36 emulator could see it; lint is the only thing that could. Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26. Also cleared the lint warnings that were real: a redundant activity label, and a round launcher icon declared but never referenced. The two that remain are deliberate and now say so where the warning is read — targetSdk 36 is Play's floor and raising it opts into untested runtime behaviour, and the -v26 mipmap qualifier stays because removing it makes AAPT fail to resolve the icon at all. ./gradlew check now passes with 0 lint errors across all seven modules. 70 unit tests, all passing. closes #7
2026-08-18 03:00:11 -05:00
# 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.
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
## Data shapes
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
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
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 |
feat: repository layer, and stop backfilled history fabricating accuracy figures core/data is the seam between storage and everything else. Reads return domain types, cycles are derived rather than stored, and the forecast is a function of the data instead of a field somebody has to remember to refresh — so §11's "recalculate after a confirmed start, after an edit, after a Not yet" is automatic rather than three call sites. Confirming a period is four writes in one transaction, because a partial result is a corrupt history rather than a failed action: write the record, score the forecast that was standing, clear the "not yet" observations it resolved, and snapshot a fresh forecast. THE DEFECT THIS FOUND A test expecting one scored prediction found three. The cause was not the test: every historical period entered during onboarding was scoring the current forecast against a date in the past, inventing an error for a prediction nobody had ever been shown. §16's "your predictions are getting better" would have been populated with figures the app made up about itself — plausible ones, which is what makes it expensive to notice. Two rules now, both pinned by tests: - exactly one unscored snapshot exists at a time. A forecast superseded before its outcome was known is not a wrong forecast, and counting it lets one cycle contribute several errors. - a confirmed start only scores a forecast made on or before it. Anything earlier is backfill and leaves the standing forecast alone. Accuracy also stays quiet below three scored predictions. One lucky forecast reading "average error: 0 days" is an overstatement, not a measurement. THE ROOM BOUNDARY, HELD THREE WAYS implementation rather than api on core:database; CycleRepository's constructor internal because it names a PeriodDatabase; reads mapped to domain types in Mappers.kt. Callers use CycleData.repository(context) and never learn Room exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain is empty, and Room appears zero times in :app's debugCompileClasspath. No fallbackToDestructiveMigration: it turns a forgotten migration into a silent wipe of the user's entire cycle history on update. Also fixed: `domain/*` inside a KDoc silently opened a nested block comment — Kotlin block comments nest — which broke compilation in a way the error message pointed nowhere near. 58 tests across the project, all passing. closes #5
2026-08-18 02:41:05 -05:00
| `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 |
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
| `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: fertility that declines rather than stretches §17 and §18. Ovulation is estimated a luteal phase before the PREDICTED next period rather than counted forwards from the last one — the luteal phase is the stable half of the cycle, which is why §17 asks for it that way — and the fertile window opens five days before ovulation and closes one day after, because sperm survive and the egg does not. The uncertainty is inherited, not invented. Ovulation is derived from a predicted date, so it can never be more certain than that prediction. THE PART THE DEVICE TAUGHT The first version showed a user one cycle in a fertile window of 8 Aug – 24 Aug. Seventeen days. Arithmetically honest, and completely useless — over half a cycle, dressed up as a feature. So the estimate now returns null past a usable uncertainty, and Today says "Not enough history to estimate. Log a few more cycles and the app will be able to estimate ovulation." Three stable cycles later the same user gets 12 Aug – 20 Aug, which is worth reading. Verified in both states on a device. That is the same shape as PredictionAccuracy refusing figures below three scored forecasts and CycleInsights withholding an average below two intervals, and it is now written down in the architecture doc as a rule rather than three coincidences: the app declines rather than stretches. §18'S PROHIBITION IS A TYPE, NOT A CONVENTION FertilityLikelihood has LOWER, HIGHER and UNKNOWN and no fourth value. Somebody reading "safe" would take a decision on it; the estimate comes from a predicted date carrying days of uncertainty; and §18 has already promised this is not contraception. A test asserts no label contains a permission word, so adding one is a deliberate act with a failing test. The disclaimer travels with the feature — same screen, same time. A disclaimer one tab away is a disclaimer nobody read. ANOTHER GREYSCALE COLLISION The ovulation star was centred, which put it directly behind the numeral: in greyscale "18" and the mark merged into one smudge. Ovulation is now the fertile ring plus a small star low in the cell, which is also semantically right — that day IS inside the window, and the pair reads as "that window, and this day". Nine Compose ModifierParameter warnings fixed properly rather than suppressed. 164 tests, all passing. ./gradlew check green, 0 lint errors. closes #21 closes #22 closes #23
2026-08-18 14:56:05 -05:00
| `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 |
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
feat: reminders that stay quiet on a lock screen §28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus kind plus day count in, two versions of the text out — so every combination is tested exhaustively without an emulator. This is the one surface whose mistakes are visible to somebody who is not the user, so the tests are exhaustive rather than representative: every kind × every mode asserts that no health word reaches a lock screen outside Direct, and that includes the ACTION LABELS, which §31 points out are visible text too. A perfectly discreet body under a button reading "Started my period" leaks anyway. TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS A private notification with no public version does not blank the lock screen — it shows the private text. NotificationText therefore has no nullable title and an instrumented test asserts every kind attaches one. And a notification channel is IMMUTABLE after creation: importance and lock-screen visibility cannot be changed. One shared channel would have kept whatever the user's first privacy mode set, forever — switching from Direct to Maximum privacy would have appeared to work and changed nothing. There is now one channel per mode. Found by an instrumented test on a device; nothing in the unit tests could have seen it. §30's stopping rule is a test of its own: the app asks a bounded number of times, says "We'll stop checking for now. Log your period whenever it begins.", and then says nothing more — while the engine keeps learning, which is the sentence §30 puts right after it. WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions task fails the build if one ever appears in the merged manifest — from here or from a dependency. That guard also failed its own first proof, reading a stale manifest because it did not depend on the task that writes one. ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for and is the requirement most likely to be missed: a "Not yet" moves the forecast, so work queued against the old one is aimed at a day that no longer means anything. 188 unit tests and 6 instrumented, all passing. ./gradlew check green. closes #24 closes #25 closes #26 closes #27
2026-08-18 15:26:59 -05:00
### 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.
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. 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 the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both 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 test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
### The prediction engine
`PersonalPredictionEngine` (`modelVersion` `personal-1`) 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.
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.
feat: fertility that declines rather than stretches §17 and §18. Ovulation is estimated a luteal phase before the PREDICTED next period rather than counted forwards from the last one — the luteal phase is the stable half of the cycle, which is why §17 asks for it that way — and the fertile window opens five days before ovulation and closes one day after, because sperm survive and the egg does not. The uncertainty is inherited, not invented. Ovulation is derived from a predicted date, so it can never be more certain than that prediction. THE PART THE DEVICE TAUGHT The first version showed a user one cycle in a fertile window of 8 Aug – 24 Aug. Seventeen days. Arithmetically honest, and completely useless — over half a cycle, dressed up as a feature. So the estimate now returns null past a usable uncertainty, and Today says "Not enough history to estimate. Log a few more cycles and the app will be able to estimate ovulation." Three stable cycles later the same user gets 12 Aug – 20 Aug, which is worth reading. Verified in both states on a device. That is the same shape as PredictionAccuracy refusing figures below three scored forecasts and CycleInsights withholding an average below two intervals, and it is now written down in the architecture doc as a rule rather than three coincidences: the app declines rather than stretches. §18'S PROHIBITION IS A TYPE, NOT A CONVENTION FertilityLikelihood has LOWER, HIGHER and UNKNOWN and no fourth value. Somebody reading "safe" would take a decision on it; the estimate comes from a predicted date carrying days of uncertainty; and §18 has already promised this is not contraception. A test asserts no label contains a permission word, so adding one is a deliberate act with a failing test. The disclaimer travels with the feature — same screen, same time. A disclaimer one tab away is a disclaimer nobody read. ANOTHER GREYSCALE COLLISION The ovulation star was centred, which put it directly behind the numeral: in greyscale "18" and the mark merged into one smudge. Ovulation is now the fertile ring plus a small star low in the cell, which is also semantically right — that day IS inside the window, and the pair reads as "that window, and this day". Nine Compose ModifierParameter warnings fixed properly rather than suppressed. 164 tests, all passing. ./gradlew check green, 0 lint errors. closes #21 closes #22 closes #23
2026-08-18 14:56:05 -05:00
### 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.
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 |
feat: guard §45's logging rules, and stop the leak that needed no log call closes #38 checkNoHealthLogging fails the build on any logging call in a module that can see a cycle date. It runs in `./gradlew check`. WHY IT IS A GUARD AND NOT A GREP Both traps were already live in this repository. PeriodApplication passes android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call. ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives", explaining why there isn't one — a naive grep fails the build on the clearest possible explanation, and the obvious fix is to delete the explanation. So it matches a call shape, and strips comments first. Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository produced exactly one failure; a comment containing Log.d( and println( stayed green. It also failed its own first run by walking domain/*/bin/, a gitignored IDE output holding stale copies of test files — a guard that fails on untracked build output is one somebody switches off. THE LEAK IT WAS NOT LOOKING FOR Prediction's init block interpolated dates into its require messages: require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." } Five predicted dates across three messages, inside an IllegalArgumentException — the one string a crash reporter collects without anybody choosing to log it. §45 forbids exactly this and no logging statement was involved. The same applies to every data class, since toString() renders every field into any string that touches it. PeriodRecord, SpottingRecord, CycleRecord, Prediction and NotYetObservation now override it: ids and cycle lengths survive, dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to fail. R8 -assumenosideeffects strips android.util.Log from release, covering what a source guard cannot reach: a dependency logging on our behalf, and a module added without being listed in the guard. VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT assembleRelease signed with the debug keystore, installed, driven from onboarding to a forecast and then logging a period: zero ISO dates in logcat, zero health words, and the only mentions of the package are the system's own. A screenshot confirms it reached a real forecast, because "no logs" is trivially true of an app that did nothing. 201 tests pass. All three guards green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
| `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 |
feat: export my data, as one plaintext file the user places "Your cycle belongs to you" was a promise the app could not keep: there was no way to get the data out. ## The format, because it outlives the batch One pretty-printed JSON file. The issue asks for "human-readable" and means it — this is what somebody's archive will be in for years, so it is a contract with eight rules written down beside it, not an implementation detail. Pinned byte-for-byte against a committed golden file, which doubles as the documented example so SECURITY.md links at it rather than keeping a second copy that would drift. A reformat, a reordered key or a changed date rendering all fail in a test rather than in an archive. Dates are ISO calendar dates with no timezone and no conversion, ever. Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident", and a zone-aware formatter here would shift every date for users east or west of whoever wrote it — a cycle tracker off by one day is wrong in the way that matters. There is a test that renders the same fixture in UTC, +14 and -12 and requires identical bytes, because that bug would never fire where it was written. ## Plaintext, and that is the decision rather than the default An earlier note said this would be encrypted. It should not be, and SECURITY.md now says why: the export is the copy that makes a lost Keystore key survivable instead of final — the exact condition recorded for ever revisiting database encryption. Putting it behind a passphrase reproduces the failure that decision was taken to avoid: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to backup, which this is not. ## Only the user's own data, as a compile error :core:export is pure JVM and depends on :domain:cycle alone. Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord live in :domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps android.os.Build off it too — so a forecast or a device fact cannot be added by accident. The key set is asserted with assertEquals rather than contains, so a new field is a failing test rather than a silent addition. Row ids are out because they are monotonic and would disclose how many records the user DELETED. The Play entitlement is out because a purchase one file-edit away from being granted is a purchase that will be. ## No second copy, ever The Storage Access Framework writes straight into the document the user picked. The alternative — write to cacheDir, share by FileProvider, delete after — creates the temporary file the issue warns about and races the receiving app still reading it. A test walks cacheDir after a successful export and requires it empty; it fails the moment anybody reintroduces that pattern. The destination is parked until the session is unlocked. Returning from the picker can re-lock, and writing while locked would hand the whole history to whoever took the phone during the save dialog. ## Two new guards, both proved to fail checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody having typed it. checkPermissions structurally cannot see it — it matches <uses-permission>, and a <provider> declaring FileProvider merges green. checkNoHealthLogging gains a completeness check. A module missing from modulesSeeingHealthData was silently exempt with a green build, which app/proguard-rules.pro has described as a hazard since before :core:security and :core:export existed. Every module must now be in that list or in an explicit modulesWithNoHealthData with its reason; being in neither is a violation rather than an exemption. 261 JVM tests, none skipped. Five guards green. closes #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
| `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 |
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.