Privacy-Period-Tracker/docs/history/DEVELOPMENT_LOG.md

72 KiB
Raw Blame History

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-20 (final) — Discreet launcher alias landed

Issue #31 is ready. The normal launcher entry is now PeriodLauncherAlias, and the optional discreet entry is IncognitoLauncherAlias, both targeting the same MainActivity and shipping under the same application id. Settings exposes the choice as Discreet launcher, confirms that the home-screen entry is removed and re-added, and uses the neutral label Daybook with a generic calendar-dot adaptive icon.

The preference lives in DataStore, survives Delete My Data, and is only recorded after PackageManager accepts the alias switch. The implementation enables the target alias before disabling the other one, so the package is never left without a launcher entry.

What this proved: LauncherAliasSwitcherTest checks the real merged-manifest component names, including debug's applicationIdSuffix; PrivacyViewModelTest checks enable, restore, failure and delete-survival behavior; UserPreferencesRepositoryTest checks the DataStore default and round trip. ./gradlew :app:testDebugUnitTest passed after clearing a generated KSP cache that was corrupted by concurrent Gradle runs, and ./gradlew :core:datastore:test checkThemedDrawables assembleDebug passed.

Device proof ran on emulator-5554, API 34: a fresh adb install -r produced one package, dev.privacyllc.period.debug; the default launcher resolver returned dev.privacyllc.period.PeriodLauncherAlias; turning on the setting through the UI switched the resolver to dev.privacyllc.period.IncognitoLauncherAlias; relaunch through that alias opened the app and Settings showed Daybook as active; restoring through the UI returned the resolver to PeriodLauncherAlias; a second adb install -r succeeded over the existing install and still listed only the one debug package.

Next action: close Batch 08 once the tracker catches the closes #31 commit, then move to Batch 07 monetization planning and issue filing.

Blockers: none.

2026-08-20 (later) — Play listing assets created

Issue #32 is ready: the Play listing artwork now exists under docs/design/brand/play-store/. The set includes the required feature graphic, the 512 px Play icon and seven phone screenshots exported from the seeded demo captures. The screenshots are top crops rather than resized screenshots, so they keep the real app UI and land at Play's recommended 1080 x 1920 portrait shape.

Google's Play Console Help was checked on 2026-08-20 before choosing the export formats: feature graphic 1024 x 500, screenshots as JPEG or 24-bit PNG with no alpha and a long side no more than twice the short side, and at least four app screenshots at 1080px+ for recommendation eligibility. The app manifest declares no Wear OS, TV, Automotive or XR surface, so no extra form-factor asset set was created.

The folder README records the upload alt text, regeneration commands and the rules that matter for a period tracker: seeded demo data only, no notification in the status bar, no medical or contraceptive claim, and none of §42's forbidden imagery.

What this proved: identify reported 1024x500 srgb for the feature graphic, 512x512 srgba for the Play icon, and seven 1080x1920 srgb phone screenshots. A failing validation script checked exact dimensions, channel rules, Play screenshot aspect ratio and file size ceilings. doc-triggers.py and doc-claims.sh were run after the docs updates; doc-claims passed with only the repository's expected menu notices.

Next action: finish #31, the neutral incognito launcher label and icon.

Blockers: none.

2026-08-20 — Dark welcome artwork corrected

Issue #44 is fixed in the artwork set and in the shipping drawables. The dark welcome source now matches the light welcome subject: overlapping translucent cycle rings over the night landscape, with a crescent moon, botanicals and no calendar, arrow, dashed forecast days, text or step badge. Only 01_Welcome was changed; dark 07_Forecast stayed as the forecast scene.

The corrected source is docs/design/dist/Dark/01_WelcomeIllustration_dark.webp, and the four drawable-night-*dpi/art_welcome.webp variants were regenerated from it at the existing bucket sizes. The owning dist README was reviewed in the same change and now records the trap: future replacements should judge dark 01 against light 01, not dark 07.

What this proved: visual inspection of the corrected source; identify confirmed 2048x1365 for the source and 617x411, 822x548, 1233x822, 1644x1096 for the night buckets; a SHA-256 check reported all 16 source WebPs unique; ./gradlew checkThemedDrawables passed; ./gradlew assembleDebug passed.

Next action: work issue #32, the Play Store listing assets, then finish #31's neutral incognito launcher label/icon against that final asset direction.

Blockers: none.

2026-08-19 (later) — Batch 06 closed, and four guards that can now fail

Export, the privacy promise, the themed-drawable guard and the notification icon all landed, and Batch 06 is closed with seven issues in it. The tracker has three issues left and none of them is code: two need artwork, and the third (#31, the incognito launcher) says in its own body that the alias must land with §32's toggle rather than ahead of it.

#35, export. One pretty-printed JSON file, plaintext, written straight into a document the user picked through the Storage Access Framework. No copy in cacheDir, no FileProvider, no path handed to another app — and a test walks cacheDir afterwards and requires it empty, which fails the moment anybody reintroduces write-then-share.

The format is pinned byte-for-byte against a committed golden file, because the issue is right that it outlives the batch: whatever ships first is what people's archives are in. It also renders identically in UTC, +14 and 12, because Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident" and a zone-aware formatter here would be off by one day for everybody east or west of whoever wrote it.

Plaintext was the decision, and it reverses an earlier note in this file. The entry above records the next action as "export with the file encrypted". That is wrong, and SECURITY.md now says why: the export is the copy that makes a lost Keystore key survivable rather than final — the exact condition written down for ever revisiting database encryption. A passphrase would reproduce the failure that decision avoided, and nothing, including this app, could open the result.

"Only the user's own data" is enforced structurally rather than by review: :core:export is pure JVM and depends on :domain:cycle alone, so Prediction, FertilityEstimate and android.os.Build are not on its classpath. Adding a forecast does not compile.

Four guards exist now that did not, and each was watched failing.

  • checkThemedDrawables (#42) — every drawable has a night twin, both directions. prove-guard.sh cannot drive it, because it replaces a string in a file and this guard's failure mode is a file that is not there, in a set that is all .webp. GUARDS.md §9 records that class.
  • checkNoSharedStorageWrites — §45's shared-storage ban was enforced by nobody having typed it. checkPermissions structurally cannot see it: it matches <uses-permission>, and a <provider> declaring FileProvider merges green.
  • checkNoHealthLogging completeness — a module missing from modulesSeeingHealthData was silently exempt with a green build. Both :core:security and :core:export would have gone through that hole.
  • checkPermissions narrowed — it was reading merged_manifest/debugUnitTest and failed on REORDER_TASKS, a test-runner permission that does not ship. The tempting fix was to allowlist it, which would have permitted it in the real manifest too and quietly undone the guard.

Three defects found that nobody was looking for.

  1. NotificationPrivacyTest could never run on PeriodMinSdk26. GrantPermissionRule asked for POST_NOTIFICATIONS unconditionally, and that permission arrived in API 33 — so every test in the class errored before its first assertion, on the one emulator where it mattered. The class guards what a locked screen shows.
  2. No Robolectric test in :app could read a string resource. unitTests.isIncludeAndroidResources was never set there, so the module owning nearly all the user-facing copy was the one whose copy could not be tested.
  3. prove-guard.sh, a third time: a PROVE_GUARD_FAIL_PATTERN matching nothing gives the same "caught it, and only it" verdict as one matching once. The empty --- what failed --- block is the tell, and GUARDS.md §8 now says to read it.

A mistake worth recording. Looking for a device to verify the export on, I installed the debug APK onto whatever adb reported and drove it — and it was the FruitFall emulator, running another project's app. My package is uninstalled and their app was left running, but the lesson is that a device on adb devices is not evidence of a device that is mine. The export's on-device checks are three new rows in SECURITY_CHECKLIST.md rather than a claim.

What this proved: 261 JVM tests, none skipped. Five guards green, four of them proved able to fail today.

Next action: file Batch 07's issues. Monetization is an open milestone with zero issues, closed or open, so the largest remaining body of code in this project is described nowhere — which also means the 84% figure on the project screen is measured against a denominator that excludes it. Ads and billing need scoping before they need writing, and §34's advertising boundary is the part that most needs stating before anybody starts.

Blockers: none for code. #31 waits on §32's toggle by its own instruction, #32 and #44 need artwork, and the public privacy page — which #37's Privacy Policy row waits on — is site-side work no agent here can do.

2026-08-19 — The app lock, and three defects only testing found

#34 is closed. The app asks for a PIN before it opens, and a forgotten PIN cannot be reset by anybody — which was the decision the issue had been waiting on since it was filed.

The gate wraps the composition, not a screen. Today, Calendar and Insights each start collecting from CycleRepository the moment they compose, so a lock built as a navigation destination — even the start destination — would have read the history before the user proved anything. content() is invoked only in the unlocked branch. There is no lazy tab to hide behind.

What is stored is not the PIN. HMAC(keystoreKey, 0x01 || salt || PBKDF2(pin, salt, 210k)). The Keystore MAC is what makes a six-digit PIN safe at all — a million candidates is nothing to an attacker who can compute the hash, and impossible for one who cannot get the key off the device. PBKDF2 underneath is for the day that assumption breaks.

The omissions in the key spec are the design. AndroidKeyStoreMacProvider sets three builder options and deliberately omits six, and its KDoc names every one. setUserAuthenticationRequired is the important absence: it would bind the key to the device lock, so changing a phone passcode would destroy it — and with no recovery, that is somebody's entire history gone for an unrelated reason. It would also be a bypass, since SECURITY.md already lists "someone who knows the unlock PIN" as an adversary this app cannot stop.

Wrong PINs cost time and never cost data. Four free attempts, then 30s→1m→2m→5m→15m, capped forever, with no attempt limit and no auto-wipe. Under no-recovery an auto-wipe would hand a partner — or a child, or a pocket — the power to destroy a cycle history while knowing nothing at all. Both clock bypasses are closed: the wait is the longer of a wall-clock and a monotonic deadline, and a reboot re-applies it in full, detected by elapsedRealtime going backwards.

Two writes had to move. Tapping "Not yet" on a reminder writes a NotYetObservation, and that button sits on the phone's own lock screen where anybody can reach it — so the action is parked and applied only after an unlock, dropped if the session never unlocks. And the erase behind "Forgot your PIN?" deletes health data, then the Keystore key, then the lock store; skipping the middle step leaves the user erased and still locked out.

Three defects, none found by reading the code.

  1. A fresh install began in a fifteen-minute lockout. "No counter yet" and "counter was tampered with" were the same value — null — and the policy treats tampering as maximum backoff. Caught the moment AppLockRepositoryTest ran, and it failed five tests at once.
  2. Setting a PIN locked you out of the session you set it in: the gate shut the instant hasPin turned true. Found by driving the emulator, not by any test, and it would have been the first thing a user saw of a feature they had just been warned was unrecoverable.
  3. A stray NUL byte inside a char literal made AppLockViewModel.kt binary to git. It compiled, and 244 tests passed. The only symptom was Bin 0 -> 7069 bytes in the commit summary where every other file showed a line count — and a source file git treats as binary produces no diff, so the one file that decides whether the app is locked would have been unreviewable in every future change.

What this proved: 244 JVM tests, 0 skipped. KeystoreVerifierTest ran on PeriodMinSdk26 and PeriodQA, confirming PBKDF2WithHmacSHA256 exists at API 26 — the one algorithm choice here with no margin — and that the key is not auth-bound at either level. Removing the key-deletion line from the erase path fails exactly one test, prove-guard exit 0. On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on the lock screen, turning the lock off requires the current PIN, and adb exec-out screencap returns mean=0 stddev=0FLAG_SECURE proved with a number rather than asserted.

androidx.biometric is at 1.1.0 because that is the newest stable; 1.4.0 is alpha-only and biometric-ktx never shipped a stable release, both checked against Google's Maven index. A full audit of the catalog the same day found every other artifact, all four plugins and Gradle itself already at current stable.

Next action: #35, Export My Data, with the export file encrypted. It is the only artifact that leaves the sandbox, which is exactly where the platform's own file encryption stops protecting — and it is also the precondition recorded in SECURITY.md for ever revisiting database encryption, because a user who can hold their own copy is a user for whom a lost key is not a lost history.

Blockers: none. Six device checks from #34's own list were not run and are now rows in SECURITY_CHECKLIST.md rather than memory: a fingerprint enrolled on API 26/27, rotation and fontScale 2.0 while unlocked, a reminder action tapped while locked on hardware, an OEM biometric overlay, a FLAG_SECURE flicker on first launch, and TalkBack on the lock screen.

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 1117% 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 130500 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 120128 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/Periodnull/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 2961 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.