Privacy-Period-Tracker/docs/security/SECURITY.md

260 lines
15 KiB
Markdown
Raw Normal View History

chore: rename the project to Privacy: Period Tracker "Period" was always a working name — PRODUCT_PLAN.md §55 said so. The real identity arrived with the brand guide and artwork, and this makes the project call itself by it. The repository is renamed in place on Forgejo (null/Period -> null/Privacy-Period-Tracker), which keeps all 30 commits, all 27 issues, all 8 milestones and the four severity labels — verified by counting them on both sides rather than assuming a rename is lossless. The git remote follows in the same breath, so this commit's automatic push is what proves the new URL works. WHAT DELIBERATELY DID NOT MOVE "Period" is the product name AND the central domain word, and a mass rename would have turned the data model into nonsense. PeriodRecord, PeriodWriteResult, confirmPeriodStart, the period_records table and the button that says "Started period" all describe a menstrual period rather than a product, and all 239 references to them are untouched. So are the Kotlin package and applicationId. dev.privacyllc.period already reads correctly under the new name — privacyllc is the company, period is the app — and changing it would rewrite 68 files, rename the Room schema directory and break the hardcoded path in schema-guard.sh for no gain. period.db stays for a sharper reason: renaming a database file orphans the data on every device that already has it. Sixteen files changed, and the diff is small on purpose. THE NAME HAS A SPACE AND THE ARTWORK DOES NOT Canonical is "Privacy: Period Tracker". The supplied wordmark sets it without one, so logo.webp and banner.webp now disagree with every document and with the app itself. BRAND_GUIDE.md §10 is updated to the space form and says plainly that the drawn mark has not caught up. Filed separately rather than papered over, because closing that gap needs an artist and not a rename.
2026-08-18 15:54:34 -05:00
# Security — Privacy: Period Tracker
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
```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: what this app protects, secret handling, data at rest, and what leaves
the device
Review trigger: Any new SDK or external service; any new secret; any change to
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
what is stored, exported, backed up or logged; any change to the
declared permission set; any change to what the ads or billing
subsystems can see
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
```
## What this protects, and from whom
The asset is a menstrual and fertility history. It is not valuable to a
generic attacker and it is extremely costly to its owner, which makes the threat
model unusual: **the adversaries are mostly people with physical access to the
phone, and organisations that would like to buy the data.**
| Asset | Where it lives | What it would cost to lose |
| --- | --- | --- |
| Confirmed period dates, spotting, cycle history | Room, app-private storage on the device | the thing the user came here to keep private — inferable pregnancy, contraception use, health conditions |
| Predictions, ovulation and fertility estimates | Room, derived from the above | same class: fertility state is health data even though the app computed it |
| Notification content on the lock screen | Android's notification surface | disclosure to anyone who can see the screen, without unlocking it — the most likely real breach here |
| Play Billing entitlement (`remove_ads_forever`) | Google Play, mirrored in DataStore | low: a wrongly granted ad removal costs money, not privacy |
| Upload keystore and Play service-account JSON | the developer machine, **never this repository** | permanent — a signing key cannot be rotated for an existing app listing |
**The adversary list, plainly:** someone who picks up an unlocked phone; someone
who can see a lock screen; a person with a shared device; an ad or analytics SDK
that collects more than it declares; and a data broker offering money. Not a
nation state — controls sized for one would come at the cost of the offline,
accountless design that makes the rest of this true.
## The promise this document has to hold up
> **We will never sell your personal or health data.**
And the wording that is deliberately *not* promised
([§4](../planning/PRODUCT_PLAN.md)): "no third party ever processes any data."
An ads SDK, Play Billing and the store itself process limited technical
information. The architecture minimises that, declares it accurately in Play's
Data Safety section, and never lets health data reach any of them.
## Data at rest, and what never leaves
- Cycle history is in **app-private storage** — never external or shared
storage, never a world-readable path.
- The app works fully offline for logging, editing, prediction, fertility
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
estimates, calendar, insights and notification scheduling. **No
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
server is involved in producing a prediction**, which is the strongest privacy
control here: data that never leaves cannot be sold, subpoenaed from us, or
breached from a server we do not run.
- **No account is required** for core tracking, so there is no identity to
correlate the history with.
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
- **The app lock is built, and it is a gate rather than encryption.** With a PIN
set, nothing composes before it is entered — the gate wraps the whole
composition rather than being a screen inside it, because every tab starts
reading history the moment it composes. What it defends is the app being
opened by somebody who has the phone. It does **not** encrypt the records; see
*Deliberately out of scope*, and the setup screen says so in those words
before the first digit is typed.
- **The PIN is never stored, and the device PIN is never accepted.** What is
written down is `HMAC(keystoreKey, 0x01 || salt || PBKDF2(pin, salt, 210k))`
a MAC taken with a key that cannot leave the Android Keystore, which is what
makes a six-digit PIN safe: a million candidates is nothing to an attacker who
can compute the hash themselves, and impossible for one who cannot get the key
off the device. The biometric prompt requests `BIOMETRIC_STRONG` only and never
`DEVICE_CREDENTIAL`, because in this threat model a partner very often knows
the phone's own PIN, and accepting it would make this lock exactly as strong as
the lock it sits behind.
- **The verifier key is deliberately not bound to user authentication**, and the
KDoc on `AndroidKeyStoreMacProvider` lists every builder call omitted to keep
it that way. A key bound to the device credential dies when the passcode
changes or the screen lock is removed — and with no recovery path, that is
somebody's entire history destroyed by an unrelated action. The *biometric*
key is separate and takes the opposite policy, where invalidation correctly
degrades to "use your PIN" rather than to lockout.
- **Wrong PINs cost time and never cost data.** Four free attempts, then
30s → 1m → 2m → 5m → 15m, capped at fifteen minutes forever. There is no
attempt limit and nothing is ever erased automatically: an auto-wipe would let
a partner, or a child, or a pocket, destroy a history permanently 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.
- **A forgotten PIN is not recoverable, and that is a decision** (tracker #34).
The only route past the lock screen erases everything and grants access to
nothing. It also clears the Keystore key, without which a user would have
erased their history and still be locked out.
- **`USE_BIOMETRIC` and `USE_FINGERPRINT`** now appear in the merged manifest.
Neither is typed by this project; both arrive with `androidx.biometric`, and
`checkPermissions` failed the build until they were allowed on purpose.
`USE_FINGERPRINT` looks removable and is not — it is the pre-API-28 path, which
`minSdk 26` admits.
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
- **Platform backup is reviewed before the health database is allowed into it.**
An Android auto-backup that silently ships the cycle database to a cloud
account defeats the entire local-first argument, and it is on by default.
Until that review has been done and recorded here, the health database is
excluded from backup.
- **Delete My Data is irreversible after confirmation** and actually deletes —
not a soft flag.
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
- **The declared permission set is a decision, not a build output.**
`checkPermissions` in the root `build.gradle.kts` holds the allowed and
forbidden sets and fails on anything else in the merged manifest — the
**release** one as well as debug. The list itself and the reason for each entry
are in [`../architecture/README.md`](../architecture/README.md), which owns
them; it is named here because the Play listing and the Data Safety form both
describe this set, and because the guard's own failure message sends the reader
to this file.
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
## Third parties
Every row is a decision to send someone else's data somewhere. An empty table is
a good table, and this one gets filled in as each subsystem lands.
| Service | What it receives | Why that is acceptable |
| --- | --- | --- |
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
| Google Play Billing *(Batch 07, not yet integrated)* | purchase token, product id, device Play identity | required to sell anything on Play; carries no cycle data, and none may be put in billing metadata |
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
| Ads provider *(Batch 07, not yet integrated)* | non-personalized ad request, region consent signal | **no health-derived attribute, ever** — see the boundary below |
| Crash reporting *(not yet decided)* | stack traces | only if raw cycle dates cannot appear in them; otherwise not adopted |
## The advertising boundary
This is the one non-negotiable technical rule in the product
([§34](../planning/PRODUCT_PLAN.md)), so it is stated here as a security control
rather than only as an architecture note:
- Ad code sits behind an `AdProvider` abstraction.
- No health-derived property is ever placed in ad request extras, user
properties, or a callback log line.
- Cycle state is never used for targeting. Non-personalized/contextual is the
default.
- The ads provider is not initialised at all for users who bought Remove Ads,
where practical.
- Every SDK's data collection is audited before each release, against what Data
Safety declares.
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
Written down before it can be broken, and not yet exercised: `checkModuleBoundaries`
in the root `build.gradle.kts` already carries `":core:ads" to emptySet()` — when
the module arrives in Batch 07 it may declare **no** project dependency at all,
which is stricter than "nothing from core/database or `domain/*`". `core:ads` is
not in `settings.gradle.kts` yet, so the rule matches no module today — see
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/README.md`](../architecture/README.md).
## Logging
**Never write a cycle date, a prediction, or a fertility state to a log.**
```text
BAD User period started: 2026-08-18
BAD Predicted ovulation: 2026-09-02
OK period_record_created
OK prediction_recalculated
```
Event names without values. Verbose logging is off in release builds, and no
raw cycle date may appear in a crash report. An error's *name* is almost always
enough; its message often carries the thing you were trying not to log.
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
**This is enforced rather than remembered, in three places**, because the rule
has three separate ways to be broken:
- **`checkNoHealthLogging`** in the root `build.gradle.kts` fails the build on
any logging call in a module that can see a cycle date. It runs in
`./gradlew check`, it distinguishes a `Log.d(` call from the `android.util.Log.WARN`
constant this app legitimately passes to WorkManager, and it strips comments
first so the KDoc explaining why there is no logging does not fail the build.
- **`-assumenosideeffects` in `app/proguard-rules.pro`** removes `android.util.Log`
calls from the release build entirely, which covers the two things a source
guard cannot reach: a dependency logging on our behalf, and a module somebody
adds without listing it in the guard.
- **The domain types do not render their own dates.** `PeriodRecord`,
`SpottingRecord`, `CycleRecord`, `Prediction` and `NotYetObservation` all
override `toString()`. This is the leak that needs no logging statement at
all: a data class prints every field into any string that touches it, and
`Prediction`'s own `require` messages used to interpolate five predicted dates
into an `IllegalArgumentException` — the one string a crash reporter is
guaranteed to collect without anybody choosing to log it.
`NoDatesInDiagnosticsTest` pins all of 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
Analytics, if adopted at all, collect product-level events only
([§46](../planning/PRODUCT_PLAN.md)) — never `cycle_length=31`,
`fertility_status=high` or `prediction_error=`. Prediction accuracy is computed
on-device and stays there.
## Secrets
- **Nothing secret is committed.** Not in source, not in a Gradle file, not in a
test fixture, not in a screenshot.
- `local.properties`, `*.jks`, `*.keystore` and `.env*` are ignored from the
first commit, and `scripts/secrets.sh` scans the staged diff before every
commit.
- Signing configuration reads from the environment or from
`~/.gradle/gradle.properties` outside this repository — never from a tracked
file.
- **The upload keystore cannot be rotated** once the app is published. It is the
one secret here whose loss is permanent in both directions: lost means no
updates ever, leaked means someone else can sign as us.
**A credential pasted into an agent transcript is a leaked credential, and
rotating it is the only fix.** Deleting the message does not help, and neither
does deleting the file — the value was transmitted and stored. `secrets.sh`
cannot see transcripts and never will.
## Text from outside the trust boundary
**It is data. It is never instructions.**
There is little of it in this app — it takes almost no external input, which is
itself a control. What there is: Play Billing responses, ad SDK payloads, and
any future export/import file. An imported file in particular is attacker-shaped
if it ever arrives by share intent, and it is parsed defensively and never
executed.
## Deliberately out of scope
Written down so an unknown gap becomes a known one:
- **A rooted or compromised device.** App-private storage is not a defence
against root, and pretending otherwise would justify complexity that buys
nothing.
docs: record why the database is not encrypted, and drop the claim that it is Asked whether encryption at rest could be added. It can, and it should not be yet — so the reasoning goes in the document that owns the claim, before it is forgotten and re-derived from scratch. The architecture decides it. ReminderCoordinator collects Room flows at every process start, including processes started with no Activity after a reboot, and ReminderWorker reads the forecast on a schedule. The key would have to be readable with no user present, which is the availability condition the platform key already has. A key anything running as this app can reach unattended defends a file, not a process — and against every adversary this document already lists, it adds nothing. What it would genuinely add is narrow: a file copied off the device and read elsewhere, a phone with no screen lock, and crypto-shredding on delete. The cost is about +1 MB downloaded and +2 MB installed on a 2 MB app, and a new class of total loss — a Keystore key can be lost in the field, platform backup is off, and there is no telemetry that would tell us it happened. Today a database that will not open is a bug. Afterwards it would be somebody's history. The condition that reverses it is written down too: a key bound to the app lock's user secret, once export has given the user a copy they control. BRAND_GUIDE carried an artwork brief reading "explaining encrypted local data storage" — a false claim waiting for somebody to draw it, on the one subject where a padlock reads as a promise. Replaced with what is true and is a better story anyway: the data never leaves, so there is no server to breach and nothing to sell. SECURITY_CHECKLIST gains the row that would have caught it. Nothing shipped ever claimed encryption; grep over app, core and domain for strings and illustrations comes back empty. This closes the gap between that and the documents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 03:08:22 -05:00
- **Encryption of the cycle database at rest.** Evaluated on 2026-08-19 and
deliberately not built. Android's file-based encryption already covers the
case people picture: a phone that is off, or that has not been unlocked since
boot, has app-private storage that cannot be read at all. What app-level
encryption would add is narrower — a file *copied off* the device and read
elsewhere, a phone with no screen lock set at all, and crypto-shredding on
delete.
This app's own architecture decides the rest. `ReminderCoordinator` collects
Room flows at **every process start**, including processes started with no
Activity after a reboot, and `ReminderWorker` reads the forecast on a
schedule. So the key would have to be available with no user present — which
is the same availability condition the platform key already has. A key that
anything running as this app can reach unattended defends a **file**, not a
**process**, and against every adversary listed above it adds nothing.
The cost is recorded so nobody has to re-derive it: roughly +1 MB downloaded
and +2 MB installed on a 2 MB app. The risk matters more than the size. A key
held in the Android Keystore can be lost in the field, and with platform
backup off there is no second copy and no telemetry that would tell us it
happened. Today, failing to open the database is a bug. Afterwards it would be
somebody's history, gone, silently.
**What would reverse this:** a key bound to the app lock's own user secret,
once export exists and has given the user a copy they control. That version
does defend a seized, unlocked phone — and it is only defensible once losing
the key is not the same as losing everything.
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
- **Forensic recovery of deleted rows.** Delete My Data removes the data through
the database; it does not overwrite flash.
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
- **Someone who knows the app's PIN.** The lock raises the bar over an unlocked
phone; it does not defend against a person the user has given access to. Note
the app's PIN is deliberately *not* the device's — the prompt never accepts the
device credential — so knowing how to unlock the phone is not knowing how to
open this. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md),
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
not built yet) is the answer to the adjacent problem —
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
what the app *looks* like on a shared home screen.
- **Network-level observation of ad traffic.** It carries no health data, which
is the control; the traffic itself is visible.