iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
(Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`,
available from 27.0). The live batch and the export.zip import now store it
as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in
milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The
accepted range is 1 to 300 ms.
RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a
day holding it from Apple Health and a strap resolves to one source
instead of a name tiebreak.
SDNN and RMSSD are different statistics and are kept apart once one
account holds both. The HRV page charts each on its own, titled with its
measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section
carries one timeline per measure under `byMeasure` (same section key, so
every reader still finds it) rather than one daily series averaging the
two; the budget pass condenses and sheds each measure's timeline like a
glucose context.
The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.
Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:
- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).
The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.
Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.
Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.
Refs #1108
The rollup writer keeps one row per bucket and source. Collapsing the stored
WEEK / MONTH / YEAR rows picked one source for the whole bucket, so a month
of manual blood-pressure readings plus one synced reading reported the synced
day alone, and a step month that switched devices lost every day the losing
device carried. The live weekly and monthly chart fold made the same pick.
Every coarse bucket is now folded from the canonical DAY buckets: the source
ladder picks one source per day, then the week, month or year counts every
one of its days. Spread, slope and r² are composed from the summed day
accumulators. The live fold picks per local day before folding into weeks
and months. Ties between sources without a ladder rank break alphabetically
on the SQL paths too, matching the rollup reader.
The warm narrow query and the cold heavy query each referenced the
canonical-source subquery twice — once in the window_stats CTE and once in
the outer body — so the DISTINCT-ON pick over the live 90-day rows ran twice
per rebuild. Extract canonicalMeasurementsCte (the SELECT body without the
alias wrapper), bind it once as WITH cm AS (...), and select FROM cm m from
both window_stats and the outer aggregate. Output is identical; the dominant
self-join runs once.
canonicalMeasurementsFrom now wraps the same body, byte-stable for the
single-reference call sites (pinned by a new parity test).
STRESS_SCORE has no source-priority ladder, so metricKeyForType returns null
and the SQL source collapse falls to an alphabetical rank-90 tiebreak. It is
safe today because the only producer is the COMPUTED HRV-derived proxy, but
the instant a device-native producer (Oura daily_stress) is enabled both rows
survive and blend nondeterministically.
Add a stress metric key with an OURA-above-COMPUTED default ladder, wire it
through metricKeyForType and RANKED_TYPES, and surface it in the Sources
settings (all six locales). A guard test asserts every RANKED_TYPES member
resolves to a non-null metric key so a future ranked type can't ship unladdered.
Regenerate the OpenAPI for the additive source-priority key.
- conversation-summary: fault-isolate the per-message decrypt in the
fold loop so one undecryptable historic turn (e.g. mid key-rotation)
no longer permanently stalls a conversation's summary refresh.
- source collapse: unify the no-ladder tiebreak on alphabetically
smallest source across the JS reader and the all-time rollup SQL, so a
ranked vital whose day carries only off-ladder sources resolves to the
same canonical source on the warm and the live-SQL read paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the source-aware rollup read path: the slim summaries slice
(rollup + live), the comprehensive aggregator (both paths, including the
dailyByType correlation feed), and the /api/measurements route (rollup
read + the live date_trunc fallback) now resolve overlapping sources to
the source-priority ladder's canonical reading per (type, day) before
aggregating. A dual-source vital (e.g. WHOOP + Apple Watch resting heart
rate) surfaces one canonical series instead of a blend or a doubled
count; cumulative metrics keep summing per canonical source.
The collapse runs in SQL where the aggregate must stay a single
round-trip (buildSourceRankCase emits a per-user ladder-rank CASE;
canonicalMeasurementsFrom restricts a live read to canonical-source rows)
and in application code (collapseRollupRowsBySource) for the row readers,
both driven by the same resolved ladders so live and rollup agree.
Also widens the enum-literal SQL whitelist to accept digits so VO2_MAX
no longer throws on the typed write-hook recompute or the rank CASE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>