A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.
`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.
The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.
The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.
The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
A how-to for subscribing from Google, Apple and Outlook and what the
feed shows; a section on the reproductive-health page saying that once
it is on, the dates also sit with the calendar provider and turning it
off does not reach those copies; a reverse-proxy note with Caddy and
Nginx examples for keeping path credentials out of access logs; and
API_TOKEN_HMAC_KEY rotation named as ending every feed link.
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.
The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.
/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.
The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.
`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.
`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
A record's cycle calendar subscription is a URL credential: one row per
feed, only the HMAC of the raw hlf_ token stored, and at most one live
feed per record through a partial unique index on user_id where
revoked_at is null. Both foreign keys cascade, so deleting the record
or the carrier who minted the feed ends it.
The row is a credential everywhere else too. It stays out of the
backup (a restore would bring back a revoked link), Delete All Data
takes it on both sides, a credential remediation revokes the feeds on
the record and the ones the account minted elsewhere, and a
managed-profile handover claim revokes any feed a guardian minted.
backupVerdict moves beside the plan into its own module so the plan
stays the lists and their reasons.
A per-envelope guard walks the category leaves of LabOverviewResponse,
customPanels on ListBiomarkersResponse and adoptedPanel on
BiomarkerMergeResult out of the Zod schemas and ties each to the web
reader that renders it, so a new leaf without a reader fails. The
resolved-field guard gains a rule that bans the category ingredients
(catalogKeyForName, resolveMarkerCategory, summariseCategory,
categoriseOverview, ALIAS_PANEL, buildLocalisedLabelIndex) from client
code: the category, its order and its status are read, never rebuilt.
Refs #1349
A draw label such as "Annual blood panel" is a panel the labs list
would show as one category holding every marker, so the demo list
would stay flat with categories turned on. Without a panel each marker
lands in its catalog category by name, both before and after the boot
backfill links the rows.
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.
The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.
Refs #1349
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.
An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.
Refs #1349
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.
The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.
Refs #1349
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
The inbound route admits two uploads per person (four per process) and
answers the next with 429 `uploadBusy` and `Retry-After: 1`. The vault
queue sent three at once, so a multi-file drop provoked that answer
itself, and the card read it as the hourly limit: "Try again in a few
minutes".
The queue now runs at `DOCUMENT_UPLOAD_PER_USER_CONCURRENCY`, moved to
`inbound-constants.ts` so client and route share one number. A busy
answer is classified on its own, carries its Retry-After, and is retried
for the same file (Retry-After clamped to 1-10 s plus jitter, at most six
attempts within 30 s) while the row stays in its uploading state. Only a
spent budget shows an error, with copy that says the server was busy.
Unmounting aborts any pending wait. The Coach's new-file attach uses the
same retry. The hourly-limit copy now names the hour.
From md up the hero's score column sat on the trailing edge, so
"steady for 4 weeks" and its siblings ended at the ring's right edge
instead of standing under its centre. Below md the lines keep their
full-width row flush with the dial.
A pointer leaving any point target closed whichever card was open, and a
blur did the same. With the pointer resting on one line's point and the
keyboard on another (the focus scrolls the chart, so the resting pointer
leaves the first point), the focused point lost its card and focusing it
again fired nothing. A point now releases only its own card, and hands it
back to the focused point when there is one.
The lab-marker journey drove into this when the menu click left the
pointer over a value point. It now sets that up on purpose and asserts the
card survives the pointer leaving, in place of the retry that could never
reopen a card on an already focused target.
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.
A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.
Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.
The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
TanStack restarts an in-flight fetch on invalidation only when the query
already holds data. A query still on its first load gets the in-flight
promise back, and that fetch's success clears the invalidated flag, so an
answer read before the write sticks with nothing scheduled to correct it.
The vault hit this when an upload settled just as a typed search switched
the list to a fresh key: the list stayed on "No matching documents" while
the stored row existed. invalidateKeys now waits for such a first load to
end and invalidates that query once more.
A statement-level hang in the integration suite surfaced only as a
60-second test timeout, because statement_timeout sat at the same 60 s.
At 45 s the server cancels first, and its error names the statement and
the lock or wait it was stuck on.
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.
Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.
Refs #1313
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.
Refs #1311
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.
Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.
The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.
It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.
Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.
Refs #1340
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.
Refs #1342
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.
Refs #1333
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.
A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.
Refs #1333
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.
The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.
Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.
Refs #1333
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.
Refs #1342
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.
Refs #1339
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.
Refs #1340
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.
Refs #1341
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.
Refs #1338
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.
Refs #1334
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
The Debian base carries HIGH advisories the distribution has not fixed
(util-linux, ncurses, systemd, acl, sqlite, perl-base), which no change
here can remove. The blocking image scan now runs with --ignore-unfixed,
so a finding with a published fix still fails the job, and a separate
step before it lists every HIGH and CRITICAL finding without failing.
The scheduled scan's critical gate follows the same rule; its SARIF
report keeps everything.