Commit Graph
267 Commits
Author SHA1 Message Date
Marc-André Bombeck e2814336e5 Merge branch 'fix/aggregation-day-boundaries' into release/v1.39.3
# Conflicts:
#	src/app/api/medications/[id]/route.ts
2026-09-26 17:00:54 +02:00
Marc-André Bombeck 026e8fc1ed Merge branch 'fix/compose-env-and-claims' into release/v1.39.3 2026-09-26 17:00:46 +02:00
Marc-André Bombeck 7860df2fba perf(rollups): fold weekly, monthly and yearly buckets from canonical days in SQL
Building the coarse buckets from the DAY tier read every day of the window
into the process: a five-year yearly read moved thousands of rows per type,
and the Coach's history bands read several such windows per request. The
per-day source pick and the fold now run in Postgres (DISTINCT ON the day,
ordered by the ladder rank and then the source name, the same order the
in-process pick uses), so a coarse read returns one row per bucket. The
long-range chart picks its tier from the span of the window's days and reads
that tier once. The Coach snapshot hands its already-loaded source order to
the history bands instead of each band reading it again.
2026-09-26 16:56:02 +02:00
Marc-André Bombeck 9d2fd1db14 fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
2026-09-26 16:23:14 +02:00
Marc-André Bombeck 91571d4674 fix(medications): count the whole cadence in the supply runway
The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever
its INTERVAL, so a quarterly injection with two doses left showed about 60
days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a
yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra
BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused,
and a PRN schedule as if it were due every day. The rate now honours
INTERVAL for every frequency, counts the listed days and months, scales a
cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job
selects the schedule type and cycle lengths so it reads the same rate.

Whole-dose counts no longer lose one to binary floating point: 2.4 mL at
0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two
doses. The stock summary, both runway estimators, the list and detail
routes, the GLP-1 route, the Coach snapshot and the inventory readout now
floor through one helper that treats anything within 1e-9 of the next whole
number as that number.
2026-09-26 15:32:29 +02:00
Marc-André Bombeck 228c5c9da6 fix(copy): say what the AI provider receives and what is encrypted
The "Send raw data" switch promised anonymised single readings from the
last 30 days. It sends bucket averages from the rollup table: per day for
the last 90 days, per week for the year before, per month back to five
years, each with its date and reading count. The label, both
descriptions and the warning now say that, and the "off" text no longer
claims the summaries carry no individual value (they include the latest
one).

The data-posture line and the Coach's own description of the app said
all health data is encrypted at rest and nothing leaves the instance
beyond the AI provider. Free text, documents, questionnaire answers,
AI-written text and tokens are encrypted; measurement values, lab
values, medication names and mood scores are not. Both now say so, and
the Coach is told not to claim otherwise. Two narrower absolutes are
scoped to what they cover: the local-model onboarding title and the
off-host backup card.

All seven locales.
2026-09-26 15:30:30 +02:00
Marc-André Bombeck 3c3e5ceab7 fix(config): treat an empty environment variable as unset
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.

Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
2026-09-26 15:20:42 +02:00
Marc-André Bombeck 14f0fc4d4f feat(body-sites): record a body site on conditions and add a body-site view
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.

- migration 0354: two nullable columns on illness_episodes, reusing the
  laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
  keeps the stored value, so an edit from the shipped iPhone app, which
  never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
  conditions, folded after the decrypt and bounded per kind; with site
  (and laterality) the visits and conditions there, one hop to their
  documents, lab results and visits. Read in the profile section;
  conditions only under an illness grant with the module on, and links
  into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
  end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
  one body-site field pair that suggests sites already used; a
  condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
  name the site on conditions; the Coach illness block carries it
  sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
  registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 21:55:14 +02:00
Marc-André Bombeck ff3ef9147c fix(coach): give no supply span for a GLP-1 medication kept as a record 2026-09-25 10:03:09 +02:00
Marc-André Bombeck 94ec9ab932 Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only 2026-09-25 09:20:15 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck ad0d50fa28 feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.

- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
  searched after the decrypt over site, side and reason, with body-site
  choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
  form offers the site for a procedure and keeps it in view on any
  visit that carries one
- backup and restore carry both fields; the body site is registered as
  ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
  procedures when no window is named; the Coach visit block names a
  procedure's site
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 08:11:37 +02:00
Marc-André Bombeck f028d9c55b fix(medications): never stamp a miss on a slot before the medication existed
The today projector mints a pending placeholder for every slot of the
current day, so a medication added in the afternoon carries placeholders
for that day's earlier slots. The hourly auto-miss pass stamped them as
forgotten doses a day later, and every reader that counts raw rows saw a
miss on a day before the medication existed: the miss-free streak broke on
the creation day. The dose history, the rates and the Coach already ignore
such a placeholder; the streak read the row as it was.

The pass now stamps a miss only on slots from the medication's creation on.
A placeholder before it stays pending, so the web and the app can still
offer the slot for logging and a dose recorded for it converges onto it. A
migration returns the placeholders an earlier pass already stamped. It
touches only rows that can be nothing else: server-minted, never acted on,
anchored before the creation but within the day before it, which is where
the projector's placeholders sit. An older auto-miss carried in with
restored history keeps its state.

Refs #1028
2026-09-24 19:52:39 +02:00
Marc-André Bombeck f700a7c0f1 fix(coach): let the capability table decide the about-me question consent
The aboutMeQuestions capability admits an ai_insights_only receipt, but the
question generator ran its own gate that demanded a Coach receipt, so a
person whose capability read available still got the fallback questions on
an operator-held chain.

The generator now runs the shared wire re-check (aiEgressRefusal) for the
aboutMeQuestions key, so the receipt kinds and the rule for when one is
needed come from the capability table alone.
2026-09-24 12:19:11 +02:00
Marc-André Bombeck bd58571d46 fix(coach): hold reminders while the Coach is unavailable
The daily sweep and the measurement-ingest hook surfaced due reminders into
a Coach conversation whatever the record's coach capability said, and the nag
cap then dismissed them after three ticks although no badge ever showed. The
app-open path already waited on the capability.

surfaceCoachReminders now holds every row while the coach capability is
unavailable (the same answer that keeps the badge dark), and the nag cap
neither charges nor dismisses a held user's surfaced rows. They surface on
the first tick after the Coach is back. The integration fixture gives its
users a provider and consent, and a held case covers the sweep, the nag cap
and the ingest hook.
2026-09-24 12:11:21 +02:00
Marc-André Bombeck 9c7ddc13b3 Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0
# Conflicts:
#	src/app/api/insights/correlations/__tests__/route.test.ts
#	src/app/api/insights/correlations/route.ts
2026-09-24 10:26:56 +02:00
Marc-André Bombeck 5bd19573ea fix(insights): pass the record's module map to every correlation scan
The Coach correlations tool, the per-metric card and the period
narrative scanned every channel, so a switched-off module still fed
their statistics and the text a model was grounded on. The matrix's
modules option is now required, each caller resolves the record's map,
and the Coach skips the lab pass with labs off. A registry guard holds
every caller to resolving the map itself.

The mask's doc comment now states what it does to the false discovery
correction: fewer channels is a smaller family, so q-values of the
remaining pairs move and a borderline pair can cross the threshold.
2026-09-24 10:17:14 +02:00
Marc-André Bombeck c509afeb2e Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
2026-09-24 10:16:53 +02:00
Marc-André Bombeck c9534b343e Merge branch 'feat/ai-jobs-stored-text' into release/v1.39.0 2026-09-24 09:29:54 +02:00
Marc-André Bombeck 3309ac177c feat(ai): serve Insights and Coach data whatever AI says
Data never depends on AI. The Insights data reads no longer ask an AI
switch or the `insights` module (now only the AI analysis opt-out): ECG
list, detail and upload, rhythm events, derived scores, rule cards,
correlations and patterns, the baseline strip, health status, intraday
pulse, and the Today digest with its dismiss. Breathing screening moves
to the sleep module and lab changes to the labs module. Stored Coach
facts, conversations, message ratings and the seen stamp stay readable
and erasable while the Coach is unavailable. Cards now say
`provider: "rules"`.

Reads that mix data with model text answer 200, null the model text
and publish an `ai` state from the capability resolver, without warming
anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider`
from the presence probe), the derived assessment override, the period
narrative, nudge status, the seeded opener, about-me questions, the
workout paragraph, the pregenerate warm (`queued: false`), the Today
digest (lead, reaction line, check-in) and the dashboard briefing,
which is now applied per read after the snapshot cache. Hiding the
Coach no longer hides the briefing.

`insights/generate` GET and POST ask the `briefing` capability and
`insights/chat` POST the `coach` capability, with the typed refusal
envelope; a missing provider keeps the chat's `coach.provider.none`
frame and adds `reason`. The /coach page, the MCP ECG and intraday
tools and `aiAvailable` on the provider route follow the same answer.

An inventory of AI and data routes replaces the Coach gate inventory:
every route that asks a capability names it, and no data route asks.

Refs #975
2026-09-24 09:19:36 +02:00
Marc-André Bombeck 3917a2face feat(ai): resolve the AI capability before any background model work
Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.

The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.

Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.

Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.

Refs #975
2026-09-24 09:07:16 +02:00
Marc-André Bombeck b36c7b6697 feat(insights): serve status notes from the new table, only while available
Every status note reader and writer moves to InsightStatusCache: the
generators, the unchanged-data gates, the stale-while-revalidate read,
the ingest debounce, the previous-note prompt block and the
medication-compliance card with its per-medication lines. A stall now
opens a negative-cache window next to the note instead of a stub row
that shadowed it.

Stored model text is never served while its capability is unavailable,
whatever the reason: the status readers and gates check statusText, the
period narrative hides a model-written row while periodNarrative is
unavailable (the deterministic narrative is data and always shown), and
the daily digest drops reaction lines while reactionLines is
unavailable. A read-only miss now reports why (`unavailable` with the
capability's reason) and keeps hasProvider meaning provider presence, so
a withdrawn consent no longer reads as "no provider".

runStatusCompletion takes the capability as a required argument and
re-checks it before the provider chain is resolved, so a job queued
before a switch flipped still stops at the wire. The comprehensive
briefing re-checks briefing the same way. hasUsableStatusProvider is the
one presence probe.

Refs #975
2026-09-24 09:06:22 +02:00
Marc-André Bombeck c41df33dd3 fix(cycle): read the phase crosstab as per-source day aggregates
The coach's cycle snapshot read a year of raw readings for the phase
crosstab, including steps, heart-rate variability and CGM glucose. On a
CGM account that is over 100 000 objects per snapshot.

The crosstab only uses each day's sum and count after picking one
source and device per day, and the pick depends only on which sources
and devices are present. readSourceDayAggregates returns one row per
type, day, source and device, and the day map now carries each row's
reading count, so the day values are the ones the raw readings give.

The 30-day clinical glucose reads stay raw: time in range, SD, J-index
and the risk indices are functions of individual readings, and a
sensor's rate bounds the window below 45 000 rows.

Refs #1023
2026-09-24 08:06:47 +02:00
Marc-André Bombeck bcef5aeeee fix(ai): hold a reserve so the chat is never locked out by jobs
The background ceiling applied only when the operator funded the chain, so on a
chain the operator does not pay for the automatic generators and the chat shared
one undivided ceiling and the first could still take all of it. That is the
configuration the recommended remedy for the original lockout produces — put the
personal Codex account first — so the guarantee was missing on exactly the setup
it was written for.

The ceiling is now expressed as what it is meant to protect. Background work may
run until the day's spend would leave less than a fixed reserve for whoever is
waiting on an answer, on both cost owners. A reserve and a quota look alike at
the boundary and mean opposite things in the middle: a quota stops useful
generation on a day nobody was ever going to interrupt, while a reserve costs it
nothing until the moment it would take the last of the day. An account
generating 1.45 million tokens of insights on its own plan keeps every one of
them and the chat still holds 400 000.

INTERACTIVE_RESERVE_SHARE replaces JOB_SURFACE_SHARE. The old name described a
quota over the jobs rather than a floor under the person waiting, and a name
that states the opposite of the intent is what put the wrong comparison in this
ledger twice already.

The fraction is a default an operator will want to change. The setting and its
documentation are separate work.
2026-09-11 12:55:52 +02:00
Marc-André Bombeck b565edc48d docs(ai): drop planning vocabulary from the ledger comments
CLAUDE.md forbids planning shorthand in committed artefacts, and this branch
introduced 74 lines of it across source, tests, a schema field and two
migrations. Someone reading the migration SQL in a year has no way to resolve a
wave letter or a round number, and a comment that points at something outside
the repository names no reason at all.

Three passes. A version followed by a parenthetical keeps the version and loses
the parenthetical, because the version says since when something holds. A
sentence that leaned on the vocabulary to carry its meaning is rewritten to
state the reason directly: the arrival reservation now says it was refused over
money the operator never spent, rather than naming the defect a wave existed to
remove. Three test titles carried the marker in the name itself and are renamed
to what they pin.

Comments only. No behaviour, no renames beyond those three titles.
2026-09-11 12:37:54 +02:00
Marc-André Bombeck 18ed57b867 fix(ai): every surface keeps a ceiling on the day's mixed total
The owner split left an operator-funded chain with no ceiling at all. Its arm
compared operator_tokens, and that counter returns to roughly zero on every
reconcile the user's own plan settled: on the 2026-09-11 evidence the shared
key answered 500 and codex served, so the counter never grew, every job
reservation was admitted forever, and a runaway client loop could write an
unbounded row. A pre-existing concurrency contract caught it from the other
side, two reservations that must not both pass one cap both passing.

reserveBudget now refuses when the owner's counter is over its cap OR the day's
mixed total is over the abuse ceiling, and a background surface gets its share
of that ceiling too, so background spend is bounded on the total and not only
under a counter the operator's key never fills. The surface is a required
argument, so the type checker enumerated every reservation site.

The hop guard learns the same lesson: an operator-funded fallback hop is now
rationed at the calling surface's ceiling rather than the full operator cap. A
job admitted under the job share could previously spend the whole 200 000 once
its chain fell back onto the shared key, which is the original defect reached
by the fallback path.

The job share applies only where the operator pays for the chain. Multiplying
whichever ceiling the chain resolved halved background generation for a
self-hoster on a local model, who costs the operator nothing.

Refusals now name the ceiling they hit and whose it was. A refusal used to emit
the day's mixed total and nothing else, which on an operator refusal is not the
counter that tripped.
2026-09-11 11:11:01 +02:00
Marc-André Bombeck d3196ee69e fix(ai): background generation cannot spend the interactive day
The automatic generators — status and reference cards, the briefing, the
arrival reaction, the nudge, document summaries and the index backfill —
reserved against the same ceiling as the chat. On the operator's account
that meant 150-330 ledger rows a day, 172 metric generations before
06:42Z, and a ceiling already spent when he opened the chat.

Every background surface now reserves through
`resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all
job surfaces together. The interactive routes keep the whole ceiling, so
a day of background work can no longer take the user's own away.
2026-09-11 10:09:34 +02:00
Marc-André Bombeck 2c46712e13 fix(ai): the operator cap counts only tokens the operator paid for
The reservation compared the day's TOTAL against whichever ceiling the
chain resolved, and the chain walker's hop guard did the same. On an
account whose background jobs run on the user's own ChatGPT plan, both
saw a total the operator had not paid for: the chat refused at 06:42Z
with 513 539 tokens against the 200 000 operator ceiling, and the
fallback hop to the shared key was closed on the same arithmetic.

An operator-funded chain is now measured against `operator_tokens` and a
user-plan chain keeps its abuse ceiling on the total; the hop guard reads
the operator share too. A day of turns the user's own plan paid for can
no longer lock anyone out of the operator's ceiling.
2026-09-11 09:56:44 +02:00
Marc-André Bombeck badef28e96 feat(ai): the daily ledger records which tokens the operator paid for
`coach_usage` counted one number per (user, day). A turn served by the
user's own ChatGPT plan after the operator's shared key answered 500 was
therefore charged to the operator-cost cap, and the operator's chat was
locked out every morning by background work he pays for himself.

The ledger gains `operator_tokens`: the share of `total_tokens` served by
an operator-funded provider. `reserveBudget` books a reservation to the
owner of the chain's primary and `reconcileSpend` settles it against the
provider that actually served, both inside the same statements as the
total so concurrent requests can never see one counter without the other.
Every call site names its cost owner and its serving hop; the argument is
required, so a new surface cannot inherit the old blind accounting.
2026-09-11 09:42:14 +02:00
Marc-André Bombeck 126383b352 fix(doctor-report): keep untagged glucose readings in the panel
Both report paths dropped them: the raw path iterated the named contexts,
and the dense path skipped any row without a context outright, so a report
built for a meter that records no meal time carried an empty glucose panel
while the readings sat in the database. The FHIR export dropped them for
the third time, at the LOINC lookup — its own comment already said an
unspecified reading codes as 2339-0, but the table had no entry to hit.

The insights context breakdown names the bucket, and the Coach's glucose
block now resolves through the shared helper rather than its own copy.

Refs #943
2026-09-09 23:03:32 +02:00
aucun6352 f7cc47850a fix(i18n): make the Korean locale actually work end to end
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.

Three failures were not missing keys but wrong assumptions:

- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
  accents become plain letters, but Hangul decomposes into jamo, which
  are not in that range and survived — so folded Korean text was a jamo
  sequence no precomposed table entry could ever match. It recomposes to
  NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
  name to the empty string, so the derived index held none of them and a
  Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
  tilde a Korean lab actually prints (`3.5~5.0`).

The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.

The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
2026-08-26 20:00:44 +09:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
2026-08-26 16:42:45 +09:00
Marc-André Bombeck b154ec52cb Merge branch 'fix/language-matchers-rest' into release/v1.37.27 2026-08-23 19:38:45 +02:00
Marc-André Bombeck 31991924f0 Merge branch 'chore/knip-dead-exports' into release/v1.37.27 2026-08-23 19:38:45 +02:00
Marc-André Bombeck 949677008f Drop the type imports left behind by the extract-prompt re-export
CadenceKind and WeekdayToken were imported only to be re-exported. The
re-export went in the previous commit but the import survived, because
the doc comment above it still named both types at the time.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 0e9c5f1a00 Drop unimported re-exports outside the barrels
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.

Definitions are untouched; imports that existed only to feed a removed
re-export go too.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 244a939d05 Drop unimported re-exports from the remaining barrels
Same shape as the derived and MCP barrels: the line in the barrel had no
importer because the consumer imports the concrete module. Affected:
cycle (15), coach tools (9), record-settings (8), links (4), data-list,
workout-detail and analytics/score (1 each).

analytics/score also carried CompositeValue on an explicit export line
above an export * of the same module, so the name stays exported either
way.

Definitions are untouched.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 3744073a01 fix(coach): tokenize prose numbers in the language the reply was written in
The grounding tokenizer read English unit words and English or German
month names, while the reply is written in the reader's language. Two
silent failures came out of that, pointing in opposite directions.

A missing unit loosens the guard. "7,4 Stunden", "45 minuti" and "8000
pasos" came back unkinded, and an unkinded token clears the kind gate
against every ledger entry, so a fabricated night's sleep could ground
itself on a glucose reading that happened to share its magnitude. Kind
scoping is the thing that stops a weight grounding a blood-pressure claim;
for five languages it was not running.

A missing month corrupts correct prose. "Le 21 juillet" left the day
untyped, the plain-magnitude pass graded it, nothing matched, and the
soft-strip replaced it — the reader was shown "Le […] juillet". Spanish
and Italian also put a particle between day and month, so the pass admits
one.

The unit vocabulary had been written out three times and the copies had
already drifted; only one of them knew about "%". One list now feeds the
classifier, the range pass and the measurement pass. Polish months are
listed in the genitive, which is what a date uses and also the only form a
trailing word boundary can match, since that boundary is defined over
ASCII and "ń" is not part of it.
2026-08-23 18:15:16 +02:00
Marc-André Bombeck 4d77fe2a43 fix(coach): let the off-topic gate read the four languages it could not
The gate is "looks off-topic AND does not look health", and both halves
were English/German word lists. Two things followed. An off-topic ask in
French, Spanish, Italian or Polish was never recognised. And a genuine
health question in those languages that happened to brush an English deny
word was refused outright: "Ho una serie di misurazioni strane" trips
"serie", and nothing on the allow side knew "misurazioni", so a question
about the reader's own data came back as a refusal.

The allow side is no longer transcribed. Every metric label key and the
health nav labels already name these concepts in all six bundles, and
those labels are the nouns people type, so the vocabulary is read from
`messages/<locale>.json` and a seventh language is understood the day its
bundle lands. Matching is whole-word over a shared fold applied to both
sides. A subtractive list keeps label scaffolding out; French "bien-être"
alone would have contributed "bien" and "etre" and marked nearly every
French sentence as health.

The deny side stays hand-written — these are not app labels — and its new
arms match the folded message. A pattern anchored with \b cannot match a
word starting with "ż", because neither the preceding space nor the letter
is an ASCII word character, so there is no boundary between them.

The prompt-injection bank is untouched. It reads the person's own message
and the foreign-text path is fenced elsewhere; this is defence in depth,
not an open door.
2026-08-23 18:03:28 +02:00
Marc-André Bombeck 8ffa9cd87a fix(insights): scan one discovery matrix from every correlation surface
Four surfaces assembled the correlation-discovery matrix and three carried a
different channel set. The Coach `get_correlations` tool had neither the
environmental-exposure channels nor the custom-metric ones, so asking it
whether air pressure relates to your sleep met a matrix the pair had never
been in. Its own file header said the opposite - "the SAME full-matrix scan
the route runs" - and had said it since the weather channels arrived in
v1.25. The period narrative was missing those two families and the
compliance / symptom pair on top.

`src/lib/insights/discovery-matrix.ts` is now the one place a matrix is
built. v1.30.3 hoisted the fetch discipline into a shared module for exactly
this reason and left the assembly duplicated behind; this finishes it. The
route, the per-metric card, the Coach tool and the period narrative all call
`assembleDiscoveryMatrix` and get the same families. What still differs is
declared as an option with its reason at the declaration: the measurement
read mode (a read-cost choice, left per surface), the extra metric types the
narrative's delta beat needs, and the RATED mood factors.

Mood factors stay on the narrative alone. That capture kind is frozen, the
two surfaces that persist a discovered pair write its channel key into a
plaintext column, and a custom factor's key is an opaque id - so admitting
them elsewhere is a decision about what to ask, not a parity fix.

Restore the per-metric filter on the assessment card's relations. It was
written in place of the pattern-dismissal filter rather than chained with it,
so a weight card's prompt had been receiving every surviving pair in the
record while the function name, the docstring and the early-return guard all
still said one metric.

`discovery-matrix-guard.test.ts` freezes who may fetch a channel, who may
construct one, and who may scan. Each matcher asserts a non-zero match count,
so a matcher that has stopped matching fails rather than agreeing with the
allowlist.

The statistics are untouched - same Pearson, same exact p-value, same
Benjamini-Hochberg, same floors. The Coach's family is larger now, which is
the point: it is corrected over the same family the insights page is.
2026-08-23 17:38:14 +02:00
Marc-André Bombeck 97d0c26ceb fix(glp1): match side-effect tags by key, not by an English/German word list
The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.

The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.

All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.

A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.

The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.

The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
2026-08-23 13:56:32 +02:00
Marc-André Bombeck c1811bc85b Stop narrating rollup buckets the record cannot account for
A rollup band outlives the rows it was folded from. The read-swap falls back to
live SQL only when a band is EMPTY, so a bucket that is merely wrong is served
as current; `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90
days only, and keys off `Measurement.updatedAt`, which a hard delete does not
bump. The WHOOP body sync hard-deletes a WEIGHT row with no invalidation, so a
MONTH bucket keeps reporting a value the record no longer holds.

The block was handing the model two irreconcilable accounts of the same record
and marking neither: the coarse tail said one thing, the all-time extremes read
live beside it said another, and `asOf` stamped the whole thing current because
it is derived from raw reading age and knows nothing about rollup age.

Reconcile them where they are assembled. A bucket mean is an average of real
rows, so it cannot fall outside those rows' own all-time extremes — when it
does, the band is describing readings the live read cannot see. That is an
invariant rather than a heuristic, so the disputed tail is dropped and
`asOf.coarseHistoryWithheld` records that it went. Narrating a ghost is worse
than narrating less.

`currentForTodayClaims` stays keyed to raw recency, which is what it means. The
surviving readings really are from today, and suppressing a true present-tense
statement to punish a stale history band would trade one dishonesty for another.

The bucket on disk is still wrong, and a wide event now says so. The deeper fix
is to make the tier notice — compare a bucket's `computedAt` against the type's
in-window MAX(`updatedAt`), or invalidate on the hard-delete path — which is a
larger change than this repair.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck 251a42bb0a Grade the Coach turn that went looking and found nothing
The numeric grounding guard keyed its activation off `loop.toolResults` alone.
A tool that finds nothing resolves to `{ present: false, reason: "none" }` with
no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools
snapshot fallback is populated only in the non-tool branch — so the guard sat
out every turn where the record was empty, which is precisely the turn where a
fabricated figure is likeliest and does the most harm. Ask about a metric you
have never logged and an invented number was persisted and streamed verbatim,
with no withheld-figure marker to say it had never been checked.

`toolTrace` is the discriminant, because it records the pure misses the results
array drops. Tools ran and every one missed: grade the reply — an empty ledger
there is not missing information, it is the positive statement that nothing can
be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to
grade against it rather than no-opping. The model answered without calling a
tool: still dormant, unchanged, because the tool-mode base prompt carries no
pre-computed figures and flagging a legitimately recalled one was the v1.32.1
regression. The inventory counts ride along as a widener so a plain count
restatement stays grounded.

Eliding the digits is not enough on an empty record. "Your sleep averaged […]
minutes, up from […]" still asserts a series, an average and a direction for a
metric with no readings; only the precision goes, the false claim stays. So a
turn that missed every tool and lost a figure to the guard is replaced with the
honest answer, in all six locales. Scoped so a reply whose figure reconciles
against a prior turn or an inventory count is left alone.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck d49479bc64 Pin where the Coach stops checking its own numbers
Three characterisation tests for grounding gaps found while verifying that
every figure the Coach states comes from the record.

The numeric grounding guard rewrites any figure the turn's Grounding Ledger
cannot account for, and it holds: removing the strip reddens the existing
cross-turn ledger test. What is not covered is its activation. On the tool
path the guard runs only when `loop.toolResults` is non-empty, and a tool that
found nothing resolves to `{ present: false, reason: "none" }` with no
`available` payload, which the loop drops. So the guard disarms itself exactly
when the record is empty: ask about a metric with no readings, and an invented
figure is persisted and streamed verbatim, with no withheld-figure marker to
tell the reader it was never checked. The no-tools snapshot fallback is
populated only in the non-tool-mode branch, so the tool path has nothing to
fall back to, and every cloud provider runs the tool path.

Separately, a rollup bucket outlives the rows it was folded from. The read-swap
falls back to live SQL only on an empty band, `ensureUserRollupsFresh` repairs
the DAY tier over the trailing 90 days only, and it keys off
`Measurement.updatedAt`, which a hard delete does not bump. The integration
test reproduces the hard `deleteMany` in the WHOOP body sync against a
MONTH-band bucket: the deleted value keeps reaching the Coach's coarse tail
while the all-time extremes beside it report only the surviving value, and the
block is stamped current because `asOf` is derived from raw reading age and
knows nothing about rollup age.

All three assert current behaviour and carry a docblock saying so. Closing
either gap will redden them, which is the point.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck 09fbac3a33 Hygiene: drop dead export modifiers, delete the dead series module, wire the tokenizer version
The knip blind spot (ignoreExportsUsedInFile) left eight symbols carrying
an export nothing outside their file consumes — the modifier goes, the
symbols stay. The score series module goes whole: its only importer was
its own guard test, whose composite- and config-guarding suites survive
in place.

DocumentContentIndex.tokenizerVersion gains the consumer that was the
point of storing it: the content-index job now re-tokenises rows whose
version trails CONTENT_TOKENIZER_VERSION, from the decrypted index text
along the same path the rotation script uses, keyset-paginated and
fault-isolated per row — a future tokenizer bump migrates the index
instead of silently splitting it. ImportJob.uploadSha256 turned out to
already have its dedupe consumer (both kick-off routes short-circuit on
same-bytes non-failed jobs); its comment now names that instead of
pretending to be write-only. The remaining write-only columns carry
their honest annotations in the shape the audit sweep parses.
2026-08-14 10:08:43 +02:00
Marc-André Bombeck 6ebd5e8711 Guard the operator's cap on fallback hops and reap dead summary promises
- The provider-chain walker now checks the day's recorded spend against
  OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A
  request whose primary provider runs on the user's own key reserves
  under the generous user-plan ceiling; a fallback hop (or health-ledger
  reorder) onto the operator's key could then spend past the operator's
  cap unchecked. An exhausted cap skips the candidate like a failed hop.
- summaryState PENDING gets a giver-upper: the detail serialiser
  degrades a PENDING older than an hour to UNAVAILABLE (which offers the
  manual generate action), and an hourly reaper persists the same heal
  following the orphan-ImportJob reconcile precedent — a job dead past
  its retry budget no longer leaves the sheet saying "generating"
  forever.
2026-08-14 05:53:17 +02:00
Marc-André Bombeck 4e12aef5fd Type the evaluator stubs as variadic mocks
tsc rejects spreading unknown[] into a zero-parameter vi.fn; give the
context-evaluator stubs a variadic signature and cast the recorded call
tuples where the tests read them back.
2026-08-14 02:07:42 +02:00
Marc-André Bombeck cd127010fe Evaluate the context cues the reminder grammar has promised since v1.22
A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED,
NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never
fired: the daily sweep skipped context triggers and no other evaluator
existed.

Build the evaluator. The three logging cues resolve on evidence — a
live matching-type measurement logged after capture — and ride the same
eventful reminder-satisfy hook every ingest path already fires, with the
daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN
resolves on the nudge-status read (the poll that mounts with the app
chrome), owner-only so a delegate's visit never satisfies it, with a
capture grace so the session that created the reminder does not count as
the next open. Every trigger delivers through the surfacing transaction
extracted from the sweep: conversation + assistant message + status
flip, all-or-nothing.
2026-08-14 01:45:47 +02:00
Marc-André Bombeck ff88dd1f8f coach: keep cancelled-turn markers out of the plan fold too
The facts fold already skips the empty assistant row a cancelled turn
leaves behind; the plan fold read it as a blank turn and spent one of
its recent-turn slots on nothing. Same skip, same reason. Also state
the accepted one-slot slack a late rate-limit refund can leave in a
freshly opened window. Refs #781.
2026-08-13 07:11:32 +02:00
Marc-André Bombeck ce6364f265 coach: keep cancelled-turn markers out of summary and fact folds
The rolling-summary and fact-extraction workers read message bodies
directly, so an empty cancelled marker would ride their prompts as a
blank assistant line. Skip empty bodies in both folds. Refs #781
2026-08-13 06:16:45 +02:00