# Security — Privacy: Period Tracker

```
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
                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
```

## 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
  estimates, calendar, insights and notification scheduling. **No
  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.
- Biometric/PIN gating is **Batch 06 and not built** — nothing gates app launch
  today, and `UserPreferences.biometricLockEnabled` in `core/datastore` is a
  persisted flag that nothing outside that module acts on yet. When it lands, any
  secret backing it is Android Keystore-backed — never a value in DataStore.
- **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.
- **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.

## 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 |
| --- | --- | --- |
| 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 |
| 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.

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
[`../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.

**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.

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.
- **Forensic recovery of deleted rows.** Delete My Data removes the data through
  the database; it does not overwrite flash.
- **Someone who knows the unlock PIN.** Biometric/PIN gating raises the bar over
  an unlocked phone; it does not defend against a person the user has given
  access to. The incognito launcher option ([§32](../planning/PRODUCT_PLAN.md),
  not built yet) is the answer to the adjacent problem —
  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.
