Commit Graph
2557 Commits
Author SHA1 Message Date
Marc-André Bombeck f29995a18c docs(api): correct the score descriptions the adaptive rule outdated
Three descriptions in the OpenAPI registry still describe the floor that
v1.38 removed, and one of them contradicts its own error-response
description two dozen lines below it.

- The score-config PATCH still said the write is refused below three
  distinct areas and one physical measurement. The route refuses the
  empty selection and nothing else; its 422 description already said so.
- The dashboard snapshot's healthScore still said the block is null until
  three eligible domains include a physiological one. It is null only
  when no pillar is readable at all.
- The pillar domain field still pointed at a three-distinct-domains rule
  that no longer exists, where what a client needs to hear is that domain
  count comes resolved on scoreBasis and must not be counted out of
  composition.

Regenerated docs/api/openapi.yaml.

The user-facing description of the score lives in the docs site's own
repository, not here; nothing under docs/ describes the composite.
2026-08-30 17:08:39 +02:00
Marc-André Bombeck d98d714ee7 feat(score): pin the rule that keeps invented targets dead
The score once graded weight against 22 x height squared, widened by 2 kg
either side: a band nobody set, no surface named, and the only producer
of that pillar's verdict. v1.34 removed it and the composite rebuild left
a pillar set where every scored band cites somebody. Nothing but review
has kept it that way since, and review is what let the original in.

So the rule is written down. PillarReference.kind becomes a runtime list
carrying the reason there is no fourth kind, reference.source is
documented as a citation a reader could go and look up, and a guard
beside the pillars checks all seven modules three ways.

The arm worth arguing about is the second. An invented target is a
continuous function of a profile field; a guideline band is a step
function into a published table. So the guard sweeps every pillar across
a matrix of heights and ages and requires the scored bands it emits to
land inside a small enumerated set transcribed from the guideline that
pillar cites. No finite enumeration can hold a per-person curve, which is
what makes the arm able to fail rather than merely able to pass. The
other two arms cover the citation string and freeze which pillar may read
which profile field, with the reason written beside it and stale entries
failing as loudly as missing ones.

The proof is in the file: the pre-v1.34 weight target is restored as a
fixture in today's shape and each arm is shown refusing it.
2026-08-30 17:08:16 +02:00
Marc-André Bombeck e4880dcdc1 feat: the score says when a pillar stopped counting
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.

The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.

Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.

The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.

This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.

Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.

Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
2026-08-30 16:16:57 +02:00
Marc-André Bombeck 61110b635c test(score): turn the eligibility pins from a floor into a tier
Six suites pinned the old rule as the rule. Each inversion, and why it is
one:

`breadth.test.ts` — the case table asserted a refusal reason per set; it
asserts a tier and a `physiological` flag now. Every row in it except the
empty set used to be a refusal, and each is a number somebody gets to see.
Adds the tier boundaries (one pillar, two areas, physiological-absent), a
case proving the same pillars produce the same number at `partial` and at
`full`, and a block whose only job is the counter-case: the empty verdict
still carries a reason and no tier, and a composite whose selected pillars
all came back short still refuses with `no_usable_data`. A rule that has
stopped refusing anything is a deleted rule, not a graded one.

`composite.test.ts` — "requires three domains and one measured
physiological domain" asserted the refusal that no longer happens; it now
asserts the score and the basis block on the same fixture. The coverage
case keeps its fixture and its arithmetic and flips its outcome: two of
three used to be a refusal and is a scored account with a real coverage
fraction. Adds the one-area case and the zero-pillar counter-case, and the
`SCORE_VERSION` floor moves 2 -> 3 because widening who gets a score is
exactly the kind of change that must not ship without a version bump.

`record.test.ts` — "records nothing when the composite did not resolve"
used a two-pillar fixture to prove a below-the-floor day stored nothing.
That day has a number now and the table has to keep it, or an account that
starts narrow and widens later has a hole where its early days were. The
assertion is now that a one-pillar day builds a draft AND reaches the
database; the absence case survives on the fixture that genuinely produces
no score.

`composition-source.test.ts` — three recipes asserted as refused at read
time. Two of them score. What the block proves is unchanged — the reader
publishes the shared rule's verdict on the reader's own resolved set, so a
second copy of the rule would still surface here — but the agreement is
over a tier rather than over a refusal.

`route.test.ts` (score config) — the two too-narrow refusals become
acceptances, because a write that refuses what the read is willing to serve
is the two layers disagreeing. The empty selection keeps its 422 and gains
the assertions the old cases carried: errorCode, reason, and the sentence.
The errorCode driver loses the two selections that no longer 422.

`score-refusal-consumer-guard.test.ts` — its non-zero control anchored on
`three_domains_required`; it anchors on `no_pillars_selected`.

New: `health-score-basis-additive-contract.test.ts` pins that `scoreBasis`
is published, is optional on all three carriers, and points at the one
shared component — a required field on a cached surface would invalidate
every cached payload with nothing failing anywhere. It also pins that the
walk does not confuse the composite's basis with the older per-pillar
`scoreBasis` that blood pressure uses for its axis, since they share a key.

Fixtures: the notice-key assertions derive the method half from
`SCORE_VERSION` rather than restating 3. Three fixtures that named
`three_domains_required` now name `no_usable_data`, a reason the server can
still emit. The settings e2e drove its refusal through a two-pillar
selection that now succeeds; it deselects every pillar instead.
2026-08-30 14:16:33 +02:00
Marc-André Bombeck aadc3a8e89 feat(api): carry the score's basis on every surface that carries the score
`scoreBasis` rides the composite, the dashboard snapshot's flattened hero
block and the daily digest's score. It says how many distinct areas of
health the number counted, how many the method recommends, which tier that
lands in, and whether any counted pillar was a physiological measurement.

Optional everywhere, because two of the three surfaces are served out of a
cache and a payload written before this release cannot grow a field. The
snapshot and digest blocks have taken six additive fields this way already;
this is the seventh.

Resolved on the server for the same reason `configured` is. Counting
`composition` client-side would get it wrong more often than right — three
of the seven pillars share the cardiometabolic area, so a three-pillar
score can rest on one.

One shared component, `HealthScoreBasis`, declared once in the insights
schema module and referenced from the other two registry modules, rather
than three inline copies that drift the first time the block gains a field.
Declared through the assigning `.meta()` form: a bare `.meta()` statement
registers an instance nothing references, and the id then never becomes a
component.

The composite's `insufficient` reason narrows to the single value
`no_usable_data` and says so in the published description. That field is an
open string in the document, so the change is additive on the wire even
though the value space shrank.

The score-config 422 description drops the two retired reasons and states
plainly that the empty selection is now the only refusal, so a client does
not keep a three-area rule of its own in front of a write that no longer
has one.
2026-08-30 14:16:06 +02:00
Marc-André Bombeck 4ad00406d3 feat(score): compute a health score from the data a person actually has
Three distinct areas of health stops being the price of admission and
becomes what it always described: a recommendation. `evaluateScoreBreadth`
returns a tier instead of a pass/fail — `full` at three areas or more,
`partial` at two, `minimal` at one — and refuses only when not one pillar
came back usable, with the reason `no_usable_data`.

The arithmetic does not change with the tier. Same mean, same worst-pillar
band drag, same noise floor. A one-area score of 82 is an honest 82 that
rests on one area, and discounting it for the pillars somebody does not
track would have been a new invented target, which is the thing this score
spent two releases removing.

The physiological requirement becomes a labelling fact rather than a veto.
An account whose only readable pillar is activity or wellbeing gets its
number with `physiological: false` on the basis block; the old refusal told
that person nothing they could not already see.

`SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still
feeds `deriveCoverage`, so the coverage fraction and the confidence blend
keep telling the truth with no new machinery: a one-area score lands at low
confidence through the path that was already there. The fraction used to be
a floor every scored account had cleared; it is a real moving number now.

`SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the
release date. Both are method-identity duties: deltas suppress as
`algorithm_changed` across the straddle window, the notice key rotates so
each account is told once, and stored days stamp 4. No v3 row is rewritten
and no v3 number changes meaning.

The settings write follows the same rule rather than a stricter one. It
refused two-area and non-physiological selections on the ground that they
could not produce a score; they can, so refusing the write would be the two
layers disagreeing about the one question they share. The empty selection
is still refused, under a single reason `no_pillars_selected`, and the seven
message catalogues trade the two old refusal sentences for one.

Also fixes the stale `./series.ts` reference in the config header — that
module was deleted as dead in v1.35; the columns the sentence describes are
still written by `./record.ts`.
2026-08-30 14:15:47 +02:00
Marc-André Bombeck 347e14d26b Centralise notification HTML stripping behind one hardened helper
Five senders (APNs, Web Push, webhook, ntfy, email) each carried their
own single-pass replace(/<[^>]*>/g, "") — the shape CodeQL flags as
incomplete multi-character sanitization, because a removal can re-form
the pattern it removed. Whether one particular regex happens to be
idempotent is an argument about that regex; five copies of the argument
is how the next edit quietly breaks one of them.

The shared stripHtml re-applies the strip until the string stops
changing, with an iteration cap and a bracket-dropping fallback so no
tag-shaped sequence can survive any input. Plain text passes through
unchanged, same as before. Unit tests pin the nested-tag bypass shapes
and the no-surviving-tag invariant.

Resolves CodeQL alerts 38-42.
2026-08-29 19:20:42 +02:00
Marc-André Bombeck 511ddf5de1 Send the offline-geo admin notice once per configuration state
The sent-latch was a module-level boolean, so every worker boot re-sent
the same notice while the host's configuration never changed (#851).

Anchor the notice in the notification_events ledger instead, through the
same claimNotificationEvent helper the reminder schedulers use. The
anchor is unwindowed: entering the unconfigured state sends once per
admin, staying in it is silent across any number of restarts, and the
anchor is released when the state exits — databases configured, or
IP_GEO_LOOKUP_DISABLED set — so a genuine re-entry notifies again.

Three consequences worth naming:

- A host with lookups disabled is no longer notified at all. With no
  egress there is nothing the notice warns about, and the disabled state
  counts as an exit, matching the notice's own remediation advice.
- The evaluation now keys on offlineGeoReady() rather than the ASN
  reader, so a host with only the ASN database mounted (city lookups
  still egress) hears about it, where before it stayed silent.
- The retention prune excludes the new ADMIN_CONFIG_NOTICE event type:
  a state-scoped anchor has a reader for as long as the state holds, and
  the 90-day horizon delete would have re-opened the repeat on a slower
  clock.

Refs #851
2026-08-29 19:20:33 +02:00
Marc-André Bombeck 6ff81b2717 Merge branch 'pr839tmp' into feat/korean-locale
# Conflicts:
#	CHANGELOG.md
2026-08-28 21:59:36 +02:00
Marc-André Bombeck 6c4682acfe Give the operator a provider-health readout
The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
2026-08-27 11:26:40 +02:00
aucun6352 31eb50e3e2 fix(i18n): give the Korean instruments their published source texts
The Korean questionnaire wording was translated freshly from English, so
a score built from it was comparable to nothing in the literature. Each
instrument now carries the text its own publisher put out, or no Korean
at all.

PHQ-9 and GAD-7 come verbatim from the instrument owner's distribution —
Pfizer's `PHQ9_Korean for Korea.pdf` (2010-06-15) and `GAD7_Korean for
Korea.pdf` (2010-05-18), the latter being the file Ahn/Kim/Choi 2019
names as what it validated. The sheets grant reproduction, translation,
display and distribution without permission. Verbatim means verbatim:
the spacing in "7 일 이상 방해 받았다" and the sheet's "안절부절 못하거나"
against the GAD-7 sheet's "안절부절못해서" are reproduced as printed.

WHO-5 has no official Korean arm. Neither the WHO's 2024 distribution
(26 languages) nor the Psychiatric Research Unit folder it inherited
from (31 languages) has ever carried one, and ePROVIDE lists the same
31. Korean therefore carries the published forward/back-translation
from Kim HJ et al., J Korean Geriatr Psychiatry 2010;14(2):90-96, which
is CC BY-NC and so redistributable; the attribution line names it. Its
validation sample was community-dwelling elderly rather than general
adults, which the definition says out loud.

The Sleep Condition Indicator reverts to English. It is validated in
English only, its cut-off was derived from those words, and no openly
redistributable Korean exists — so `ko` now matches de/es/fr/it/pl:
items, section stems and response anchors byte-identical to en.json,
under the localized "validated in English" note.

`ALL_APP_LOCALES` grows to seven, which is what stops the check-in
wizard claiming English wording over Korean items on every Korean
check-in.

One gap is deliberate and not yet resolved. The official Korean PHQ-9
and GAD-7 sheets disagree on anchor 2 — "7 일 이상 방해 받았다" against
"2 주 중 절반 이상 방해 받았다" — but both instruments read one shared
`mentalHealth.options` set, which holds for the other six locales
because their two sheets agree word for word. Korean takes the PHQ-9
wording for both here. GAD-7's anchors are therefore validated in
substance but not verbatim; splitting them into a `gad7Options`
namespace is the fix, and is left for the maintainer to weigh against
the 24 duplicated keys it would add across the other locales.

The functional-impairment item and its four anchors come from the same
PHQ-9 sheet. They are not scored and not covered by the
`validatedItemLocales` contract.
2026-08-27 17:37:26 +09:00
Marc-André Bombeck 7aecae863d Teach the pin inventory the conditional openai-client shape
The SSRF pin inventory asserted a literal unconditional pin in the
openai-client; the previous commit made that pin conditional for the
two person-typed base-URL tags. The guard now asserts the exact
ternary — allowlist helper for gateway/admin-key, literal true as the
floor — so neither the floor nor the condition can drift silently.
2026-08-27 08:56:53 +02:00
Marc-André Bombeck fb82954077 Let the operator provider reach an allowlisted private host
The global admin provider's base URL is operator-typed in the admin
settings, but the client pinned requirePublicHost unconditionally for
its tag — so an operator pointing it at a private OpenAI-compatible
proxy (an OAuth sidecar on the instance's own Docker network) got a
safeFetch refusal on every call, indistinguishable from an outage. The
admin-key tag now consults the same ALLOW_LOCAL_AI_PRIVATE_HOSTS
policy as the user-configured gateway: a public URL never consults the
allowlist, a private host without an entry is refused exactly as
before, and both ends of the grant are the operator's own. The codex
tag stays hard-pinned; its base URL is a repository constant. Every
non-operator admin-key construction pins the public api.openai.com
constant, so the personal-key posture is unchanged.

The test asserting the old blanket pin is split into the three cases
that actually differ: allowlisted private host passes, unlisted
private host still refuses, codex refuses even a blanket allowlist.
2026-08-27 08:48:00 +02:00
Marc-André Bombeck 0823a4e222 Refine the tiered rollup read by the actual data span
The tier for a chart series keyed off the requested window width. The
All range always requests ~3650 days, so every account got MONTH
buckets regardless of how much history exists — a record spanning four
months came back as four monthly means, and the chart captioned them
from the real span as a weekly average. The reader now re-reads at the
tier the returned rows' span calls for; a coverage miss on the finer
tier keeps the coarser rows.

The ladder comment claimed to mirror the client's pickBucket
thresholds and misquoted them. The two ladders are deliberately not
identical; a test now pins the direction that keeps the caption honest
(server never coarser than the client captions) for every span up to
ten years.
2026-08-26 17:55:43 +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 5808f8f91a Merge branch 'fix/share-viewer-leaves' into release/v1.37.28 2026-08-24 02:57:12 +02:00
Marc-André Bombeck d7d8e437ee feat(insights): let a caller choose the derived-metric window
`GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and
threads it to the dispatcher. Omitting it changes nothing: every engine
keeps its own default, so a caller that asks for nothing reads exactly
what it read before.

Ninety is where the tier this route promises runs out. The rollup router
opens its WEEK floor at 91 days, and the baseline engines cannot compose
a spread from WEEK buckets, so a 91-day request falls through to a raw
`measurement.findMany` with no row cap — for a densely sampled type that
is a full history scan, and the analytics-read budget allows 120 of them
a minute per account. The trailing `series` is capped at 30 points
regardless, so past the ceiling a wider window would only broaden the
mean behind the value. Out of range is a 422 rather than a clamp.

An unhonoured window is visible rather than silent. `provenance.windowDays`
is the window the engine actually used and `coverage.historyDays` the days
that actually backed it, so a three-week record answering a thirty-day
request reports both numbers instead of implying coverage it does not
have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the
widest; a new suite freezes that it is the only metric that does, so an
arm added later cannot quietly drop the parameter.

Recovery keeps its canonical resolution under the wider window. A worn
band stamps the wake morning and the computed proxy the night before, and
only `resolveCanonicalRecovery` pairs them; a test seeds forty such
nights, asks for sixty days, and holds the answer to forty nights with a
flat trend — a bypass leaves eighty rows and a false climb.

Contract: model `WellnessScoreValue` field for field and publish it. The
envelope's `value` has to stay an open record because one route serves
eighteen differently-shaped metrics, so nothing can `$ref` the shape and
it went out undocumented — `series`, `daysInWindow` and `asOf` were on
the wire with no way to find them short of inspecting a live response. It
is registered in the forced-components slot instead, held against the
runtime interface by a test, and the fields it does not cover are named
in the schema comment so the remaining gap is known rather than invisible.
2026-08-24 02:00:55 +02:00
Marc-André Bombeck b59a78155b docs(api): publish the share link's PDF and FHIR downloads
Both routes were exempt from the contract as `shareLink` — "authenticated
by the URL credential, for a recipient with no account". That reason
holds for the page a person opens. It does not hold for two routes whose
whole purpose is to hand a practice a file it files into another system,
which is exactly the audience a contract has. `/c/{token}/d/{id}` was
published all along, one route away in the same tree.

Each entry states the gate it shares with the page, the frozen selection
it can never exceed, the flat 404 that covers every miss class including
a documents-only link, and the 20/h per-link bucket. The FHIR entry says
plainly that it is a download and not a REST face, and names the two
resource families the bundle deliberately omits.

The create-request description claimed "there is no FHIR or other
machine-readable face behind a share token", which stopped being true
when these routes landed. It now describes what a link actually serves
and records that the insurance leaf is refused outright.

The coverage guard gains the other half of its staleness check. It caught
an exemption for a route that had disappeared, and never the reverse — an
exemption left behind after the route was published, which is how these
two would have stayed on the list while the contract already carried
them. Break-proof: re-add either entry and it goes red naming it.
2026-08-24 01:10:24 +02:00
Marc-André Bombeck fbe2081506 feat(share): carry the owner's module verdict beside the payload
A leaf on a share link can be absent from the payload for two reasons
that look identical downstream: the owner shared it and recorded
nothing, or they shared it and the domain is switched off on their
account. The aggregator ANDs the selection and the module map and
returns the same null either way, so the recipient could not tell an
empty section from one that never had a chance to carry anything.

`loadShareViewData` now resolves the whole module map — it needed the
`doctorReport` key from it anyway — and returns `unavailableLeaves`: the
leaves the link DOES carry whose owning module is off. Only selected
leaves appear, because a withheld leaf's module state is not the
recipient's business in any direction.

The map is deliberately NOT handed to the aggregator. The third argument
is the frozen selection and there is no fourth, which is what keeps this
surface from growing an options object that widens what it asks for; the
resolver memoises its reads per request, so the aggregator resolving its
own map again costs no round-trip.

The module-gate suite moves off `isModuleEnabled` with it, and gains
three cases for the new verdict, including the one that proves an
unshared leaf is never named.
2026-08-24 01:02:33 +02:00
Marc-André Bombeck 29ab274775 fix(i18n): name a visit kind instead of printing its enum
Three surfaces built the label key by interpolation — the doctor-report
PDF's visit table, the daily digest's upcoming-visit line and the
clinician share view. `EncounterKind` is `ROUTINE` and the bundle leaf is
`routine`, so all three rendered `encounters.kind.ROUTINE` verbatim: into
a document a practice files, onto a lock screen, and onto the page.

Route them through `encounterKindLabelKey`, a literal switch beside the
existing `encounterKindLabel`, so the mapping is written down once.

The enum-derived i18n guard could not have caught this: it only knows
key spaces of the form `prefix + member`, and this one is not. It gains a
second arm for spaces whose mapping is a function, with `encounters.kind`
as its first entry. Break-proof: point one arm of the resolver back at
the uppercase key and six locale cases go red.
2026-08-24 01:02:20 +02:00
Marc-André Bombeck 92b8040796 perf(shell): import the settings-destination predicate from its leaf
The authenticated shell wraps every route and read one destination
predicate off the `@/lib/record-settings` barrel. That barrel also
re-exports the managed settings-patch schemas, so Zod, the module
registry and three validation modules rode into the eager client
bundle of every authenticated page for a function that inspects a URL
slug. The shell imports `record-settings/classification` now; the
barrel loses the re-export nobody else used.

Measured over all 585 client-rendered routes: none grew, 100 fell, by
4.7 KB gz at the median and 8.1 KB at the most. /insights had been
sitting on its 445 KB ceiling for three releases and went over it on
this branch's CI run; it measures 437 now, /measurements 435, / 450,
/insights/mood 450.

The whole-build sum moved the other way, 3451 to 3476, because the
aggregate counts each emitted chunk once: a graph that leaves the
chunk group every route shares gets counted again wherever it lands,
here inside the deferred settings chunks that use it. Bytes on disk
rose, bytes a reader downloads only fell. The aggregate cap moves to
3500 with the same 24 KB headroom the four earlier re-measurements
kept, and the reasoning sits next to the number.
2026-08-23 20:56:13 +02:00
Marc-André Bombeck 1d733ef192 Let a German verdict reach the classifier the merge gave it
The two halves of the ECG fix were built apart. One taught the parser
Apple's full English verdict set; the other taught it the metadata keys and
verdict wording a German watch writes. Each was correct alone, and each
left "Sinusrhythmus" unresolved for a different reason.

Together they meet: the alias table translates the wording onto Apple's
own English, and the classifier decides what it means. One place still
owns the meaning, which is what stops a per-language verdict table from
drifting the first time Apple adds a case.

The test that expected null was pinning the gap between the branches
rather than a decision about German. It now expects NOT_DETECTED, and a
French verdict takes over the null case, because four of the six
languages genuinely have no observed key map yet.
2026-08-23 19:42:45 +02:00
Marc-André Bombeck f4695457b1 Merge branch 'fix/ecg-csv-real-exports' into release/v1.37.27
# Conflicts:
#	src/lib/apple-health/ecg-csv.ts
2026-08-23 19:39:36 +02: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 b0d7705491 Merge branch 'fix/data-envelope' into release/v1.37.27 2026-08-23 19:38:44 +02:00
Marc-André Bombeck 8bf3c53d7d fix(apple-health): read an ECG export whose metadata keys are translated
The CSV is written in the device's language, and the keys are translated
along with the values: a German watch writes `Aufzeichnungsdatum` and
`Klassifizierung`, not `Recorded Date` and `Classification`. Indexing on
the English keys found none of them, so the parser never located the
waveform and rejected the recording outright — a lost import, not a
missing field.

Two alias maps close it, both taken from real exports rather than from a
dictionary: nine metadata keys and two verdicts, consistent across every
German file observed. Adding a language is a data addition to those maps
and nothing else.

The verdict map is an indirection onto the English wording rather than a
second verdict-to-enum table, so there stays exactly one place that
decides what a verdict means.

The other four shipped languages are absent on purpose. A wrong key
silently mis-files a value and a wrong clinical verdict is worse than
none, so an export this parser cannot place is refused — which an operator
can see — rather than read into the wrong column, which they cannot. Two
gaps are written down at the map: no observed file carries an
average-heart-rate row in any language, and the paired layout's column
header has only ever been seen in English.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck 29bad0a2b8 fix(apple-health): read the sampling rate as real exports spell it
The rate matcher demanded "Hz". Observed exports spell the unit out and
lower-case it — "511,422 hertz" — and the German ones separate it from the
number with a no-break space. The fixtures had only ever used "512 Hz", so
the mismatch never showed.

Rate and heart rate now split into a magnitude and a unit word, with the
magnitude read through the same decimal helper the waveform uses. "512 Hz",
"512 hertz" and "511,562 Hertz" all resolve; a missing unit, the wrong
unit, a grouped number and a rate past the bound are all still refused.

The unit word is matched loosely and the magnitude strictly. That is the
right way round: the unit is presentation and varies by region, while the
number is the measurement.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck a85c6681bb fix(apple-health): read an ECG waveform written with decimal commas
A watch set to a comma-decimal region writes its samples as "-180,596".
The single-column reader rejected any row containing a comma outright, so
every one of those files failed on its first sample row.

The two waveform layouts disagree about what a comma means, which is why
this cannot be decided per line. The parser now names the section it is in
— header, single-column, paired — and both crossings are commented at the
point they happen: the paired layout announces itself with a `Lead,Voltage`
column header, while the single-column one begins at the first blank line
AFTER Lead and Unit have been declared, because real exports also carry
blank lines mid-header.

Scalars in both waveforms now read either dialect through one helper, so
"-180.596" and "-180,596" give the same microvolts and neither is
privileged. A number carrying thousands grouping is refused rather than
guessed at, since resolving it needs the file's region and the file never
states it. The one genuinely ambiguous shape is a separator followed by
exactly three digits; it is read as a decimal, because observed waveforms
put two- and three-digit fractions in the same file and grouping is always
three, and because a machine writing one bare value per row has no reason
to group at all.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck 19699ce8ce fix(apple-health): accept the valueless header row an ECG export opens with
Every real export begins with a bare `Name` when the name field is blank —
Apple omits the trailing comma rather than writing an empty value. The
parser required a comma on every header row, so it rejected the file on
line 1, before it had read anything.

A valueless header row is now skipped. It contributes no metadata entry
rather than an empty-string one, because `Lead` and `Unit` presence is what
switches the parser into the single-column waveform mode and a key with no
value must not be able to trip that switch.

The relaxation is confined to the header. Inside the single-column
waveform a comma-less row is a sample and is read as one; inside the
paired waveform every row is lead and voltage, so a missing comma there is
still malformed.
2026-08-23 19:17:02 +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 fb344c4f78 Remove two dead alias constants
SENSITIVE_LEAVES and COACH_SUGGESTED_ACTION_TYPES each aliased a
constant defined elsewhere and were never imported. Neither is a
re-export the way the barrel lines were — the definition itself is the
dead thing, and both doc comments describe a consumer that does not
exist: the purity guard reads SENSITIVE_LEAF_IDS from ./catalogue
directly, and nothing mirrors the suggested-action allowlist.

The imports that fed them go with them.
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 c00904068b Drop unimported re-exports from the MCP barrel
The barrel mirrored the tool, resource, prompt, write and scope registries;
its only consumer is the /mcp transport route, which needs three of them.
The registries are imported from their concrete module by ./server and by
their own tests, so the other 31 lines had no importer.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 7e1c63cec3 Drop unimported re-exports from the derived-metrics barrel
The barrel mirrored every export under src/lib/insights/derived. Of the
124 lines, 101 had no importer: consumers and per-metric tests reach for
the concrete module (./sleep-score, ./trajectory, …) directly. Keep the
cross-cutting surface — registry, dispatcher, baseline loader, and the
value shapes outside consumers read — and say in the header that the
barrel is not a mirror, so the next addition needs a consumer first.

Definitions are untouched; only the re-export lines go.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck b15bf4da19 fix(apple-health): record the ECG verdicts the parser was dropping
The classification map held three values. Apple's set is larger, so a real
verdict was being thrown away on English devices: "Heart Rate Over 120"
and "Heart Rate Under 50" both became null, and the column cannot tell
that apart from a recording that carries no classification at all.

The wordings are observed rather than remembered. The CSV writes a display
string and it does not match the framework's case names — the case
documented as InconclusiveHighHeartRate reaches the file as "Heart Rate
Over 120". Across a corpus of 128 real exports the field held five
distinct values; three of them mapped and two were dropped. The threshold
belongs to the watch generation, so the two heart-rate arms match the
number instead of pinning it, and the rest of the map takes its wording
from Apple's ECG instructions for use.

Classification is now split from the column write so the parser can tell a
verdict it has never heard of from one it knows it cannot represent.
Unrecognized is the second kind: the device did not fail to classify the
waveform, it received a verdict it does not know, so recording it as
"could not classify" would assert something Apple never said. Both still
store null, because that is the column's whole vocabulary, and no column
is added for a distinction nothing reads yet.

The old table pinned one row for "Localized or unknown verdict". That
assertion is still true, but it fused three different facts — a localised
verdict, an unknown one, and the two documented verdicts being silently
dropped beside them — and it read as "everything else is unknown" when two
of those were verdicts Apple documents. It is split into the four outcomes.

The language half stays open and is now named at the mapping. A German
export writes the KEY in German too, so a non-English verdict never
reaches this function; it fails the parse earlier.
2026-08-23 18:39:19 +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 2d26a6fdfe fix(telegram): answer someone who asks for help in their own language
The bot's real commands are slash commands, and `telegram.helpBody`
documents exactly those in all six bundles, so nothing a user is TOLD to
type depended on their language. The undocumented courtesies did: the bare
word "help" and "hilfe" opened the help text and five greetings were
answered, everything else fell through to the intake keyword, failed it,
and returned without sending anything. Someone typing "aide" or "pomoc"
got silence, which is the worst possible answer to a request for help.

Both lists now cover the six shipped languages and are compared against
the folded message, so "cześć" and "czesc" are one entry and a capital
cannot miss. The match stays anchored to the whole message: "help me log
my weight" is prose the later paths should still see.

These are ordinary words rather than app labels, so there is nothing in
the bundles to derive them from.
2026-08-23 18:06:58 +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 267497dd3a fix(insights): measure citation coverage in the language the answer was written in
The normative-claim bank held English and German words. Insight
recommendations are generated in the reader's locale, so for French,
Spanish, Italian and Polish every recommendation scored non-normative:
the annotation reported zero normative claims and zero uncited ones, and
the admin quality dashboard read that as full coverage rather than as no
measurement at all. A check that cannot fail is indistinguishable from a
check that passes.

The bank now has one arm per shipped locale and the caller passes the
generation locale; the reader's arm and the English one both run, because
a fallback provider answers a non-English reader in English. Matching
folds both sides through the shared label fold and anchors the leading
word boundary, so inflections still hit while the everyday Italian
"soprattutto" no longer counts as a comparative.

These words are the model's prose, not app labels, so they are not in the
message bundles and cannot be derived from them. The structural test
refuses an empty arm instead: a seventh language fails the suite rather
than quietly scoring nothing. An empty arm also used to compile to an
alternation that matched every string, which would have flipped the same
blindness to the opposite extreme.
2026-08-23 17:55:45 +02:00
Marc-André Bombeck 4e182862c5 Let a passkey satisfy the passkey-removal step-up
The gate landed with a softer mode for accounts holding no second factor,
on the premise that they could not clear it. That premise was wrong. A
passkey is a possession factor here and the codebase already says so in
three places: a primary passkey login stamps Session.mfaVerifiedAt,
passkey sits in FRESH_FACTOR_METHODS so a passkey-proved elevation reaches
the fresh-factor routes, and the enrolment policy counts a primary passkey
as satisfying it outright. The only thing refusing such an account was the
reachability pre-check inside requireFreshMfa, which counts second factors
and nothing else.

So the mode is gone and the route asks for a full freshFactor: true with
proofSource: "any-possession" — a second knob that widens which credentials
count as "could ever prove this", and changes nothing about the gate. A
passkey-only account now gets a real gate with the re-provable
auth.stepup.required rather than no gate at all, and the dead-end
auth.stepup.mfa_not_enrolled is reserved for an account holding nothing.

hasSecondFactorEnrolled is deliberately NOT widened. It answers a different
question, and requireFreshMfaIfEnrolled asks it before deciding whether
account deletion, the data reset, the password change, the encrypted export
and key rotation gate at all — counting a passkey there would change five
destructive actions in passing. The two predicates sit beside each other
with docblocks saying why they differ, and collapsing them turns eight
existing tests red across three files.

Recovery is named rather than assumed. On Bearer: mint an elevation with
method "passkey". On the web: sign in again, which is what stamps the
session — there is no in-page re-proof for a cookie session because the
mint endpoint is Bearer-only by design. That limit is written into the
route docblock instead of left to be discovered, and the removal's refusal
now renders its own sentence in all six locales rather than the server's
English "second-factor" prose, which was wrong for the account this card
mostly serves.
2026-08-23 17:44:13 +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 283699db63 Refuse a backup that claims a section it does not carry (#237)
A restore's first act is to delete the class of records it is about to
rebuild, so a payload whose documents section is simply absent did not
"restore everything else". It emptied the vault, reported success, and left
the operator to find the hole later.

The whole file is refused instead, above the transaction that wipes the
account, and the refusal names the section and says nothing was changed —
an operator holding the only copy of an account cannot act on "restore
failed". The sections ride the error envelope as meta.errorCode
backup.section.missing, and the admin console holds them in a panel beside
the skip report rather than a toast that outlives nothing.

The manifest is the discriminator, and only the manifest. A portable export
leaves the mental-health screeners and the consent receipts out on purpose
and declares both as omitted; those files still restore. So does a file
whose section is present and empty, which is what an account with no
documents honestly writes, and so does every backup written before the
manifest existed. The integration test proves the distinction with the same
builder and the same deleted keys, differing only in what the manifest said.

The route is deliberately outside the published contract — requireAdmin is
cookie-only — so the OpenAPI document is unchanged.
2026-08-23 16:55:59 +02:00
Marc-André Bombeck e3be71f0e5 Gate passkey removal on the same step-up as second-factor removal
DELETE /api/auth/passkeys/{id} took a plain cookie session or a wildcard
Bearer, while DELETE /api/auth/me/mfa/webauthn/{id} beside it demanded a
fresh factor proof. The softer gate sat on the primary sign-in credential:
the last-method check stops an account locking itself out, but nothing
stopped a hijacked session stripping the credentials the owner signs in
with.

The route now goes through requireMfaManagementAuth, the same gate the
security-key removal uses, in a new "if-enrolled" mode. An account with a
second factor pays the full price on either transport — a freshly verified
cookie session, or a factor-proved X-Step-Up elevation on Bearer, so the
native client keeps the capability rather than being sent to the website.
An account with no second factor keeps the contract it had, because a gate
it can never satisfy is not a safeguard but a locked door in front of its
own credential list.

The elevation is spent immediately before the delete, so a foreign id or a
refused last credential does not burn a proof that then has to be minted
again. The route is added to the elevation allowlist with its reason, and
the either-factor enrolment test that was written out three times in the
API kit is now one function.
2026-08-23 16:55:59 +02:00
Marc-André Bombeck fffdc83ca4 Make the reset-every-threshold delete ask first
DELETE /api/user/thresholds without ?metric= erased the account's whole
override set on an omitted query parameter alone: no confirmation, no dry
run, and no rate limit of its own while the PUT beside it had one.

The wide form now wants { "confirm": "RESET_THRESHOLDS" } in the body — the
same typed-confirmation shape /api/settings/account and /api/settings/data
already use — and refuses 422 without writing anything. The ?metric= form
is untouched: it resets one band the person can retype, and the targets
sheet fires it in a loop. Both forms now run the PUT's 30 / 5 min limit.

The settings card's "reset all" control already opened a confirm dialog;
it now sends that answer to the server too. Its token is compared against
the route's declaration in the tests, so the two ends cannot drift apart
into a permanent 422.
2026-08-23 16:55:59 +02:00
Marc-André Bombeck 01b3221c33 Point the doctor-report references at the route that exists
Both named /api/doctor-report, a family removed some releases ago. The
contributor doc sent a reader to a POST endpoint that answers nothing,
and the renderer's own header described itself as serving an endpoint
that is gone.

Found by the sweep behind the retired-route registry, which asks what
this repository still references rather than what a client calls. Two
hits, both prose, neither a call.
2026-08-23 16:15:11 +02:00
Marc-André Bombeck 1ae21b5971 Merge pull request #828 from MBombeck/feat/retired-routes
Answer a retired route with 410 rather than a bare 404
2026-08-23 15:29:10 +02:00
Marc-André Bombeck 4f2c90b66c Fold the side-effect tag through the shared helper
The three locale matchers were built in parallel and each grew its own
fold. Two of them are the same function: the side-effect tag fold and
foldForMatch agree character for character, so one now calls the other.

The third stays. foldLabel decomposes with NFKD, maps sharp s to ss and
joins with underscores; foldForMatch decomposes with NFD, leaves sharp s
alone and joins with spaces. That is a disagreement about who typed the
text, not an accident: one side of a lab comparison is a shipped label
retyped or prose a lab printed, where tolerating a different spelling
claims more than the fold can prove, while someone naming a metric to an
assistant is composing rather than reproducing. Both docblocks now say
so and name the other.
2026-08-23 14:58:51 +02:00