The labels of the mood tags and mood categories an account created
travelled as the stored ciphertext in a portable file, so a restore onto
a host with another key refused the file or wrote labels nobody could
open. They now follow the cycle note: readable in a portable file, sealed
under the receiving host's key on restore, ciphertext kept only where
this host opens it and otherwise reported as moodLabelCiphertext.
The sealing helpers move to one module both sections share.
A portable file carried the day-log note, the sensitive-category envelope
and custom symptom labels as the stored ciphertext, so a restore onto a
host with another key either refused the file or wrote values nobody
could open. The portable file now carries them readable and the restore
seals them under the receiving host's key; a disaster-recovery file keeps
the ciphertext.
A file written before this still restores: each ciphertext value is
opened with this host's keys first, and one that does not open is kept
out of the row and reported in the skip summary as cycleCiphertext. The
key preflight leaves the cycle sections to that per-value check.
A column-level round trip over every two-ended model found four values
that left with the export and came back empty or re-stamped:
- MenstrualCycle.absorbedIntoId: the disaster-recovery file carried the
folded, soft-deleted start but not the start that absorbed it, so after
a restore removing the absorbing start could no longer bring the folded
one back. Carried in the disaster-recovery arm and written back only
when the file carries the start it names.
- Workout.externalSourceVersion: the source's version marker, stored so
the sync can replay it unchanged, was in neither end. Carried beside
metadata in the disaster-recovery arm.
- NutrientIntakeDay.externalSourceVersion and createdAt: neither was
selected, so a restored day lost its source marker and its first-arrival
time. Carried in both arms.
Files written before this change parse as before; every new field is
optional on the way in.
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.
The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.
A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.
`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.
The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.
The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.
A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.
Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
Four things an independent read found, three of them things this release said
about itself that were not true.
A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.
The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.
The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.
And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
restoreCycleData was loud about one missing reference and silent about
another, three lines apart. An unknown cycle reference threw; an unknown
symptom key was filtered out of the list, so the day-log came back with one of
its symptoms quietly gone and the restore reported success.
The cause was upstream: the seeded catalogue is reference data every instance
has, but a symptom the user created lives only in their account and was never
carried. The key had nothing to resolve against, and the filter turned that
into silence.
Both ends now. The account's own definitions travel with the backup, the
seeded rows deliberately do not — shipping those would let one instance's
restore rewrite another's reference data. And the resolve step throws on an
unresolvable key rather than filtering it, so the same function answers the
same question the same way twice.
Widening the builder's client type made four export tests fail on a missing
delegate, which is the sweep working: those are the call sites, named by the
compiler instead of found by reading.
The cycle write path and the PR worker cast Prisma inputs to `never`,
which switches off the compiler exactly where a schema/enum drift would
otherwise be caught.
- day-log-write: `ExistingRow` and `SensitiveFields` widened every enum
column to `string`, forcing 21 `as never` writes. Type them to the
generated enums (FlowLevel, OvulationTest, CervicalMucus, Cervix*,
HomeTestResult, ContraceptiveKind) — the Zod input enums already match
member-for-member — and build the create row from the typed locals
instead of re-casting the update-input shape.
- cycle/backup: thread the enum element type through `enumOrNull` and pin
each allow-set to its enum, so the 12 restore writes type-check against
the generated create input rather than `never`.
- pr-detection-worker: type the candidate `source` as MeasurementSource
(both Workout and PersonalRecord use it) and give `findBestWorkout` a
real `Prisma.WorkoutWhereInput` + `WorkoutOrderByWithRelationInput`.
No behaviour change; the writes are now compiler-checked. No latent
drift surfaced — every enum set lines up with the schema.
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.
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>
- 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>
Promote the reproductive HealthKit category types out of the
Apple-Health-import privacy-hold list and route them into cycle day-logs
instead of measurements, and surface cycle data on the three export
surfaces.
Import promotion:
- A new HealthKit -> cycle mapping module is the single source of truth for
how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality,
OvulationTestResult, SexualActivity, PregnancyTestResult,
ProgesteroneTestResult, Contraceptive and the symptom category types fold
into CycleDayLog fields / seeded symptom keys. The XML importer
accumulates same-day reproductive samples and writes one canonical
day-log per day with source APPLE_HEALTH and a synthetic per-day
externalId for idempotent re-import. The fold is gated on cycle tracking
being enabled, so a non-cycle export never provisions cycle rows. A
contraceptive method nudges the profile goal toward avoiding pregnancy
only when the goal is still on its default. Basal body temperature stays
a measurement and mirrors onto the day-log. Pregnancy/Lactation status
and the pregnancy-bleeding types stay deferred; the cycle awareness types
remain server-derived.
Export:
- FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period
length (64698-4) and the current phase as opt-in reproductive-health
observations grouped into their own document section.
- The doctor PDF gains an opt-in cycle section (recent cycles, average
length and variability, current phase), privacy-default off like mood.
- The full-backup export, the weekly backup worker and the off-host DR
snapshot include the cycle tables, and the admin restore re-creates them;
the day-log note round-trips as ciphertext and is never decrypted onto a
report surface.
Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit
+ integration coverage for the mapping, the import routing, the FHIR
observations and the backup round-trip. OpenAPI regenerated. Six-locale
strings added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>