Commit Graph
906 Commits
Author SHA1 Message Date
Marc-André Bombeck e9d28b399e fix(copy): make delete confirmations, sleep-debt labels, and the glucose band honest
Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.

Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.

Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.

Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.

The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
2026-07-19 12:28:05 +02:00
Marc-André Bombeck 2ab21bb433 chore(release): v1.30.32 — consent that stays withdrawn; quotes that come from the document 2026-07-19 11:59:28 +02:00
Marc-André Bombeck 0627648bea Merge remote-tracking branch 'origin/work/document-and-model-correctness' into release/v1.30.32 2026-07-19 11:52:41 +02:00
Marc-André Bombeck 1c2cfffdb8 fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw
The settings mount fires an idempotent consent heal so an account that
predates the gate does not fall into the no-key state. It looked only for
an active receipt, so a deliberate withdrawal — which sets revokedAt —
read the same as never having consented, and the next visit to the AI
settings granted again, writing an audit row for an act the user never
performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the
grant; one undone by a page load is not a withdrawal.

The mint now distinguishes the two callers. The mount heal grants only to
an account with no consent history at all and leaves a revocation
standing; switching document auto-read on is the consent act itself and
may supersede an earlier revocation. The revocation is re-checked inside
the transaction so a withdrawal landing mid-mint is not overwritten.

The revoke endpoint has existed since v1.4.40 but only the native client
called it: on the web, consent could be given and never taken back. The
AI settings now carry the standing decision and the control that changes
it, in all six languages.
2026-07-19 11:52:34 +02:00
Marc-André Bombeck 80258914f2 feat(documents): catch up unread documents when auto-read is switched on
The per-document summary job is enqueued at upload time and no-ops while the
documentsAutoAiRead opt-in is OFF. Nothing ever re-enqueued it, so the switch
only applied to documents uploaded AFTER the flip — a user who filled the vault
first and opted in later had documents that would never be read, and the
setting looked inert.

A genuine OFF->ON transition now schedules one bounded catch-up pass over the
user's un-summarised documents. It grants nothing of its own: it only enqueues
the ordinary summary job, which still re-reads the opt-in, re-asserts the
per-document egress consent and reserves the daily token budget. The pass
itself re-reads the opt-in first, so a toggle flipped back OFF before it runs
egresses nothing.

Bounded by an id-cursor walk capped at 200 documents per pass (a summary is a
provider call, unlike the local-compute thumbnail backfill). Idempotent at four
levels: the trigger fires only on a real transition, the per-user singleton
collapses a double toggle, the candidate set excludes summarised documents, and
the summary job skips one that already has a summary.
2026-07-19 11:09:57 +02:00
Marc-André Bombeck 02a56047a4 Merge branch 'main' into release/v1.30.31
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 10:49:00 +02:00
Marc-André Bombeck 54634a4f12 Merge branch 'main' into release/v1.30.30
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 10:32:59 +02:00
Marc-André Bombeck cd502d5aef Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31 2026-07-19 10:07:42 +02:00
Marc-André Bombeck d773f460e5 Tell the four document-summary outcomes apart
Opening a document said "the summary is being prepared" and kept saying it.
The view had one fallback for a null summary column and used it for every
outcome: a document nobody ever enqueued, a missing vision provider, a spent
budget, a withdrawn consent, an unreadable file, a provider error, and a
summary the outbound screen withheld all rendered as work in progress.

The common case is not the screen. The background job is enqueued at exactly
one place — a fresh upload with the auto-read opt-in already on — and no
backfill reaches anything else. Every document uploaded before the toggle went
on has a null column no job will ever fill, and the view promised all of them a
summary that was never coming.

Record the outcome as `summary_state` (migration 0254, additive, defaults to
NONE which is truthful for every existing row) and branch on it. Only PENDING,
set when a job genuinely exists, may claim generation is running. NONE,
WITHHELD and UNAVAILABLE each say what happened and offer to generate one.

The withhold keeps its rule and loses its dead end. Blocked prose is still
never shown and never stored — only the fact of the refusal is kept, and it is
no longer terminal, so asking again re-runs the gauntlet. That was the original
intent; leaving the column null defeated it, because null read as "generating"
and the guard meant nothing would ever overwrite it.

The on-demand summary now persists what it produces. It is the only repair path
for the documents the job never ran for, and re-deriving the same paragraph on
every open costs a provider call for nothing. Generation is still only ever a
click — nothing runs because a view rendered. Transcription stays session-only.

Also drop the "read by AI" line from the detail footer.
2026-07-19 10:01:06 +02:00
Marc-André Bombeck f8bb8f4b9b fix(daily): surface a due dose on Today and keep the last grounded briefing
The Today hero stayed on its all-clear line while a dose was actually due:
the dose-window item only fired once the slot was already overdue, so a
dose the user could take right now produced no rail item at all. Emit the
item from the moment the dose becomes takeable — the on-time window's lead
plus the early grace, the same instant logging it starts crediting the
slot — and give the two faces distinct wording and status: a due dose reads
calm and informational, an overdue one keeps the warning. A settled day
still emits nothing, and a cached block whose anchor has since passed keeps
rendering neither face. Push reminders are untouched; they still fire only
on overdue.

The briefing grounding gate discarded the whole block over one number it
could not verify, and it could not verify plenty of legitimate ones: the
allow-set was a hand-maintained list of feature blocks that had already
drifted from the prompt three times, and still omitted medication
compliance, the context counters, the correlation coefficients, every
coverage object and the comparison snapshot — all of which the prompt hands
the model verbatim. Derive the allow-set by walking the prompt payload
instead, so grounded means exactly what it says: a figure the server gave
the model. A fabricated number still matches nothing.

Fix the disposal to match. A content-failed retry left a hole while a
transport-failed one fell back to the previous cached briefing, though in
both cases the rejected text is discarded and the previous briefing passed
this same gate when it was written. Fall back on both paths, and only
report the briefing as withheld when there is genuinely nothing to show.
The on-demand route had no fallback at all; it now mirrors the background
generator that writes the same cached payload.
2026-07-19 09:11:57 +02:00
Marc-André Bombeck d4c4e5d08b Merge branch 'worktree-agent-a9a623139a6af16d5' into release/v1.30.30 2026-07-19 06:58:47 +02:00
Marc-André Bombeck c23f2545cc Give every locale its own strings on the non-prompt surfaces
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.
2026-07-19 06:02:27 +02:00
Marc-André Bombeck 0e0f006702 Merge branch 'worktree-agent-a4a0ede7316e1ad06' into release/v1.30.29
# Conflicts:
#	src/lib/insights/comprehensive-generate.ts
2026-07-19 05:56:05 +02:00
Marc-André Bombeck b77a721474 Remove the superseded budget read-then-write pair
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.
2026-07-19 05:35:13 +02:00
Marc-André Bombeck 5c2bee79d8 Merge branch 'main' into release/v1.30.28
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 05:32:41 +02:00
Marc-André Bombeck de088febe6 Cap the medication-extract budget by the paying credential
The route used the enforceBudget + recordSpend read-then-write pair and
passed no cap, so every call measured against the operator ceiling — a
self-hoster on their own key was rationed by the operator's bill while
paying for their own usage. It also left the TOCTOU window the rest of
the codebase closed: concurrent calls could each read a sub-cap total
and all proceed.

Move the gate after the chain resolves and reserve atomically against
resolveDailyCap(chain), matching runStatusCompletion and the document
paths. Reconcile on every exit: the provider's reported count on a
completed call, a full refund when nothing was generated.
2026-07-19 05:31:38 +02:00
Marc-André Bombeck 395d59fd30 Merge branch 'worktree-agent-aeb38510a52ce89ac' into release/v1.30.28 2026-07-19 05:22:51 +02:00
Marc-André Bombeck cd7e2bc0c4 Merge branch 'worktree-agent-ae1242804dbb4517b' into release/v1.30.27 2026-07-19 05:15:52 +02:00
Marc-André Bombeck 79922631ce Cover the briefing tier's ledger accounting
Pin the four guarantees at the new chokepoint and at both call sites: spend
lands on the ledger, over-cap is refused before any egress, a BYOK chain is
measured against the user-plan ceiling rather than the operator's, and a
failed call refunds in full. Also pin the ordering the consent gate depends
on — a consent-blocked user is never charged.
2026-07-19 05:15:30 +02:00
Marc-André Bombeck 8a1d7502d0 Meter the comprehensive briefing tier against the daily token ledger
The status tier funnels every generation through runStatusCompletion, which
reserves against the day's ledger before egress and reconciles after. The
comprehensive briefing did not: the shared generator and the on-demand
POST /api/insights/generate route both called the provider chain directly, so
none of that spend reached coach_usage and no daily ceiling applied to it.

Route every briefing provider call — first pass, JSON-shape retry,
grounding-correction retry, and the daily paragraph re-roll — through one
chokepoint that reuses reserveBudget / reconcileSpend and resolveDailyCap. The
reservation lands after the consent gate, so a consent-blocked user is never
charged, and a failed or timed-out call is refunded in full.

Over-cap is a typed skipped outcome for the off-request generator (no upstream
fault to retry against, so no failure marker and no retry enqueue) and a 429
for the on-demand route.
2026-07-19 05:10:12 +02:00
Marc-André Bombeck 3ccc991851 test: pin the rollup window, the intra-batch stats: winner, and the resting merge
Each case fails on the pre-fix code: the rollup tests on an unaligned
recompute window, the batch tests on the older stats: snapshot winning,
and the resting test on a series that stops at the fold horizon.
2026-07-19 05:08:20 +02:00
Marc-André Bombeck a877dcddf5 fix(measurements): let the newest stats: entry win inside one batch
Two entries carrying the same stats: key in one batch both miss the
existing-row probe, so the overwrite branch never runs, both land in the
bulk insert, and skipDuplicates keeps whichever the unique index saw
first. The older snapshot of a per-day cumulative total or of a filling
ten-minute heart-rate bucket therefore won and the newer, larger figure
was dropped silently — both entries reported inserted, the counters
still summed, and the client checkpointed past both.

stats: rows carry overwrite semantics by contract, so within one batch
the last entry for a key is the authoritative one. Resolve it up front
and report the superseded earlier entries as duplicate with a
superseded_in_batch reason, a status the client already checkpoints
past.
2026-07-19 05:05:41 +02:00
Marc-André Bombeck eb443a314c Merge branch 'worktree-agent-a9fff6921d1f27c02' into release/v1.30.26 2026-07-19 04:54:34 +02:00
Marc-André Bombeck 0511e36ac9 Fence user-controlled text in the Coach and document-extraction prompts
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.
2026-07-19 04:39:31 +02:00
Marc-André Bombeck b1370626fc Merge branch 'worktree-agent-acf4680fc308d77c8' into release/v1.30.25 2026-07-19 04:24:13 +02:00
Marc-André Bombeck 7edf79bff3 Cover the status ledger, the locale collapse, and snapshot re-grounding
The status-provider suite drives the real budget module against an
in-memory stand-in for the day's usage row, so it exercises the atomic
reserve/reconcile path rather than asserting a mock was called: spend
lands on the ledger, an exhausted day is refused before any egress, a
failed generation is refunded, and a chain on the user's own key is
measured against the user-plan ceiling rather than the operator's.

The snapshot suite walks a 60-turn conversation and pins the full
snapshot to exactly two turns — the first and the elision crossing —
including the case where a lost reply shifts the turn parity.
2026-07-19 04:18:16 +02:00
Marc-André Bombeck 0ddc5097e4 Meter the status tier and stop the snapshot re-shipping every late turn
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.
2026-07-19 04:15:28 +02:00
Marc-André Bombeck 0becc2a56d Screen the whole comprehensive-insight prose surface before it persists
The briefing gate grades numbers only, and only the paragraph, signals and
key-findings blocks -- so a digit-free risk assertion with a dose suggestion
attached passed it untouched, and recommendations and summary were not graded
at all. Screen every readable prose field against the insights contracts.

A safety violation cannot be surgically stripped the way an ungrounded number
can: the offending sentence may sit in any field, and deleting one would leave
the rest referring to it. So the background generator fails the whole run and
persists nothing, leaving the previous cached payload in place, while the
user-initiated route returns an explicit error for the same reason.
2026-07-19 03:47:23 +02:00
Marc-André Bombeck 97df5c4704 Screen the document summary and split its policy by surface
The summary is model prose about the document and is contractually
descriptive-only, but it was returned to the browser verbatim with no guard.
Screen it against the insights contracts and let each caller apply its own
policy: the on-demand route replaces the text with a short honest statement
because a user is waiting on it, while the background job persists nothing --
its updateMany is guarded on a null summary, so a stored refusal would stamp
the document permanently with no path to regenerate.

Transcription stays unscreened by construction. It reproduces the user's own
document verbatim; a discharge letter that says to increase a dose is the
prescriber's instruction, and censoring it would delete the user's own record
from their own view. Locale is required on the summary input type and absent
from the transcription input so a caller cannot silently fall back to the
English banks.
2026-07-19 03:39:31 +02:00
Marc-André Bombeck ca37fab63c Screen model output on the status-card surface and thread locale into the outbound guard
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.
2026-07-19 03:36:42 +02:00
Marc-André Bombeck 3ffaf1906c fix(admin): refuse a restore whose payload names a different owner
The restore target came from the encrypted payload's own userId field
while the audit entry recorded the backup row's userId. A file declaring
a different account would therefore have been written into that account,
with the trail naming the one the operator selected.

Both values are in hand at that point, so compare them and refuse on
mismatch, recording both ids. Adds the first test coverage for this
route; the mismatch case fails without the guard.

Also correct the CLAUDE.md claim that every API route wraps apiHandler -
the three MCP OAuth routes deliberately use withBackgroundEvent so they
can return RFC-format error bodies, and carry the same protections by
hand.

chore(release): v1.30.20
2026-07-19 02:16:54 +02:00
Marc-André Bombeck 9084737635 Make the module gate inventory prove a gate, not a mention
The inventory had a BUILDER-GATED bucket that accepted the mere presence
of an aggregator's name -- loadFhirContext, collectDoctorReportData -- as
proof a route was gated. That proved nothing. loadFhirContext filtered
per-domain modules but never evaluated doctorReport, so all five
/api/fhir data routes sat in a green bucket while serving the whole
record to an account that had the module off.

Drop the bucket. A route now counts as gated only when it calls a gate,
and the orphan message says plainly that importing a module-aware builder
is not one.

Add two assertions the old shape could not make. REQUIRED_TREE_MODULE
pins a tree to the key its routes must name, so /api/fhir cannot satisfy
the inventory by gating on some other, more permissive module.
BEHAVIOURAL_GATE_TESTS lists the tests that actually exercise a disabled
module end to end and fails if one goes missing, since this file reads
source text and can never prove a refusal itself.

Verified by removing the gate from the Patient route with the builder
import left in place: the old inventory passed, this one names the file.
2026-07-19 01:46:51 +02:00
Marc-André Bombeck a83fb6f936 Gate the dashboard summary payload on the module map
GET /api/dashboard/summary builds its own payload instead of going
through readDashboardSnapshotCached, so it never picked up the module
gate the shared snapshot builder applies. It emitted glucose and sleep
cards on data presence alone while /api/dashboard/snapshot correctly
withheld them for the same account.

Lift the drop decision out of gateSummariesByModules into
disabledSummaryTypes in the client-safe widget-modules module, so the two
dashboard aggregates decide from one map. The snapshot keys its summaries
by MeasurementType and the summary route emits cards keyed by an iOS
MetricKind, so what is shared is the decision, not the filtering;
SUMMARY_METRIC_TYPE_BY_KIND is the join between the two.

Omit rather than refuse, matching the snapshot sibling: the payload spans
ten cards across several domains, and blanking the dashboard because one
module is off would be the wrong trade. Core vitals always pass through.

The filter runs outside the analytics cache on purpose. That LRU is keyed
by user with a 60 s fresh / 1 h stale window and a module toggle does not
evict it, so gating inside the builder would keep serving a disabled tile
for up to an hour after the user turned it off.

Wire shape unchanged -- a gated card is absent, not null.
2026-07-19 01:42:56 +02:00
Marc-André Bombeck 4548df84bf Gate the FHIR REST face on the doctorReport module
/api/fhir/* serves the same whole-record aggregate as
/api/export/health-record -- right down to the decrypted insurance number
on the Patient resource -- but never consulted the module gate. The
export refuses with a 403 module.disabled envelope when the account turns
the doctor-report module off; the FHIR face happily returned the full
Bundle to the same token.

Gate all five data routes (Patient, Observation, MedicationStatement,
MedicationAdministration, $everything). Refuse rather than omit: a
whole-record export has no truthful partial answer, and unlike the sync
delta feed no background client depends on it draining to stay
consistent. The refusal reuses the shared module.disabled envelope so the
errorCode clients already branch on stays the same.

loadFhirContext gains a fail-closed assertion as a second layer. It is
the one door to the aggregate, so it refuses outright instead of trusting
that every future caller remembers its gate; reaching it means a route is
missing one, which is a bug to surface rather than a flow to serve.

The static CapabilityStatement at /api/fhir/metadata stays open -- server
metadata, no user data.
2026-07-19 01:39:58 +02:00
Marc-André Bombeck 076339c274 Gate the cycle domains in the sync delta feed
The multi-domain feed at GET /api/sync/changes read cycleDayLog and
menstrualCycle without consulting the module gate, so an account with
cycle tracking off still received period days and per-day symptom rows
over the same token that every /api/cycle/* route refuses with a 403.

Resolve the cycle module once per request and skip both queries when it
is off, rather than filtering after the read. The blocks come back empty
and the request still succeeds: the feed multiplexes five domains over
one cursor, so refusing it outright would wedge measurement, mood and
intake catch-up for every account that has cycle tracking off. The two
cycle watermarks stay untouched while the module is off, so re-enabling
resumes where the client left off.

A client that synced cycle rows before the module was turned off keeps
its stale local mirror. Tombstones would be the wrong signal there --
they mean deleted, and disabling a module must leave the rows intact --
so purging a paired mirror needs an explicit signal in the feed contract
and is left for a coordinated contract cycle.

Wire shape unchanged.
2026-07-19 01:35:14 +02:00
Marc-André Bombeck 8012aa095b feat(tokens): retire the generic API-token mint
POST /api/tokens minted ["medication:ingest"], and that token could never do
the job the settings copy advertised: /api/ingest/medication gates on the
per-medication medication:<id>:ingest grant as well, which this endpoint never
issued. It was refused at the ingest surface and admitted everywhere else.

With Bearer scope now fail-closed the token is inert rather than dangerous,
which is a worse thing to hand a user than the bug it replaces. Remove the
mint. The working credential comes from the per-medication API-endpoint
toggle, which issues both grants scoped to one medication.

List and revoke stay so existing tokens remain visible and revocable, and the
settings card now points at the per-medication toggle instead of offering a
name field. Six locales updated.
2026-07-19 00:51:29 +02:00
Marc-André Bombeck 5c83156dcf Merge branch 'wt/l4-pipeline' into release/v1.30.15 2026-07-18 23:48:03 +02:00
Marc-André Bombeck ae3d291feb refactor(insights): name the instruction-body rule on the derived grid
The derived batch grid carries only the deterministic assessment, whose
templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was
correct but indistinguishable from the accidental binaries that were just
removed; route it through instructionLocale so the intent is explicit.
Behaviour unchanged.
2026-07-18 23:33:37 +02:00
Marc-André Bombeck e4ec0eec49 fix(insights): stop sending German period narratives to fr/es/it/pl readers
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.
2026-07-18 23:25:44 +02:00
Marc-André Bombeck 37c33d0205 fix(insights): carry the reader's locale through the assessment pipeline
The per-metric assessment pipeline typed every locale as `de | en` and
narrowed on the way in, so a French, Spanish, Italian or Polish account
never reached a prompt with its own locale — the output-language directive
could not fire and the reader got English, or German where the binary
defaulted that way.

Widen `SupportedLocale` to the six locales the UI ships and replace the
narrowing binaries with one validating `normalizeLocale` that passes a
recognised locale through unchanged and defaults an unknown value to
English. The status-generate payload, status batch, status cache,
comprehensive generation, the pre-generate warm passes and the forced-warm
handlers all carry the full locale now.

Fix the collapse sites that discarded it: the warm enqueue on the
comprehensive route and the pregenerate route, the nightly batch cron, and
the forced-warm job handler — the last two defaulted to German outright.
The deterministic derived-score templates and the previous-context block
ship de/en bodies only, so they route through `instructionLocale` (German
for German readers, English otherwise) while the prompt names the reader's
own language.
2026-07-18 23:23:40 +02:00
Marc-André Bombeck 5b464b26de chore(release): v1.30.14 — server-prefetch the workouts list; correct the aggregated heart-rate bucket docs
Move the workouts list read into a shared cached module so the RSC
wrapper and GET /api/workouts share one deduplicated projection pass,
and dehydrate it under the client cell's exact query key so the page
paints its rows instead of a loading skeleton.

Correct the aggregated heart-rate externalId documentation: the example
and the min/max field notes still described the original hourly bucket,
while the accepted contract has been 10-minute buckets since v1.30.7.
2026-07-18 22:42:23 +02:00
Marc-André Bombeck e0387c5ceb perf(prefetch): server-prefetch above-the-fold data for /insights, /medications, /coach
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.
2026-07-18 20:36:19 +02:00
Marc-André Bombeck 16fd4966d0 fix(auth): force the native-bundle branch on the OIDC token exchange
Adversarial review found the transport gate (isCookielessNativeCaller) was
broader than finishLogin's bearer predicate: a blank-UA caller with no
X-Client-Type could pass it and fall into the cookie branch, receiving a
Set-Cookie session instead of the token bundle (inert, not exploitable, but
the endpoint's own invariant was false). Also require isNativeClientRequest
so the cookie path is structurally unreachable here, and report the actual
revoke outcome on a replay so the wide event never overclaims.
2026-07-18 17:02:45 +02:00
Marc-André Bombeck ada147522f test(auth): cover the native OIDC SSO handoff
Unit-test the handoff library (mint stores only the hash + binds the
challenge + 90s TTL; consume covers not-found / replay-with-revoke /
expiry-burn / PKCE-burn / atomic consume / lost-race), the
token-exchange route (rate limit, transport gate, one identical generic
401 across every invalid-code class, success with issued-hash stamp +
audit, and OIDC_ONLY does not block it), the callback native branches
(handoff mint + scheme redirect with no session, MFA ticket on the
scheme with no session/cookie, post-identity + IdP errors to the
scheme, CSRF web-fallback), the login native-param handling, and the
by-hash refresh-token revoke used for replay containment.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck e6d9cc229a feat(auth): native OIDC SSO session handoff for the iOS client
The web OIDC flow ends in a session cookie the Bearer-transport native
client cannot consume, so on an OIDC_ONLY instance the iOS app had no
sign-in. Add a native leg: the app opens the login URL with
client=native and its own PKCE S256 challenge inside an in-app browser;
the callback resolves identity exactly as before, then mints a one-time
handoff code and 302s to the fixed compiled-in scheme
healthlog://oidc-callback (or hands back the existing MFA ticket when
the account has a second factor). The app exchanges the code plus its
PKCE verifier at POST /api/auth/oidc/native/token for the standard
native token bundle.

The return address is a compile-time constant — no redirect_uri
parameter and no env allowlist, so the open-redirect / scheme-hijack
class is removed by construction. The native flag and app challenge
travel only inside the AES-256-GCM state blob, so a network attacker or
the IdP cannot flip a web login into a native one. The handoff code is
256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a
guarded consume; a failed PKCE check burns it. A replay of a consumed
code revokes exactly the pair the first exchange issued. The token pair
never rides a URL; only the opaque code or the MFA ticket do. The SSO
login can never satisfy requireFreshMfa step-up — the transport is
Bearer and step-up is cookie-only.

Adds the OidcNativeHandoff model (migration 0252), the token-exchange
route, a daily cleanup sweep, and the OpenAPI entry.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck e61fe42bd1 feat(workouts): enrich the per-workout detail view (#67)
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
2026-07-18 16:05:17 +02:00
Marc-André BombeckandClaude Opus 4.8 04320c415d fix(insights): localise correlation narratives across all six locales
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.

Route every correlation narrative through the server translator:

- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
  the reader's locale, resolved from the correlations route the same way
  other insight generators resolve it. Sentence templates, the direction
  word, and every channel label live under a new insights.correlation.*
  namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
  (BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
  localised the same way; the analytics cache key now includes the locale
  so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
  causal framing ("not a cause" / "an association, never a cause") and the
  faint/moderate/high hedging tiers are preserved in every language.

English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-18 16:05:17 +02:00
Marc-André Bombeck 271abd7b64 perf(dashboard): server-prefetch the Today hero + chart series into the dehydrated cache
Kill the above-the-fold first-load flash on `/`: the hero digest and the
batched chart series still fired client-side after JS download + hydrate,
so the hero and charts painted empty and filled in seconds later (LCP
~5.5 s mobile-throttled while the snapshot already dehydrated).

Extend the existing `page.tsx` snapshot prefetch to also seed the daily
digest and the chart series batch into the same dehydrated TanStack cache,
under keys byte-identical to the client's:

- Extract the series-batch route core into `readSeriesBatch(...)` so the
  RSC and the route share one code path (no HTTP-to-self); the route thins
  to auth + Zod + the shared reader, behaviour unchanged.
- New client-safe pure module `batch-chart-types.ts`: `deriveBatchChartTypes`
  (the single implementation of the visible-with-data type-set derivation,
  order + resting-HR-vs-pulse pick preserved) and `computeBatchWindow`
  (profile-tz end-of-day minus the coverage window). Server and client run
  the same function over the same snapshot payload, and the window is
  computed once server-side and threaded to the client as a prop, so the
  `chartSeriesBatch` key matches on both sides by construction.
- Render the hero data-first so the SSR HTML carries the populated hero
  (the LCP element) — hydration-safe because the digest is dehydrated and
  never restored from the IndexedDB persist allowlist; bound that invariant
  with comments on both the hero gate and the allowlist.
- Warm the shared chart-runtime chunk during hydration.

Both prefetches honour the `DASHBOARD_SSR_PREFETCH=false` escape and fail
soft to the client-fetch path on any error; a ~400 ms soft budget bounds
the cold-day digest cost. No contract change, no visual change.
2026-07-18 14:39:56 +02:00
Marc-André Bombeck d4f78811d9 chore(release): v1.30.8 — harden aggregated heart-rate bucket validation 2026-07-18 11:59:38 +02:00
Marc-André Bombeck 6af0e36c88 fix(measurements): harden the aggregated HR bucket validation
Two data-integrity gaps found in review of the v1.30.7 contract:

- The bucket externalId guard accepted a non-canonical timestamp that V8
  silently rolls over (T24:00, 04-31, non-leap 02-29). Such a row would key
  under a literal string the canonical client never emits, so the two would
  never collapse on the overwrite key, and it would plot on the wrong local
  day. Require canonical round-trip (toISOString() === suffix) in both the
  guard and the bucket-start parser.
- The per-bucket min/max bypassed the plausibility-range guard that value
  passes; now that they drive the DAY min/max band and the intraday envelope,
  an out-of-range or mis-ordered spread poisoned the aggregate. Persist the
  spread only when both extremes are in range and ordered around value;
  otherwise drop to null and keep the average.
2026-07-18 11:58:51 +02:00