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).
Add a "Your reminders" panel under Settings → Coach that lists the stored
reminders, highlights the due ones, and gives the user view, confirm,
done/dismiss, and delete control over their own memory. Light the Coach
launcher dot when a reminder is due, alongside the existing nudge signal.
Add the generic confirm-card component for the suggested-action frame: a
plain-text preview with a localized per-action heading and a single confirm
tap that posts the validated params to the build endpoint.
Translate the new copy in English and German.
Add the management surface for the reminder memory: list, create, confirm,
done/dismiss, and soft-delete, every query scoped to the session owner with
the same no-IDOR posture as the plans routes (a cross-user or unknown id is
a 0-count no-op, never a leaked existence). The note is the only writable
field; timing is resolved server-side from the closed grammar.
Add the confirm endpoint behind the generalised action card. It switches on
a closed action-type allowlist and builds each entity field-by-field from a
server-resolved spec: a preventive-care reminder whose schedule comes from a
closed interval catalog, or a reminder note. It never auto-applies, and the
allowlist excludes any medication or clinical change.
Regenerate the OpenAPI contract for the new routes.
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.
Introduce a third Coach memory type alongside facts (durable traits) and
plans (standing if-then habits): a time-anchored "bring this back later"
reminder with a due moment and a done state. The free-text note is
AES-256-GCM ciphertext via the shared Bytes codec; metric, status, the
trigger fields and the dates stay plain so the sweep and the injection
picker rank and filter without a per-row decrypt.
Migration 0196 is purely additive (status and trigger_kind are app-side
closed enums, not DB enums). Register the new note_encrypted column in the
encrypted-columns registry and the rotation script so a key rotation can
never silently skip it.
Thread the rolling early-detection pass and the labs -> outcome pass through the
correlations route and the coach correlations reader, both of which already
build the same daily series. The route response gains an optional `emerging`
block (recent-window pairs not yet established, flagged provisional) and a
`labCorrelations` block; the coach reader gains matching `emerging` and
`labDrivers` fields. Regenerate the OpenAPI contract for the new response shape.
The daily briefing's always-on feature payload carried only the core vitals,
mood, sleep, steps and medication compliance, leaving four captured domains
invisible to it. Add four blocks to the aggregated features:
- Labs: recent flagged biomarkers (out-of-range or trending vs the prior
reading). Retired markers are excluded; qualitative results stay neutral with
no fabricated verdict.
- Preventive care: due and overdue items off the server-authoritative next-due
date, so an overdue screening can prompt an early nudge.
- Workouts: a provider-agnostic count, duration and distance aggregate over the
trailing windows (the briefing previously saw activity only as step counts).
- Glucose: the present-and-trend block the other vitals carry, where glucose
previously reached the briefing only as a single signal-of-the-day row.
The three extra reads are scoped to the wide briefing window; the tight
snapshot window that already carries its own context skips them. Every block is
omitted when its domain is empty rather than emitting zeros, and user-entered
labels are sanitised before they reach the prompt.
Add a rolling 14-21 day early-detection pass to the FDR correlation engine
so an emerging shift (illness pulling HRV and sleep down together) surfaces
before the 180-day retrospective scan accrues the n>=20 it needs. The recent
pass re-runs the same engine over the trailing window with a lower paired-day
floor, a tighter Benjamini-Hochberg target, and a no-faint effect bar, then
drops any pair the retrospective scan already established so a pattern is never
reported as both established and emerging.
Extend the curated behaviour x outcome matrix with three clinically meaningful
links: medication adherence -> next-day blood pressure (systolic and diastolic,
the latter previously never a candidate channel), sleep as a behaviour
(poor sleep -> next-day mood / HRV), and a separate point-vs-window labs ->
outcome pass (each draw against the contemporaneous outcome mean) since sparse
lab draws cannot meet a daily-lag floor. Every pass keeps FDR control, the
effect-size floor, and shrinkage; thin data degrades to absent, never a
fabricated link. All associations stay descriptive, never causal.
The bottom-right launcher opened a blank new chat on an unread proactive
message instead of the thread the Coach wrote. Return the owning
conversation id from the nudge-status endpoint, have the launcher open
/coach?c=<id> on the unread path, and stamp the same deep link on the push
metadata so the notification lands in the exact conversation rather than
relying on a most-recent heuristic.
Introduce a read-only Model Context Protocol surface built on the official
@modelcontextprotocol/sdk so a user can query their own HealthLog data from an
MCP-capable assistant over local stdio with a pasted, scoped Bearer token — no
extra infrastructure.
The tool and resource layer in src/lib/mcp is transport-agnostic: five read
tools — list_metrics, get_metric_series, get_medication_compliance, get_labs,
get_correlations — each a thin wrapper over the existing server-authoritative
retrieval paths, returning structured values, units, reference bands, and
provenance with an explicit { present: false } for absence and no prose
verdicts. Two resources expose the user's profile (data-minimised) and
medication list. A stdio entrypoint (src/cli/mcp-stdio.ts, pnpm mcp:stdio)
resolves the token through the shared Bearer path and binds the session to
<user>:<token>; with no token it refuses to start.
Read-only and admin-unreachable by construction: only read tools are
registered, and requireAdmin stays cookie-only so the Bearer/MCP wire can never
elevate. Covered by unit tests for every tool and resource, the auth/scope
model, and the cookie-only admin boundary. The server ships in the existing
container; docker compose up is unchanged.
Move the canonical Bearer-token validation out of the api-handler request
wrapper into src/lib/auth/bearer.ts so it can run outside a Next.js request
scope without dragging next/server, next/headers, or the Wide-Event machinery
into the import graph. requireAuth's Bearer path now delegates to it and keeps
the existing auth.bearer.* audit trail and Wide-Event annotation, so the
revoked / expired / scope / lastUsedAt semantics live in one place and cannot
drift between callers.
Covered by a focused unit test pinning the hash-only lookup and the
revoked / expired / insufficient-permission rejections; the existing
require-auth Bearer suite stays green.
Clicking a type on the measurements list now drills into that metric's
insights sub-page when one exists, falling back to the existing
?type= filtered list when it does not. A reverse type-to-slug map backs
the navigation, preferring the dedicated single-metric page so an
ambiguous type resolves correctly (PULSE to pulse, not blood-pressure;
WEIGHT to weight, not bmi). No API change.
resolveInsightsLayout merged any default tile a saved layout did not
enumerate as hidden, so a late-added tile such as steps (introduced in
v1.12) was force-hidden on every read of an existing or iOS-synced
layout and its nav pill never appeared, even with step data present.
Appended tiles now inherit the default visibility instead, matching the
fresh-account default; the per-tile data floor still keeps an empty
metric out of the nav.
Add a `hidden` boolean to the Biomarker model (migration
0195_biomarker_hidden) so a marker the user no longer needs can be
soft-removed instead of deleted: it leaves the active catalog and the
lab-entry pickers but keeps its readings and canonical unit/range.
Thread `hidden` through the DTO, the update schema, the PUT handler
(field-by-field), and the list endpoint (visible markers first). Regen
the OpenAPI contract.
Move the preventive-care surface from the German-derived slug /vorsorge to
the English /checkups and make the feature read consistently across locales.
The route directory, the nav entry, the dashboard card link, and the
onboarding tour step now point at /checkups. A permanent redirect keeps old
bookmarks, the PWA's cached navigation, and push deep-links resolving.
Display label is now "Checkups" in English and the equivalent term in
Spanish (Revisiones), French (Examens), Italian (Controlli), and Polish
(Badania); German keeps "Vorsorge". The internal id stays "vorsorge" so
saved layouts, module-order preferences, and tour state keep resolving
without a data migration.
The comprehensive insight that feeds both the daily briefing and the
insights trend narrative is a single reasoning-heavy generation over the
full feature snapshot. On a large account it runs past the provider
client's fixed 60 s timeout and is aborted mid-stream, so the chain
reports an all-failed outcome, no cached block is written, and the
surface falls back to its static placeholder on every visit. The warm
paths bounded the same generation tighter still (45 s) and counted the
failure with no observable signal, so the blank state was permanent and
silent.
Make the per-request upstream timeout configurable
(CompletionParams.timeoutMs, default 60 s for every existing caller) and
raise it to 120 s for the comprehensive surface only, across all three
generation paths: the on-demand regenerate, the on-demand warm, and the
nightly pre-generation. Raise the warm-pass budget to 130 s so the
provider's own timeout governs a genuine completion rather than clipping
it early. Annotate the comprehensive failure in both the generator and
the nightly loop so a generation that keeps failing is queryable instead
of leaving the surface empty without a trace.
The dedicated conversation-history page gates on the operator master
flag and the per-user opt-out like the main Coach route, so add it to
both grep-based gate-site allowlists.
Drop the unused extractCoachClaims helper (+ its CoachClaim types) and the
unused CoachPlanStatus type — the deterministic grader uses the boolean claim
helpers directly, and the plan status enum is consumed via the const array.
Update the dashboard-snapshot suite for the locale-keyed cell: stub the i18n
context and add getQueriesData to the fake query client so the locale-aware
preload probe is exercised.
A Codex backend 400 on the richer Coach tool-call request (and the daily
briefing) propagated out of the provider chain runner unwrapped: a 4xx other
than 401/403/429 was not classified as a hard provider failure, so it bypassed
AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain
status-card completions, which never send a tools array, were unaffected.
Harden the failure path so a provider error can never surface as a 500:
- Treat any 4xx a provider wire returns as a hard failure so it cascades and is
wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422
surface) stays the only non-hard class, so that diagnostic is preserved.
- The chat route now also classifies a tagged provider error that reaches its
catch unwrapped and emits the graceful coach.provider.* SSE frame instead of
rethrowing. Only an untagged error keeps the 500 + error-reporter path.
Make the Codex 400 diagnosable: fold the redacted upstream response body into
the thrown error message and the chain wide-event, so the field the backend
rejects is visible in logs (it was previously dropped).
Fix the Codex request shape behind the tool-call 400:
- Send the required strict flag on each Responses-API function tool.
- Replay prior assistant turns as output_text, not input_text, per the
Responses API multi-turn contract — the violation only surfaced on round two
of the tool loop, which is the path that broke.
Codex-only; the OpenAI, Anthropic, local and mock providers are untouched.
A deterministic golden-set + red-team floor that gates every Coach change
offline, plus an opt-in live-judge layer that scores warmth and groundedness
with a pinned model only when COACH_EVAL_API_KEY is set.
- 42 golden cases across grounding, cross-metric, data-honesty, provider-parity
and own-baseline taxonomies, graded by behaviour-anchored binary criteria.
- Structured-claim grounding helpers extend the numeric verifier from numbers
to threshold / own-baseline / confident-verdict claims; the numeric API is
unchanged so the existing grounding tests stay green.
- Red-team battery asserts the existing inbound refusal, outbound dose screen,
acute red-flag escalation and learn-link guards fire.
- Nightly + manual workflow runs the deterministic floor always; the live judge
step runs only when the secret is present and is non-blocking.
Add a CoachPlan model: durable, user-scoped if-then implementation plans
the Coach proposes and the user confirms. The background memory-refresh
pass extracts plans the user agreed to and writes them as "proposed"; only
the user-facing PATCH on /api/coach/plans/[id] activates one. The free-text
cue / action / target are encrypted at rest with the shared Bytes codec;
metric, status and dates stay plain so the injection picker ranks without a
per-row decrypt. Active plans fold into the Coach snapshot's memory block
and survive the prompt-budget drop alongside facts. List, confirm and
soft-delete routes are Coach-gated and narrow the owner from the session.
Key the dashboard-snapshot query cell by locale so a locale switch reads
freshly localised tile copy on its own cell instead of serving the prior
locale's prose until the slot goes stale. The zero-arg key stays
byte-identical, so every prefix-based invalidation keeps reaching the
locale-keyed cells; the offline-banner and preloader match on the prefix.
Migration 0194 (additive CREATE TABLE). Encryption-rotation registry and
script cover the three new Bytes columns.
The A1–A3 source files carried v1.22.0 docstrings from before the work was
re-cut as the v1.21.2 point release. Comment-only sweep so blame and the
in-file version tags read consistently with the shipped version.
The builder is server-only by construction — only the metric-page route
imports it and it reads Prisma — and no other module in the tree depends on
the `server-only` package, so the import was both redundant and an undeclared
dependency. Removing it keeps the dead-code gate clean without weakening the
boundary.
The hero score card and the daily briefing accept tension, returnToBand, and
memory props, but nothing populated them server-side, so the lines never rendered.
Resolve all three server-authoritatively on the dashboard snapshot DTO (iOS rides
the same payload):
- tension — fires when the readiness composite's contributors disagree (a strongly
favourable one alongside a strongly unfavourable one), reusing the readiness
components the wellness ring grades and the shared disagreement detector.
Suppressed under a clinical red-flag (a coincident-deviation that no illness
explains) so the red-flag path is never reconciled into a calm verdict.
- returnToBand — runs the streak detector across the salient vitals and surfaces
at most one metric that has come back inside its own personal range after a
genuine prior out-of-band run.
- memory — maps the machine-derived coach memory block to the briefing's recall +
forward-look.
The blocks are locale-agnostic (contributor keys + a metric type); the insights
hero localises them through the existing readiness-contributor and metric-name
labels before handing the cards their resolved strings. Each value omits rather
than guesses. The snapshot cache key now carries the locale for the recall prose.
Add a deterministic narrative layer the Coach narrates rather than invents:
- Briefing callbacks + forward-looks: the daily briefing recalls the prior
period and points ahead, anchored to a figure, one callback per surface.
- Tension Verdict: when the readiness composite's contributors disagree
(good sleep but a rising resting pulse), surface a brief honest internal
read on the health-score card. A real clinical red-flag is never reconciled
away into a calm verdict.
- Streaks + return-to-baseline: a pure detector over a DAY-bucket series
closes a worry once a metric returns inside the personal MAD band after a
genuine out-of-band run; a gap day breaks a streak.
- Coach prompt: add develop-discrepancy, roll-with-resistance, and an
anti-persuasion clause; ratchet PROMPT_VERSION.
The tension flag and streak event are computed server-side; the card lines
render resolved fields and the model narrates them.
Replace the hidden composer prefill with a visible scope affordance: when
the Coach is launched narrowed to a metric, the hero shows a scope pill
naming that metric plus a tappable seed question, and the drawer sources
rail surfaces the same scope line. When the Coach opens unscoped, resolve
today's most notable derived signal server-side and offer it as a tappable
opener; fall back to the neutral greeting when no signal crosses the gate,
so the blank box is never cold and never fabricated.
Add an internal GET /api/insights/coach/seeded-question that reuses the
briefing's derived-signal detector to select the opener server-side; the
client renders the resolved DTO.
Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.
The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.
Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
The post-hoc numeric verifier only graded the tool path, so local /
no-tools providers got no grounding net. Grade the no-tools prose
against the snapshot the model was shown — its structured sections,
which already carry the correlations-snapshot block — and flag any
cited number that traces to no snapshot leaf. Non-blocking: the count
is annotated under coach.prose.number_unverified and the unverified
token soft-stripped, never a stream-killer. Only runs when the full
figures were delivered this turn; a cheap follow-up has no fresh set
to grade and no-ops. Tool-path behaviour and tolerance unchanged.
The three intake dialogs (log, edit, dose-history add) still used a raw
datetime-local input, so they ignored the user's date-format preference.
Route them through DateTimeField like every other date surface. Also
correct a stale diastolic-floor comment in the dashboard verdict.
The correlation discovery matrix gained two non-MeasurementType channels
(medication compliance, symptom severity), but the Coach get_correlations
reader still filtered its measurement query by hand and never built the new
channels. The channel keys leaked into the Postgres enum cast and threw for any
user with medication or illness data.
Extract the route's two channel-series fetchers into a shared module
(correlation-channel-series.ts) so both consumers build the channels identically,
then wire the reader to derive its type filter from discoveryMeasurementTypes and
fold the fetched compliance + symptom series into the discovery input. The reader
now humanises the channel keys to "medication adherence" / "symptom severity".
Render daily medication-compliance rate and illness symptom-severity as
first-class series in the FDR discovery engine, so the adherence-dip →
symptom-flare link (and compliance → vital drift) can finally surface.
Compliance pools every active medication's dose-history ledger into a per-day
taken/(taken+missed) rate, re-keyed to the user's display timezone. Symptom
severity reads the illness day-log functional impact, zero-filling healthy days
only across real episode spans so a user with no episodes yields an empty
series rather than a constant. Both flow through the existing n >= 20, BH-FDR,
effect-size, and shrinkage gates unchanged; a sparse series degrades to absent.
Each channel forms its own metric family (no self-lag tautology) and keeps the
association-not-causation framing. Add discoveryMeasurementTypes() so every
caller drops the non-measurement channels before a Prisma type filter.
The labs block already surfaces value + unit + range + date, and the Coach
prompt instructs it to quote them verbatim, so labs are cited end-to-end — no
gap. Pin that with a tool-level guard asserting value and unit survive
get_labs, so a future change to the labs pass-through can't quietly strip the
number the Coach needs to cite.
The Coach may point at a published /learn guide, but only a slug in the
catalog. That was a prompt instruction with no enforcement — a model could
hallucinate /learn/lower-your-cortisol and ship a dead link. Add a deterministic
post-filter on the assembled reply that keeps a published slug verbatim and
strips any reference whose slug is not in the catalog, tidying the prose around
the removed link. Makes the catalog's impossible-by-construction claim an
enforced guarantee.
The illness-recovery tool and the illness context block carried only labels,
lifecycle, and dates plus the recovery composite — never the recovery-gap, the
metric that drove it, the nadir, the pre-onset deviations, or the red flags the
illness card renders. So a recovery question got the composite, not the gap the
user sees, and a sustained-fever or low-SpO2 escalation was invisible
in-conversation.
Run the existing episode-correlation read-layer (the same engine the card and
the red-flag notifier use) for the most relevant episode — active first, else
the most-recently-resolved — and attach the computed scores to get_illness_recovery,
coverage-gated so a thin signal yields nothing rather than a fabricated number.
Document the illnessScores shape in the Coach prompt so it restates those
numbers verbatim and escalates a red flag rather than reassuring.
Each retrieval tool re-scoped the snapshot to a single source, so its cache key
never matched the inventory's full-source build — a four-tool turn paid four
extra full snapshot builds on top of the inventory's. Thread the inventory's
probe scope to every tool so a per-tool read lands the one cache entry the
inventory already primed, collapsing the turn to a single build (a window
override still gets its own correct build). Raise the loop's round cap from two
to three so a sequential cross-metric why-chain isn't starved; the per-round
token budget is unchanged and the absolute ceiling tracks one above so the
final round still forces prose.
The tool-mode path dropped the launch scope's sources: the inventory probes the
full source set, so a Coach opened from a metric page saw the whole inventory
and could roam, while only the no-tools path honoured the narrowing. Inject a
one-line FOCUS hint naming the launched domain(s) into the tool-mode user turn
so the model prioritises that metric and fetches its figures first, with every
other domain still reachable if the question leads there. Empty on a generic
open, so the prompt prefix is unchanged on that path.
The per-bucket OLS accumulators stored x on the raw epoch-day axis (x ~ 20540),
so sum_xx accumulated past ~1e10 and the square shed ~10 of a double's
significant digits before the value was ever stored. The sub-day x detail was
lost at accumulation time, which no read-side identity can recover; an
ill-conditioned window's composed slope/r2 then drifted from the live REGR_*
probe past the parity gauge.
Rebase x to a fixed origin (epoch-days of 2020-01-01) at write time so the
squared terms stay O(1e7) and exact. Slope, r2, and population sd are invariant
under the affine x-shift, so the live raw-epoch probe still parity-matches the
cross-bucket compose; composeRegression needs no formula change. The per-bucket
slope/r2 columns stay on the raw axis (shift-invariant).
Migration 0193 recomputes sum_x/sum_xy/sum_xx from measurements on the rebased
basis, one set-based update per granularity. Recompute-from-source is idempotent
and rerun-safe; it touches only rows that already carry the accumulators and
leaves pre-migration NULLs for the boot re-fold.
Un-quarantine the DST-boundary parity test: it now matches live REGR_* within
the existing 1e-9 relative bound, with the WEIGHT case still bit-identical.