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>
This commit is contained in:
null 2026-08-18 16:33:14 -05:00
parent b338380cb2
commit ad085fb4ce
19 changed files with 284 additions and 109 deletions

View File

@ -4,7 +4,7 @@
Status: Draft
Owner: _null
Last reviewed: 2026-08-18
Governs: README.md as the project-facing overview for Period
Governs: README.md as the project-facing overview for Privacy: Period Tracker
Review trigger: The first buildable feature release; any change to the stack, the
privacy promise, or how the project is built and run
```
@ -45,9 +45,13 @@ Two promises hold the product up:
> information, ovulation estimates, cycle predictions or personally identifiable
> information — to advertisers, data brokers or anyone else.
The second is enforced structurally, not remembered: health data cannot reach
the advertising subsystem, because the module that will hold ad code declares no
dependency on the cycle database or the prediction domain, and a guard proves it.
The second is held structurally rather than remembered, and the rule was written
before the code it constrains: `checkModuleBoundaries` in the root
`build.gradle.kts` already carries `":core:ads" to emptySet()`, so when the ads
module arrives in Batch 07 it may declare **no** project dependency at all —
stricter than "nothing from the cycle database or the prediction domain". It is
not in `settings.gradle.kts` yet, so today the rule matches no module, which is
why the guard is proved against injected violations rather than trusted.
What it deliberately is **not** — no community, no pregnancy mode, no chatbot,
no article feed, no symptom encyclopedia, and not contraception — is in
@ -55,28 +59,31 @@ no article feed, no symptom encyclopedia, and not contraception — is in
## Status
**Skeleton.** There is no usable app yet, and this section says so rather than
listing features that do not exist.
**Five of the eight batches are done.** Foundation, prediction engine, core UX,
fertility and notifications have landed; privacy and security (Batch 06) is the
next work. Every row below cites the file or test that proves it.
| Surface | Status | Evidence |
| --- | --- | --- |
| Documentation tree and tracker convention | Adopted | this tree; milestones and issues in the tracker |
| Gradle / Kotlin / Compose project | Builds | `./gradlew assembleDebug` and `assembleRelease` both pass |
| Material 3 theme and tokens | Built | `core/designsystem` |
| Four-tab navigation shell | Built, placeholder screens | `app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt` |
| Cycle model and interval derivation | Built | `domain/cycle`, 5 tests |
| Prediction engine | **Baseline only** | `domain/prediction`, 12 acceptance tests from PRODUCT_PLAN §51 |
| Room persistence, DataStore | Not built | Batch 01 |
| Onboarding, Today, Calendar, Insights | Not built | Batch 03 |
| Fertility, notifications, privacy features, monetization | Not built | Batches 0407 |
| QA | No round run | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) |
| Four-tab navigation shell | Built | `app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt` — Today, Calendar, Insights, Settings, each wired to its real screen |
| Cycle model and interval derivation | Built | `domain/cycle` (13 tests); `domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/IntervalAnalysis.kt` (12 tests) |
| Prediction engine | Built — personalized | `PersonalPredictionEngine`, wired in `app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt`; the 12 acceptance tests from PRODUCT_PLAN §51 run against both engines (`PersonalAcceptanceTest`, `BaselineAcceptanceTest`), and `EngineComparisonTest` scores the personal engine against the retained baseline every build |
| Room persistence, DataStore | Built | `core/database` (schema v1 exported, `SchemaTest` + `scripts/schema-guard.sh`), `core/datastore`, `core/data` |
| Onboarding, Today, Calendar, Insights | Built | `app/src/main/kotlin/dev/privacyllc/period/feature/` — onboarding, today, calendar, insights; `OnboardingViewModelTest`, `TodayViewModelTest`, `TodayUiStateTest` |
| Fertility window and ovulation estimate | Built | `domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt`, 11 tests; shown on Today and Calendar |
| Discreet notifications | Built | `core/notifications` (24 JVM tests), instrumented `NotificationPrivacyTest` |
| App lock, export, irreversible delete, monetization | Not built | Batches 0607 |
| QA | Round 3 run, partial | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) — partial at `0451fbe`; TalkBack, text scaling, `minSdk` and a real locked screen still unreached |
`BaselinePredictionEngine` is a robust-median prototype and is **not the
product**. PRODUCT_PLAN §11 names a plain average as an acceptable prototype and
an unacceptable final engine; Batch 02 replaces it with the recency-weighted,
trend-aware, "not yet"-conditioned engine §12 specifies. The acceptance tests
exist now so that replacement can be shown to be better rather than merely
different.
`BaselinePredictionEngine` is still in the tree, but it stopped being the
product in Batch 02. `DataModule` provides `PersonalPredictionEngine` — the
recency-weighted, trend-aware, "not yet"-conditioned engine PRODUCT_PLAN §12
specifies — and the baseline is now the control: `EngineComparisonTest` scores
both over the §51 fixtures every build, so "the new engine is better" is a number
rather than an opinion.
## Build and run
@ -104,13 +111,17 @@ prevent. `post-commit` pushes; see
## Repository map
```text
app/ application module, MainActivity, navigation shell
app/ application module, MainActivity, navigation shell, DI
core/designsystem/ Material 3 theme and colour tokens
core/database/ Room entities, DAOs, converters, the exported schema
core/datastore/ user preferences that are not health history
core/data/ CycleRepository — the only module that touches a DAO
core/notifications/ reminder copy, privacy modes, WorkManager scheduling
domain/cycle/ pure Kotlin — period records, cycle derivation
domain/prediction/ pure Kotlin — the forecast, window and confidence
scripts/ the six template scripts this project adopted
scripts/ six from the template, plus schema-guard.sh, our own
.githooks/ pre-commit, commit-msg, post-commit
docs/ product, architecture, design, QA, security, history
docs/ product, architecture, design, brand, QA, security, history
```
`domain/*` are `kotlin("jvm")` modules and cannot see the Android SDK. That is

View File

@ -43,6 +43,7 @@ disagree, and nothing will say which one is right.
| What is a QA round? | [`qa/ClaudeQAPlan.md`](qa/ClaudeQAPlan.md) |
| How is it built — modules, boundaries, data shapes, migrations? | [`architecture/README.md`](architecture/README.md) |
| What should it feel like, and what words does it use? | [`design/README.md`](design/README.md) |
| What colour, logo or type do I use? | [`design/BRAND_GUIDE.md`](design/BRAND_GUIDE.md) — it outranks [`planning/PRODUCT_PLAN.md`](planning/PRODUCT_PLAN.md) §39 where they differ |
| What is protected, from whom, and what must never be logged? | [`security/SECURITY.md`](security/SECURITY.md) |
| What is checked before a Play release? | [`security/SECURITY_CHECKLIST.md`](security/SECURITY_CHECKLIST.md) |
| Which script do I run, and can it stop me? | [`TOOLS.md`](TOOLS.md) — the signpost; [`architecture/README.md`](architecture/README.md) has the table |
@ -80,10 +81,10 @@ Where the two disagree, `PRODUCT_PLAN.md` is the specification and
| `docs/planning/` | `PRODUCT_PLAN` — the V1 specification. `PROJECT_PLAN` — the vision. Neither is the schedule; that is the tracker. |
| `docs/qa/` | `ClaudeQAPlan` (playbook, passes AH), `ClaudeQACoverage` (what each pass reached), `ClaudeReport` (the verdict) |
| `docs/architecture/` | modules, boundaries, data shapes, the migration table; `GUARDS.md`; `githooks/README.md` |
| `docs/design/` | tone, the four rules that settle arguments, and which specification sections own each surface |
| `docs/design/` | `README.md`tone, the four rules that settle arguments, and which specification sections own each surface. `BRAND_GUIDE.md` — the owner-supplied visual identity, which outranks `PRODUCT_PLAN.md` §39 on colour. `brand/` — the source PNGs the marks were cut from |
| `docs/security/` | `SECURITY` — threat model, the advertising boundary, logging rules. `SECURITY_CHECKLIST` — the pre-release list |
| `docs/history/` | `DEVELOPMENT_LOG` (dated, append-only), `HISTORY` (decisions and their reasons), `BATCH_LEDGER` (archived) |
| `docs/data/` | the three branding marks privacyllc.dev renders — **currently absent, tracked as issue #8** |
| `docs/data/` | `README.md` — the rule, and why this folder cannot be exempted. `img/README.md` — the three required names, their dimensions and the 512 KB ceiling. `img/icon.webp`, `img/logo.webp`, `img/banner.webp` — the marks privacyllc.dev renders |
| `docs/` root | this map; `WORK_CYCLE`; `TOOLS` |
`README.md` stays at the repository root; it is the landing page and moving it
@ -96,7 +97,7 @@ look identical from outside, and only one of them is fine.
- **docs/OPERATIONS.md — deleted.** Named without backticks, because
`doc-claims.sh` reads a backticked path as a claim the file is there and this
one says the opposite. Period is an offline-first Android app
one says the opposite. Privacy: Period Tracker is an offline-first Android app
distributed through Google Play. There is no host, no container, no uptime and
no restore path of ours. An empty runbook reads as one nobody wrote rather
than one that never applied.
@ -111,7 +112,7 @@ look identical from outside, and only one of them is fine.
`doc-claims.sh` noting a document that mentions a script this project does not
have is expected, not a failure.
- **A master copy of the hooks under `docs/architecture/githooks/`.** The
template keeps one and installs copies; Period keeps only `.githooks/`,
template keeps one and installs copies; this repository keeps only `.githooks/`,
because `pre-commit` here is adapted for Gradle and a second copy would drift.
- **No `Exempt:` declarations.** Every document this convention asks for is
either present in git or listed above as deliberately deleted.
@ -184,12 +185,13 @@ Exempt: <the path> — <why, in a few words>
and the checker will report that document as deliberately absent. Keep
illustrations fenced, and use placeholders anyway, as the form above does.
Period currently declares none. Where a document is genuinely not applicable it
This repository currently declares none. Where a document is genuinely not applicable it
has been **deleted** and recorded above instead, which is the honest form: an
exemption says "kept elsewhere", and `OPERATIONS.md` is not kept anywhere.
**`docs/data/` and `docs/data/img/` cannot be exempted.** A declaration naming
either is refused rather than honoured, because the Command Center renders what
is in them — an exemption would produce a project card with no icon and nothing
explaining why. Their absence here is a filed issue, which is the mechanism that
gets them drawn.
explaining why. If one of them is ever missing, the mechanism is a filed issue —
[`data/README.md`](data/README.md) says what to file and why a placeholder is
worse than a gap — never an exemption.

View File

@ -23,21 +23,34 @@ A second table here would be two records of one fact, and the other one would
never hear that a script was renamed. So this file answers the questions that
table does not, and points at it for everything else.
## Period has seven of them, and that is deliberate
## This project has seven of them, and that is deliberate
The template this repository adopted ships around twenty scripts. Period took
six of them and wrote one of its own. `scaffold.sh` copies none of them on purpose — *"an unconfigured
`release.sh` landing in every new repository is a loaded gun, not a head
start"* — so each one is taken having been read and configured.
The template this repository adopted ships around twenty scripts. Privacy:
Period Tracker took six of them and wrote one of its own. `scaffold.sh` copies
none of them on purpose — *"an unconfigured `release.sh` landing in every new
repository is a loaded gun, not a head start"* — so each one is taken having been
read and configured.
**The ones not taken were not forgotten.** `release.sh`, `deploy.py`,
`backup.sh`, `restore-check.sh`, `preflight.sh`, `healthcheck.sh`, `migrate.sh`,
`status.sh`, `dev.sh`, `audit-gate.mjs`, `release-notes.mjs`, `duplication.py`
and `dead-code.py` all assume a deployed Node or Postgres service with an npm
dependency tree and a URL. Period is an Android app that ships through Google
Play and has no server at all. A release here is an AAB and a Play Console
submission, so [`security/SECURITY_CHECKLIST.md`](security/SECURITY_CHECKLIST.md)
carries what `release.sh` would have gated.
`status.sh`, `dev.sh`, `audit-gate.mjs` and `release-notes.mjs` all assume a
deployed Node or Postgres service with an npm dependency tree and a URL.
Privacy: Period Tracker is an Android app that ships through Google Play and has
no server at all. A release here is an AAB and a Play Console submission, so
[`security/SECURITY_CHECKLIST.md`](security/SECURITY_CHECKLIST.md) carries what
`release.sh` would have gated.
`duplication.py` and `dead-code.py` need no server, but both read a JavaScript or
TypeScript source tree: `dead-code.py` walks an export graph and excludes
Next.js entry points, and `duplication.py` does not scan `.kt` at all. Either
would have to be adapted before it said anything true about Kotlin, so neither is
taken yet.
`controls.sh` reports which operational controls a project has — backups, a
proven restore, somewhere errors go, an environment that is not production — and
exits `2` until some are declared expected. An app with nothing running has none
to declare, so taking it would install a check that could only report that it did
not run.
Two were deferred rather than declined:
@ -45,7 +58,7 @@ Two were deferred rather than declined:
Today `./gradlew check` is the whole answer and wrapping it would add a layer
that could only be wrong.
- **`check-env.sh`** — its SPEC ships empty and exits `2` until it has entries,
and Period has no environment variables yet. It gets taken with the first
and this project has no environment variables yet. It gets taken with the first
signing or Play credential, which is exactly the moment it becomes worth
running.

View File

@ -208,8 +208,8 @@ is* — one copy, named once:
```bash
set -a; . ~/.openclaw/docker-registry.env; set +a
python3 docs/architecture/scripts/forgejo-issue.py list
python3 docs/architecture/scripts/forgejo-issue.py close 42 "Fixed in a1b2c3d; tests/foo.test.js covers it."
python3 scripts/forgejo-issue.py list
python3 scripts/forgejo-issue.py close 42 "Fixed in a1b2c3d; PredictionAcceptanceTest covers it."
```
Step 7 talks to the Command Center instead:
@ -262,7 +262,7 @@ one, and never report progress the API did not return.
- **Not the work list.** That is the tracker: milestones are batches, issues are
deliverables.
- **Not the release procedure.** Period has no `release.sh` — a release here is
- **Not the release procedure.** This project has no `release.sh` — a release here is
a signed AAB and a Play Console submission, so
[`security/SECURITY_CHECKLIST.md`](security/SECURITY_CHECKLIST.md) carries it.
- **Not the QA procedure.** That is `docs/qa/ClaudeQAPlan.md`, which ends in its

View File

@ -4,8 +4,9 @@
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/architecture/**, the Gradle module graph, and the data shapes that
outlive a function
Governs: docs/architecture/**, settings.gradle.kts, build.gradle.kts,
core/database/**, domain/** — the Gradle module graph and the data
shapes that outlive a function
Review trigger: Any new Gradle module, any change to a module boundary, any change
to a Room entity or a DAO, any new Room migration, any dependency
added to a domain/* module
@ -30,14 +31,14 @@ function calls. Nothing below the ViewModel knows Compose exists.
## Modules
Seven today — a module created before it has contents is a place
Eight today — a module created before it has contents is a place
for things to be put by accident. The wider layout sketched in
[`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same
way, with the batch that needs it.
| Module | Plugin | Owns | May depend on |
| --- | --- | --- | --- |
| `app` | Android application | `MainActivity`, the four-tab navigation shell, DI wiring | everything below |
| `app` | Android application | `MainActivity`, the four-tab navigation shell, DI wiring | `core/designsystem`, `core/data`, `core/datastore`, `core/notifications`, `domain/*` — never `core/database` |
| `core/designsystem` | Android library | Material 3 theme, colour and type tokens | nothing in this project |
| `core/database` | Android library | Room entities, DAOs, converters, the schema export | `domain/cycle`, `domain/prediction` |
| `core/datastore` | Android library | `UserPreferences` and the settings that are not health history | nothing in this project |
@ -187,8 +188,9 @@ for months.
## Data shapes
Defined in `domain/cycle` as plain Kotlin, and mirrored by Room entities in
core/database once issue #3 creates it. The full field lists are
Defined in `domain/cycle` and `domain/prediction` as plain Kotlin, plus
`UserPreferences` in `core/datastore`, and mirrored by Room entities in
`core/database`. The full field lists are
[`PRODUCT_PLAN.md` §10](../planning/PRODUCT_PLAN.md); what matters here is why
each exists and what must not happen to it.

View File

@ -17,8 +17,8 @@ exactly one clone on exactly one machine.
## The hooks are in `.githooks/`, and only there
The template this repository adopted keeps a master copy under
`docs/architecture/githooks/` and installs copies into `.githooks/`. **Period
does not**, deliberately: `pre-commit` here is adapted for Gradle rather than
`docs/architecture/githooks/` and installs copies into `.githooks/`. **This
repository does not**, deliberately: `pre-commit` here is adapted for Gradle rather than
npm, so a second copy would be a second version of a file somebody edits once
and forgets — the exact failure `../../DOC_TRUST_MAP.md` exists to prevent.
@ -90,7 +90,7 @@ the version without it.
## Escape hatches, and why they are loud
```bash
SKIP_GUARDS=1 git commit ... # skips the scan and the tests, and says so
SKIP_GUARDS=1 git commit ... # skips the scan, the tests and the message check, and says so
SKIP_PUSH=1 git commit ... # commits without publishing, and says so
git commit --no-verify ... # skips the hooks entirely, silently
```

View File

@ -26,10 +26,10 @@ All **webp**. All **required**. Only `img/` is checked — an asset left in
document owns the rule; that one owns the spec. Stating both in both places is
how two copies of one convention start disagreeing.
**No placeholders ship with this template, deliberately.** It carried a 0-byte
`logo.webp` once, and an empty file is the worst of the three states: a check
that asks "does the path exist" calls it present, and anything that reads the
bytes rejects it — a consumer verifying the webp signature answers 415, which
**No placeholders ship here, deliberately.** The template this repository
adopted carried a 0-byte `logo.webp` once, and an empty file is the worst of the
three states: a check that asks "does the path exist" calls it present, and
anything that reads the bytes rejects it — a consumer verifying the webp signature answers 415, which
reads as a corrupt asset rather than a missing one. Absent is honest and the
conformance check reports it as absent, which is what gets it filled in.

View File

@ -49,7 +49,7 @@ specified.
Extra sizes and variants are welcome beside these — `icon-512.webp`,
`logo-dark.webp`. Only the three exact names are checked for.
No placeholders ship with this template. An empty file is the worst of the three
No placeholders ship here. An empty file is the worst of the three
states: a check that asks whether the path exists calls it present, and anything
reading the bytes rejects it. Absent is honest, and the conformance check reports
it as absent — which is what gets it filled in.

View File

@ -367,11 +367,12 @@ The brand name is:
Keep the colon between Privacy and Period, with a single space after it.
> **The drawn wordmark does not match this yet.** The supplied artwork sets the
> name as *Privacy: Period Tracker*, with no space, and that is what `logo.webp`
> and `banner.webp` currently show. The written form above is canonical — it is
> what every document, the store listing and the app itself use — so the
> lockup needs redrawing to agree with it. Filed as an issue rather than fixed
> here, because closing the gap needs an artist and not a rename.
> name as *Privacy:Period Tracker* — no space after the colon — and that is what
> `docs/data/img/logo.webp` and `docs/data/img/banner.webp` currently show. The
> written form above is canonical — it is what every document, the store listing
> and the app itself use — so the lockup needs redrawing to agree with it. Filed
> as issue #28 (Batch 08, `P2`) rather than fixed here, because closing the gap
> needs an artist and not a rename.
Preferred presentation:
@ -381,6 +382,13 @@ Preferred presentation:
Typography should feel like an elegant modern serif.
> **This governs the wordmark, store assets and marketing artwork.** In-app
> typography is unchanged and still belongs to
> [`../planning/PRODUCT_PLAN.md` §40](../planning/PRODUCT_PLAN.md) — system or
> Material type, no licensed font — which is what `PeriodTypography` in
> `core/designsystem` implements; its one override is the forecast's
> `displayLarge`, because §38 makes the number the hero.
Look for fonts similar to:
- Cormorant Garamond
@ -655,7 +663,10 @@ Future feature icons should look like they belong to the same family.
Examples:
### Period
Droplet or calendar dot
Calendar dot or filled cycle disc — never a droplet.
[`../planning/PRODUCT_PLAN.md` §42](../planning/PRODUCT_PLAN.md) forbids blood
drops, and [`README.md`](README.md) applies that list to every in-app
illustration and marker.
### Fertility
Small flower/petal or ovulation circle

View File

@ -4,8 +4,8 @@
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/design/**, the design tokens in core/designsystem, and the product's
tone and interface copy
Governs: docs/design/**, core/designsystem/** — the design tokens — and the
product's tone and interface copy
Review trigger: Any new user-facing screen or state; any change to the colour or
type tokens; any change to notification copy or to a privacy or
fertility disclaimer
@ -28,7 +28,8 @@ disclaimer come to exist.
| Insights | §27 |
| Notification modes, types and flow | §28, §29, §30 |
| Incognito launcher | §32 |
| Look and feel, visual direction, colour, typography, motion | §37§41 |
| Look and feel, visual direction, typography, motion | §37, §38, §40, §41 |
| Colour | §39 — **superseded by [`BRAND_GUIDE.md`](BRAND_GUIDE.md)**; see [Colour](#colour) below |
| Artwork | §42 |
| Accessibility | §43 |
@ -53,8 +54,12 @@ wrong, including anything of ours.
**2. Nothing asserts certainty the model does not have.** A window and a
confidence label, never a bare exact date presented as fact. "Estimated
ovulation", never "you are ovulating today". Fertility copy carries the
not-contraception line wherever it appears.
ovulation", never "you are ovulating today". Every **screen** that shows a
fertility estimate carries the not-contraception line, which is where
[§18](../planning/PRODUCT_PLAN.md) scopes it — "near fertility features and in
About". Notification copy is the deliberate exception: it says "Estimated
fertile window" and stops, because a disclaimer is a long sentence about
fertility on a lock screen.
**3. State is never colour alone.** Confirmed period is a solid fill, predicted
is dotted or outlined, fertile window is a ring, ovulation is its own small
@ -62,6 +67,11 @@ marker — distinguishable in greyscale, because that is also what makes them
distinguishable to a colourblind user and to a screenshot in a bug report.
Predicted and confirmed days must never look identical.
**4. Ads never touch a health action.** No banner in onboarding, in the
period-start confirmation, in the period-end confirmation, or between steps of a
health workflow — and never an interstitial after logging
([§33](../planning/PRODUCT_PLAN.md)).
**The banner space is reserved before there is an ad to put in it.** §48 wants no
layout jump when one loads and a graceful gap when one fails, and both are
properties of the space existing whether or not it is filled. Retrofitting the
@ -69,12 +79,6 @@ reservation in Batch 07 means shipping the jump first and finding it in a QA
round. It is empty and invisible now — deliberately not a "your ad here" box,
which would be a placeholder for the thing a user pays to remove.
**4. Ads never touch a health action.** No banner in onboarding, in the
period-start confirmation, in the period-end confirmation, or between steps of a
health workflow — and never an interstitial after logging
([§33](../planning/PRODUCT_PLAN.md)). Banner space is reserved in the layout so a
failed ad does not move the content.
## Colour
The palette is [`BRAND_GUIDE.md`](BRAND_GUIDE.md), supplied by the project owner
@ -131,13 +135,20 @@ Designed here on purpose, because they are the two most people meet first:
rather than showing a confident forecast it has not earned. "Getting to know
your pattern", not a percentage.
## The artwork is placeholder, and says so here
## The in-app artwork is placeholder, and says so here
Every illustration and calendar marker in the app is a **Compose vector path**
in [`core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/`](../../core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/), written as PRODUCT_PLAN.md §42 asks: polished
placeholders behind replaceable names, so real artwork changes one function body
and no call site.
Real brand artwork exists — the owner's sources in [`brand/`](brand/) and the
three exported marks in [`../data/img/`](../data/img/) — and none of it is in the
app yet. The launcher icon keeps its simplified vector because the supplied
emblem has content close to its edges and an adaptive icon masks about a quarter
of the canvas away, so dropping it in unmodified would crop the shield. Fitting
it to the safe zone is Batch 08's final-artwork work.
The visual language is deliberately narrow — **overlapping circular forms** and
nothing else. §42's forbidden list (blood drops, tampons, pads, uterus imagery,
gender symbols, anatomy) is a product decision rather than squeamishness: this

View File

@ -14,8 +14,9 @@ Planning used to live in markdown as a numbered batch list. It now lives in the
tracker: **milestones are batches, issues are deliverables**, and severity is
`P0` / `P1` / `P2` / `release-blocker`.
This file is what that list *was*. It is kept because the reasoning in it is
still worth reading, and deleted files are not readable.
In repositories that made that move, this file is what that list *was*. This one
never had a batch list to archive — see the final section below — so it is kept
for the section above it and for nothing else.
**It must not be updated.** A batch list beside the tracker is a second answer to
"what is open", and two records of the same thing will disagree without saying
@ -38,7 +39,7 @@ ledger and no batch percentages.
Creating that file to turn the line green would rebuild, in a new name, the
exact second record this one was archived for.
## Period never had one
## This repository never had one
This repository adopted the tracker convention in its **first commit**, so there
was never a markdown batch list here to migrate. Nothing was lost and nothing was

View File

@ -32,6 +32,76 @@ written and stay true. It is exempt from review for the same reason a receipt is
## Entries
### 2026-08-18 — A doc audit against the code: 57 wrong claims in eighteen files
No code changed. Every document in the tree was checked against the source, the
tracker and git history, and each finding was then handed to a second reader
whose job was to refute it — 74 raised, 12 refuted, 57 applied after dedup.
**The README described a skeleton.** Its Status table — the one place in this
repository a claim about what is built is allowed to live — still said "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. The rename commit touched the file and left
the table alone, which is the whole failure mode in one line: a document gets
opened, edited for one reason, and its stale half is not what the editor was
looking at.
**Two guards were silently never firing.** `docs/architecture/README.md` and
`docs/design/README.md` both wrote their `Governs:` field 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 that match
nothing, and because each file had one real glob it was classed as
path-governing rather than subject-governing — the one state the script's own
header calls invisible. Editing a Room entity never fired the document that owns
the migration table; editing the palette never fired the document that owns
colour. Both now fire, checked by running the script rather than by reading it.
**`SECURITY.md` claimed four things the app does not do.** App lock listed among
what works offline, biometric/PIN gating described as protecting app launch, the
incognito launcher as existing, and Google Play Billing sitting in the third
parties table without the *not yet integrated* marker its two neighbours carry.
All four are Batch 06 and 07 work. A security document that describes unbuilt
protections as shipped is worse than one that omits them, because it is the
document somebody checks *instead of* the code.
**The advertising boundary was overstated in the same way, in two files.** Both
`SECURITY.md` and the README said the ads module declares no dependency on the
cycle database and a guard proves it. There is no ads module. What exists is
better than the prose and had to be described accurately: `":core:ads" to
emptySet()` is pre-declared in the root build script, stricter than the sentence
it replaced, and matching no module until Batch 07 — which is exactly why the
guard is proved by injection rather than trusted.
**Smaller, and each a real trap.** `WORK_CYCLE.md` told the reader to run
`docs/architecture/scripts/forgejo-issue.py`, a template path that does not
exist here — missed by `doc-claims.sh` because it only reads backticked prose,
not fenced blocks. `ClaudeReport.md`'s Round notes said "No rounds yet" after
three rounds, because `ClaudeQAPlan.md`'s after-a-round list never mentioned that
section; both are fixed, the playbook 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. The
brand guide's wordmark callout had been rewritten by the rename into saying the
artwork has "no space" while printing the spaced form — destroying the only
counter-example in the repository.
**What was deliberately left alone.** `ClaudeReport.md`'s last verified build
SHA stays at `0451fbe`: no QA round has run since, and moving it to `HEAD` would
claim a verification nobody performed. Every `DEVELOPMENT_LOG.md` entry stays as
written. `PRODUCT_PLAN.md` §39's teal palette stays superseded rather than
rewritten — the routing table now says so at the point a reader would go looking.
- **Closed:** nothing — these were doc defects, not tracker deliverables.
- **Next action:** Batch 06 — Privacy and Security, unchanged. File its issues
first; the milestone is empty. Biometric and PIN lock, export, irreversible
delete, the privacy promise in Settings, and making §45's logging rules true in
a release build. `SECURITY.md` now describes that surface as unbuilt, so the
batch has an accurate starting description for the first time.
- **Blockers:** #9, the Command Center webhook, still needs a person. The four QA
gaps are unchanged and still the better spend: no real lock screen has been
looked at, TalkBack has never been run, text has never been scaled, and nothing
has run at `minSdk` 26.
### 2026-08-18 — Renamed to Privacy: Period Tracker
"Period" was always a working name — `PRODUCT_PLAN.md` §55 said so — and the real

View File

@ -30,9 +30,9 @@ monetization and compliance — written to be handed to a coding agent whole. It
is still here, unchanged in substance, as
[`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md).
That is worth recording because it explains a shape a newcomer would otherwise
find odd: this repository has an unusually complete specification and no code.
Most projects have the reverse problem.
That is worth recording because it explains the shape the first weeks of this
repository had: an unusually complete specification and, at the start, no code at
all. Most projects have the reverse problem.
## Decisions and their reasons
@ -49,15 +49,32 @@ was true at the time" survives Y stopping being true.
| 2026-08-18 | WorkManager for reminders, and **no exact-alarm permission** | a period reminder does not need alarm-clock precision, and requesting that permission is a Play scrutiny cost with no user benefit |
| 2026-08-18 | Ads stay behind an abstraction in a module that cannot reach cycle data | the privacy promise is the product; a rule people remember is not a control, a compile error is |
| 2026-08-18 | One-time purchase to remove ads, not a subscription | it keeps prediction quality free for everyone and avoids an entire class of entitlement defect — nothing to claw back on cancellation |
| 2026-08-18 | Six Gradle modules at the skeleton, not the seventeen the specification sketches | a module created before it has contents is a place for things to be put by accident; the rest arrive with the batch that needs them |
| 2026-08-18 | Four Gradle modules at the skeleton, not the seventeen the specification sketches | a module created before it has contents is a place for things to be put by accident; the rest arrive with the batch that needs them |
| 2026-08-18 | Eight milestones opened at once, issues filed only under Batch 01 | the roadmap is genuinely known and worth being visible; the *work items* are not, and inventing them would make every tracker percentage permanently wrong |
| 2026-08-18 | OPERATIONS.md deleted rather than kept empty | this is an offline app, not a deployed service; an empty runbook reads as one nobody wrote |
## What was tried and dropped
Nothing yet. This is the most useful section in the file and the one most often
missing — an approach abandoned for a good reason will be proposed again by
somebody who does not know it was tried, including you, in a year.
This is the most useful section in the file and the one most often missing — an
approach abandoned for a good reason will be proposed again by somebody who does
not know it was tried, including you, in a year.
- **A Room schema-export comparison as the schema-drift guard.** `SchemaTest` was
written believing it caught drift. Room regenerates the export during
compilation, so both sides of every comparison agree by construction, and
adding a column without bumping the version stayed green.
`scripts/schema-guard.sh` asks git instead, which is the one party Room cannot
overwrite.
- **`BaselinePredictionEngine` as the product engine.** A robust median over
recent intervals, correct and not personal enough: PRODUCT_PLAN §11 names a
plain average as an acceptable prototype and an unacceptable final engine.
Replaced in Batch 02 by `PersonalPredictionEngine`, and kept as the control
`EngineComparisonTest` scores the new engine against — which is what makes the
replacement demonstrably better rather than merely different.
- **PRODUCT_PLAN §39's muted teal and sage palette.** Dropped for the owner's
plum-and-rose brand guide, including a pink fertile window where §39 wanted
teal. Safe to drop only because the calendar's states differ in shape rather
than colour, which is what §43 actually requires.
The specification already carries a partial version of it: every `Avoid:` and
`Do not:` list in [`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md)

View File

@ -61,7 +61,7 @@ repository:
Two of those are harder rules than the rest. **Prediction quality is never a
paid feature**, and **health data never reaches the advertising subsystem**
see [`PRODUCT_PLAN.md` §34](PRODUCT_PLAN.md) and the boundary guard in
see [`PRODUCT_PLAN.md` §33 and §34](PRODUCT_PLAN.md) and the boundary guard in
[`../architecture/README.md`](../architecture/README.md).
## Stack and platform
@ -80,7 +80,7 @@ specification's own version numbers — which the specification itself asks for.
| Background work | WorkManager 2.11.2 | reminders must survive process death; **no exact-alarm permission** — a period reminder does not need alarm-clock precision |
| DI | Hilt 2.60.1 | wired at the skeleton stage; retrofitting DI across modules later is the expensive order |
| Prediction engine | pure Kotlin JVM module | testable without an emulator, which is the only way it gets the test coverage §50 asks for |
| Target | `compileSdk`/`targetSdk` 36, `minSdk` 26 | Play requires API 36 for new apps and updates from **2026-08-31** |
| Target | `compileSdk` 37, `targetSdk` 36, `minSdk` 26 | current AndroidX requires compiling against 37; `targetSdk` 36 is Play's floor for new apps and updates from **2026-08-31**, and raising it opts the app into runtime behaviour changes, which is a tested decision rather than a build fix |
## Success looks like

View File

@ -19,7 +19,8 @@ Batches 04 and 05 landed: fertility estimates and the reminder system. Pass F
became runnable for the first time.
**Environment:** emulator `PeriodQA`, API 36, Pixel 6 profile, debug build, plus
8 instrumented tests on the same device.
6 instrumented tests on the same device — 4 in `NotificationPrivacyTest`, 2 in
`PeriodCrudTest`.
| Pass | Result | Notes |
| --- | --- | --- |
@ -28,7 +29,7 @@ became runnable for the first time.
| C — Failure paths | **Partial** | As Round 2. Airplane mode, denied notification permission and a killed process still untried. |
| D — Persistence | **Partial** | As Round 2. Reboot and update-over-install untried — the second matters more now that WorkManager holds scheduled work. |
| E — Forecast under hard histories | **Partial** | Unit-tested against both engines; the §51 histories still have not been entered by hand. |
| F — Notification privacy | **Partial, and this is the important row** | Eight instrumented tests on a device assert what a lock screen *would* render: every kind × both private modes attaches a public version, is marked `VISIBILITY_PRIVATE`, and leaks no health word — including the action labels. **Nobody has yet looked at an actual locked screen.** That is a real gap: the tests check the notification object, and the last mile is what the system chooses to draw. |
| F — Notification privacy | **Partial, and this is the important row** | Four instrumented tests on a device assert what a lock screen *would* render: every kind × both private modes attaches a public version and is marked `VISIBILITY_PRIVATE`, no public title or body carries a health word, Direct is the only mode marked public, no channel name in any of the three modes carries one either, and Discreet cannot pop over the screen. Action labels are checked by the JVM unit tests in `NotificationCopyTest`, not on the device. **Nobody has yet looked at an actual locked screen.** That is a real gap: the tests check the notification object, and the last mile is what the system chooses to draw. |
| G — Accessibility | **Partial** | Unchanged. Calendar verified in real greyscale again after the palette change. TalkBack and font scaling still never run. |
| H — Data ownership | **Partial** | Unchanged. |
@ -80,7 +81,7 @@ Three defects, none of which any unit test would have caught:
3. **Today's underline collided with the spotting dot**, on the one day that was
both. Invisible in the colour screenshot, obvious in greyscale.
The pattern is now four rounds old and worth stating as a rule: **the defects in
The pattern is now two rounds old and worth stating as a rule: **the defects in
this project are found by opening it, not by reading it.**
## Round 1 — 2026-08-18 at `adc5075`, partial

View File

@ -40,8 +40,10 @@ skipped one is visible.
| G | Accessibility: TalkBack through the core loop, largest font scale, calendar states distinguishable in greyscale, touch targets, focus order, reduced motion |
| H | Data ownership and leakage: export, Delete My Data, biometric/PIN gate, incognito launcher, and the built artifact inspected for anything health-derived reaching the ads or analytics path |
<Add, remove and rename to fit. A pass that never applies is noise; a pass that
is always skipped is a lie.>
Add, remove and rename to fit the product. A pass that never applies is noise; a
pass that is always skipped is a lie — which is why the template's money and
authorisation passes were deleted here rather than carried as permanently
skipped rows; *What is deliberately not here*, below, says why.
### Why E is separate from B
@ -105,10 +107,12 @@ Severity is what it costs, not how annoying it is to fix.
## After a round
File each finding as a labelled issue. Update `ClaudeReport.md`'s run-state
block and its overall sentence, and `ClaudeQACoverage.md` with what each pass
actually reached. A pass that could
not be run is recorded as blocked, with what blocks it — never quietly left out,
which reads identically to "passed".
block, its overall sentence, and its **Round notes** — one entry per round, the
verdict only, because the pass-by-pass detail belongs in `ClaudeQACoverage.md`
and a second copy of it will drift. Then update `ClaudeQACoverage.md` with what
each pass actually reached. A pass that could not be run is recorded as blocked,
with what blocks it — never quietly left out, which reads identically to
"passed".
Then **push, and reconcile**. The verdict on the project screen at
privacyllc.dev is read out of `ClaudeReport.md` in the pushed repository, so a

View File

@ -27,7 +27,7 @@ Review trigger: Any QA round run
two-tap logging, a calendar readable in greyscale, insights that decline to
overstate, fertility estimates that refuse to appear when the forecast is too
vague to support them, and discreet reminders. It now wears its real brand,
supplied by the owner. Eight instrumented tests assert that nothing about a
supplied by the owner. Four instrumented tests assert that nothing about a
period reaches a lock screen in either private mode — but **nobody has yet
looked at an actual locked screen**, and that last mile is the single most
valuable thing left to check, because a notification read over a shoulder is
@ -57,4 +57,20 @@ build SHA it was found at — a finding that cannot be re-tested cannot be close
## Round notes
No rounds yet.
One entry per round, the verdict only. What each pass reached, and what it could
not, lives in [`ClaudeQACoverage.md`](ClaudeQACoverage.md) and is deliberately
not repeated here.
- **Round 3 — 2026-08-18 at `0451fbe`, partial.** Fertility and reminders landed
and pass F became runnable for the first time. Verdict: fit to keep building,
not fit to ship — the privacy promise is proved against the notification
object and never against a locked screen. Two defects, both in how Android
behaves rather than in the app's logic, and both found by running on a device.
- **Round 2 — 2026-08-18 at `19edf4c`, partial.** Every screen Batch 03 built
was driven by hand. Verdict: the product became coherent, and three defects
surfaced that no unit test would have caught — one of them dark mode, broken
since Batch 01.
- **Round 1 — 2026-08-18 at `adc5075`, partial.** Two passes were all the first
build could support. Verdict: sound enough to keep going, and the first thing
tried by hand crashed the app with 70 unit tests green — which is the argument
for driving it at all.

View File

@ -7,8 +7,9 @@ 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 what
the ads or billing subsystems can see
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
@ -47,14 +48,16 @@ Data Safety section, and never lets health data reach any of them.
- 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, notification scheduling and app lock. **No
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 protects app launch. Where a secret is needed to back
that, it is Android Keystore-backed — never a value in DataStore.
- 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.
@ -62,6 +65,14 @@ Data Safety section, and never lets health data reach any of them.
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
@ -70,7 +81,7 @@ a good table, and this one gets filled in as each subsystem lands.
| Service | What it receives | Why that is acceptable |
| --- | --- | --- |
| Google Play Billing | 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 |
| 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 |
@ -90,8 +101,11 @@ rather than only as an architecture note:
- Every SDK's data collection is audited before each release, against what Data
Safety declares.
Enforced structurally, not remembered: the `ads` module declares no dependency
on core/database or `domain/*`, and a Gradle guard proves it — see
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
@ -154,7 +168,8 @@ Written down so an unknown gap becomes a known one:
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 exists for the adjacent problem —
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.

View File

@ -28,6 +28,7 @@ it means the release list stays short enough to finish rather than skim.
- [ ] Play **Health apps** declaration completed as applicable — proves the category rules were checked, not assumed
- [ ] Store listing carries no medical or contraceptive claim — proves the copy did not drift past what the app can support
- [ ] Current Play target-API requirement confirmed **at submission time**, not from this document — proves the deadline was checked rather than remembered
- [ ] `./gradlew checkPermissions` passes and every permission it reports is one somebody decided on — proves the Play listing and the Data Safety form describe a chosen set rather than whatever the dependencies brought
## Standing checks
@ -41,7 +42,7 @@ it means the release list stays short enough to finish rather than skim.
The one group that is not generic. Every item proves part of
[`../planning/PRODUCT_PLAN.md` §34](../planning/PRODUCT_PLAN.md).
- [ ] the ads module declares no Gradle dependency on core/database or `domain/*`, and the boundary guard was **proved to fail** this release — proves the check is evidence rather than decoration
- [ ] `core:ads` is included in `settings.gradle.kts` and declares **no** Gradle project dependency at all — the rule is `":core:ads" to emptySet()` in the root `build.gradle.kts`, stricter than "nothing from core/database or `domain/*`" — and the boundary guard was **proved to fail** this release — proves the check is evidence rather than decoration, and that it examined the module rather than a rule matching nothing
- [ ] No health-derived value appears in an ad request extra, user property, or callback log — proves targeting cannot happen by accident
- [ ] Ad requests are non-personalized/contextual, with region-appropriate consent — proves the default is the private one
- [ ] The ads provider is not initialised for entitled (ad-free) users where practical — proves the purchase removes the SDK, not just the view