# M6 — Cycle (Fertility) — Session Card

> **Planning artifact.** This card is opened by a future build session so it knows exactly what to build for module **M6 Cycle (Fertility)**. It contains no production code. It is faithful to `docs/plan/13-execution-modules-and-sessions.md` (the session framework), `03-protocol-engines.md §6` (the engine spec), `04-backend-togo.md` (backend conventions), `02-platform-divergences.md §2.1` (this module closes Divergence #1 on iOS), and `12-legacy-reference-map.md` (legacy precedents).

## 1. Purpose

The Cycle (Fertility) engine tracks user-logged menstrual cycles and offers predictions **only** once enough consistent data exists, and otherwise **fails safe to "no prediction."** It is a backend state machine in ToGO (the Single Source of Truth); every client — web, mcp, apple, android, desktop (there is **no chrome surface** for this module) — only renders the engine state the API returns and submits log intents. This module closes **platform Divergence #1** (Meds & Cycle Tracking, historically missing on iOS): in the greenfield build the 3-cycle prediction gate and irregularity fail-safe live once, server-side, so the iOS/Android/web screens are pure presentation against an already-live contract and can never re-derive the rule. Because cycle/fertility data is highly sensitive, cross-user isolation (404-not-403) and per-user SSE/event routing (no cross-user leakage) are hard requirements, not niceties — the legacy build had to fix an event broker that leaked across users, and this module must not reintroduce that class of bug.

## 2. Engine parameters relevant to this module

### Spec-fixed constants (from `03-protocol-engines.md §6.2` and shared context — do NOT invent others)

| Parameter | Value | Notes |
|---|---|---|
| Calibration requirement | **3 consecutive logged cycles** before **any** prediction is made | Hard gate. No prediction, ever, below this. |
| Irregularity rule | On irregularity the engine **fails safe: no prediction** | Predictions are **withdrawn**, never "widened." |
| Prediction sentinel | `unavailable` (+ reason code) | NOT NULL rule: an explicit sentinel value, **never SQL NULL**. |
| States | `Insufficient` / `Calibrated` / `Suspended` | Categorical only — the user never sees a confidence percentage (Core Opinion 1). |
| "Consecutive" | no unlogged cycle between logged ones | Engine never infers a skipped cycle happened. |

State transitions (`03 §6.3`): `Insufficient → Calibrated` on the 3rd consecutive cycle; `Calibrated → Suspended` when irregularity is detected; a logging gap resets the consecutive counter to `Insufficient`; `Suspended → Insufficient` on recalibration (a fresh 3-consecutive run — see PENDING items).

### Legacy precedent (PENDING RATIFICATION — precedents from `cycle.go`, NOT spec-fixed; carry as fail-safe categorical/temporal, no medical certainty)

These are the concrete numbers the legacy engine used. They are **candidate answers**, not authority — the greenfield spec (`03 §6.2`, and Open Question #6 in `03 §Open Questions`) leaves the quantitative definition of "irregularity" undefined. Use them as the default implementation to be ratified during the backend session, and encode whatever is ratified as literal constants inside the engine `core/` package.

| Precedent | Legacy value (`cycle.go`) |
|---|---|
| Minimum cycles for prediction | `MinCyclesForPrediction = 3` (matches the spec gate) |
| Irregularity threshold | `IrregularThresholdDays = 9`-day spread → refuse to predict |
| Averaging window | average of the **last 6 cycles** |
| Ovulation estimate | next cycle start **−14 days** |
| Fertile window | **−5 / +1 day** around the ovulation estimate |
| BBT confirmation | 3 readings **≥ +0.2 °C** over the prior 6-day mean |
| Pregnancy dating | **Naegele: +280 days** from cycle start |
| Safety data | `SafetyProvider` interface with `NoSafetyData{}` default — **no drug DB wired, by design** |

All predictions are behavioral-data extrapolations, explicitly labelled non-medical, and every AI-adjacent / fertility surface ends in **"Consult your doctor"** (Core Opinion 2). The engine never smooths, averages away, or best-guesses an irregular history.

### Data model (this module owns)

- Tables: **`cycles`** and **`cycle_days`** (per-user). Every column `NOT NULL` with a defined default; UUID ids; `timestamptz` timestamps (`04 §4/§6`).
- Endpoints (names from `03 §6.6`, under the `/api/v1/` prefix per `04 §API`): `POST /api/v1/engines/cycle/logs` (log a cycle event; accepts `Idempotency-Key`), `GET /api/v1/engines/cycle/state` (calibration state + prediction-or-`unavailable` + reason), `GET /api/v1/engines/cycle/history`.
- Engine output shape: `{ state, consecutive_cycles_logged, prediction: value|unavailable, reason }`.

## 3. Session table

One row per session. Surface order per `13 §3`: backend → web → mcp → (no chrome) → apple → android → desktop. **The backend session must land before any client session** (`13 §4`). Replace `<date>` (YYYY-MM-DD) and `NN` (per-cell counter) when the session actually runs.

| Session ID | Surface | Scope (what this one session builds) | Entry deps | Done criteria (template from `13 §5`) | Plan docs to read |
|---|---|---|---|---|---|
| `S-<date>-M6-backend-NN` | backend | The **cycle ToGO plugin**: pure `core/` state machine (Insufficient/Calibrated/Suspended, the 3-consecutive gate, irregularity → no-prediction fail-safe, `unavailable` sentinel + reason) + `plugin.go` registration; `cycles` + `cycle_days` expand-contract migration; the three endpoints; engine unit tests as executable spec; integration test on real Postgres. Ratify the PENDING legacy numbers (§2) and encode them literally in `core/`. Verify cross-user isolation (404-not-403) and that any SSE/event emitted for cycle carries **no cross-user leakage**. | M0.1–M0.4 (foundation backend: kernel, TOGO auth + isolation test, plugin framework, expand-contract + error-envelope harness); **M1 backend** as the reference-engine pattern to copy | **Backend-engine template:** plugin registered on the kernel; migration is expand-contract and every column `NOT NULL` with a default; endpoints return computed state; engine unit tests encode every spec/ratified constant literally and pass (no prediction until 3 consecutive cycles; fail-safe = no prediction on irregularity); integration test on real Postgres passes (idempotency replay; contradictory-edit → `Suspended`; cross-user isolation 404-not-403); **no protocol constant appears outside the engine's `core/` package** | `13` (§3–§6), `03 §6` (engine spec), `04` (§2 layout, §3 sqlc+Atlas, §4 NOT NULL rule, §5 expand-contract, §8 plugin/purity, §9 testing), `12 §2` (legacy `cycle.go` constants) |
| `S-<date>-M6-web-NN` | web | The **cycle screen** (`/engines/cycle`): renders server state — shows calibration progress / "needs 3 consecutive logged cycles" when `Insufficient` or `Suspended`, and shows a prediction **only** when the server state is `Calibrated`. Cycle-log entry control submits intents only. Both themes; Arabic RTL. | `S-<date>-M6-backend-NN`; M0.5–M0.7 (web shell, theme engine, i18n engine); **M1 web** as the reference card pattern | **Client-engine template:** renders the cycle endpoint state and submits log intents only; **thin-client/SSOT audit passes** — no gate/irregularity/prediction logic in client code, grep for the protocol constants finds nothing, prediction shown only when server says `Calibrated`; both themes render per the strict color spec; Arabic RTL mirrors correctly (incl. progress/calibration bars); strings are ICU, none concatenated; "Consult your doctor" deferral rendered verbatim | `13` (§5 client template, §6), `03 §6`, `05` (§3 routing `/engines/cycle`, §4 query keys, §5b RTL strategy, component inventory), `02 §2.1` (Divergence #1) |
| `S-<date>-M6-mcp-NN` | mcp | **Cycle status read-only** MCP tool(s): `cycle_status` → `GET /api/v1/engines/cycle/state`, returned verbatim. **No prediction tool beyond what the backend exposes** — the engine's fail-safe rules apply unchanged; no client-side inference. | `S-<date>-M6-backend-NN`; **M0.8** (MCP server + CLI skeleton) | **MCP template:** each tool calls the real API with personal-access-token auth, returns server state verbatim, holds no protocol logic and no AI keys; the excluded tools stay excluded (no cycle/fertility *prediction* tool beyond the backend `state`/`history` reads; the general `complete_break` exclusion still holds) | `13` (§5 MCP template, §6), `11` (§thin-layer rule, planned tools, deliberately-excluded tools incl. "no cycle/fertility prediction tool"), `03 §6` |
| `S-<date>-M6-apple-NN` | apple | Native Swift **Meds & Cycle screen** (iOS; + macOS/widget surfaces as they apply) rendering cycle engine state against the live API — **this session closes Divergence #1 on iOS.** No local re-implementation of the 3-cycle gate or irregularity rule. | `S-<date>-M6-backend-NN`; **M1 apple** (Apple project scaffold + reference engine pattern) | **Client-engine template:** renders the cycle endpoint state and submits log intents only; thin-client/SSOT audit passes (no gate/prediction/irregularity logic in Swift — grep finds no protocol constants; iOS never re-derives the rule); both themes per the strict color spec; Arabic RTL mirrors correctly; strings are ICU/String-Catalog, none concatenated; "Consult your doctor" deferral present | `13` (§5 client template, §6), `03 §6`, `07` (§principles, project layout, iOS app row — "Meds & Cycle tracking (closes Divergence #1)"), `02 §2.1` |
| `S-<date>-M6-android-NN` | android | Native Kotlin/Compose cycle screen (phone; + Wear/Glance as they apply) rendering cycle engine state; cycle-log intents only. | `S-<date>-M6-backend-NN`; **M1 android** (Android scaffold + reference engine pattern) | **Client-engine template:** renders the cycle endpoint state and submits log intents only; thin-client/SSOT audit passes (no protocol logic in Kotlin, grep finds no constants); both themes per the strict color spec; Arabic RTL mirrors correctly; strings are ICU, none concatenated; "Consult your doctor" deferral present | `13` (§5 client template, §6), `03 §6`, `08` (project layout, engine screen pattern, en/ar parity), `02 §2.1` |
| `S-<date>-M6-desktop-NN` | desktop | Electron cycle surface, **reusing the M6 web components** (`13 §3` step 7) — thin renderer of server cycle state. | `S-<date>-M6-backend-NN`; `S-<date>-M6-web-NN` (component reuse); **M1 desktop** (Electron scaffold pattern) | **Client-engine template:** renders the cycle endpoint state and submits log intents only; thin-client/SSOT audit passes (reused web components carry no protocol logic; PAT auth via safeStorage, no keys embedded); both themes per the strict color spec; Arabic RTL mirrors correctly; strings are ICU, none concatenated | `13` (§5 client template, §6), `03 §6`, `09` (Electron layout, web-component reuse), `05` (reused components) |

**Session count: 6** (backend, web, mcp, apple, android, desktop — no chrome).

## 4. Notes & gotchas

**Legacy precedents worth reusing**
- `internal/protocols/cycle.go` and its `*_test.go` are the **executable protocol specification** — validate the new `core/` engine against those cases first (`12 §2`). Constants: `MinCyclesForPrediction=3`, `IrregularThresholdDays=9`, last-6-cycle average, ovulation −14d, fertile window −5/+1d, BBT +0.2 °C ×3 over prior 6-day mean, Naegele +280d.
- The legacy `SafetyProvider` / `NoSafetyData{}` seam (no drug DB wired, by design) is the correct posture — keep the safety hook empty rather than inventing safety data.
- Legacy per-user CRUD already modeled `cycles` and `cycle_days` (and `pregnancies` with a partial-unique "one ongoing per user") — a schema precedent, but re-evaluate the legacy anti-patterns (text ids → use UUIDs; absent dates as empty text → use proper defaults; no FK to the auth users table; CSV-text fields).

**Liabilities to avoid**
- **#1 liability — duplicated protocol logic on a client.** The legacy iOS app re-implemented engines locally and never called the API; that is exactly the drift this module closes. Every client session's done-criteria include the thin-client/SSOT grep audit for precisely this reason.
- **Sensitive-data routing.** Fertility data is sensitive: the legacy fix replaced ToGO's event broker (which **leaked events across users**) with a per-user SSE hub carrying resource names only, no state (`12 §4`). Any cycle event/SSE emitted here must be per-user with **zero cross-user leakage**; foreign rows return **404 not 403**. Add an isolation assertion to the backend integration test.
- **Never emit a prediction outside `Calibrated`.** The prediction field must carry the `unavailable` sentinel (not SQL NULL) plus a reason in `Insufficient`/`Suspended`. Clients must show a prediction **only** when the server state is `Calibrated` — no client-side "we probably have enough data" heuristic.
- **No medical certainty.** Predictions are behavioral extrapolations, never fertility guarantees or contraceptive advice; all such copy defers to "Consult your doctor" (Core Opinion 2).

**PENDING DESIGN / PENDING RATIFICATION**
- **PENDING RATIFICATION:** the quantitative definition of "irregularity" is undefined in spec (`03 §6.2`, Open Question #6). Ratify the legacy 9-day-spread / last-6-average / BBT numbers (or a replacement) during the backend session and encode them literally in `core/`.
- **PENDING (spec open, `03 §6.7`):** whether recalibration after `Suspended` requires a fresh full 3-consecutive-cycle run (planning default: yes, fail-safe); and whether **backfilled** historical cycles count toward "consecutive" (planning default: stored but not counted — only forward-logged cycles calibrate).
- **PENDING PRODUCT/CLINICAL (`03 §7`):** whether an active contraceptive method (M7) should suspend cycle predictions — a product/clinical decision, not an engine guess. Coordinate at M7, do not hardcode an interaction now.
- **PENDING SCOPE:** pregnancy dating (Naegele +280d) and a `pregnancies` table — decide in the backend session whether it is in M6 scope or deferred; it is a legacy precedent, not a spec-fixed M6 deliverable.
- **PENDING DESIGN:** the calibration-progress UI (Insufficient state) and the cycle/prediction screen layouts across web/apple/android come from the separate design track (`02 §2.1`, `03 §6.3`).

## 5. Handoff reminder

At the end of **every** M6 session, per `13 §6`: write **one** `session-handoff` memory to the `healthdebug` brain (retain, then `memory/edit` to attach metadata — retain cannot set metadata) using the exact handoff content structure, with `source_ref: handoff/M6-cycle-fertility/<surface>/<n>` (unique per session; never reuse) and `metadata: {project:"healthdebug", category:"session-handoff", era:"greenfield", status:"current", surface:"<surface>", module:"M6", title:"Handoff <session-id>", next:"<module+surface>"}`. Then **update the single build-ledger memory** (`source_ref: build-ledger`, edited in place) so one recall shows the whole frontier and the recommended next session. Any architectural decision or new open question also gets its own classified memory and, if it changes the plan, an edit to the relevant plan doc.
