# Development log — Period

```
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: the dated record of what happened
Review trigger: Nothing. This file is appended to, never revised.
```

## How to use this

Newest first. **One entry per work session**, written before you stop — that is
step 6 of `docs/WORK_CYCLE.md`, and the two lines it insists on are `Next
action` and `Blockers`.

Those two are not decoration. The next session starts by reading the top of this
file, and a session that ended without saying what came next hands the one after
it a re-derivation instead of a starting point — which is where drift enters.
Neither line competes with anything: the live next action is the field on the
project at privacyllc.dev and the live blockers are issues in the tracker, while
these say what both were **at this date**. A record of then never disagrees with
a record of now.

**Append-only by convention.** Correcting an old entry rewrites the record of
what was known at the time, which is the one thing this file is for. If an entry
turns out to be wrong, add a later entry saying so; do not edit the first.

Note the Review trigger above says "nothing", deliberately. A dated log cannot
rot the way a description of current state can — the entries were true when
written and stay true. It is exempt from review for the same reason a receipt is.

## Entries

### 2026-08-18 — Batch 04 shipped: fertility that declines, and a hook that refused every deletion

Three issues closed and the milestone with them. Four of the eight batches are
now done.

**Fertility, and the decision that made it worth shipping.** Ovulation is
estimated a luteal phase before the *predicted* next period — §17 asks for that
direction because the luteal phase is the stable half of the cycle — and the
window opens five days before it and closes one after. The uncertainty is
inherited from the forecast rather than invented.

The first version was arithmetically correct and useless. A user one cycle into
the app was shown a fertile window of **8 Aug – 24 Aug**: seventeen days, over
half a cycle, dressed up as a feature. So the estimate now returns null past a
usable uncertainty and the screen says *"Not enough history to estimate"* with a
reason to keep logging. Three stable cycles later the same user gets 12 Aug – 20
Aug, which is worth reading. Both states were checked on a device.

That is the third instance of one pattern, so it is now written into
`docs/architecture/README.md` as a rule rather than a coincidence: **the app
declines rather than stretches.** Accuracy figures wait for three scored
forecasts; averages wait for two intervals; fertility waits for a forecast tight
enough to hang off.

**§18's prohibition is a type.** `FertilityLikelihood` has LOWER, HIGHER and
UNKNOWN and no fourth value, and a test asserts no label contains a permission
word — so adding "safe" is a deliberate act with a failing test rather than a
slip in a string.

**Another greyscale collision.** The ovulation star was centred and sat directly
behind the numeral; in greyscale the "18" and the mark merged. Ovulation is now
the fertile ring plus a small star low in the cell, which is also the more honest
picture — that day *is* inside the window.

**The hook refused every deletion-only commit.** `secrets.sh` exits 2 for
"nothing was scanned", which is the correct answer for a commit that only
deletes files. The hook treated any non-zero as a refusal and printed *"possible
credential in the staged changes"* over a plain `git rm`. Found while removing a
`bin/` directory Buildship had been writing — a directory full of `.kt` files,
which is the one kind of build output that does not look like build output in a
`git status`. It only got committed at all because a `.gitignore` edit was
staged alongside, which gave the scanner something to read.

**And a mistake worth recording rather than tidying away.** While proving the
fix, I committed a throwaway, then ran `git reset --hard HEAD~1` — which did not
undo the push the post-commit hook had already made, and silently discarded the
uncommitted hook fix I was in the middle of proving. The second proof then
failed for a reason that had nothing to do with the guard. Reconciled forward
rather than force-pushed. **Commit the work before proving the guard, not after.**

- **Closed:** #21, #22, #23 — and the `Batch 04 — Fertility` milestone.
- **Next action:** Batch 05 — Notifications. File its issues first; the milestone
  is empty. The work is WorkManager-scheduled reminders (§29, §30) with the three
  privacy modes from §28 — and the mode that matters is Discreet, already the
  stored default and already chosen in onboarding, so this batch is about the
  lock screen actually honouring it. **Pass F cannot be run until this lands**,
  and it is the pass that checks the most likely real privacy breach in this
  product.
- **Blockers:** Unchanged, both needing a person: the three branding marks (#8)
  and the Command Center webhook (#9). The three standing QA gaps are also still
  open and are getting more expensive the more UI exists — TalkBack has never
  been run, text has never been scaled, and nothing has run at `minSdk` 26.

### 2026-08-18 — Batch 03 shipped: every screen the app has, and three defects only looking could find

All six Batch 03 issues closed and the milestone with them. Period stopped being
a data layer with a debug surface and became something you can actually use:
onboarding that ends in a forecast, a Today screen whose six states each say
something different and true, two-tap logging, a calendar, and Insights.

**Placeholder artwork, as §42 asks for it.** Every illustration and calendar
marker is a Compose vector path behind a replaceable name — no raster anywhere
under `src/`. The visual language is overlapping circular forms and nothing
else, because §42's forbidden list is a product decision: this app gets opened
in public and a glance over a shoulder should learn nothing. Note the deliberate
contrast with `docs/data/img/`, where a placeholder is still forbidden and #8
stays open; `docs/design/README.md` now explains why the two differ rather than
leaving it looking inconsistent.

**Three defects, none findable by reading:**

1. **Dark mode was broken for the whole of Batch 01.** `PeriodTheme` never
   wrapped its content in a `Surface`, so any `Text` without an explicit colour
   inherited Material's default — black — and the app's background never
   painted. Light mode looked right by accident, because dark-on-cream is what
   was wanted anyway. 129 tests were green throughout.
2. **"Period ended" appeared to do nothing.** The logic was correct: a period
   that ends today still includes today, so the state genuinely does not change.
   The screen was identical afterwards and the button read as broken, which is
   worse than being broken somewhere visible.
3. **The today-underline collided with the spotting dot** on the one day that
   was both. Invisible in the colour screenshot and obvious the moment it was
   converted to greyscale — which is how §43 says to check, and why the check is
   written that way rather than trusting that shapes differ because they were
   designed to.

Two more came from tests doing their job: the typical-range quartiles were
indexed off `size` rather than `size - 1`, so a regular cycle with one long gap
was reported as "typical range 29–61 days" on the screen whose only job is to
say what has been learned; and Room's own executor meant a test's virtual clock
returned before the writes landed, which read as an empty database rather than
as a timing bug.

**The habit is now five pieces of work old and worth writing down as a rule: the
defects in this project are found by running it.** Three guards were green over
exactly what they claimed to check, dark mode was broken for a whole batch, and
a button looked broken while behaving correctly. None of it was visible in the
source.

- **Closed:** #15, #16, #17, #18, #19, #20 — and the `Batch 03 — Core UX`
  milestone.
- **Next action:** Batch 04 — Fertility. File its issues first; the milestone is
  empty. The work is estimated ovulation and the fertile window from the
  forecast and a configurable luteal-phase assumption (§17, §18), the calendar
  and Today states that already have their shapes and their `null` placeholders
  waiting, and the not-contraception disclaimer everywhere fertility appears.
  `CycleStatus` and `CalendarMarks` both already take fertility parameters and
  currently receive null — Batch 04 is largely filling those in rather than
  adding surfaces.
- **Blockers:** Unchanged. #8 needs the three branding marks drawn by a person.
  #9 needs the Command Center webhook URL and secret, which are not readable
  from this machine. Neither blocks Batch 04. Worth doing before much more UI:
  run TalkBack once, scale the font to maximum once, and run the app once on a
  device at `minSdk` 26 — all three are recorded as standing gaps and none has
  ever been done.

### 2026-08-18 — Batch 02 shipped: the prediction engine, and three bugs the tests found

All five Batch 02 issues closed and the milestone closed with them. The app now
ships the engine PRODUCT_PLAN.md §12 specifies rather than the prototype it was
explicitly labelled as.

**The shape is the design.** `PersonalPredictionEngine` keeps a discrete
probability distribution over candidate start dates, not a date with a margin.
The mode is the forecast, the window is the narrowest span holding 80% of the
mass, and a "Not yet" is the distribution conditioned on what the user said.
§13's requirement — that a "Not yet" updates the date, the window *and* the
confidence rather than shifting a fixed prediction by a day — is not extra work
in that design; it is the only thing that structure can do.

**It is better, as a number.** `EngineComparisonTest` scores both engines over
the §51 fixtures on every build: mean absolute error 0.67 against 1.00, and the
window contained the actual start 9 times out of 9 against 7.

**Coverage is the measure, not width — and measuring taught that.** The first
version of the comparison asserted the new windows must not be wider. It failed,
and it was the assertion that was wrong: where the personal engine is wider, it
is right to be, and the baseline answers a history with a suspected missing
period using a two-day window and misses. A window that misses is a broken
promise rather than a tight forecast.

**Three modelling bugs, each found by a test failing rather than by reading:**

1. Median absolute deviation alone reads a user alternating 25 and 37 days as
   perfectly consistent — half her deviations are zero. Twenty disagreeing
   cycles came back High, which is exactly the §15 rule about volume not buying
   confidence. Spread is now the larger of MAD and mean absolute deviation.
2. Recency weighting assumes the recent past predicts the near future. For a
   variable user that is false, and weighting it equally cost three days on the
   §51 variable fixture. Recency is now trusted in proportion to how much her
   cycles agree — which is a better statement of what recency weighting is
   actually for.
3. A fixed one-day floor on trend detection fired on a 42-day-cycle history
   whose medians differed by one day. One day is a real trend at 28 and rounding
   error at 42, so the floor is relative to the user's own spread.

That is now three consecutive pieces of work — the schema guard, the boundary
guard, and the engine — where the thing that found the defect was **running it,
not reading it**. Worth stating as a habit rather than a coincidence.

**Wired through, not just tested.** `PredictionInput` carries recent absolute
errors and the repository feeds scored errors back in. Without that the app
would store every error it makes and never read one back — measuring accuracy
rather than learning from it, with §12 step 5's widening happening only in a
unit test.

- **Closed:** #10, #11, #12, #13, #14 — and the `Batch 02 — Prediction Engine`
  milestone, which closing the last issue does not do.
- **Next action:** Batch 03 — Core UX. The onboarding flow in §19, the designed
  Today screen and its six dynamic states in §21–§22, period start and end
  logging in §23–§24, the calendar in §26 and Insights in §27. The working
  surface built in Batch 01 is deliberately ugly and says so on screen; it is
  the thing Batch 03 replaces. Before starting, file the Batch 03 issues — the
  milestone exists and is empty.
- **Blockers:** Unchanged and both need a person. #8, the three branding marks,
  which an agent must not fake. #9, the webhook, whose URL and secret are not
  readable from this machine — until it is registered an opened `P0` raises no
  alert at all. Neither blocks Batch 03.

### 2026-08-18 — Batch 01 foundation: seven of nine issues, and three guards that were wrong

Room, DataStore, the repository layer, period CRUD end to end on a device, and
the module boundary guard. #3 to #7 closed; #8 (branding) and #9 (webhook) are
the two that need a person rather than an agent.

**What was built.** `core/database` with the four entities from §10, DAOs
returning `Flow`, and the schema exported and committed. `core/datastore` for
settings, deliberately separate from the cycle database so Delete My Data cannot
reset a privacy choice the user made. `core/data` as the seam: domain types out,
cycles derived rather than stored, the forecast a function of the data instead
of a field somebody has to refresh. Hilt wiring and a working Today surface that
says "Batch 01 · working surface" so nobody mistakes it for the designed screen.

**Three guards were written, and all three were wrong at first.** This is the
day's real lesson and it is worth carrying forward:

1. `SchemaTest` looked like a Room schema-drift guard. Room regenerates the
   schema export during compilation, so both sides of every comparison agreed by
   construction — adding a column without bumping the version left it green.
   `scripts/schema-guard.sh` asks git instead, which Room cannot overwrite.
2. `checkModuleBoundaries` reported "7 modules checked, no violations" while
   checking nothing: the root project is configured before its subprojects, so
   every configuration read as empty. Caught by `prove-guard.sh` on its first
   run. Collection moved to `afterEvaluate`, and the task now throws rather than
   passing when it examined nothing.
3. The repository let `SQLiteConstraintException` escape into
   `viewModelScope.launch`, so **tapping the primary button twice killed the
   app** — found by hand on an emulator, with 70 unit tests green.

Each was caught by actually trying to break it. None would have been caught by
reading the code, and two of them would have been trusted for months.

**Two more bugs came from wiring the guard into `./gradlew check`**, which ran
Android lint for the first time: `LocalDate.ofInstant` and `LocalDate.EPOCH` are
API 34 and `minSdk` is 26. Both sit on the recalculation path — a crash on every
device below Android 14, invisible to the unit tests and to an API 36 emulator.

**QA.** Round 1 recorded as partial in `docs/qa/`. Passes A and B green, C and D
and H partial, the rest not run and each saying why.

- **Closed:** #3, #4, #5, #6, #7
- **Next action:** Batch 02 — replace `BaselinePredictionEngine` with the engine
  §12 specifies: recency weighting, a robust centre, variability-driven windows,
  trend detection, and "not yet" as a real conditioning step rather than a floor
  on the window. The §51 acceptance tests already exist and must keep passing
  against the new engine, which is what makes the replacement demonstrably
  better rather than merely different. Before that, one cheap thing worth doing:
  run the app once on a device at `minSdk` 26, because nothing here ever has.
- **Blockers:** None for code. #8 needs the three branding marks drawn — an
  agent must not fake them, so the project card shows an initials tile until
  somebody does. #9 needs the Command Center's webhook URL and secret, which are
  not readable from this machine; until it is registered an opened `P0` raises
  no alert at all.

### 2026-08-18 — Template adopted; Kotlin/Compose skeleton builds

Period went from a bare directory holding one specification file to a git
repository with the standard documentation tree, a tracker, and a project that
compiles. Adoption followed `Projects/Template/START-HERE-New-Project.md`.

**Documents.** `scaffold.sh` created 19 paths, 0 skipped. The specification moved
from `Docs/period_tracker_product_plan.md` to `docs/planning/PRODUCT_PLAN.md`
unchanged in substance, with a status header added; the capitalised `Docs/` is
gone, since every script and the Command Center expect the lowercase tree. Every
scaffolded document was filled in for Period rather than left with placeholders.
docs/OPERATIONS.md was deleted — an offline app is not a deployed service.
`docs/DOC_TRUST_MAP.md` was written last and describes what is actually here,
including a section naming what this project deliberately does **not** have.

**Code.** Four Gradle modules: `app`, `core/designsystem`, and `domain/cycle`
and `domain/prediction` as `kotlin("jvm")` 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 is explicitly
not the product — it exists so Batch 02's replacement can be shown to be better
rather than merely different.

**Three things that cost time and are worth knowing next session:**

- **AGP 9 ships Kotlin built in.** Applying `org.jetbrains.kotlin.android` is now
  a hard error, not a redundancy. The Compose compiler plugin is still separate.
- **Current AndroidX requires `compileSdk 37`.** Only up to 36 was installed;
  `platforms;android-37.0` and `build-tools;37.0.0` were installed into
  `~/Android/Sdk`. `targetSdk` stays at 36 — Play's floor from 2026-08-31 — and
  the two being different is deliberate, not an oversight to tidy up.
- **Versions were verified, not inherited.** 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 — each checked against
  its official source today, which `PRODUCT_PLAN.md` asks for rather than
  trusting its own numbers.

**Tracker.** Eight milestones opened, `Batch 01 — Foundation` through
`Batch 08 — Polish`, and nine issues filed under Batch 01 only. Seven milestones
are deliberately empty: the roadmap is genuinely known and worth being visible,
but the work items under it are not, and inventing them would make every tracker
percentage permanently wrong. `forgejo-issue.py check` warns about this, and the
warning is correct about the mechanism and expected here.

- **Closed:** #1, #2
- **Next action:** Start issue #3 — core/database with Room entities for
  `PeriodRecord`, `SpottingRecord`, `PredictionRecord` and `NotYetObservation`,
  DAOs returning `Flow`, schema export committed, and a version-1 migration test
  that proves the harness works before there is a migration that matters. Its row
  goes in `docs/architecture/README.md`'s migration table in the same commit.
- **Blockers:** None for the code. Two things need a person rather than an agent:
  the three branding marks (#8), which cannot be drawn here and must not be
  faked, and the Command Center webhook (#9), whose URL and secret are not in any
  credential file readable from this machine. Without the webhook an opened `P0`
  raises no alert at all — it waits for the next reconcile.
