getBaseSystemPrompt appended the directive, then each metric module
appended its own section after it, so the directive was no longer the
last instruction the model reads - the one property that makes it
authoritative. The base now exposes its body, and every composing module
wraps the finished prompt so the directive ends it.
Also drops a duplicate import that survived the merge of the two prompt
branches.
Three layers. The German and English narrative prompts are frozen by content
hash, normalised for the prompt-version string that a prompt change is
required to move — any other drift fails. The four riding locales must name
their language, carry the verbatim reply-language directive from their own
safety-contract file as the last instruction, and contain no German-body
sentinel. A generator-level test drives a French run end to end and asserts
the row is keyed fr, the cache action is locale-scoped, and the system prompt
reaches the provider in English with the French directive.
Alongside it a source-level ban on the exact shape the bug took
(`=== "en" ? "en" : "de"`, `?? "de"`) across the coach and narrative
surfaces, with a self-check so a typo in the pattern cannot turn the guard
into a silent pass, and coach-memory recall cases for all four locales.
Covers the assertion that would have caught the narrowing: normalizeLocale
passes all six locales through and defaults an unknown value to English, the
de/en cache keys stay byte-identical while the four widened locales get their
own, and a fr locale entering a status generator reaches the prompt builder
as fr and writes the fr cache row. The forced-warm case pins the same for the
comprehensive and per-status warms.
Holds every sibling prompt module to one invariant: strip the directive
and the interpolated language name out of a French, Spanish, Italian or
Polish prompt and what remains is the English prompt, byte for byte. A
German instruction body leaking back into a non-German prompt, an
English body drifting away from what the four locales receive, and a
module appending a second directive on top of the one the base prompt
already carries all fail here.
Also drops the now-unused narrow locale alias from the two modules whose
prompt builders take the full locale.
The narrative read route narrowed every non-English locale to German, so a
French, Spanish, Italian or Polish account read the German narrative row and
warmed it from the German prompt body. Carry the resolved locale through
instead: the row is keyed (user, period, locale) across the full UI locale
union, and the four locales now compose the English instruction body with
their language named and the locale's own reply-language directive appended
last. The German and English prompts are unchanged apart from the version
string.
The nightly warm keeps the user's stored locale for the same reason — it has
to write the key the read route reads — and the coach and dashboard narrative
recalls read that same key. Coach memory stays de/en: it is model-facing
context, not user-facing prose, so the four locales compose and key English
rather than German.
NARRATIVE_PROMPT_VERSION 1.13.0 -> 1.14.0.
The per-metric assessment pipeline typed every locale as `de | en` and
narrowed on the way in, so a French, Spanish, Italian or Polish account
never reached a prompt with its own locale — the output-language directive
could not fire and the reader got English, or German where the binary
defaulted that way.
Widen `SupportedLocale` to the six locales the UI ships and replace the
narrowing binaries with one validating `normalizeLocale` that passes a
recognised locale through unchanged and defaults an unknown value to
English. The status-generate payload, status batch, status cache,
comprehensive generation, the pre-generate warm passes and the forced-warm
handlers all carry the full locale now.
Fix the collapse sites that discarded it: the warm enqueue on the
comprehensive route and the pregenerate route, the nightly batch cron, and
the forced-warm job handler — the last two defaulted to German outright.
The deterministic derived-score templates and the previous-context block
ship de/en bodies only, so they route through `instructionLocale` (German
for German readers, English otherwise) while the prompt names the reader's
own language.
The derived-score archetype and the Coach preference overrides branched
on the raw locale, so fr/es/it/pl composed German instruction text. Both
now route through `instructionLocale`. The score archetype rides on
`getBaseSystemPrompt` and therefore appends no directive of its own —
the base prompt already carries it.
The biomarker assessment is the one prompt in the family that does not
compose the base scaffold, so it appends the reader's directive itself;
the append collapses into the shared path once that prompt moves onto
the base scaffold.
The two prompt builders in each of those modules now take the full
`Locale` rather than the narrowed de/en alias, so the routing is right
the moment the pipeline stops collapsing the four locales on the way in.
Also targets the output-language contract test's regex at the compile
target instead of the dotAll flag, which does not compile.
German and English prompts are unchanged, byte for byte.
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.
Route every correlation narrative through the server translator:
- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
the reader's locale, resolved from the correlations route the same way
other insight generators resolve it. Sentence templates, the direction
word, and every channel label live under a new insights.correlation.*
namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
(BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
localised the same way; the analytics cache key now includes the locale
so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
causal framing ("not a cause" / "an association, never a cause") and the
faint/moderate/high hedging tiers are preserved in every language.
English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
ECG recordings had no pill in the tab strip, no entry in the sub-page
manager, and no routed page — the only inbound pointer was a cross-link
card on the spot-pulse page. Hiding the overview ECG teaser left no route
back to a user's recordings.
- Add a routed `/insights/ecg` sub-page that reuses the existing ECG
section (heading suppressed so the page shell owns the title); the
overview teaser stays.
- Add an ECG entry to the Heart group of the tab strip, gated on a
dedicated `hasEcgRecordings` signal since ECG carries no measurement
type. The Heart group surfaces for an ECG-only device too. No ECG pill
appears without recordings.
- Mount the ECG cross-link on the resting-pulse and HRV pages, matching
its placement on the pulse page.
- List ECG in the sub-page manager under Heart, its eye toggling the ECG
overview section so hiding it is reversible.
- Point the metric catalog's ECG row at the new page.
- Add `navEcg`, the sub-page description, and the empty-state copy across
all locales.
The eleven per-metric/derived-score status helpers (pulse, weight,
bmi, mood, blood-pressure, medication-compliance, general, the generic
metric card, biomarker, the shared feature cache, the derived-score AI
warm layer) computed todayKey from toBerlinDayKey(new Date()) before
resolving the user's own timezone — several fetched the profile only
for downstream day-bucketing, after the cache read had already run on
the Berlin key. A non-Berlin user's card regenerated at their local
evening (Berlin midnight) and, for biomarker series and the
derived-score prompt label, mis-keyed data or displayed a hardcoded
"(Europe/Berlin)" regardless of their actual zone.
Threads resolveUserTimezone()/userDayKey() ahead of every cache read
so the day-key and the data bucketing agree, and reuses the one
resolved value everywhere downstream instead of re-deriving it.
The asc+take(20000) window-cap bug (fixed on the correlations route
alone) and the mixed-grain toDailyMeans bug (fixed on three of four
readers) both survived on the Coach get_correlations tool, the
per-metric assessment card, and the period narrative. Dense accounts
saw the cap drop the newest rows on the coach/card readers and drop
the CURRENT period on the narrative — backwards for a recent-window
and a current-vs-prior surface respectively; the narrative's own local
toDailyMeans twin also averaged sleep stage-segments and blended
per-sample step chunks with drained daily totals instead of summing.
Hoists the fetch + grain resolution into
fetchMeasurementWindowSeries/fetchMoodWindowSeries
(correlation-channel-series.ts) — desc+cap+resort, full column select
for buildMeasurementDailySeries's per-type grain — and routes the
route, the coach tool, the per-metric card, and the period narrative
through it, deleting every local copy.
Coach conversation history was capped at the rail's first ~20 threads and
searched only that loaded page. The rail and the standalone conversations
page now walk the caller's entire history via cursor pagination
(useInfiniteQuery + a scroll sentinel), and search runs server-side against
the conversation title (GET /api/insights/chat?q=) instead of filtering an
already-truncated client array.
Also extends the "automatic screening" info line in the metric catalog to
name the labs "what changed since your last panel" card alongside the
rhythm/breathing/health-status watchdogs it already covered — it un-mounts
the same way when there's nothing to flag and was missing from the copy.
Two in-memory key builders joined their parts with a literal NUL
(`${a}\x00${b}`): the sleep writer-bucket key and the correlation
pair key. The embedded NUL made grep classify both files as binary, so a
plain `grep symbol src/**` skipped them silently.
Swap the separator for U+241F (␟, the unit separator). It cannot appear
in a `MeasurementSource` enum literal, a device-type tag, or a
correlation-channel name, so the key semantics are identical — the sleep
key still slices its source prefix unambiguously and the correlation Set
still excludes double-counts. Both keys are ephemeral (one pass, never
persisted), so the byte change is invisible to any stored state.
The insights tab strip's "Cardiovascular" popover held only three rare
Withings markers while pulse, resting heart rate, HRV, and cardio
recovery sat scattered across a flat pill and the unrelated Vitals
group. Cardiovascular is now Heart and gathers all six; bmi and
workouts — flat only by historical accident — collapse into their
correct Body / Activity groups, and breathing disturbances moves out
of Vitals into the Sleep manager bucket instead of vanishing whenever
the sleep module is off. Blood pressure, weight, sleep, mood, and
medications stay pinned as flat top-level pills — now a documented
`PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates
v1.4.32" carve-out. Active energy moves from Vitals to Activity to
match its own tab-strip section comment. All changes are grouping /
presentation data only — no slug, route, or redirect changed.
The Settings sidebar's 19 entries rendered as one flat list with no
visual structure. They now cluster under seven group headers (Account,
Your data, Display, Health profile, AI, Connectivity, System),
reordered to match; the desktop sidebar gets a non-interactive text
header per group, the mobile chip strip a quiet divider. Presentation
only — no route changed.
Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6.
Metrics with zero data stay hidden from the dashboard and Insights nav
by design — that keeps the daily views uncluttered but leaves no
reachable surface telling a sparse-device-kit account which
capabilities exist at all. Add /insights/catalog: every metric domain
grouped by category, each row showing whether the account has data
for it and what would supply it, with a connect CTA into
settings/integrations when absent and device-sourceable.
Reuses the existing tab-strip gating (hasMetricData over the shared
analytics/comprehensive/workouts/nutrients reads) and the per-metric
empty-state copy already shown on each sub-page, so names, sources,
and presence can't drift from what's shown elsewhere. Adds one quiet,
always-visible pill at the tail of the Insights tab strip as the entry
point — no new loud nav destination.
The nutrients pill required BOTH the opt-in module on AND at least one
NutrientIntakeDay row — but the in-context enable CTA lives on
/insights/nutrients itself, so a user who had just turned the module on
(or wanted to) had no way to reach the page that would let them. Drop
the row-count floor for this one metric: the pill now gates on the
module toggle alone, matching the deliberate opt-in the user already
made. The page itself is unchanged and still degrades from "enable" to
"no rows yet" to the three-card spine.
Four data-correctness fixes in the same class as the workouts-list
ordering bug (05f80ad37):
- GET /api/measurements groupBy=day: the raw-row scan ordered oldest
first and sliced to `limit` before bucketing, so a long history never
surfaced its recent days. Reads newest-first now. The per-day sum
also ran over every source unfiltered — an Apple Health + Withings
write for the same day doubled the total. Routes through
pickCanonicalSourceRows first, matching the rollup tile's collapse.
- PR detection's cumulative-day picker summed every row on a calendar
day regardless of source (false "record" days) and bucketed on the
UTC calendar day instead of the user's timezone. Rewritten to
source-collapse before summing and bucket on the user's IANA zone,
reading through the worker's injected Prisma client so the existing
dependency-injection contract for tests stays intact.
- The correlation-discovery matrix (the insights route, the per-metric
assessment card, and the Coach's get_correlations tool — three
independent copies of the same fetch) averaged cumulative types
(steps, daylight) and sleep across mismatched grains: per-sample
chunk means blended with drained daily totals, and sleep averaged
per-stage segment durations instead of summing a night's total.
Hoisted the grain resolution into one shared
buildMeasurementDailySeries helper (source-collapsed day-sum for
cumulative types, per-night reconstruction for sleep, mean
unchanged for spot vitals) so all three surfaces agree.
Regression tests cover newest-first ordering, cross-source collapse
matching the rollup, PR no-double-count, and the shared grain helper.
New routed sub-page, gated on the opt-in nutrients module with an
in-context enable CTA (the module stays opt-in — the 2026-07-17
consent memo's reasoning holds; this just makes the toggle
discoverable instead of buried in Settings):
- Hydration card (hero): today's total, a muted EFSA reference-intake
line (sex-resolved, omitted when the profile has no sex on file),
a 30-day bar chart with a dashed reference line, and quick-add
chips (+200/+300/+500 mL + custom + edit-today's-total) in a
ResponsiveSheet, POSTing to /api/nutrients/water.
- Caffeine card: same anatomy, rendered only when the window has
data; the EFSA safe-level reads as a ceiling, never "exceeded"
colouring.
- Micronutrients card: rows only for the 24 vitamin/mineral codes
with data in the window (catalog order), a muted "M of 24 tracked"
footer, and one honest sentence that sparse logging isn't a
deficiency. Zero rows collapse to a single EmptyState.
No ring, no attainment colour anywhere — the bar chart plus its
dashed reference line IS the context (UI-STANDARDS: values stay
foreground).
The bar chart is a new NutrientDailyBarChart, registered in the
shared chart-runtime barrel behind its own next/dynamic boundary so
recharts stays off the route's first-load JS.
Tab-strip wiring: nutrients joins the flat pill set (event/store
driven like medications/workouts — a lightweight 1-day probe read
drives the pill's data-availability gate, module gate on top).
~20 new i18n keys across en/de/es/fr/it/pl.
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.
- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
count, device-verdict distribution, latest recorded-days-ago + average HR)
flows into the nightly briefing prompt. The waveform never enters the
payload; the grounding gate admits the pre-computed counts so a
device-attributed sentence stays grounded. A prompt rule (EN + DE)
constrains any mention to the device's attribution and forbids reading
morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
within the last day — calm, info-level, deep-linking the viewer. Gated on
the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
"Ask the Coach" on a document used to navigate to /coach?doc=<id> — a full-page
jump away from the vault. It now opens the real, doc-scoped conversation in the
shared side drawer, and the drawer's maximize control expands it to the full
/coach page while preserving scope.
- Thread an optional documentId through the launch context: askCoach gains a
fourth argument, the provider tracks it alongside prefill/scope and clears it
on close, and LayoutCoachMount forwards it to the drawer as initialDocumentId.
The open-state precedence moves into a pure resolveLaunchState helper.
- The vault detail sheet closes itself and calls askCoach(..., doc.id) instead
of routing away, so the drawer owns the surface.
- Maximize preserves continuity: the drawer reads the live conversation id via
a new imperative getter out of the conversation surface (parallel to the
reset registration) and hands off to /coach?c=<id> once a thread exists,
falling back to /coach?doc=<id> for a chat maximized before its first turn.
The send path is untouched: every document turn still routes through the
hardened fenced endpoint keyed on documentId — only where the conversation
renders (drawer vs page) changed. The documents-chat e2e now asserts the
drawer opens in place, doc-scoped, rather than a URL change.
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.
The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
Deferred perf fixes from the campaign's verified perf-correctness audit,
correctness-first (each with an output-equivalence test):
- comprehensive-generate fan-out: replaced per-type 400d findMany+JS-average
with DB prisma.aggregate(_avg) over the exact half-open windows the JS
reducer used (current 30d, lastMonth (60d,30d], lastYear (395d,365d]), same
rounding + null-on-empty, pLimit(4). SLEEP_DURATION stays on the raw path
(per-night reconstruction dedup). Byte-equality test vs the old algorithm.
- boot-discovery: replaced Prisma in-memory distinct with DB SELECT DISTINCT
in the 4 backfill discovery scans + migration 0243 (7 additive
partial indexes matching each predicate). Integration test asserts identical
discovered set against real Postgres.
- ChatBubble wrapped in React.memo with a comparator (onRegenerate compared by
presence — the thread rebuilds it each render), so only the streaming bubble
re-renders per token.
- doctor-report mood read narrowed to {score, tags} (the only fields the
collector uses); the 730d main measurement read left as-is — a blind bound
would corrupt clinical stats (documented deferred follow-up).
Migration 0243 additive-only.
The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).
Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
The recurring no-briefing day had a chain of causes, closed end to end:
Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.
Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.
The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.
Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.
Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.
One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.
Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.
Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
A one-time background pass re-reads each connected account's Google Health
sleep history so nights stored twice before the sleep fix collapse to their
true total (new sleep_repaired_at marker, migration 0238; watermark-safe,
enqueued once at boot per un-repaired connection).
The sleep night read reports when two writers disagree about the same night —
the served total is unchanged, the disagreement rides along as an annotation
(sourceDiscrepancy on the night DTO) so clients can mark the number instead of
silently picking a side.
A GLP-1 medication whose weight has plateaued on the current dose says so next
to the estimated drug level (efficacy tab + insights) — a factual note from the
existing detector, association only, verb-guard covered.
Google Health diagnostics: the sync log now separates a daily-totals read that
returned nothing from one the importer could not parse
(googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's
structure probe reports the raw response envelope key names when a rollup type
parses to zero — a per-account naming drift becomes visible from one report.
Five timezone-correctness fixes across the medication / compliance surfaces:
- getUserTodayBounds now derives both bounds through the DST-safe
startOfLocalDayInTz, so the today window is 23h on spring-forward and
25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
local dose no longer drops off the med cards, dashboard, PWA badge and
reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
per-day rate with userDayKey, so a non-Berlin user's evening dose is
filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
the achievements perfect-day check passes the user's zone and reads the
scheduled minute-of-day in-zone, so a dose far from UTC is graded
against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
include, so a future engine column reaches both surfaces.
Thread the interpretation block into the generic per-metric assessment
path (the ~40 HealthKit metric cards). For a metric the knowledge base
covers, the prompt now carries the band table plus the server-computed
position of the current value, so the assessment names where the value
sits on the guideline scale and judges the trend by that position — the
visceral-fat 2.7 case now reads as "comfortably in the healthy band"
rather than reciting how many times it was logged.
The block is absent for uncovered metrics and for a sex-split band with
no known sex, so those cards fall back to exactly today's personal-relative
behaviour. Band edges carry no strip risk here — the generic assessment
path runs no post-generation numeric grounding gate.
Wire the two new screening totals through the read surfaces on the
PHQ-9 / GAD-7 precedent: signal-registry entries (higher-better
direction, off the Coach snapshot and MCP by construction, no FHIR
coding attached — neither total has a verified LOINC, and the export
skips uncoded signals rather than shipping an invented code), the
scores category cluster, list labels/icons/colours, the generic
insight-chart tokens, a null PR direction (screeners are never
gamified), and the module-gated mental-health section of the doctor
report. The vitals-table exclusion rationale is documented alongside
the existing screener entries.
The recovery detail page rendered only the ring — the one wellness
score without a composition view, although the COMPUTED score IS the
readiness blend persisted nightly. The wellness-score read now attaches
the blend's per-factor sub-scores (RHR, HRV, sleep, respiratory rate,
mood stability — same engine, same weights, resolved server-side on the
same request) when the canonical latest row is the COMPUTED proxy, and
the anatomy view renders them through the identical ranked contributor
rows the readiness and sleep-score composites use, reusing their
labels. A WHOOP-native percentage is not our blend, so it keeps the
row-less presentation; a gated blend simply omits the breakdown. The
new value field is optional and additive — existing clients decode
unchanged.
When the corrective grounding retry dies on transport (provider outage,
timeout) the model never got its chance to self-correct — stripping the
briefing to null left the morning surface empty. The strip now falls
back to the previous payload's briefing, which passed this same gate
when it was written; a content-failed retry still strips hard so a
fabricated number never persists.
The Coach adherence storyline derived its rate from the raw coverage
rollup, whose denominator counts logged slots rather than scheduled
doses, so a user who logged only taken doses read ~100% and the dip gate
suppressed a real decline. Extract the dashboard tile's schedule-anchored,
cadence-aware compliance into one shared engine and route both surfaces
through it, so Coach and the dashboard report the same number.
Reference-band fills, classification tones, and the trend-chart palette
carried hardcoded hex that rendered identically in both themes and
glared on the light surface. Route them through the semantic and chart
CSS variables, which already hold per-theme values, so the light theme
gets the muted set and the dark theme stays byte-identical.
The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.
Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.
Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.
Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
The insights alert cards computed medication compliance with the naive
`schedules.length × days` denominator, so a fully-adherent weekly
injectable read ~14% and fired a false low-compliance alert (and a paused
medication mis-fired the same way). Route the alert path through the
canonical `calculateCompliance` engine like every other compliance
surface — pause eras, schedule revisions and dose windows honoured.
Document confirm: committing a numeric observation against an existing
biomarker silently replaced the stated unit with the catalog's, with no
conversion — a stated "6.1 mmol/L" against a mg/dL marker committed
"6.1 mg/dL". Reject on a unit mismatch (fail closed) so the user
reconciles on the review screen, the same posture as the unit-required
guard.
Wire grip strength, waist (with waist-to-height) and pain into the
briefing feature set and the grounding allow-set so the clinical-depth
signals reach the daily narrative; the mental-health screeners stay
excluded by construction.
Biomarker detail: fall back to a generic description for catalog-less
markers, and keep the heading + description with a retry on the error
state. Collapse the medication External-API and Lifecycle groups by
default. Re-enable the insights scroll-to-top regression and assert the
shipped main-content reset.
Align the EN haemoglobin/haematocrit display names to British spelling.
Drop the redundant `(userId, deletedAt)` index on inbound documents — a
strict prefix of the new three-column index. Trim the dead
MedicalDisclaimer variants to the one live `dataPosture`. Move the
crisis-resources card off the retired amber tint to a calm
destructive-subtle border. Add a registry invariant asserting every
biomarker signal key resolves to a real catalog slug.
Grip strength and waist circumference resolved against a single sex-aware
bracket for every adult age. Replace each with age x sex norm bands:
- Grip strength: age x sex bands from Dodds et al. 2014 (PLOS ONE
9(12):e113637) British normative data — band low is the 10th centile
floored at the EWGSOP2 probable-sarcopenia cut-off (men 27 kg, women
16 kg), band high is the 50th-centile median. Strength peaks in the 30s
and declines from midlife, so the bands step down with age.
- Waist circumference: WHO increased-risk threshold (men 94 cm, women
80 cm) held flat through midlife and raised for older adults toward the
WHO substantially-increased-risk line, since the WHO cut-points derive
from cohorts aged 20-74. The standard threshold is never lowered.
The norms resolver gains an age-independent clinical cut-off row per sex,
used only as the no-age fallback so a profile with a known sex but no date
of birth keeps the sex cut-off rather than the coarse registry anchor. The
signal registry structure is unchanged — only the resolver tables move.
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.
Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).
Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.
Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).
MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
Register the seven environmental-exposure signals through the signal registry
as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1),
and fold their daily series into the correlation engine as lagged behaviour
channels. Weather/daylight now pair against mood, sleep, and vitals through
the existing FDR / effect-size / shrinkage gates, so a thin series degrades to
absent rather than a spurious link. All env channels share one ENVIRONMENT
family so two same-day fields never lag-correlate against each other.
The PHQ-9 / GAD-7 screener totals and the physical clinical measures
(grip strength, pain NRS, waist circumference, waist-to-height) are
continuous numeric series. Register their metric:<TYPE> tokens so the
chart-token drift guard passes and the AI insight prose can reference
them through the generic chart renderer.
Generalise sleep debt onto the existing per-metric source-priority picker
(a new sleepDebt ladder, native-above-computed, default WHOOP > Oura > Polar >
computed) instead of assuming one provider. The rhythm read resolves the active
source off the ladder and carries it on the DTO; when the active source is our
own COMPUTED engine — every user today, since no provider ships a native
sleep-debt balance — the Sleep page explains what the figure means and that it
differs from a wearable's native number. The computed model itself is unchanged
and correct (a short rolling balance of need minus sleep, surplus-credited);
only stale comments that still referenced the old 14-night window are corrected.
Replace the hand-maintained MEASUREMENT_LOINC literal and the BUCKETED_TYPES
correlation list with signal-registry projections. Both reads are byte-identical
to the previous tables (guarded by the registry-invariant test), so there is no
behaviour change — only one source of truth for each.