Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fffdc83
Make the reset-every-threshold delete ask first
MBombeck Aug 23, 2026
e3be71f
Gate passkey removal on the same step-up as second-factor removal
MBombeck Aug 23, 2026
283699d
Refuse a backup that claims a section it does not carry (#237)
MBombeck Aug 23, 2026
8ffa9cd
fix(insights): scan one discovery matrix from every correlation surface
MBombeck Aug 23, 2026
4e18286
Let a passkey satisfy the passkey-removal step-up
MBombeck Aug 23, 2026
267497d
fix(insights): measure citation coverage in the language the answer w…
MBombeck Aug 23, 2026
4d77fe2
fix(coach): let the off-topic gate read the four languages it could not
MBombeck Aug 23, 2026
2d26a6f
fix(telegram): answer someone who asks for help in their own language
MBombeck Aug 23, 2026
fb41df4
fix(mood): search the icon picker with the headers it just rendered
MBombeck Aug 23, 2026
3744073
fix(coach): tokenize prose numbers in the language the reply was writ…
MBombeck Aug 23, 2026
b15bf4d
fix(apple-health): record the ECG verdicts the parser was dropping
MBombeck Aug 23, 2026
b9fe29c
Bump knip from 6.16.1 to 6.32.2
MBombeck Aug 23, 2026
7e1c63c
Drop unimported re-exports from the derived-metrics barrel
MBombeck Aug 23, 2026
c009040
Drop unimported re-exports from the MCP barrel
MBombeck Aug 23, 2026
244a939
Drop unimported re-exports from the remaining barrels
MBombeck Aug 23, 2026
0e9c5f1
Drop unimported re-exports outside the barrels
MBombeck Aug 23, 2026
fb344c4
Remove two dead alias constants
MBombeck Aug 23, 2026
9496770
Drop the type imports left behind by the extract-prompt re-export
MBombeck Aug 23, 2026
03293bc
Record what the dead-code gate was blind to before knip 6.28.0
MBombeck Aug 23, 2026
19699ce
fix(apple-health): accept the valueless header row an ECG export open…
MBombeck Aug 23, 2026
a85c668
fix(apple-health): read an ECG waveform written with decimal commas
MBombeck Aug 23, 2026
29bad0a
fix(apple-health): read the sampling rate as real exports spell it
MBombeck Aug 23, 2026
8bf3c53
fix(apple-health): read an ECG export whose metadata keys are translated
MBombeck Aug 23, 2026
dbee6b7
Merge branch 'fix/discovery-matrix' into release/v1.37.27
MBombeck Aug 23, 2026
b0d7705
Merge branch 'fix/data-envelope' into release/v1.37.27
MBombeck Aug 23, 2026
3199192
Merge branch 'chore/knip-dead-exports' into release/v1.37.27
MBombeck Aug 23, 2026
b154ec5
Merge branch 'fix/language-matchers-rest' into release/v1.37.27
MBombeck Aug 23, 2026
f469545
Merge branch 'fix/ecg-csv-real-exports' into release/v1.37.27
MBombeck Aug 23, 2026
1d733ef
Let a German verdict reach the classifier the merge gave it
MBombeck Aug 23, 2026
5803d16
chore(release): v1.37.27 — the checks that were green because they we…
MBombeck Aug 23, 2026
92b8040
perf(shell): import the settings-destination predicate from its leaf
MBombeck Aug 23, 2026
7f708a4
docs(changelog): record the shell's eager settings-schema import
MBombeck Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## [1.37.27] — 2026-08-23

Five things this release fixes have the same shape: a check that was green because it was not checking.

### Fixed

- Importing an Apple Health archive rejected every ECG recording exported outside a US locale, and counted each one as a failure without recording why. Four separate causes, none about the recording: the file opens with a header row that has no value after it, waveform samples are written with a decimal comma, the sample rate reads `511,422 hertz` rather than `512 Hz`, and a German watch writes the column names in German. The parser had been written against a test fixture and the fixture was US-style throughout. All four forms are accepted now, and both dialects are pinned side by side so a later edit cannot narrow it back to one.
- Three of Apple's own English verdicts were dropped as unknown. `Heart Rate Over 120` and `Heart Rate Under 50` are what the export actually writes, which matches neither the framework's documented case names nor the wording in Apple's instructions. A verdict the parser cannot represent is now told apart from one it has never heard of.
- The Coach could not report a single environmental correlation. Four surfaces assembled the correlation matrix independently and three carried different channel sets, so asking whether air pressure related to your sleep searched a matrix that never held the pair. All four assemble from one place now. The Coach's file header had claimed for months that it ran the same scan as the insights page, which is why nobody looked.
- A metric card's assessment received every surviving correlation in the record rather than the ones about that metric. The filter that narrows to one metric had been replaced by the dismissal filter instead of chained with it, so the name, the docstring and the early-return guard all said "one metric" while the return said otherwise. Its test passed because the fixture held one pair.
- Reading a personal record issued the same single-row lookup once per metric. On a three-year record the dashboard did it 25 times and the analytics page 17. The comment above the helper had warned about exactly this since it was written. It resolves once per request now.
- Citation coverage reported full coverage for four of the six languages, because it measured against an English and German word bank while the text it measured was written in the reader's language.
- The Coach refused legitimate questions in some languages and let off-topic ones through. Its topic vocabulary was English and German, so the Italian word for "series" sat on the off-topic list while nothing recognised the Italian for "measurements".
- A number the Coach wrote in prose could be matched against an unrelated reading. Unit words were recognised in English only, so `7,4 Stunden` carried no kind, and a value with no kind clears the gate against every entry in the ledger: a sleep figure could ground itself on a glucose result. French dates were not parsed either, so correct prose was stripped as unverifiable.
- Asking Telegram for help in French, Spanish or Polish got no reply at all.
- The mood icon picker searched only English names, so typing back the translated group header it had just displayed returned an empty grid.

### Changed

- Deleting every threshold override at once asks first. The endpoint erased the whole set when the metric parameter was omitted, with no confirmation and, unlike its sibling, no rate limit of its own. It takes the same explicit confirmation the account deletion does. Deleting a single metric's override is unchanged.
- Removing a passkey now needs a fresh proof of possession, the same gate that has always guarded removal of a second-factor security key. The softer gate had been sitting on the primary sign-in credential. A passkey satisfies its own gate, so an account with nothing else enrolled keeps control of its own credential list, and the app keeps the capability through the step-up it already uses.
- A portable backup that claims a section it does not carry is refused whole rather than restored in part, and the refusal names the missing section. A section the export deliberately left out and declared as omitted still restores, which is what keeps the disaster-recovery export working.

### Internal

- The read surfaces have measured budgets for the first time. Against a three-year record (29,565 measurements, 1,095 mood entries, 5,475 medication intakes, 20,688 rollup rows) the heaviest surface issues 103 statements and 77 KB, and page load does not depend on record size at all. The budgets pin query counts, payload sizes and which rollup arm was taken rather than wall-clock, because counts were identical across runs while wall-clock moved up to sevenfold on the same machine. One ratio budget asserts that counts do not grow between a 30-day record and a three-year one.
- Every authenticated page was downloading the settings validation layer. The app shell wraps all of them and read one small function from a barrel that also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode along on every page load for a check that reads a URL slug. The shell reads that function from its own module now. No page grew, a hundred of them lost 5 to 8 KB, and the Insights overview has room under its budget again after three releases pressed against it.
- The dead-code gate had been blind to every re-export since knip 6.16. The tool changed at 6.28 and the old silence turned out to be an artefact of one setting rather than a clean tree: with that setting off, the old and new versions agree exactly. Two dead constants and 204 unimported re-exports are gone; every definition still in use stayed.

## [1.37.26] — 2026-08-23

The app is offered in six languages, and in seven places it was reading what you wrote as though there were two.
Expand Down
4 changes: 2 additions & 2 deletions bundle-budget.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 3452,
"totalClientKbGz": 3500,
"maxRechartsChunks": 1
}
68 changes: 57 additions & 11 deletions docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.26
version: 1.37.27
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down Expand Up @@ -2581,9 +2581,21 @@ paths:
the account has no password — an account must keep at least one way in.


No step-up: this endpoint takes a plain cookie session or a wildcard Bearer token, where removing a
second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}) requires a fresh factor proof. The asymmetry
is in the code as it stands, not a description of intent.
Step-up gated, on the same mechanism and at the same strength as removing a second-factor security key (DELETE
/api/auth/me/mfa/webauthn/{id}). The caller must present a fresh possession proof: on the cookie path a session
that completed one within the last five minutes, on Bearer an `X-Step-Up` elevation minted at POST
/api/auth/step-up against a re-proved factor. A password-proved elevation is NOT enough.


A PASSKEY IS SUCH A PROOF here, unlike on the second-factor routes. An account whose only credential is a
passkey can satisfy this gate: a passkey login stamps the session, and POST /api/auth/step-up accepts `method:
"passkey"` against the account's primary passkeys. So its refusal is `meta.errorCode: auth.stepup.required` —
re-prove and retry — and not the dead-end `auth.stepup.mfa_not_enrolled`, which is reserved for an account
holding no credential this gate could ever accept.


The elevation is spent only when the deletion is about to happen. A 404 for an unknown id, or the
last-credential refusal, leaves it unconsumed and reusable.
parameters:
- in: path
name: id
Expand All @@ -2604,7 +2616,16 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"401":
description: Not authenticated, or the request carried no fresh possession proof (`meta.errorCode` =
`auth.stepup.required`). Re-prove and retry — on a native client by minting an elevation at POST
/api/auth/step-up, on the web by signing in again, which is what stamps the session.
`auth.stepup.mfa_not_enrolled` is the other arm and means the account holds no credential this gate can
accept, so retrying will not help. Nothing was removed either way.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: No such passkey for this account.
content:
Expand Down Expand Up @@ -7701,18 +7722,27 @@ paths:
summary: Reset one threshold override, or all of them
description: >-
With `?metric=` it drops that metric's override; WITHOUT the parameter it drops EVERY override on the account.
There is no confirmation step and no dry run, so a client that means to reset one metric must send the
parameter.
The wide form is not reachable by omitting the parameter alone — it requires `{ "confirm": "RESET_THRESHOLDS" }`
in the body, the same typed-confirmation shape account deletion and the data reset use. The `?metric=` form
takes no body and is unchanged.


Audit-logged with the before and after maps. Unlike the PUT this path runs no rate limit of its own.
Audit-logged with the before and after maps. Rate-limited 30 / 5 min per user, matching the PUT.
parameters:
- in: query
name: metric
schema:
description: The single metric to reset. Omit to reset every override on the account.
description: The single metric to reset. Omit to reset every override on the account — which then requires the
confirmation body.
$ref: "#/components/schemas/ThresholdMetric"
description: The single metric to reset. Omit to reset every override on the account.
description: The single metric to reset. Omit to reset every override on the account — which then requires the
confirmation body.
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/ThresholdsResetAllRequest"
responses:
"200":
description: The remaining override map.
Expand All @@ -7727,7 +7757,12 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"422": *a4
"422":
description: '`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.'
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/auth/me/devices:
get:
Expand Down Expand Up @@ -23030,6 +23065,17 @@ components:
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
ThresholdsResetAllRequest:
type: object
properties:
confirm:
type: string
const: RESET_THRESHOLDS
description: Required when `metric` is omitted. Compared exactly; any other value is refused.
required:
- confirm
description: Confirmation body for the reset-everything form. Send no body at all when resetting a single metric with
`?metric=`.
DevicePatchRequest:
type: object
properties:
Expand Down
Loading
Loading