The dedup key on the medication intake surfaces is not only `externalId`.
`MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted
with the dose row forever, with no TTL and no sweep, matched by plain
equality on the per-medication POST, the bulk drain and the external
ingest bridge. That makes it an identity, not a replay token, and it
carries the same failure mode: a key that rotates per client launch never
matches its own earlier post, so every retry logs the dose again. The
blank case is sharper still — the column is unique table-wide, so an
empty key resolves to whichever unrelated row stored it first. Refuse
both shapes at the schema on the single-entry paths and per entry on the
bulk drain.
The MCP write path hashes a host-supplied key into the `externalId` half
of its dedup key, so the shape is laundered before anything downstream
could see it and the check has to run on the input. That closes a live
one: every tool handler falls back to `""` when the host omits the
argument, and `""` digests to a constant, so each keyless write after the
first reported `already_logged` and was silently dropped.
The HTTP `Idempotency-Key` header stays out, and now says why in the
guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps
it, so it is a windowed replay token and rotation is its design.
Extend the structural guard with a provenance rule for identities that
never pass a Zod request schema — a spreadsheet column, an MCP tool
argument. Inside the import and MCP trees, a module handling an identity
must apply the floor itself or import a module that does. That is a real
chain rather than a list of known offenders: a new importer that mints an
`externalId` from file input and imports nothing checked fails it, with
no allowlist to escape into.
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.
Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.
Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.
A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Let a user pick the Sensiplan double-check's second indicator — cervical
mucus (default) or cervix observation — mirroring the published NFP
standard where the temperature primary sign pairs with one chosen
secondary sign.
- `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which
second sign the engine confirms against. Casual users keep temperature
+ mucus untouched; the choice lives behind the advanced cycle settings.
- `CycleDayLog` gains the three standard cervix signs — position
(LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable
columns (migration 0157), wired through Zod, the day-log upsert, the
DTO, the sync feed, backup/restore and OpenAPI. Additive and
back-compatible: existing profiles backfill MUCUS, existing day-logs
keep NULL cervix signs, iOS inherits the fields.
- The engine's `confirmSymptothermal` takes the secondary-symptom choice;
on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix
day followed by three closed days — the cervix-closure rule) instead of
the mucus peak. The double-check still requires BOTH the temperature
shift AND the secondary peak: a single cervix sign never confirms, and
the still-learning gate and conservative posture are unchanged.
Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula &
Frank-Herrmann standard.
A fever, illness, alcohol or a late/short-sleep reading can fake or mask
a temperature rise. Add a per-day `temperatureExcluded` flag on
CycleDayLog (migration 0156): when set, the engine drops the reading from
the cover-line baseline AND the shift evaluation, so a spike neither
fabricates nor masks a rise (Sensiplan: the cover line is drawn over the
last six unbracketed values).
Wired end to end — schema + Prisma, the engine's `detectTempShift`
filter, the Zod input/patch schemas, the day-log upsert (partial merge +
change detection), the read DTO, the calendar/insights/coach selects, the
backup round-trip, and the OpenAPI contract (a new boolean on the day-log
and calendar-day DTOs for the iOS client). The log-day sheet gains a
"mark reading as disturbed" toggle that appears once a temperature is
entered, translated across all six locales.
Citation: de.wikipedia "Symptothermale Methode"; myNFP "Temperaturkurve
auswerten" — Störfaktor bracketing.
The predictions panel suppressed its fertile-window and ovulation rows while
fewer than three cycles were observed, but the month calendar did not — so a
brand-new user who logged a single period saw the grid paint a confident
fertile-window shading, a predicted-ovulation dot, and full phase bands off a
population 28/14 prior at ~0.20 confidence. Calendar-based fertility estimation
is unreliable below several recorded cycles, so the grid was asserting days it
could not know.
Thread the same still-learning gate into the calendar build: while fewer than
three cycles are observed, suppress the fertile window, the ovulation dot, and
the phase band (the predicted-period bar stays, mirroring the panel). Surface a
top-level stillLearning flag on the calendar response so the client renders a
calm "learning your cycle" banner over the grid instead of a bare, falsely
confident calendar. The flag is additive and back-compatible; older clients
ignore it and degrade to the previous render.
Also pin the observed-cycle count to a single source — the engine's
post-exclusion value — so the response never carries two divergent counts.
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
- Add CYCLE_FERTILE_SOON to the cycle-purge push_attempts eventType list so a
data reset leaves no dated reproductive-intent delivery trace.
- Rate-limit the custom-symptom mutations (POST create, PATCH, DELETE) under a
shared cycle:symptom:custom:<userId> bucket (30/min), mirroring the insights
route, so a session can't flood the encrypted-label catalogue.
- Re-run the active-count cap inside PATCH when a symptom reactivates
(hidden → active); a hidden row doesn't count toward the cap, so without the
recheck a user could exceed it by hiding then re-enabling rows.
- Gate the log-day "limit reached" copy on the cap errorCode rather than any
create failure, so a transient/validation error no longer masquerades as the
cap being hit; thread the returned key so a new custom symptom auto-selects.
- Move the history-chart bar fill alphas into named phase-tokens
(HISTORY_REST_OPACITY / HISTORY_PERIOD_OPACITY) so bar intensity lives in one
token source alongside FLOW_OPACITY.
- Strengthen the confirmed-ovulation calendar marker (ring 1.5px → 2px, fill
alpha 0.16 → 0.22) so it stays distinct from the predicted dot on small light
screens; keeps its data-attr + aria naming.
- Raise the phase-education chip border from /10 to /15 for legible contrast on
the tinted tile.
Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint
their own symptoms beyond the 15 seeded ones, picked from a label + icon
popover via a dashed ghost-chip in the log-day sheet's symptom grid.
- POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a
custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM),
per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by
default, hard-purge on ?purge=true.
- The day-log write already resolves a symptom key against the catalogue plus
the caller's own rows, so a custom: key links by construction.
- The log-day sheet fetches the user's customs and merges them under a new
custom category in the chip grid; the ghost-chip + popover reuse the chip
styling and the Lucide icon set.
- Seed the global custom symptom category (migration 0134).
- The purge route (DELETE /api/cycle/all) now drops the user's custom symptom
rows too, so no intent-revealing label survives a purge.
- The label is never logged: only the icon + counts reach annotate/auditLog.
- i18n: cycle.symptom.custom.* across all six locales.
The Web Push subscribe route validated the endpoint with a bare URL check
while the hardened schema (https + isPublicUrl) sat orphaned, so an
authenticated user could register an endpoint pointing at an internal host
and the dispatcher — which dials it through web-push's own fetch, bypassing
safeFetch — would reach it. Route the subscribe input through the hardened
schema and re-check isPublicUrl at egress time in the sender to cover any
row stored before the fix.
Also cap GET /api/cycle/insights with a per-user rate limit; it runs the
365-day FDR phase contrast, the heaviest read in the cycle vertical.
Add the cycle-native counterpart to the phase×vitals crosstab: for each
logged symptom, where in the cycle it clusters. A pure aggregator tallies
each symptom's occurrences across the phase-labelled days (the same phase
map the calendar uses), surfaces only symptoms logged on at least three
phased days, and reports per-phase counts + the dominant phase + its
share. The insights tab renders it as a proportional phase-hued bar with
the dominant phase named in words (never colour-only), observational and
never causal.
Also correct the phase-crosstab OpenAPI row enum, which had drifted
behind the runtime (mood + glucose channels were missing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hard-purge kept the CycleProfile row (so the gate + settings keep
working) but left the intent-revealing fields behind. Reset goal to
GENERAL_HEALTH and null the cycle/period/luteal priors inside the purge
transaction so no reproductive intent survives, matching the route's
"no reproductive trace persists" contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the second-round audit UI cluster:
- Cycle reminders: add the missing notification-event labels so the
period-soon / period-confirm toggles read correctly on the
notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
length, confirmed-ovulation chip) — the cycles were fetched but only
the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
the nullable PATCH); period start/end work on any selected date for
retroactive correction; "end period" shows only while one is open.
Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
in before the gated page is reachable; wire the cycle data-purge button
to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
the grid; tighten the luteal prior bound to the engine clamp; mute the
BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the second-round audit (server cluster):
- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
stronger "not a contraceptive method" disclaimer — wire the existing
strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
batch instead of re-reading the profile per day; hoist the bulk cycle
attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `GET /api/cycle/day-logs?date=YYYY-MM-DD` returning the full day-log DTO
(or null) for one tz-anchored day so the web log-day sheet pre-fills instead
of opening blank — a blank re-log no longer nulls untouched or HealthKit-sourced
fields. Delete resolves the row id from the same read.
Persist an optional 1-4 Likert severity per symptom link (migration
0133_v1150_cycle_symptom_severity), carried through the shared symptom-link
writer and surfaced in the day-log DTO.
Sheet now captures the previously missing fields — spotting between periods,
pregnancy/progesterone home-test results, contraceptive kind — plus per-symptom
severity chips, a one-tap end-period boundary, and a Delete action. The wheel
ring marker sits at the 12-o'clock origin again (drop the double rotation), the
cycle view goes two-column on desktop, and the empty ring carries a first-period
call to action. New copy is localized across all six locales; the prediction and
fertility surfaces keep their disclaimers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Push the cycle-reminder cron's gating into the query instead of loading
every CyclePrediction row each tick: filter on the user's
cycleTrackingEnabled / predictionEnabled relation and restrict to rows
whose nextPeriodStart falls inside a tz-generous [today-grace, today+lead]
string window. The per-tick cost now scales with the in-window cohort, not
the whole table; the exact per-user-timezone window still runs in JS.
Make the period-boundary shortcut transactional and idempotent: wrap the
close-prior + open-new (or stamp-current) cycle mutations in a single
$transaction so two concurrent taps cannot double-close a prior cycle or
compute a length against a stale read, and add withIdempotency so an exact
key replay is a no-op. The boundary day-log now ranks the incoming flow
against any existing same-day entry and never downgrades a richer flow.
Decode sync cursors additively: a live beta v1 token keeps its
measurement / mood / intake watermarks and simply fresh-scans the two
domains added in v2 (cycleDays, cycles) rather than forcing a full
re-init. Correct the stale "no live consumer" comment.
Thread the user's timezone through the Coach cycle snapshot so "today",
day-of-cycle and days-until-period derive from the user's zone — matching
the calendar and insights routes — instead of wall-clock Berlin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The symptothermal layer ran against the full day-log history: detectTempShift
returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day
across all cycles, so a multi-cycle user with historical BBT confirmed a
months-old ovulation and the predicted next-period start landed in the past.
Scope the temperature/mucus detectors to the current cycle window
[lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift,
and reject any confirmation whose next-start is already in the past.
Resolve luteal length through a single clamp. The predictor clamped to [10,16]
while the calendar adapter and phase mapper used the raw stored value, so the
ovulation dot and the OVULATORY band could land on different days for an
out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar,
buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod
bound to the same [10,16] range.
Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the
algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE.
Bound the day-log reads on the calendar and insights routes to the rendered span
plus the symptothermal lookback instead of the full history; cycle-length stats
already run off the MenstrualCycle rows.
Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR
and per-group day floors. Mood is read from MoodEntry and injected as a synthetic
channel since it is not a measurement row.
Add multi-cycle, luteal-clamp and fertile-window fixtures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- When the sensitive-category encryption preference is on (the default),
encrypt the intent-revealing day-log fields (sexual activity, protection,
pregnancy/progesterone tests, contraception) into an encrypted envelope and
leave the queryable columns null, decrypting on read. The toggle now does
what it says rather than storing those fields in plaintext.
- Make the day-log write a partial merge so a re-log only overwrites the
fields it carries, never nulling untouched ones (no more data loss when the
same day is logged again).
- Make the upsert collision-safe: a manual entry and an imported entry on the
same date adopt one canonical row instead of throwing, and the single POST
returns a clean conflict rather than a 500; one bad date no longer aborts a
whole import.
- Compare the decrypted note, not the ciphertext, so an unchanged re-log is a
true no-op and stops needless re-sync.
- Hoist cycle attribution out of the per-entry loop; map bulk failures to
stable reason codes; audit the import goal change; mask the event name in
discreet reminder pushes; add a one-tap hard-delete purge for all cycle data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the menstrual-cycle phase as a channel in the existing FDR-guarded
correlation machinery, the same way rated mood factors were folded in —
no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same
day floors and q = 0.10 gate.
Two complementary mechanisms, both confined to a gender-gated surface:
- a categorical luteal-vs-follicular contrast per outcome metric (resting
heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature),
surfacing one headline finding (resting-heart-rate-by-phase, falling
back to HRV); and
- the continuous phase ordinal folded into the lagged-Pearson matrix.
Phase never reaches the general correlations route — the temperature
channels held out of the general matrix are un-blocked only here, behind
the cycle gate, so a reproductive inference can never leak onto a
non-gated surface. Every row shows n, effect size, p and q; nothing is
fabricated on thin data and nothing is framed as causal.
Surface it through a new gated GET /api/cycle/insights and a drop-in
card that mirrors the mood-factor crosstab.
Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle,
the next predicted event (period range + confidence + method, fertile
window goal-gated), and the headline phase finding — present only for a
cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground
rule keeps the Coach descriptive: never contraception-grade, never a
"safe day" claim.
OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added;
copy localised across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the `/api/cycle/*` capture, calendar, history, and settings routes
plus the offline-sync delta extension for the v1.15 cycle-tracking
feature, all against the locked iOS contract.
- Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile
(migration 0132). A null toggle derives from gender (FEMALE → on).
`isCycleEnabled()` is the single gate resolver; every `/api/cycle/*`
route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved
flag surfaces additively on GET /api/auth/me.
- Routes (apiHandler + Zod + envelope + gate + annotate + auditLog):
POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent,
capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/
skipped), POST period boundary, GET calendar (runs the deterministic
engine, debounced stale-while-revalidate cache, goal-gated fertile
window), GET cycles (history + median/MAD stats), GET profile,
DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge).
- Day-log note encrypts at rest (notesEncrypted); intent fields stay
queryable plaintext. Upsert keys on (userId, source, externalId) when
externalId present, else (userId, date).
- Offline sync: extend GET /api/sync/changes + the cursor with the
`cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays
tombstones key on externalId else id; cycles on id.
- OpenAPI: register every route + DTO; regenerate the committed spec.
- i18n: cycle prediction disclaimer across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>