Four locales received English on surfaces whose translations were
already complete, because the resolving code was a de/en binary.
- Medication reminder push and the Telegram inline-keyboard labels
admitted only de/en, so es/fr/it/pl got the English template on every
channel. Route the resolver through a shared coerceLocale().
- The Coach's trailing "reply now in X" clause said English for any
non-German locale, overriding the correct system-prompt directive.
Read the language name from a shared map instead.
- Coach refusal copy was a de/en constant pair, answering four locales
in English at the one turn the Coach is declining to help. Move the
copy into the bundles; the EN/DE constants now derive from them.
- The sleep-stage chart formatted axis ticks as en-US for four locales;
the cycle calendar and the device-score tile followed the *browser*
locale rather than the app locale.
- Three components carried a byte-identical duration formatter that
branched on German. Lift it to one key-driven helper.
Polish needs a third plural tier: 2-4 takes a "few" form a two-form
scheme cannot express, so "2 godzin temu" read as "2 of-hours ago".
Add a pluralKey() helper driven by Intl.PluralRules and a Few tier to
the relative-time and stale-hint keys. The five locales whose rules
have no "few" category resolve exactly as before by construction, and
their Few values mirror Other. Teach the reverse-coverage guard to
expand a pluralKey() base into its three tiers.
Three things the write surface claimed but did not hold.
Scope: the run bodies never re-checked ctx.canWrite. Enforcement was
real but lived entirely in the registration decision, and the test
asserted registration only — so a refactor that registered the write
tools unconditionally and meant to refuse at call time would open the
surface with the suite still green. Wrap every run body in a scope
guard at the registry level, so a new write tool inherits it, and drive
the run bodies directly from a read-only context in the test.
Free text: medication names, doses, treatment classes and lab analyte
names were interpolated into tool prose with no delimiter. A lab
analyte name is transcribed out of an uploaded document, so a hostile
document chooses it, and the write tools ride the same wire. Fence the
assembled prose with the shared data-fence markers and scrub short
result titles, which would leak markers into a host's result list.
Confirm: the header called the confirm flag a PROTOCOL-LEVEL CONFIRM
GATE. It is a client-asserted boolean with nothing binding it to a
prior preview. Binding it to a server-issued preview token would prove
only that a preview call happened, which any client can do, so it buys
no human-in-the-loop for a round trip and server state the stateless
transport does not want. Describe it as the convention it is and name
what actually bounds the blast radius.
enforceBudget and recordSpend have no callers left. Both defects they
carried recurred every time a surface copied them: a TOCTOU window
between the read and the bump, and a cap parameter defaulting to the
operator ceiling so an omitted argument rationed a self-hoster paying
for their own key. Deleting them means a future surface cannot reach
for the older, simpler-looking helper and reintroduce either.
getDailyTokenSpend goes with them — it existed to feed the read half.
The ledger-poisoning clamps recordSpend carried (NaN, negative,
fractional token counts) move to reserveBudget, where the property now
lives. Refresh the doc comments that still narrated the old flow.
The red-team corpus probed only user messages and model replies. Neither is
the channel that reaches the prompt unguarded: a lab analyte, panel or unit
name, or a medication label, none of which the account holder authors when
the row was committed from an uploaded document.
The free-text guard's file list was hardcoded and had rotted — three of its
four entries were no longer reachable from the snapshot, and it covered one
of the dozen files that actually emit free text, which is why the lab fields
went unnoticed. Derive it from the import graph instead; it now covers 75
files, with a floor assertion so a resolver break cannot silently empty it.
Deriving the list immediately surfaced a second leak: features.ts shipped a
raw medication name and dose into the snapshot medications block.
Also fix sanitizeForPrompt itself. It deleted control characters, which
welded neighbouring tokens together and destroyed the word boundary its own
injection patterns anchor on, so a payload preceded by a newline survived
because of the newline. Replace with a space and collapse before matching.
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 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 causal bank lived in the narrative validator as an English and German list,
so the no-causation rule was enforced in code on one surface and for two of the
six locales it ships in. It now comes from the shared screen, which carries all
six, and the narrative also gets the dose and risk-score contracts it never
checked -- persisted prose with a dose imperative in it is as unsafe as a chat
reply with one.
Red-team cases carry their language. The German outbound case was probed as
English, which would have reported a pass the guard never earned.
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.
One suite per gate: the rich reads omit with an honest module_disabled
reason, the correlations reader omits and touches no channel, the
doctor-visit resource and prompt refuse without assembling the record,
and the clinician share collapses to its documents-only state. Each
asserts the operator availability path alongside the user toggle, and
that the gate short-circuits before the engine runs rather than building
the answer and withholding it.
The structural test pins that every metric the rich reads can resolve has
either an owning module or a written review decision, and is explicit in
its own comment about what it cannot prove — it does not show that any
read calls the gate. Writing it surfaced an unowned HRV_RMSSD, the
fallback type an RMSSD-only account resolves to, which would have served
recovery data the primary type refuses.
The MCP surface reached three data families that deliberately bypass the
Coach snapshot and so inherited none of its module narrowing: the
doctor-report aggregate behind healthlog://report/doctor-visit and the
doctor_visit_summary prompt, the rollup-backed rich reads (compare_metric,
get_metric_baseline, detect_changepoints), and the correlations reader.
A disabled module — or one an operator switched off server-wide — still
egressed to a third-party assistant.
Gate at the read rather than per call site. The module-to-data ownership
table moves out of the Coach snapshot builder into
lib/modules/measurement-scope so every read gates off one map; the rich
reads resolve metrics through a module-aware resolver; the correlations
reader gates on insights itself, which also closes the same gap on the
Coach chat tool and the metric-page read strip.
Per-domain reads omit with an honest module_disabled reason; the
whole-record aggregate refuses, mirroring /api/export/health-record.
collectDoctorReportData gains a fail-closed backstop on its own module
key. The clinician share link, found while tracing the aggregate's
callers, had the same gap and now collapses to its documents-only state.
The self-context clarifying questions and the AI-composed proactive nudge
both reached a provider without a consent check of any kind. Either could
resolve to the operator's server-managed credential (admin-openai or the
shared central Codex), so a user with no AI credentials and no receipt on
file had health data leave the server on the operator's key.
The questions path is the sharper of the two: its prompt carries the
complete Coach snapshot. Gate both with the chain predicate pair the status
path already uses, on the coach surface. The gate is skip-shaped on both —
the questions fall back to the deterministic hints, the nudge keeps its
deterministic template — so a missing receipt costs the user nothing but the
model's phrasing. BYOK, local and ChatGPT-OAuth chains stay ungated; that
egress is the user's own act.
Move the questions path off the read-then-write budget check onto the atomic
reserve/reconcile pair, so two concurrent saves can no longer both observe a
sub-cap spend and both call the provider. The cap now follows the chain's
cost owner instead of always charging the operator ceiling.
The deterministic fact-pattern pass fell back to German when no locale was
passed, the same de-default polarity the narrative surface just lost. Fact
text is model-facing memory, so English is the fallback for every locale
without a reviewed body; German stays for German readers.
Also correct the InsightNarrative.locale doc comment — the column holds any
shipped UI locale, not just de/en. Comment only, no SQL change.
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.
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 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.
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 Coach snapshot read raw prisma.workout rows while GET /api/workouts
runs pickCanonicalWorkoutRows, so a dual-source user (e.g. Apple Watch +
Withings recording the same run) got the session double-counted in the
Coach prompt and the get_workouts MCP tool. Route the snapshot's workout
block through the same picker, keyed off the user's own sourcePriorityJson.
HRV resolves SDNN only on every MCP/coach path, so an Oura/Polar/WHOOP-only
account (RMSSD, not SDNN) saw a false empty HRV block even though the app's
HRV sub-page already unions both flavours. The coach snapshot now queries
both types; the MCP rich reads (compare_metric, get_metric_baseline,
detect_changepoints) fall back to RMSSD when the user has zero SDNN rows,
mirroring the app's primary-then-fallback rule.
Also aligns surfaces.mcp as a real gate: a metric-status id the signal
registry marks mcp:false (cardio recovery, wrist temperature, sleep score,
...) no longer resolves through the rich-read registry path, closing a
contract gap between the two registries (no data was actually exposed
before this, but a future mcp:false signal without this guard would have
leaked silently).
The remaining day-edge tz mismatch between the rollup-backed rich reads
and the user-tz coach timelines is left documented rather than patched:
the underlying rollup buckets are themselves written on UTC calendar
boundaries, so shifting only the read-side window would trade one
one-sided inconsistency for another without fixing the root cause.
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.
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.
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.
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:
- Sleep rendered nothing in the doctor-report PDF: the sleep section is
default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
nothing (FHIR already emitted it). Added it to the allowlist with a
minutes-to-hours conversion; corrected the stale 'intentionally excluded'
comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
glucose module was on, with no report-level privacy control. The toggle
actually already existed in the UI (with i18n) but was dead because the prefs
fold dropped the key. Wired it end to end (glucose added to the prefs Zod
schema + defaults, default-on to preserve behaviour; gated the render loop +
the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
five sources.
Regression test per fix.
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 Coach self-context loader failed soft per field (undecryptable
allergies/conditions → null) with no signal, so a key-rotation gap made
the Coach generate guidance without ever seeing the user's recorded
allergies, indistinguishable from an empty profile. Keep the per-field
fail-closed posture but emit a wide-event on each swallowed decrypt and on
the DB-read guard, so a systemic failure surfaces instead of masquerading
as 'the user never wrote anything'.
The structured Allergy / FamilyHistoryEntry stores reached FHIR export
only: the doctor-report PDF shipped without the two sections every
clinical intake asks for first, and the Coach knew allergies only from
the free-text answer — a penicillin allergy recorded in the structured
records UI never reached either surface.
Doctor report: two new section toggles (allergies, familyHistory; both
default ON, same stance as labs) across the prefs schema, the export
selection, the aggregator, the PDF renderer, and the export panel. The
tables render stored fields only — substance/category/kind/severity/
reaction/status and relationship/condition/age-at-onset; the reaction
decrypts fail-soft per row and free-text notes are never read, matching
the illness-journal stance. OpenAPI regenerated for the extended
sections shape.
Coach: the about-me context block now appends the structured rows as
clearly labelled entries (authoritative over the free-text answer on
conflict); the projection is a pure composer with unit tests. New
section labels land in all six locales.
The shared contract banned every structure beyond 1-3 paragraphs, so
longer replies read as unformatted blocks. It now asks for short
blank-line paragraphs (2-4 on longer replies), permits a plain "- "
list when a reply genuinely enumerates three or more parallel items,
and one bold key takeaway per reply. The Coach ground rule keeps
prose-first; a list is the exception, never the default shape.
Introduce one canonical "what this is / is not, not a medical device"
statement plus a layered, calm disclaimer set, and apply it on the
surfaces that earn a context note.
- New shared `MedicalDisclaimer` component renders the copy as plain
React text (no markdown renderer, per the XSS posture). Variants:
intendedPurpose, coach, insights, labs, medications, emergency,
dataPosture.
- New `selfDescription` i18n namespace across all six locales: the
intended-purpose line, the per-surface notes, the emergency note, and
an explicit data-posture statement (self-hosted, data stays on your
instance, encrypted at rest, BYOK / local model, no third-party
analytics).
- Apply once per surface, layered not plastered: a calm standing line on
the Coach new-chat hero, one framing line in the Insights layout shell
(covers the briefing and trends), a labs note (ranges vary, only a
clinician interprets), a neutral medications note, and the data-posture
line where the provider key is configured. Mental-wellbeing keeps its
existing crisis copy; records keep their existing note.
- Extend the Coach system prompt (EN + DE) with a self-description ground
rule so the Coach answers "what are you / where does my data go"
honestly and stays non-diagnostic.
App-authored copy was already observational; the few diagnose/treat
tokens are negations, idioms, or framed as the user's own self-reported
statement, so no softening was required.
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.
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.
- extractFeatures: read newest-first with a 6000-row cap (mirrors the
Coach snapshot) and reverse to ascending, so a 365-400 day window no
longer materialises hundreds of thousands of PULSE / glucose rows per
Coach turn and nightly briefing. Full-history extremes still come from
readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
the type-less range scans that extractFeatures and the consolidation
passes drive; the existing (userId, type, measuredAt) cannot serve them
as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
(measuredAt, id) instead of one unbounded findMany, removing the giant
single result-set allocation that drove the boot storm. Output is
identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
offsets so the heavy full-history loads no longer all drain in parallel
onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
for the weekly and off-host writers, bounding transient heap. Off-host
still includes soft-deleted rows. Covered by a serialize/restore
round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
already records auth_method and user_id, so the polling client no longer
drives a write + pool checkout on every request. Failure-path audit
stays.
- Coach conversation detail: cap the message include to the newest 200 so
a long thread no longer decrypts every message on each open; response
shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
and its count stop sweeping open-ended over the substring filters.
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).
Add the medication-class to target-vital map and the adherence-to-vital
storyline so the Coach can surface, association-only, when an adherence dip
lines up with a drift in the vital that medication targets. Never causal,
never a dose-change suggestion; dose questions defer to the prescriber.
Add an in-repo CUSUM / binary-segmentation changepoint detector over DAY
rollups with a high firing bar (persistence plus within-segment-spread
magnitude) so the Coach can name a dated, sustained level shift instead of a
vague "lately", and stays silent when no break clears the bar.
Surface which source recovery is read from when two sources materially
diverge, with the honest reason, instead of silently averaging.
Lay the n-of-1 experiment substrate: a nullable encrypted outcome column on
coach_plans (migration 0197), the review_due / reviewed lifecycle statuses
wired into the encrypted-column registry and rotation script, and a daily
worker that reads the experiment result back in the user's own before/after
numbers — association-only, honest-null, no cheerlead on an adverse trend,
and routing a worsened vital to the clinician. The user-visible read-back
stays behind COACH_EXPERIMENT_VERDICT until its review cases clear.
Extend the outbound dose screen to catch experiment-shaped dose changes
(halve / skip / stop a medication to test it). Add evaluation cases and unit
tests for each new surface.
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.
Add unit coverage for the closed when-grammar and `---REMEMBER---` capture
(propose-free active write, recall-only note, malformed drop, per-user cap),
the closed action-card allowlist (off-list and bad-interval rejection, no
auto-apply), the daily sweep (due flip, plan-review mint, undecryptable
skip), and the routes (owner-scoped no-IDOR 404, field-by-field build, the
off-allowlist 422, and the module gate).
Capture the casual "remember this / remind me about X" utterance the Coach
used to drop. The model emits a provider-neutral `---REMEMBER---` sentinel
that the chat route strips inline on EVERY turn — so a short conversation,
where the request usually happens, no longer waits for the long-chat memory
worker. The note is the user's own explicit request, so it is stored
silently as active; a validated closed grammar (ISO date, relative +Nd/+Nw,
or a context cue) sets the trigger, and a malformed block is dropped.
Surface the stored reminders two ways. A snapshot recall sub-block injects
near, overdue, and already-surfaced reminders so the Coach references them
unprompted next turn. A daily sweep flips overdue reminders to due and
mints a one-off review reminder from a plan whose review date has passed,
finally activating the plan review-date column. In-app surfacing only here;
push delivery follows later.
Also generalise the propose-then-confirm action card: a closed
`---SUGGEST-ACTION---` allowlist (a preventive-care item or a reminder note)
rides an additive SSE frame. Nothing is created until the user confirms.