Push the cycle-reminder cron's gating into the query instead of loading
every CyclePrediction row each tick: filter on the user's
cycleTrackingEnabled / predictionEnabled relation and restrict to rows
whose nextPeriodStart falls inside a tz-generous [today-grace, today+lead]
string window. The per-tick cost now scales with the in-window cohort, not
the whole table; the exact per-user-timezone window still runs in JS.
Make the period-boundary shortcut transactional and idempotent: wrap the
close-prior + open-new (or stamp-current) cycle mutations in a single
$transaction so two concurrent taps cannot double-close a prior cycle or
compute a length against a stale read, and add withIdempotency so an exact
key replay is a no-op. The boundary day-log now ranks the incoming flow
against any existing same-day entry and never downgrades a richer flow.
Decode sync cursors additively: a live beta v1 token keeps its
measurement / mood / intake watermarks and simply fresh-scans the two
domains added in v2 (cycleDays, cycles) rather than forcing a full
re-init. Correct the stale "no live consumer" comment.
Thread the user's timezone through the Coach cycle snapshot so "today",
day-of-cycle and days-until-period derive from the user's zone — matching
the calendar and insights routes — instead of wall-clock Berlin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the menstrual-cycle phase as a channel in the existing FDR-guarded
correlation machinery, the same way rated mood factors were folded in —
no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same
day floors and q = 0.10 gate.
Two complementary mechanisms, both confined to a gender-gated surface:
- a categorical luteal-vs-follicular contrast per outcome metric (resting
heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature),
surfacing one headline finding (resting-heart-rate-by-phase, falling
back to HRV); and
- the continuous phase ordinal folded into the lagged-Pearson matrix.
Phase never reaches the general correlations route — the temperature
channels held out of the general matrix are un-blocked only here, behind
the cycle gate, so a reproductive inference can never leak onto a
non-gated surface. Every row shows n, effect size, p and q; nothing is
fabricated on thin data and nothing is framed as causal.
Surface it through a new gated GET /api/cycle/insights and a drop-in
card that mirrors the mood-factor crosstab.
Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle,
the next predicted event (period range + confidence + method, fertile
window goal-gated), and the headline phase finding — present only for a
cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground
rule keeps the Coach descriptive: never contraception-grade, never a
"safe day" claim.
OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added;
copy localised across all six locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the launch-blocking gap where the ConsentReceipt infrastructure was
built but never enforced as a precondition: a direct API caller with a valid
token could forward a health snapshot to a server-managed external LLM with no
receipt on file. Consent was enforced client-side only.
Add a consent guard (src/lib/ai/consent-guard.ts) and call it before the first
external-provider call on the server-managed path. The gate fires only when the
resolved chain could egress via the operator's global key (admin-openai); a
user's own BYOK key (openai/anthropic), their own ChatGPT OAuth account, and
the self-hosted local provider stay ungated — that egress is the user's own act
and the settings flow is its consent. The check fails closed: a BYOK-primary
chain with an admin-openai fallback still requires a receipt, since the runner
may cascade to the operator key. The surface maps to the consent kind (coach ->
ai_coach, insights -> ai_insights_only; master ai_full satisfies either).
- Interactive routes (insights/chat, insights/generate POST) throw
ConsentRequiredError; api-handler renders a 403 with
meta.errorCode = "consent.ai.required", mirroring assistant.disabled.* so
the client can render an inline grant-consent notice.
- Off-request pipelines fail closed quietly: comprehensive generation returns a
typed skipped:no-consent outcome, and the per-metric status family
(runStatusCompletion, gating every status card + the period-narrative warm +
the off-budget Coach memory workers) surfaces the no-key fallback instead of
egressing.
Also rate-limit POST /api/fitbit/sync (the one Fitbit route without a limiter):
a 5/60s baseline plus a tighter 1/hour bucket on the expensive fullSync walk,
keyed by user id, matching the sibling Fitbit routes.
Harden the Fitbit OAuth redirect_uri: assert it is an absolute https origin (or
http on localhost), lands on /api/fitbit/callback, and stays same-origin with
NEXT_PUBLIC_APP_URL — defence-in-depth behind Google's registered-URI check so
a Host-coerced or misconfigured app URL cannot redirect the authorization code
off-origin.
(cherry picked from commit 46b5fc2cea1b6e80bee0445bfdacf5790442c67d)
Apple Health (and some WHOOP shapes) write BOTH an unspecified ASLEEP
aggregate row AND the granular CORE/DEEP/REM breakdown for the same
sleep period. The per-night reconstruction summed both, so a real ~8 h
night surfaced as ~16 h across every sleep surface — the chart showed
~16-18 h points and the target-range 30-day average read ~16.4 h/day.
Prefer the granular partition: when any CORE/DEEP/REM stage is present
for a night/source, count only those; fall back to the bare ASLEEP (or
a stage-less legacy/manual) row only when no granular stage exists.
IN_BED and AWAKE stay excluded; dual-source nights still collapse to one
canonical source via the user's sleep priority ladder. The same rule
applies inside the per-source asleep tiebreak so a source writing both
shapes is not over-weighted.
Align the surfaces that bypassed the shared helper:
- insights/targets: SLEEP_DURATION was averaged as raw per-stage rows
(minutes) and fed into a target band expressed in hours, producing the
bogus 16.4 h/day. Reconstruct per-night time-asleep, convert to hours,
and classify + roll up consistency on the nightly hour totals.
- coach snapshot: route the sleep block through reconstructSleepNights
so the prompt narrates the same nightly numbers (was summing every
stage row, including IN_BED + AWAKE).
- sleep card chart: read /api/measurements/series?kind=sleep (night
reconstruction, hours) instead of the generic measurement
daily-average path, and label the axis in hours instead of minutes.
Add regression fixtures: a night with both a bare ASLEEP row and the
granular breakdown asserts the granular total (no double-count); a
dual-source night with both shapes per source; and a multi-night 30-day
average that lands at ~8 h, not ~16 h.
The snapshot builder ran its independent reads sequentially on a cache
miss: feature extraction, the shared measurement query, the mood,
compliance, sleep and workout table reads, and the GLP-1, derived,
trajectory and memory helper blocks each awaited in turn. None consumes
another's result, so they now fire concurrently and resolve in batched
hops — feature extraction and the measurement read first (both gate the
per-metric blocks), then the remaining reads in one Promise.all. The
source guards are unchanged, so a disabled source still issues no query,
and the synchronous block assembly keeps its original order, so the
provenance envelope is byte-for-byte the same. Cuts several round-trips
off the cold build; the warm path is cached and unaffected.
- Drop the unused `now` field from the coach `ExtractOpts` type; fact
extraction never read it (conversation-summary keeps its own `now`).
- Correct the no-ladder tiebreak docstring in measurement-read: the
collapse picks the alphabetically smallest source name, matching the
live-SQL `ORDER BY … source` tiebreak, not max count.
- Replace the hand-inlined canonical-source subquery in the summaries
rollup-path narrows query with `canonicalMeasurementsFrom(rank, "90 days")`
for live/rollup parity with its siblings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the two never-wired *_PROMPT_VERSION exports the dead-code gate
flagged, and consume the summary-length target as a real backstop that
caps the stored rolling summary so a misbehaving provider can't bloat
the encrypted column or the next prompt's history window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- conversation-summary: fault-isolate the per-message decrypt in the
fold loop so one undecryptable historic turn (e.g. mid key-rotation)
no longer permanently stalls a conversation's summary refresh.
- source collapse: unify the no-ladder tiebreak on alphabetically
smallest source across the JS reader and the all-time rollup SQL, so a
ranked vital whose day carries only off-ladder sources resolves to the
same canonical source on the warm and the live-SQL read paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildCoachMemoryBlock gains a third fault-isolated sub-source — the
top-N durable facts — folded under snapshot.memory so they ride the
existing lowest-priority registration and are shed first under the
char budget. Add an EN + DE system-prompt paragraph instructing the
Coach to use facts as the user's own stated, descriptive context —
never as a diagnosis, never invented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fetchConversationWithMessages now returns the decrypted rolling summary
(fail-closed → null), and buildHistoryWindow substitutes it for the dead
placeholder so a long conversation keeps memory of its elided turns.
Once a conversation grows past the turn cap the chat turn fires a
fire-and-forget coach-memory-refresh so the next long turn is fresh,
without adding a provider round-trip to the interactive reply.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the coach-memory-refresh pg-boss queue: a fire-and-forget enqueue
contract (coach-memory-shared) and a worker pipeline that runs the
conversation-summary refresh then the fact extraction for one long
conversation, each fault-isolated and budget-gated. Register the queue
in allQueues + a boss.work handler in the worker, with a source-grep
inventory guard so the queue can never ship unregistered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the two background generators behind Coach long-term memory, both
reusing the budget-aware provider chain (runStatusCompletion) with a
local fallback and encrypting their output at rest:
- conversation-summary: folds the turns elided past the history window
into a rolling natural-language summary, merging into any prior
summary; short-circuits cheaply until a conversation grows long.
- facts: extracts durable, descriptive personal facts (preferences,
stated conditions, goals, constraints, context), gated by a Zod
schema + de-dup + per-user cap; buildCoachFactsBlock ranks the top
facts for snapshot injection. Never diagnostic, never numeric.
Both are pure/injectable for unit testing and annotate counts only,
never content. Wiring into the queue, route, and snapshot follows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the AES-256-GCM Bytes codec from persistence into a shared
bytes-codec module so the conversation summary and the new personal-fact
store reuse one ArrayBuffer-backed implementation. Add the CoachFact
table (migration 0116) for durable, user-scoped facts the Coach extracts
from conversations — encrypted fact text, plain category/confidence/
source for rank-without-decrypt, soft-delete for a reversible forget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold the deterministic short-horizon forecasting engine into the Coach
prompt as a compact, lowest-signal snapshot block. For each in-scope
metric the block carries direction + slope + the projected horizon end
with its widening prediction band, the last observed value, and the
fit's R² / server-computed confidence — read straight from the existing
projection engine, never recomputed. A metric below the fit/history/
staleness floor is omitted; the block is dropped entirely when none
qualify, and it sheds first under prompt-budget pressure.
Add a conditional-projection ground rule so the Coach narrates a
trajectory only when the block is present and only for the metric it
covers: direction and range, always conditional, never a certainty, a
risk score, a diagnosis, or a dated event; absent block means no
projection. The rule lands in the safety-contract matrix across all six
locales and inline in the EN + DE system prompts, with an overclaim
probe asserting a projection is never upgraded. Bump PROMPT_VERSION.
Assemble a zero-LLM longitudinal memory block from artefacts already
persisted: the latest period-narrative headline and a per-metric
prior-vs-current band recall. The Coach can now reference what was true
last period instead of re-deriving a cold snapshot every turn.
The block is machine-derived (labels plus numbers only), so it is
unencrypted and adds no model cost. Each sub-source is fault-isolated —
a failing narrative read or context build drops that sub-block and never
sinks the turn. It registers against the lowest-signal cluster so the
budget degrader sheds it first, before any clinical data.
Add a "derived" block to the Coach snapshot: one tiny object per high-signal
derived metric (readiness, recovery, sleep, stress, strain, HRV balance,
cardio-fitness band, vascular-age delta) carrying value + band + confidence +
historyDays — never the raw series. A fully-populated block is ~250 tokens,
comfortably inside the snapshot budget. Insufficient metrics are omitted so the
model never sees no-data noise; each metric's compute is fault-isolated so a
transient failure on one never sinks the turn. Gated on the underlying signals
staying in scope. The system prompt now documents the block and frames the
bands as descriptive composites, not clinical findings.
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.
Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.
The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
silent-disable gap the wave was conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
Eight touch-disjoint waves landed on develop before this release:
- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
persist helper and wire all seven *-status routes through it, closing
the recurring ~14 s warm response the iOS client sees on dashboard
open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
(export bundle, gamification achievements, doctor-report aggregator)
with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused tx?
params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
formalisation; knip CI gate staged for v1.4.42 flip.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.
pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.
Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.
Waves shipped (each with a phase report under .planning/):
- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
integration contracts close audit Critical Finding #3 (soft-delete
invisibility) across dashboard, analytics, summaries, rollup tiers,
and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
/api/insights/{features,targets,cards} routes plus the
compliance-rollup hook gap closure for bulk-projection mints; the
post-QA reconcile patch closes the six remaining measurement.findMany
sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
queryKey factory-bypass walker test (extended to comparison/ in the
reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
time-sensitive APNs payload (priority 10 + interruption-level)
scoped via a parameterised test that pins all 6 other event-types
do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
append-only invariant pinned in test; revoke-then-regrant leaves
both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
(week/month/year) wired through summaries-slice and
health-score-fast-path with a linear-composability parity test
pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
eliminates a sibling-drift risk; knip whitelist trimmed to three
files pending follow-up.
QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):
- senior C1 — dashboard-suspense-boundaries regex aligned with the
post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
compliance recompute on both /api/medications/intake and
/api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).
Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.
User-facing prose: see CHANGELOG.md.
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.
Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.
Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.
The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small reuse wins from the W6 simplify review:
- openapi/registry.ts swaps the lazy require() for a static import.
The ESLint disable + as-cast carried no real benefit; the only
callers always loaded the route table anyway.
- Extract useCoachPrefs() into a shared hook so the settings sheet
and message thread share one queryKey + one fallback shape. Removes
the drift between the two existing inline blocks.
- Replace the 9-deep wantsX boolean ladder in coach snapshot with a
single CoachScopeSource -> MeasurementType[] map. Drives both the
WHERE type IN (...) build and the appleHealthBlocks gate, so adding
a new metric is one entry instead of three parallel edits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and
fired `coach.keyvalues.parse_failed` only when the entire block was
unusable. Mixed-format drift (one row breaks, the other six parse)
landed without any signal. Surface it.
- `SentinelParseResult.malformedEntries[]` records each skipped row
with a typed reason (`missing_colon` / `value_overflow` /
`label_overflow` / `no_END_marker` / `byte_overflow` /
`schema_invalid`). Block-level failures (truncation, missing close
marker) land here as synthetic entries so the wide-event always
carries a typed cause.
- Per-row label and value caps moved out of the Zod schema into the
parser so the row-level reason can be `label_overflow` /
`value_overflow` rather than the catch-all `schema_invalid`.
- Chat route splits the annotation: `coach.keyvalues.parse_partial`
when at least one row parsed AND at least one row failed, the
existing `coach.keyvalues.parse_failed` stays for full-block
failures. Both annotations carry the per-line reason array.
- Backwards-compatible `parseKeyValueLine` wrapper preserved.
Tests +5: label_overflow, value_overflow, byte_overflow,
mixed-malformed entry assertions, and a tryParseKeyValueLine
direct-API surface check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the strict insight schema, Coach snapshot pipeline, and
provenance envelope with the seven Apple Health categories that landed
in Wave 2 (HRV, sleep, resting HR, steps, active energy, flights,
distance, VO2 max, body temperature). All fields are additive and
optional — web-only accounts pay zero extra SQL and the prompt's new
GROUND RULE 12 instructs the model to stay silent when the snapshot
doesn't carry the HealthKit blocks.
Ratchets PROMPT_VERSION 4.22.0 → 4.23.0 and refreshes the
sourceMetric / trendAnnotations enums in both EN + DE. Two new test
files pin the prompt body and the snapshot fan-out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the v1.4.20 "conversational summariser" persona with a partner
who sits alongside the user — warm, curious, conservative, with seven
numbered ground rules that prefer invitation over refusal when data is
missing and reserve specific numbers for a structured evidence block
at the end of each reply. The day-level timeline section from v1.4.21
stays verbatim so weekday questions still resolve cleanly. Bumps the
shared PROMPT_VERSION from 4.20.2 to 4.22.0 and broadens existing
schema tests off the 4.20.x pin onto the 4.x train.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `.planning/phase-v1421-hotfix-report.md` summarises the six
fixes (Daily Briefing regenerate · Coach day-level snapshot · Coach
prompt update · streaming-bubble dedup · drawer header · sources rail
scope picker), the test-count delta (2026 → 2036 unit), the
token-budget impact of the day-level snapshot (~190 → ~3000 tokens
per full-scope turn), and a recommendation to ship as a v1.4.20.1
hotfix.
STATE.md grows a Phase F section + status block tracking the six
commits on develop. Includes prettier polish across the touched
coach surfaces (sources-rail, drawer, message-thread, snapshot,
chat route).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the Sources rail listed BP / Weight / Pulse / Mood /
Compliance as a static legend. Marc asked for a real "what I can
see" surface — per-source checkboxes (include/exclude) plus a
window selector (last 7d / 30d / 90d / all-time) that flow through
the Coach request body.
Wire the controls all the way down:
- <SourcesRail> grows scope + onScopeChange props. Each row
becomes a 36px tall flex row with a real checkbox; the row
fades to 60% opacity when its source is excluded.
- A Select trigger above the list drives the window. Both control
surfaces share the existing insights.coach.window.* i18n keys;
add a new windowLabel key so the dropdown has an EN+DE label.
- <CoachDrawer> owns the scope state (resets on each mount per
the v1.4.20.1 plan — no conversation-level persistence yet) and
forwards the picked scope through useSendCoachMessage to the
chat request body. The payload only ships when the user has
narrowed away from the all-source last30days default so the
server can tell "no opinion" from "intentionally narrow".
- buildCoachSnapshot already accepts the optional CoachScope from
the previous commit; it now flows end-to-end.
No new dependencies — checkboxes are plain inputs styled with
Tailwind + Dracula tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Coach prompt only said "ground every number in the
SNAPSHOT" without telling the model that the snapshot now carries a
timeline.recent block of day-level rows with weekday labels.
Without that pointer the model kept defaulting to aggregate-only
phrasing on day-specific questions.
Add a DAY-LEVEL READINGS section to both EN and DE prompts:
- explain the timeline.recent / timeline.weekly shape
- tell the model to answer day-specific or weekday-specific
questions out of timeline.recent and to cite the actual reading
with date + weekday
- when no row exists for the day named, say so plainly and offer
the closest available day rather than the aggregate
- older weekday questions fall back to timeline.weekly
- the scope.sources field is the conversation's source-allowlist;
a question about a non-listed metric should be acknowledged as
out-of-scope rather than answered from the aggregate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Coach snapshot used to ship only aggregated statistics (mean,
slope, SD, range, count) per metric. A turn that asked
"why was BP higher on Monday?" had no day-level rows to ground in
and the model fell back to apologetic aggregate-only phrasing
("from the snapshot I cannot isolate the specific Monday").
Extend buildCoachSnapshot to fold in:
- timeline.recent: one row per UTC day for the last 14 days, each
tagged with its weekday so the model can answer day or weekday
questions directly. BP rows pair systolic + diastolic; days with
only one side measured are dropped to avoid fabricating a
complement.
- timeline.weekly: ISO-week buckets covering days 15..N where N is
the configured window. Keeps the prompt budget tight on 30/90-day
windows while still letting the Coach cite older weeks.
- per-day medication adherence rows from MedicationIntakeEvent so
the Coach can answer "did I miss my dose Tuesday?" without
inventing the schedule.
Also add the optional `scope` field to coachChatRequestSchema (sources
+ window) so the upcoming sources-rail picker can narrow the snapshot
without breaking older clients — missing fields fall through to
all-source last30days.
A typical 30-day, 5-source snapshot lands around 2.4-2.8 KB JSON
(≈700-900 prompt tokens) — comfortably inside the 25k/day Coach token
budget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- weekly-report eyebrow chip drops uppercase + tracking-wide for the
sentence-case rule the rest of the app follows; print stylesheet
now neutralises bg + colour so monochrome output reads clean
(Design-M4, Design-L3).
- correlation-card "Try a 7-day experiment" CTA flips from default to
outline while disabled. Disabled-primary read as "the system is
broken right now"; outline reads as "this is on the roadmap"
(Design-M7).
- coach-drawer settings cog aria-label switches to the coming-soon
tooltip copy so SR users hear the same "arrives in v1.4.21" message
sighted users see (Design-M9).
- sources-rail static "fresh" dot becomes aria-hidden — v1.4.20 has
no real freshness state behind it; SR users no longer hear "Fresh"
five times in a row (Design-L4).
- summariseTitle slices on Unicode code points rather than UTF-16
code units so a user message ending mid-emoji ("🩺") no longer
produces a half-character on disk (Code-LOW-04).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every POST /api/insights/chat called extractFeatures(userId, false),
which ran prisma.measurement.findMany({ where: { userId } }) with no
date filter — fetching every measurement the user had ever recorded.
For power users with multi-year Withings imports that's tens of
thousands of rows on every Coach turn.
Add an optional sinceDays bound to extractFeatures (default unchanged
= full history; analytics dashboard + insights/generate keep their
existing behaviour) and have the Coach snapshot pass sinceDays: 90.
The Coach already only narrates the last7 / last30 / last90 windows,
so the 90-day floor preserves every metric the snapshot surfaces.
Fixes Senior-HIGH-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route gates on requireAuth (cookie or bearer), enforces the daily
token budget, runs the prompt-injection / off-topic refusal scanner,
and only then walks the user's provider chain.
The conversation is created via withIdempotency() when no
conversationId is supplied, so a retry under the same Idempotency-Key
replays the original assistant reply rather than spinning up a second
thread. Existing-thread turns skip the wrap.
Replies stream as SSE: one "token" frame per word-sized chunk, then a
"provenance" frame describing the windows + metrics the assistant
could see, then a "done" frame carrying the persisted message id.
Provider failure cascades to a single "error" frame and the assistant
message is never persisted. Conversation history is capped at 20 turns
in the prompt window via a synthetic summary placeholder so cost stays
bounded.
`buildCoachSnapshot()` reuses the analytics features pipeline so the
Coach narrates the same numbers the dashboard tiles render.
`getCoachSystemPrompt()` extends the strict-insight ground rules with
conversational framing while keeping the zero-hallucination rule.
5 integration tests pin the round-trip, refusal short-circuit, budget
429, foreign-conversation 404, and validation 422 paths against the
postgres testcontainer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the foundation layer for the AI Coach:
- `types.ts` defines the SSE event shapes (token / provenance / done /
error), the chat-request zod schema, and the conversation/message
DTOs the route + UI share.
- `persistence.ts` encrypts every message body with the active
ENCRYPTION_KEYS entry before insert and decrypts on read.
`summariseTitle()` keeps the rail snippet ≤80 chars with a smart
word-boundary cut.
- `budget.ts` introduces a per-(user, UTC-day) ledger with a 25 000-
token daily cap. `enforceBudget()` throws HttpError(429,
"coach.budget.exceeded") so the route can short-circuit before
spending. UTC alignment matches the LLM-provider billing cycle.
- `refusal.ts` runs prompt-injection and off-topic detection on inbound
messages. Pattern bank covers EN+DE injection wording, jailbreak
variants, and obvious off-topic asks (weather, code-help, recipes).
Health-allow tokens override the off-topic deny list so mixed
questions land on-topic.
- i18n keys `insights.coach.refusal*` + `insights.coach.error*`
added to both locale bundles.
- 28 unit tests cover the helpers with mocked Prisma.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>