The Coach SNAPSHOT was spliced into the prompt bare. Its JSON leaves are not
all server-computed: lab analyte, panel and unit names are transcribed by a
model out of an uploaded PDF and stored verbatim as the biomarker name, so a
hostile document chooses those strings. The about-me block has been fenced
since v1.16.1; extend the same pattern rather than adding per-field escapes.
- Move the fence markers and the wrapping primitive into a shared data-fence
module. Content is scrubbed against every known marker, not just its own
pair, so text in one block cannot forge another block's boundary.
- Fence the SNAPSHOT with an explicit data/instruction frame.
- Fence the OCR text handed to the document extractor; the previous
'OCR TEXT:' label was a label, not a boundary.
- Sanitise analyte, panel and unit at the labs source, which feeds both the
snapshot prompt and the labs_read tool result, and the medication label in
the adherence storyline.
- Capture a ---REMEMBER--- sentinel as proposed rather than active. It was
the only model-driven write with no confirm step, and the reminder recall
block fed it back into every later turn. Proposed reminders are already
excluded from that block and already carry a confirm control.
The status-provider suite drives the real budget module against an
in-memory stand-in for the day's usage row, so it exercises the atomic
reserve/reconcile path rather than asserting a mock was called: spend
lands on the ledger, an exhausted day is refused before any egress, a
failed generation is refunded, and a chain on the user's own key is
measured against the user-plan ceiling rather than the operator's.
The snapshot suite walks a 60-turn conversation and pins the full
snapshot to exactly two turns — the first and the elision crossing —
including the case where a lost reply shifts the turn parity.
The provider chokepoint every status and reference generator funnels
through ran with no budget accounting at all. Nine generator families
egressed behind it — the specialised status cards, the generic metric
cards, biomarker cards, the batched assessment, the derived scores, the
period narrative, and the off-request Coach memory workers (rolling
summary, fact extraction, plan proposals) — and none of that spend landed
on the daily ledger or met any ceiling. On an operator-key account it was
unmetered operator spend.
Reserve and reconcile around the provider call, using the same atomic
upsert the Coach and document paths use. The ceiling follows the cost
owner: a chain whose primary is the operator's own credential draws the
operator ceiling, everything else draws the user-plan ceiling, so a
self-hoster on their own key is never rationed by the operator's bill.
The status enqueue keyed its de-dupe singleton on (user, metric, locale).
The locale on a status read is client-chosen, so each of the six
supported locales opened its own slot and one legitimate generation pass
could be multiplied into six. Key on (user, metric) and carry the locale
in the payload instead.
The Coach re-shipped the full snapshot on every turn past the history
cap, not just the turn that crosses it, so a long conversation paid the
per-turn snapshot cost the snapshot-once model exists to remove. Re-ground
on the crossing only, tested as a narrow window rather than an equality
because a turn advances the count by one or two depending on whether the
reply persisted.
Give /api/ai/test a daily ceiling and a ledger entry. Its burst bucket
bounded rate but nothing cumulative, and with an empty body it resolves
the same chain generation uses — which can be the operator's credential.
Correct the memory-refresh worker comment, which asserted a budget check
inside the chokepoint that did not exist.
The briefing gate grades numbers only, and only the paragraph, signals and
key-findings blocks -- so a digit-free risk assertion with a dose suggestion
attached passed it untouched, and recommendations and summary were not graded
at all. Screen every readable prose field against the insights contracts.
A safety violation cannot be surgically stripped the way an ungrounded number
can: the offending sentence may sit in any field, and deleting one would leave
the rest referring to it. So the background generator fails the whole run and
persists nothing, leaving the previous cached payload in place, while the
user-initiated route returns an explicit error for the same reason.
The outbound safety screen ran on the Coach only. A dose-change imperative or
a fabricated clinical risk score written onto a per-metric, biomarker or
derived-score status card persisted as that day's cached assessment, while the
identical sentence in the Coach was caught and replaced -- the only transform
between provider and persistence was whitespace normalisation.
Move the pattern banks into a shared screen that takes the reader's locale.
The old signature took no locale, so the dose-refusal and risk-score contracts
had no enforcement for fr/es/it/pl even though the ground rules ship in all six
contract files. Banks now cover all six locales and each screen runs the
reader's bank plus English, since a provider often answers in English
regardless of the language directive.
Status cards withhold on a trip: serve the deterministic stub and persist no
model text. Nobody waits on a background-generated card, and persisting a
refusal would pin it to the card for the day.
The derived batch grid carries only the deterministic assessment, whose
templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was
correct but indistinguishable from the accidental binaries that were just
removed; route it through instructionLocale so the intent is explicit.
Behaviour unchanged.
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.
Extend the dashboard's RSC server-prefetch pattern to three more route roots
so their above-the-fold content paints populated on first load instead of
flashing a shell that fills in on mount.
Each route gains a thin RSC wrapper that runs the same cached read its API
route uses, dehydrates the result under the exact client query key, and hands
it to the client leaf through a HydrationBoundary. Every wrapper fails soft to
today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH
kill-switch the e2e server sets.
- /insights: prefetch the dashboard snapshot — the same locale-keyed cell the
dashboard warms, resolved once server-side so the key matches by
construction. The AI briefing and the heavy analytics aggregations stay
client-lazy (the briefing must never trigger generation; a cold analytics
read would trade FCP for TTFB).
- /medications: prefetch the medications list through the shared
caches.medications cell (extracted to src/lib/medications/list-read.ts). The
list cell keeps refetchOnMount: "always" — a cross-device take/skip produces
no client mutation event, so the mount refetch is a correctness gate; the
prefetch removes the initial skeleton flash without dropping that guarantee.
- /coach: prefetch the nudge status (extracted to
src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is
ready at hydrate, collapsing the one sequential waterfall. Only runs when the
Coach surface is reachable; the streaming conversation is never warmed.
Add per-route tests pinning server-key === client-key, JSON wire-shape
round-trip, and the fail-soft / module-gate paths. Repoint the source-reading
insights, take-all-due, and Coach gate-cascade tests at the client leaves.
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
computeBpInTargetPct's same-calendar-day pairing fallback (for readings
whose sys/dia timestamps drift past the 5-minute session window) keyed
on toBerlinDayKey regardless of the caller's actual user. A non-Berlin
user's two same-local-evening readings straddling Berlin midnight (but
not their own midnight) were rejected as a pair, running the in-target
percentage on fewer readings than it should have — precisely the
population the cross-tz runtime guard in bp-in-target-fast-path.ts
force-routes to this same pairing fallback to protect.
Threads an optional tz through computeBpInTargetPct / collectBpPairs /
computeBpInTargetWindows (default Berlin for untouched legacy
callers), wires it through the fast-path's live fallback and the
comprehensive-insights route's own computeBpInTargetPct call — the
other caller with the user's tz already in scope.
Investigated but left the comprehensive route's resting-pulse-proxy
day-key and BP-medication-continuity day-key on their current UTC
convention: both are joined by an exact YYYY-MM-DD key against a
UTC-anchored partner (the mood rollup's bucketStart, and the
aggregator's date_trunc'd BLOOD_PRESSURE_SYS daily series). Switching
only one side to the user's tz would trade "evening readings shift a
day" for "evening readings mismatch their pairing partner" — a
different bug, not a fix. Documented in place; a real fix needs the
mood-rollup / aggregate tiers themselves to bucket per-user-tz.
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.
The FDR discovery read capped a dense account's 180-day window at
orderBy asc + take, so exceeding the cap silently dropped the NEWEST
rows. The early-detection pass exists specifically to see the recent
window, so a capped read gave it nothing to work with.
Order desc for both the measurement and mood reads, re-sort ascending
in JS afterward (every downstream day-bucketing pass groups by day
key, so pre-sort order doesn't matter to them), and annotate when a
read actually hit its cap.
The CSV import loop ran a findUnique duplicate check before every
single create — up to 2000 sequential round trips on a 1000-row
import. Resolve takenAt + the idempotency key for every row first,
then read every key's existence in one indexed query before the write
loop; create and the per-row inventory consumption stay unchanged.
Also runs prettier over the batch-route test files touched in the
prior two commits.
/api/insights/cards pulled every raw PULSE row from the last 90 days
into JS to compute an average and an anomaly count — on a dense
wearable account that's 100k+ rows the DAY rollups already summarise.
WEIGHT and blood pressure stay on the bounded raw read (manual-entry
volume, and blood pressure needs exact-timestamp pairing for the
in-target percentage); PULSE now reads through the existing rollup
read-swap helper, falling back to a bounded per-day-grouped live read
on a coverage miss.
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.
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.
Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.
The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.
Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.
The fence is re-established at both enforcement points, fail-closed:
- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
fresh chat with staged attachments) to the new /api/insights/chat/fenced
endpoint; the fenced body is strict and never carries scope, guided-question,
or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
fetched row still carries an attachment (flag/join drift), emitting a
fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
code path — the fenced pipeline — with no tools and no health snapshot; a
module-graph test pins that the tool loop is unreachable from it.
The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.
Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.
Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.
The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.
The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
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.
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.
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.
- Schema (migration 0235): three encrypted columns on AppSettings for the
operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
(default false). Register the new encrypted columns in the rotation registry
and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
chain only when they opted in AND the operator connected it. Tokens refresh
server-side against the singleton row (CAS-style), same primitives as the
per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
consent machinery (self-snapshot and document surfaces alike). No existing
consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
honest copy on account pooling, shared rate limits, and training-by-default;
a per-user opt-in switch shown only once the operator has connected it, off by
default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
consent gate, vision capability, and document order; a mocked e2e for both
the admin connect surface and the per-user switch.
The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
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.
- Reword two LOINC verification comments in the signals registry so they
no longer trip the forbidden-marker grep, keeping the meaning intact.
- Resolve the dead-code gate: gate the seeded-question opener on the
per-user ambient-suggestions opt-out (wiring the previously orphaned
resolveCoachAmbientSuggestionsEnabled, server-authoritative), drop the
unread decryptDocumentFromBytes helper and its now-unused import, and
remove the unused SignalKind type alias.
- Delegate undici's fetch to the global stub in the provider-chain
integration test so the egress-pinned client HTTP is mocked again.
- Defer the JSON example-download cleanup a tick so revoking the blob URL
no longer races the browser download start (stabilises the e2e).
- Sync the service-worker version fallback to v1.25.0.
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.
Add coverage for the new biomarker-assessment seam and the marker detail
surface:
- the generator serves today's cache, re-stamps unchanged readings without an
LLM call, and regenerates only when the latest-reading fingerprint changes
- the read-only endpoint returns the envelope, 422s a missing id, and honours
the auth + insightStatus gates
- the detail page paints the stat strip, the chart placeholder, and the
assessment card
- the biomarker resource route drops the marker with its readings in one
userId-narrowed transaction and rejects an inverted target range
Hold the input gate independent of the worker's forced run so an idle marker
re-stamps cached text instead of regenerating, and list the new route on the
non-Coach gate allowlist.
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.
The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.
The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
The daily briefing and the per-metric status assessments ignored the
per-user response-timeout setting: they hardcoded their per-surface
AI_BUDGETS default, so raising the timeout for a slow self-hosted or
local backend had no effect and the surfaces still aborted early. Only
the coach threaded the setting through.
Resolve the effective timeout from User.aiResponseTimeoutSeconds in one
shared helper and thread it onto every non-coach generation call:
- runStatusCompletion is the single chokepoint every status / reference
surface funnels through (per-metric cards, the batched assessment, the
derived assessments, the period narratives), so resolving it there
applies the setting to all of them at once — on both the upstream
call's own timeout and the outer wall-clock cap that previously clipped
a raised value at 60 s.
- The comprehensive briefing generator and the user-initiated regenerate
route thread it onto the main generation, the JSON retry, the grounding
retry, and the paragraph re-roll.
On a failed generation the briefing keeps its last good text — no cache
row is written, so the previous payload stays intact — and records a
dated, append-only failure marker (no migration; reuses the audit-log
JSON pattern). The read path compares the marker against the last
successful generation so it can tell a held briefing from a current one.
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:
- Paused medications. A doctor-ordered pause flipped `active` off and stamped
`pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
and every paused day became a missed dose after resume — collapsing
adherence across the Health Score, the BP gate, the Coach snapshot and the
report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
migration 0211), close the open era on resume, thread the eras through the
compliance context and drop slots inside a paused interval from the
denominator. Every compliance call site now fetches and passes the eras.
- Health Score eras. The Health-Score fast path was the only compliance
caller that skipped the shared schedule select and schedule revisions, so
after a schedule edit it scored past days against the current schedule
while every other surface used the historical era. Switch it to the shared
select and thread revisions (and pause eras) like the canonical path.
- Insight bucketing timezone. The per-metric status series bucketed every
user in Europe/Berlin, so a near-midnight reading landed in the wrong day
and the relative-day phrasing was off for non-Berlin self-hosters. Thread
the resolved user timezone and key off `userDayKey`.
- Correlation significance. User-facing correlations surfaced from five
paired points with strength read straight off the coefficient. Route them
through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
so small-N coincidences no longer render as confident findings.
- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
daily low-percentile proxy) so the label matches the input.
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.
- Health status: surfaces what is drifting from the user's own normal by
folding the personal-band deviations (coincident-deviation engine) with
the dated, sustained level shifts (changepoint detector) at
GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
sleep-breathing-disturbance index plus device-flagged events at
GET /api/insights/breathing-screening. Mirrors the device's own
classification and states plainly that it is a screening signal, not a
diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
each shared analyte's signed delta and reference-band standing at
GET /api/insights/labs-changes. Neutral framing only.
Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.
Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
Code-review and design follow-ups before the v1.22 cut. No behaviour change
beyond the items below.
Coach / insights:
- Coach SSE route: a non-provider error raised inside the open producer (e.g. a
DB failure persisting the reply) now records on the wide event and forwards to
GlitchTip instead of masquerading as a provider outage; it still surfaces an
error frame since the stream is open.
- Lab ↔ outcome correlations: hedge faint-tier pairs ("a faint hint, if
anything … too small to lean on") so the narrated confidence never outruns the
effect, matching the daily engine.
- Reminder sweep: mint the plan-review reminder and clear the plan's reviewDate
in one transaction so a half-applied write can't duplicate the reminder.
- Fold the two raw provider-fallback runners onto one shared chain walker.
Design / accessibility:
- Lift sub-44px tap targets to the 44px mobile floor (suggested-action card,
Coach bubble affordances), brighten the resting timestamp clock for non-text
contrast, and route the lab chart height through the shared constant.
- Replace ad-hoc arbitrary type sizes with the scale token and static size-11
icon buttons with the named icon-lg variant.
- Settings: add the missing About card-header descriptions, normalise the
low-stock numeric widths, and relax the notification-status rows from nested
cards to a divided list.
Housekeeping:
- Drop three unused exported types (and the now-orphaned source const), re-stamp
v1.21.5 comments to v1.22, and register the live-judge eval harness as a knip
entry so the script-only module resolves.
Add three per-user AI settings, all configured in the app (no new env vars),
behind one migration (0198):
- Response timeout (seconds). A per-user upstream timeout threaded onto the
provider call. A local/self-hosted backend can take more than the legacy
60 s default to load a model on the first request, which timed the Coach
out before the first token. Defaults to a generous ~180 s when unset;
validated to 10–600 s.
- True token streaming for the local OpenAI-compatible client. The client now
POSTs with stream:true and parses the SSE delta chunks, emitting tokens as
they arrive, with a per-idle-gap timeout instead of a whole-call ceiling and
a graceful fallback to the buffered path when a server cannot stream. The
Coach route runs the provider call inside the response stream behind a
keepalive heartbeat so a reverse proxy no longer idle-drops the connection
during model load or generation. Grounding, refusal, and sentinel-stripping
still run on the complete reply before any token reaches the client.
- Dedicated document-scan (Lab-OCR) provider. Off by default — the OCR path
uses the main provider chain. When enabled, lab-report processing resolves a
separate provider/model/key, with a hint when the chosen model is not
vision-capable. The new key column is AES-256-GCM at rest and registered in
the encrypted-columns registry and the rotation script.
i18n: EN + DE translations for the new Settings → AI strings (other locales
carry interim copies to keep locale parity green).
Feed the user's own usual blood-pressure range — the median plus or minus
MAD band, computed from the readings already in the snapshot — into the Coach
context with a clause to quote those bounds verbatim, so a band is never
invented from window means. Thread the stored first name into the prompt and
surface it sparingly on roughly one turn in three, never as a rote greeting.
Add the context-layer rule that names the relevant context before a
suggestion, a single-chart-token allowance that works on every provider, and
a per-turn opener hint.
Wire the already-shipped suggested-action SSE frame to render: the streaming
hook collects it and the message thread shows the action card, mirroring the
reminder-suggestion path. Extend the eval golden set with band-verbatim,
chart-token, and no-overclaim cases, and pin a red-team case proving the
chart-token syntax cannot smuggle a dose past the outbound screen.