984 lines
58 KiB
Markdown
984 lines
58 KiB
Markdown
# Development log — Privacy: Period Tracker
|
||
|
||
```
|
||
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-19 — Encryption, declined for a reason; and the crash it uncovered
|
||
|
||
The question was whether the cycle database could be encrypted at rest. It can.
|
||
It is not, and the reasoning is now in `SECURITY.md` under *Deliberately out of
|
||
scope* rather than in anybody's head.
|
||
|
||
**The architecture answers it.** `ReminderCoordinator` collects Room flows at
|
||
every process start — including processes WorkManager starts after a reboot with
|
||
no Activity — and `ReminderWorker` reads the forecast on a schedule. So a
|
||
database key would have to be readable with no user present, which is the same
|
||
availability condition Android's own file-based encryption key already has. A
|
||
key anything running as this app can reach unattended defends a **file**, not a
|
||
**process**. Against every adversary `SECURITY.md` lists — someone holding an
|
||
unlocked phone, someone reading a lock screen, a rooted device — it adds
|
||
nothing. What it would add is narrow and real: a file copied off the device and
|
||
read elsewhere, a phone with no screen lock, and crypto-shred on delete.
|
||
|
||
Costed rather than argued: about +1 MB downloaded and +2 MB installed on a 2 MB
|
||
app, and a new class of total loss, since a Keystore key can be lost in the
|
||
field, platform backup is off, and no telemetry would tell us. The reversing
|
||
condition is written down — a key bound to the app lock's secret, once export
|
||
gives the user a copy they control.
|
||
|
||
**Looking for the encryption seam found a live defect instead.** The
|
||
application scope was built with `SupervisorJob` and no
|
||
`CoroutineExceptionHandler`, and two Room flows are collected on it.
|
||
`SupervisorJob` stops a failing child cancelling its siblings; it does not stop
|
||
the exception, which reaches the thread's default handler and ends the process —
|
||
headless, at every boot, with nothing on screen to explain it. Both ViewModels
|
||
already had a handler. The one scope that runs with nobody watching did not. The
|
||
trigger is not hypothetical: `repository.forecast` runs the prediction engine
|
||
inside the flow, and `Prediction`'s init block enforces its window invariants
|
||
with `require`. Filed as #45, fixed in `424a513`, closed with its evidence.
|
||
|
||
**`prove-guard.sh` told me it had caught something it had not.** The first proof
|
||
ran `./gradlew :app:test --tests …`. `:app:test` is AGP's lifecycle task and
|
||
takes no `--tests` option, so Gradle failed in 544 ms with `Unknown
|
||
command-line option`, the mutation was never compiled, no test ran — and the
|
||
script reported *"the guard caught it"*. It decides from the exit code and
|
||
cannot tell a broken test from a broken command.
|
||
|
||
Pulling that thread: **the three documented boundary-guard proofs in
|
||
`architecture/README.md` have never exited 0.** Gradle prints no test-style
|
||
summary for those tasks, so `prove-guard` counts log lines, and its default
|
||
pattern also matches `FAILURE:` and `BUILD FAILED` — one caught violation reads
|
||
as three, exit 3, "not a pass". They now carry a `PROVE_GUARD_FAIL_PATTERN` and
|
||
were re-run at 0, 0, 0. Both lessons are `GUARDS.md` §8, which is exactly that
|
||
document's review trigger.
|
||
|
||
**And a false claim that was one drawing away from shipping.**
|
||
`BRAND_GUIDE.md` carried an artwork brief reading *"explaining encrypted local
|
||
data storage"* — on the single subject where a padlock reads as a promise.
|
||
Replaced with what is true and is the better story anyway: the data never
|
||
leaves, so there is no server to breach and nothing to sell. `SECURITY_CHECKLIST`
|
||
gained the row that would have caught it. Nothing shipped ever claimed it; grep
|
||
over `app`, `core` and `domain` comes back empty.
|
||
|
||
Also registered the Forgejo webhook (#9), verified with a signed replay that
|
||
returned `applied:true` — so a `P0` now raises an alert immediately instead of
|
||
waiting for the next sweep.
|
||
|
||
**What this proved:** 207 tests, 0 failures, 0 skipped; `checkModuleBoundaries`,
|
||
`checkNoHealthLogging` and `checkPermissions` all exit 0, the last against a
|
||
freshly built release manifest; removing the new handler fails exactly one test,
|
||
and removing either `catch` fails exactly its own.
|
||
|
||
**Next action:** #34, the biometric and PIN app lock. The recovery decision is
|
||
settled — none — and the full design is on the issue. Start with `:core:security`
|
||
and remember both rows it needs in the root `build.gradle.kts`, since
|
||
`checkNoHealthLogging` reads a hardcoded module list and a missing row fails
|
||
silently in the one module that holds key material.
|
||
|
||
**Blockers:** none. #34 needs `androidx.biometric`, which fails `checkPermissions`
|
||
until `USE_BIOMETRIC` and `USE_FINGERPRINT` are added to the allowlist with their
|
||
reasons, and needs `MainActivity` to become a `FragmentActivity` — both known,
|
||
neither blocking.
|
||
|
||
### 2026-08-19 — Eleven screenshots, and the day the seeding chose a bad hero
|
||
|
||
Privacy: Period Tracker was the only Privacy LLC product without a page on
|
||
privacyllc.dev — the other three have one, `/work/privacy-period-tracker` returns
|
||
404 — and no screenshot of this app existed anywhere in the repository. There are
|
||
eleven now, in `docs/design/screenshots/`.
|
||
|
||
**They match the house style**, which was worth reading the site to learn rather
|
||
than guessing: the product pages run a portrait gallery of six or seven captioned
|
||
images, no device frames, no decorative background, with a one-line *Data
|
||
posture* under Platform notes. `/privacy` itself shows no screenshots at all — 18
|
||
sections of text and a 64 px logo — so it was never the target.
|
||
|
||
**The seeding is the substance of the work.** Six invented starts giving gaps of
|
||
28/29/28/28/29 days, so the forecast on screen is earned by history rather than
|
||
asserted. #32 is blunt about why that matters: a screenshot of a period tracker
|
||
is a screenshot of health data.
|
||
|
||
**And the first seeding produced a bad hero.** It landed the capture day exactly
|
||
on the estimated ovulation date, so Today read *"estimated ovulation in 0 days"*.
|
||
Entirely true, and the wrong lead for a gallery — a fertility-forward first
|
||
impression for a privacy product, and a hero number of zero. Shifting the history
|
||
ten days earlier gives *"period likely in 4 days"* with the 72sp number, which is
|
||
the screen this app is built around. Worth remembering that seeded data has a
|
||
composition as much as a value.
|
||
|
||
**The status-bar check was the useful discipline.** SysUI demo mode fixes the bar
|
||
to 9:30, wifi, full battery on every frame; then the top strip of all eleven was
|
||
cropped into one image and compared. That is what proves the thing #32 actually
|
||
warns about — a reminder notification visible in a published screenshot — rather
|
||
than trusting that none appeared.
|
||
|
||
A smaller find: the trust map still said `splash.png` after that file became
|
||
`splash.webp`. `doc-claims.sh` tolerates a bare filename, so nothing failed;
|
||
corrected here.
|
||
|
||
- **Closed:** nothing. #32 keeps its two remaining halves — a feature graphic,
|
||
which is artwork rather than a screenshot, and Play's own sizes confirmed in
|
||
the console at submission time. Recorded as a comment on it.
|
||
- **Next action:** the page itself is admin work on privacyllc.dev — the agent
|
||
API has no asset or media route, so the gallery cannot be populated from here.
|
||
Hand the eleven frames over. Then Batch 06's remainder: #37 is smallest and
|
||
unblocked, #35 export next.
|
||
- **Blockers:** #9, the webhook, still needs a person. #44 needs one re-render.
|
||
And §4 requires the privacy promise on the public privacy page — the live page
|
||
carries no Period-specific promise and no per-app page exists, which is a
|
||
compliance row on `SECURITY_CHECKLIST.md` and what #37's policy link is waiting
|
||
for.
|
||
|
||
### 2026-08-19 — Full-bleed onboarding heroes, and a hash check that proved the wrong thing
|
||
|
||
The onboarding illustrations went from 11–17% of the screen to a third of it,
|
||
full width, fading into the page. Three issues closed, one opened.
|
||
|
||
**The artwork took two attempts and the first one looked right.** It arrived at
|
||
the correct 2048 × 1365 and 3:2 — and was the old portrait art upscaled ~2.8×
|
||
with the sides padded by a blurred copy of itself. The sharp region was
|
||
1024 × 1365, aspect 0.750 against the originals' 0.748: the same composition.
|
||
Measuring edge detail across each image found it falling 2.1×–8.8× in the outer
|
||
quarters on 15 of 16 files, and at real hero size a hard seam showed around the
|
||
sharp centre. The canvas was right and the composition was still portrait. The
|
||
second delivery is natively wide.
|
||
|
||
**A hash check proves two files differ, not that either is right.** `Dark/01`
|
||
and `Dark/07` were byte-identical, which I recorded as "the forecast's dark
|
||
illustration is a copy of the welcome one". Backwards: **both were forecast
|
||
scenes.** Replacing 07 fixed the duplicate hash, the set passed a uniqueness
|
||
check, and dark mode still opens on a calendar where light mode opens on
|
||
overlapping rings. Filed as #44 — found by looking at the screen after wiring it
|
||
up, which no checksum was ever going to do.
|
||
|
||
**The cap is the part worth keeping.** A landscape phone is 914 × 411 dp, so a
|
||
fixed 280 dp hero is 68% of the screen and every step breaks on rotation —
|
||
nothing here uses `WindowSizeClass` and onboarding does not lock orientation.
|
||
`Illustration` caps at a third of the screen, and it reads
|
||
`LocalConfiguration.screenHeightDp` rather than `BoxWithConstraints`, because
|
||
inside a `verticalScroll` column a child is measured with an **infinite** height
|
||
constraint. `maxHeight` there is `Dp.Infinity`, so the obvious implementation is
|
||
a guard that looks right and never applies.
|
||
|
||
**Compose has no negative padding**, so the screen's 24 dp inset moved off the
|
||
scrolling column and onto the content inside each step. That is what lets a
|
||
full-bleed child exist at all.
|
||
|
||
**And a live memory defect went with it.** The illustrations sat in a
|
||
density-less `drawable/`, treated as mdpi and pre-scaled at decode — about 17 MB
|
||
of heap for one bitmap on an xxxhdpi device, before this artwork made them
|
||
larger. Four buckets per theme now. 2.3 MB across all of them, but a release is
|
||
an AAB and Play splits by density, so a device downloads 130–500 KB.
|
||
|
||
Seven hand-rolled step layouts became one `StepBody`, keeping `artHeight` per
|
||
step: the two carrying three or four controls take a shorter hero, which is the
|
||
constraint the old 104 dp values existed for.
|
||
|
||
- **Closed:** #39, #40, #41.
|
||
- **Next action:** #44 needs one re-render — dark `01_Welcome` as overlapping
|
||
rings to match its light twin. Then Batch 06's remainder: #37 is smallest and
|
||
unblocked, #35 export next, #34 app lock still waiting on the forgotten-PIN
|
||
decision.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack
|
||
has still never been run and no real lock screen has been looked at. #42 would
|
||
have caught a missing night asset but not this one — a pairing guard checks
|
||
presence, not subject.
|
||
|
||
### 2026-08-18 — The wordmark got its space, and #28 closed
|
||
|
||
The owner supplied a redrawn lockup, `docs/design/dist/splash.webp`. It sets the
|
||
name as **Privacy: Period Tracker** — with the space after the colon — which is
|
||
the whole of what #28 asked for.
|
||
|
||
**It was necessary and not sufficient**, which is worth writing down because the
|
||
tempting move was to look at the new artwork and close the issue. #28's `Verify`
|
||
line names the two *derived* assets, not the lockup: the wordmark in
|
||
`docs/data/img/logo.webp` and `banner.webp` had to match `app_full_name`. Both
|
||
still carried the old spaceless form, because they were generated from the
|
||
earlier source months of decisions ago. Regenerated from the new lockup, checked
|
||
by cropping the colon out of each finished webp at 1350px wide and reading it.
|
||
|
||
**The banner is composed, never cropped.** The lockup is square and the banner
|
||
is 3:1, so a crop takes the ends off the wordmark — the exact trap the first pass
|
||
at these assets hit. A 660px square card centred on a 2176×725 field, same as the
|
||
existing asset did it. No flood-fill this time either: `splash.webp` has real
|
||
transparency where the earlier sources rendered their rounded corners against
|
||
black.
|
||
|
||
**`icon.webp` was deliberately left alone.** It carries no wordmark, it is the
|
||
same visual family, and #28 is about the name. Regenerating it would have been
|
||
churn dressed as consistency.
|
||
|
||
`BRAND_GUIDE.md` §10 carried a callout saying the drawn mark disagreed with the
|
||
written name. That statement is now false, so it says what happened instead, and
|
||
names the old lockup sources as superseded rather than deleting them — they are
|
||
the owner's files.
|
||
|
||
- **Closed:** #28.
|
||
- **Next action:** Batch 06 has #34, #35 and #37 left, and Batch 08 has #31 and
|
||
#32. #37 is the smallest and is unblocked. #34 still carries the open product
|
||
question about a forgotten PIN.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack
|
||
has still never been run and no real lock screen has been looked at — both
|
||
cheap, both never done.
|
||
|
||
### 2026-08-18 — A logging guard, and the leak it was not looking for
|
||
|
||
#38 asked for a guard that stops health data reaching a log. Writing it found a
|
||
worse problem than the one it was written for.
|
||
|
||
**The guard.** `checkNoHealthLogging` fails the build on any logging call in a
|
||
module that can see a cycle date. Two things make it a guard rather than a grep,
|
||
and both were live in this repository already: `PeriodApplication` passes
|
||
`android.util.Log.WARN` to WorkManager as a **constant**, which is not a log
|
||
call, and `ReminderWorker`'s KDoc says *"a `Log.d` in a worker is the kind that
|
||
survives"* — the comment 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 in both directions, per GUARDS.md §1: injecting a real `Log.d` into
|
||
`CycleRepository` produced **exactly one** failure, and a comment containing both
|
||
`Log.d(` and `println(` stayed green.
|
||
|
||
It also failed its first run for a reason worth recording — it walked
|
||
`domain/*/bin/`, an IDE output directory that is gitignored and holds stale
|
||
copies of test files, and reported a `println` in one of them. A guard that
|
||
fails on untracked build output is a guard somebody switches off.
|
||
|
||
**And the thing it could never have caught.** `Prediction`'s `init` block
|
||
interpolated dates into its `require` messages:
|
||
|
||
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is after window end $windowEnd" }
|
||
|
||
Five predicted dates across three messages, in an `IllegalArgumentException` —
|
||
the one string a crash reporter is guaranteed to collect without anybody
|
||
choosing to log it. §45 says "do not include raw cycle dates in crash reports",
|
||
and this was the app doing exactly that, with no logging statement anywhere near
|
||
it.
|
||
|
||
The same shape applies to every data class: `toString()` renders every field
|
||
into any string that touches it. `PeriodRecord`, `SpottingRecord`,
|
||
`CycleRecord`, `Prediction` and `NotYetObservation` now override it — ids and
|
||
lengths survive, dates do not, because an id identifies a row without describing
|
||
a person and a cycle length says nothing about when. `NoDatesInDiagnosticsTest`
|
||
pins all seven cases, and was itself proved to fail.
|
||
|
||
**The release build was driven, not reasoned about.** `assembleRelease` is
|
||
unsigned, so it was signed with the debug keystore and installed alongside the
|
||
debug build. Onboarding to a forecast, then logging a period: **zero ISO dates in
|
||
logcat, zero health words, and the only mentions of the package are the system's
|
||
own.** The screenshot confirms it reached a real forecast rather than failing
|
||
early, because "no logs" is trivially true of an app that did nothing.
|
||
|
||
- **Closed:** #30, #38.
|
||
- **Next action:** Batch 06 has #34, #35 and #37 left. #37, the privacy promise
|
||
in Settings, is the smallest and needs only the screen that now exists. #35,
|
||
export, is the bigger one and its trap is §45 again — an export is health data
|
||
leaving the app, so it uses the Storage Access Framework rather than writing to
|
||
shared storage. #34, app lock, still carries the open product question: what
|
||
happens when somebody forgets their PIN, with an irreversible delete on the
|
||
same screen.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack
|
||
has still never been run and no real lock screen has been looked at. A
|
||
docs/design/dist/splash.png — named bare, because it no longer exists under
|
||
that name — appeared in the tree during this session and was untracked at the
|
||
time. It turned out to be the redrawn lockup, and is now committed as
|
||
`splash.webp`.
|
||
|
||
### 2026-08-18 — Batch 06 starts, and two defects that only a device could show
|
||
|
||
The designed Settings screen and Delete My Data both landed. Neither is the
|
||
interesting part of the day.
|
||
|
||
**A privacy control whose label did nothing.** The delete confirmation promises
|
||
"your reminder settings are unchanged", and proving that meant changing a
|
||
setting first — so I tapped "Maximum privacy" on a device and watched nothing
|
||
happen. `PrivacyRow` and onboarding's `PrivacyOption` both put `onClick` on the
|
||
`RadioButton` and left the row inert. The option deciding what a lock screen
|
||
shows could only be changed by hitting a 20dp circle, in the two places a user
|
||
ever chooses it. Fixed with `Modifier.selectable` on the row, which also merges
|
||
the semantics so TalkBack announces one option instead of a radio and two loose
|
||
strings.
|
||
|
||
It was found by trying to verify a different claim. That is worth noting on its
|
||
own: the check that found it was not looking for it.
|
||
|
||
**And the setting does survive deletion** — set to Maximum privacy, deleted
|
||
everything, still Maximum privacy. Now demonstrated rather than argued from the
|
||
fact that `core/datastore` is a separate store.
|
||
|
||
**A design I had wrong before writing it.** My first draft of `PrivacyViewModel`
|
||
cancelled the reminder schedule on delete, which seems obviously right and is
|
||
not. `ReminderWorker` reads the forecast each run and `NoData` maps to no
|
||
decision, so the scheduled work already does nothing while there is nothing to
|
||
say — and scheduling only happens from `ReminderCoordinator` and the settings
|
||
screen, so cancelling would have left reminders silently off until the user next
|
||
toggled something, long after they had logged a new period. Checked the call
|
||
sites instead of reasoning from the name.
|
||
|
||
**Also fixed, from the font-scale pass:** the bottom navigation wrapped its
|
||
labels mid-word at scale 2.0 — *"Calenda / r"*. `maxLines = 1` and an ellipsis.
|
||
That defect predates all of today's work; the font-scale gap is simply the first
|
||
thing that looked.
|
||
|
||
**Settings shows only the sections that have something behind them.** A
|
||
greyed-out "Delete My Data" tells a user that control over their own health data
|
||
exists and that they may not have it, which is a bad first thing for this
|
||
product to say. Recorded in `docs/design/README.md` so the next person adding a
|
||
row knows it was a decision.
|
||
|
||
Round 4 recorded in `docs/qa/`. Four rounds in, the pattern has not moved: every
|
||
defect of consequence has been found by a person using the app, and none by a
|
||
test.
|
||
|
||
- **Closed:** #29, #33, #36.
|
||
- **Next action:** Continue Batch 06. #38, the release-build logging guard, is
|
||
independent of the rest and can go next — write the guard first and prove it
|
||
fails, per `docs/architecture/GUARDS.md`, because three guards in this project
|
||
have been green over the exact failure they claimed to catch. #35 export and
|
||
#37 the privacy promise both need only the Settings screen, which now exists.
|
||
#34, app lock, still carries an open product question: what happens when
|
||
somebody forgets their PIN, given an irreversible delete sits on the same
|
||
screen.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack
|
||
has still never been run and no real lock screen has been looked at — and
|
||
after today's find, the TalkBack gap looks more expensive than it did: two
|
||
screens had merged-semantics problems nobody would see without it.
|
||
|
||
### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong
|
||
|
||
All eight illustrations are in the app in both themes, every onboarding step has
|
||
art for the first time, and **#29 closes**. The interesting part is that the plan
|
||
I pushed hardest for is not the one that shipped.
|
||
|
||
**I argued for vectors and was wrong.** The issue said redraw the set as Compose
|
||
vector paths taking colour from theme tokens. That is the right answer for
|
||
artwork that does not exist yet — it is exactly what §42 prescribes and what the
|
||
placeholders did. It is the wrong answer for gradient landscapes with glow and
|
||
depth: there is no honest `VectorDrawable` of one, and the attempt would either
|
||
lose the drawing or produce path soup slower than the image. §42 objects to
|
||
*unnecessary* raster, and I had been reading it as objecting to raster.
|
||
|
||
The number settled it. All sixteen files at the height they are actually drawn
|
||
at cost **130 KB**: the release APK went 1.90 MB to 2.03 MB. I had been arguing
|
||
against a cost I never measured.
|
||
|
||
**The theme swap is resource resolution, not a branch.** `drawable/` and
|
||
`drawable-night/`, so Android picks the asset and the artwork follows a device
|
||
theme change with nothing of ours running. It also means a missing night asset is
|
||
invisible in light mode, which is why `OnboardingPreviews.kt` now has one entry
|
||
per illustration rather than a representative few.
|
||
|
||
**What only a device showed.** Each drawing is composed as a card with its own
|
||
rounded corner painted in, so the pixels outside that curve are the card's own
|
||
backdrop — near-black in the dark set. Rendered unclipped, every illustration had
|
||
four visible notches against the app background. Nothing in the build says so,
|
||
the previews are small, and it was obvious within a second of looking at a phone.
|
||
`Illustration` now clips just inside the painted curve.
|
||
|
||
**Two vectors stayed vectors**, and the line is worth keeping: the calendar
|
||
markers and `CycleProgressMark` are drawn *from data* — a marker's shape is what
|
||
a day is, and the progress arc is where the user actually stands in their cycle.
|
||
Good artwork does not make those into images. `CycleProgressMark` moved from
|
||
`Illustrations.kt` to `CycleMarkers.kt` to put that boundary in the file layout.
|
||
|
||
**Sizing is by height, not a square box.** The set does not share one aspect
|
||
ratio and a square would letterbox some and crop others. Height is also the
|
||
dimension that decides whether the primary button stays on screen, so steps 3 and
|
||
6 — three buttons and three option cards respectively — take 104 dp where the
|
||
others take 120–128 dp. Checked by driving all seven steps rather than by
|
||
reasoning about it.
|
||
|
||
Driven end to end on `PeriodMinSdk26` in both themes, no crash.
|
||
|
||
- **Closed:** #29.
|
||
- **Next action:** Batch 06 — Privacy and Security. File its issues first; the
|
||
milestone is still empty. Biometric and PIN lock, export, irreversible delete,
|
||
the privacy promise in Settings, and making §45's logging rules true in a
|
||
release build.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. The QA gaps
|
||
are unchanged: no real lock screen has been looked at, TalkBack has never been
|
||
run, and text has never been scaled — the last one now matters more, because
|
||
every onboarding step has an illustration above its heading and nobody has seen
|
||
that layout at the largest font scale.
|
||
|
||
### 2026-08-18 — The app ran at `minSdk` for the first time
|
||
|
||
A second emulator exists — `PeriodMinSdk26`, API 26, Pixel 6 — and the app was
|
||
built, installed and driven on it start to finish. Everything before today ran on
|
||
API 36, which the standing gaps list has said since Batch 01.
|
||
|
||
**It works.** Onboarding from *Get Started* to a first forecast, the Material 3
|
||
date picker, Today, Calendar, Insights and Settings, all rendering correctly on
|
||
Android 8.0. The forecast was right — 4 August plus 28 days is 1 September, an
|
||
11-day window, confidence honestly **Low** on one cycle of history — and
|
||
fertility declined to estimate rather than inventing one. No crash, and no
|
||
`NoSuchMethodError`, `VerifyError` or `NoClassDefFoundError` attributable to the
|
||
app. The one `NoClassDefFoundError` in logcat is
|
||
`com.google.android.googlequicksearchbox` failing to resolve `TracingController`,
|
||
an API 28 class, in its own process.
|
||
|
||
That is a genuinely useful negative result: the two `NewApi` bugs found in Batch
|
||
01 were exactly this shape and would have crashed here.
|
||
|
||
**Three mechanical traps, all now in the QA playbook** rather than in somebody's
|
||
memory:
|
||
|
||
1. **A windowed emulator dies with the session's X server.** The first one was
|
||
killed by `XIO: fatal IO error on X server ":0"` mid-flow. `-no-window` has no
|
||
such dependency and screenshots still work through `adb exec-out screencap`.
|
||
2. **`adb` regularly has three devices attached here**, because other projects
|
||
keep their own emulators running, and a bare `adb shell` then fails with *more
|
||
than one device/emulator*. The serial has to be resolved from the AVD name;
|
||
assuming `emulator-5554` targets somebody else's work.
|
||
3. **The debug build's application id carries a `.debug` suffix**, so
|
||
`monkey -p dev.privacyllc.period` aborts with *No activities found to run*
|
||
after a successful install — which reads as a broken app rather than a wrong
|
||
package name.
|
||
|
||
**And a free persistence result.** The emulator was killed and cold-booted with
|
||
onboarding half finished. The install survived, and the app restarted at the
|
||
beginning of onboarding with nothing partial committed — which is correct, and is
|
||
the first reboot evidence pass D has ever had.
|
||
|
||
**A guard failure of my own, from the entry above.** That entry named the bad
|
||
`forgejo-issue.py` path in backticks, and `doc-claims.sh` reads a backticked path
|
||
as a claim the file exists — so commit `ad085fb` shipped with the guard failing.
|
||
I had run it before writing the log entry and not after. The path is named bare
|
||
now, which is the convention `DOC_TRUST_MAP.md` already documents for
|
||
deliberately-absent files, and the guard reports 251 claimed paths all present.
|
||
|
||
- **Closed:** nothing. This closes no issue; it partly closes a standing QA gap,
|
||
which lives in `docs/qa/ClaudeQACoverage.md` rather than the tracker.
|
||
- **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.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. Three of the
|
||
four QA gaps are unchanged and remain the cheaper spend: nobody has looked at a
|
||
real lock screen, TalkBack has never been run, and text has never been scaled.
|
||
The fourth — nothing has run at `minSdk` — is now partly answered, though
|
||
notifications, the instrumented suites and any multi-cycle history are still
|
||
unreached at API 26.
|
||
|
||
### 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 a
|
||
`forgejo-issue.py` under docs/architecture/scripts/ — a template path that does
|
||
not exist here, named bare above because `doc-claims.sh` reads a backticked path
|
||
as a claim the file is present and this sentence says the opposite. The guard
|
||
missed it in `WORK_CYCLE.md` for the neighbouring reason: it reads backticked
|
||
prose, not fenced code 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
|
||
identity arrived with the brand guide. Everything that called the project Period
|
||
now calls it **Privacy: Period Tracker**.
|
||
|
||
**The rename was mostly about deciding what NOT to touch.** "Period" is the
|
||
product name and the central domain word at the same time. `PeriodRecord`,
|
||
`PeriodWriteResult`, `confirmPeriodStart`, the `period_records` table and the
|
||
button reading "Started period" all describe a menstrual period rather than a
|
||
product — 239 references, every one of them left alone. A find-and-replace would
|
||
have turned the data model into nonsense. Sixteen files changed in total.
|
||
|
||
Also deliberately unmoved, and written down so the absence reads as a decision:
|
||
|
||
- **The package `dev.privacyllc.period` and `applicationId`.** They already read
|
||
correctly under the new name — `privacyllc` is the company, `period` the app —
|
||
and changing them would rewrite 68 files, rename the Room schema directory and
|
||
break the hardcoded path in `schema-guard.sh`.
|
||
- **`period.db`.** Renaming a database file orphans the data on every device
|
||
that already has it. The emulator's existing install opened fine afterwards,
|
||
which is the proof.
|
||
|
||
**The repository was renamed in place**, not recreated: `null/Period` →
|
||
`null/Privacy-Period-Tracker`, keeping all 30 commits, 27 issues, 8 milestones
|
||
and 4 labels. Counted on both sides rather than assumed, because "a rename is
|
||
lossless" is exactly the sort of thing that is true until it is not.
|
||
|
||
**Three names have to agree**, and only two of them are obvious: the Forgejo
|
||
repository, the git remote, and the Command Center's `forgejoRepo` mapping. The
|
||
third is the silent one — a stale mapping degrades to *"pointing at a repository
|
||
that does not exist"* on the next reconcile and the site quietly stops counting.
|
||
All three verified, and the reconcile afterwards reported `0 pointing at a
|
||
repository that does not exist`.
|
||
|
||
**The Command Center slug could not be renamed.** `PATCH` accepted a `slug` field
|
||
and ignored it; `period` still resolves and `privacy-period-tracker` returns 422.
|
||
It is an internal key, invisible on the card, and recreating the project to
|
||
change it would throw away its history — so it stays, recorded here rather than
|
||
retried later by somebody who assumes it was missed.
|
||
|
||
**One inconsistency created on purpose.** The canonical name has a space after
|
||
the colon; the supplied wordmark does not. `BRAND_GUIDE.md` §10 now says the
|
||
space form and says plainly that the drawn mark disagrees. Filed as #28 rather
|
||
than papered over, because redrawing a lockup needs an artist.
|
||
|
||
- **Closed:** nothing — this is a rename, not a deliverable. #28 filed.
|
||
- **Next action:** Batch 06 — Privacy and Security, unchanged by the rename. 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.
|
||
- **Blockers:** #9, the Command Center webhook, still needs a person. Its body
|
||
named the old repository path and has been corrected in the tracker, so
|
||
whoever picks it up gets a command that works. The four QA gaps are unchanged
|
||
and still the better spend: nobody has looked at a real lock screen, TalkBack has never been run,
|
||
text has never been scaled, and nothing has run at `minSdk` 26.
|
||
|
||
### 2026-08-18 — Batch 05 shipped, and the app got its real brand
|
||
|
||
Reminders landed and the milestone closed. **Five of eight batches are done, and
|
||
exactly one issue is open in the whole tracker** — #9, the webhook, which needs a
|
||
person.
|
||
|
||
**Notifications, and the two Android behaviours that leak if you trust the
|
||
docs.** `NotificationCopy` is a pure function, so every kind × every mode is
|
||
tested exhaustively without an emulator — including the action labels, which §31
|
||
points out are visible text too. A perfectly discreet body under a button
|
||
reading "Started my period" leaks anyway.
|
||
|
||
Then the device found what the unit tests could not:
|
||
|
||
1. **A private notification with no public version does not blank the lock
|
||
screen — it shows the private text.** `NotificationText` therefore has no
|
||
nullable title, and an instrumented test asserts every kind attaches one.
|
||
2. **A notification channel is immutable after creation.** Importance and
|
||
lock-screen visibility cannot be changed, so one shared channel would have
|
||
kept whatever the user's first privacy mode set, forever. Switching from
|
||
Direct to Maximum privacy would have *appeared* to work and changed nothing.
|
||
One channel per mode now.
|
||
|
||
**A third guard failed its first proof.** `checkPermissions` — new, and worth
|
||
having, because adding WorkManager silently brought four permissions that appear
|
||
in the Play listing — read a stale merged manifest because it did not depend on
|
||
the task that writes one. An injected `SCHEDULE_EXACT_ALARM` went unnoticed.
|
||
That is the schema guard, the boundary guard and now the permission guard: three
|
||
for three. **Assume a new guard is broken until it has been watched failing.**
|
||
|
||
**And a mistake of mine worth keeping.** While proving the pre-commit fix I
|
||
committed a throwaway and ran `git reset --hard HEAD~1`, which did not undo the
|
||
push the post-commit hook had already made and silently discarded the
|
||
uncommitted fix I was mid-proof on. Reconciled forward rather than rewriting
|
||
published history. Commit the work before proving the guard.
|
||
|
||
**The brand arrived mid-session.** The owner supplied a full visual guide and
|
||
artwork, which answers #8 — the three marks an agent must not fake now exist
|
||
because a person made them. Converted to the required webp with two things
|
||
handled rather than blindly transcoded: the sources render their rounded corners
|
||
against black, and the lockup is square so a 3:1 crop would have cut the wordmark
|
||
in half.
|
||
|
||
The guide **supersedes §39's colour direction**, and one conflict is real: §39
|
||
wants a muted teal fertile window and the guide is plum-and-rose throughout with
|
||
a pink for fertility. The guide wins, and it is safe for it to win *because the
|
||
calendar's states differ in shape rather than colour* — which is what §43
|
||
actually requires. Written down in `docs/design/README.md` rather than left to be
|
||
found.
|
||
|
||
- **Closed:** #8, #24, #25, #26, #27 — and the `Batch 05 — Notifications`
|
||
milestone.
|
||
- **Next action:** Batch 06 — Privacy and Security. File its issues first; the
|
||
milestone is empty. The work is biometric and PIN lock, export, irreversible
|
||
delete, the privacy promise in Settings, and making §45's logging rules true in
|
||
a release build. Much of the groundwork exists — `deleteAllHealthData` is
|
||
written and tested, backup is already excluded in the manifest — so this batch
|
||
is largely surfacing what the data layer can already do, plus the designed
|
||
Settings screen the Batch 05 working surface is standing in for.
|
||
- **Blockers:** One issue, and it needs a person: #9, the Command Center webhook.
|
||
Until it is registered an opened `P0` raises no alert at all. The QA gaps are
|
||
now the more pressing thing: **nobody has looked at an actual lock screen**,
|
||
TalkBack has never been run, text has never been scaled, and nothing has run
|
||
at `minSdk` 26. All four are cheap and none has ever been done.
|
||
|
||
### 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.
|