The clinician view rounded numbers and printed them bare, so a German
record showed 24.5 where every other surface shows 24,5. Its number
formatter now goes through the locale formatter it already builds,
at most two decimals.
The range badge renders nothing for a reading without a reference range, so
that row had four cells instead of five and the range bar and the trend
moved one column to the left. The badge now sits in a cell of its own. The
chevron is hidden from lg up, where the row's hover already says it opens;
a phone keeps it.
A chart showing the last N days fixed its window end at the mount
instant. The query key kept that bound, so the refetch after a new
reading asked for the same old window and the reading stayed hidden
until the range tab was switched. The window now ends at the end of
the current day in the profile time zone (openEndedFetchWindow, keyed
on the day): every reading saved today falls inside it, the cache key
stays the same all day, and it moves on after midnight. The dashboard
batch shares the end-of-day helper.
Refs #1102
The sleep field asked for decimal hours only. A shared parser
(parse-duration.ts) now reads a bare number with point or comma,
H:MM, and hours and minutes with unit words (7h, 7h30, 7 h 30 min,
45m), answers in the field's unit, and refuses a negative, unknown or
longer-than-a-day entry. The form shows how the entry was read under
the field and puts a refusal on the field itself. The stored value and
the API are unchanged.
Refs #1101
The boot check decided on the first value it found under a key id, so
one damaged or foreign-sealed row could take a correctly keyed server
down with 503 on every request. The probe now samples up to five
values, one per column first. One value that opens clears the key; a
mismatch needs at least two values and none opening. A single value
that does not open is inconclusive: the server keeps serving, writes no
canary, logs a warning and probes again at the next start.
Integration setup truncates encryption_key_canaries between tests.
A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".
The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.
GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
Seven locales for the background-run copy: the calm line while a document
read or a lab scan runs in the worker, the hint when no worker has picked it
up, and the two failures only a background run can produce.
Readers label every linked reading with the marker's unit, so changing
it relabelled stored numbers without converting them. PUT
/api/biomarkers/{id} now answers 422 biomarkers.unit.locked with
meta.readingCount when the unit differs and any reading (tombstoned
ones included) is linked. The marker editor says so under the unit
field.
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.
Both now run as a background run (`DocumentAiRun`, migration 0366):
- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
answers 202 with a run id once every quick refusal has passed. Without
the header it answers exactly as before, which the iPhone app relies on.
A second request while a read of the same document is queued or running
attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
route answers with, or the failure code and status it would have given.
Scoped to the caller; another account's run is a 404.
The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.
Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.
The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.
The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.
HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
The in-memory memo of the last provider that answered reorders the
chain for up to an hour. Saving a new chain now drops it for that
account, so a provider the person removed or disabled no longer leads
the chain they just arranged.
The manual form sends one manual: id per opening and resends it after
an edit. The batch route now treats a MANUAL entry with a manual: id
as an overwrite, the way stats: measurements are: the stored row takes
the new values and the entry reports "updated", or "duplicate" when
nothing changed. Records keyed on the workout are removed under the
personal-records lock and re-derived. Every other id stays
first-write-wins. The status enum gains "updated".
The workout-slot detection reads the best workout and writes its record
inside one transaction under a per-user advisory lock, and the workout
DELETE takes the same lock before removing records and the workout. A
delete can no longer land between the read and the write and leave a
record for a workout that is gone.
The DELETE's match for a legacy record without an external id is scoped
to the slots of the deleted workout's sport, so a record another sport
set at the same instant stays.
The insight claim row is now the daily-cap ledger. Its workout_id is
nullable with ON DELETE SET NULL (migration 0367), so deleting a workout
no longer frees the day's slot. The cap counts claims that reached the
provider plus live leases, and drops the separate count of insight rows.
A daily workout-insight-claim-cleanup queue removes claim rows untouched
for seven days.
The round-trip fixture deleted its measurement on a fixed date. The restore
drops a measurement tombstone older than TOMBSTONE_RETENTION_DAYS (75) on
purpose, so on 2026-10-03 the fixture aged out and the suite failed on main
and on every open branch. The deletion is now ten days before the run.
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.
- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
the reading's unit in meta, and writes nothing. The unit is compared only
when one was sent: on the biomarkerId path it may be omitted, and the
marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
reads partial or failed instead of a clean save, and the toast names the
analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
shows a calm badge on a row in another unit, starts it unticked like a
duplicate, and treats it as a blocking error on the unit field if it is
ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
prefix, the case of the litre, and a unit printed in capitals for a short
list where capitals cannot mean another unit. Case is otherwise significant,
so mIU/L and MIU/L, or g/L and G/L, are never merged.
The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.
Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.
Fixes#1094
The form's start defaults to the minute it opened, so a workout logged
right after it finished was stored as ending up to a day ahead. The
duration now fails validation when start + duration lies past now, with
the same one-minute slack the start check uses.
The placeholder said "Default (~120)" while most surfaces fall back to
60 s and the briefing to 180 s. The placeholder and the help line now
interpolate both numbers from the constants the server applies
(PROVIDER_DEFAULT_TIMEOUT_MS, AI_BUDGETS.comprehensive.timeoutMs), in
all seven locales, and a test fails if a locale writes a number of its
own.
Refs #1090
Pins the four ways the setting could be skipped again: a wire client
reading params.timeoutMs itself instead of callTimeoutMs, an exported
resolver in provider.ts returning a provider it did not bind, a client
constructed outside provider.ts (the offline evaluation harness is the
named exception), and a surface ceiling not listed with its reason.
Each matcher asserts a non-zero match count.
Refs #1090
The browser gave up on its own: 120 s for the document routes, 90 s for
a lab scan, and the 15 s default on the lab text path, while a slow
self-hosted model with a raised response timeout was still allowed to
answer. A five-page report read by a local model failed on the client
side with the server still working.
Each request now sizes its abort from ai.provider.responseTimeoutMs on
the account payload, times the model calls the route can make, plus
30 s: one call for suggest and summary, three for Read with AI (the
transcription, then the lab staging read and its corrective retry), two
for a lab scan (the read and its retry). With the setting unset that is
90 s, 210 s and 150 s.
Refs #1090
The response-timeout setting reached the briefing, the status notes and
the Coach, which threaded it by hand. Document reads, document
extraction, lab OCR and lab staging called the provider without a
timeout, so every client fell back to its own 60 s literal and a slow
self-hosted model was cut off however high the setting stood.
The setting now rides on the provider instance. resolveProvider,
resolveProviderChain and resolveProviderForTest stamp the record's
aiResponseTimeoutSeconds on everything they return, and each wire
client reads its ceiling through callTimeoutMs: the setting, else the
surface's own timeoutMs, else 60 s. A call site no longer has to pass
it, so a new one cannot forget it. The connection test runs under the
same ceiling a read does.
The Coach nudge tick and the reaction line keep their short ceilings
(timeoutPolicy: "surface-ceiling"): the tick runs accounts one after
another under a shared budget, the reaction line's claim lease is
shorter than the largest setting, and both have deterministic copy
that stands in.
/api/auth/me publishes the effective value as
ai.provider.responseTimeoutMs, so a client can size its own request
timeout from it instead of guessing.
Refs #1090
The spec compared field tops within 8 px. A field label that wraps onto a
second line, which depends on the runner's font metrics, moves that
field's input down by one line while it stays in the same row, and the
560 px case failed on it. A field is now beside the value when it sits to
its right and starts above the value's bottom edge.
On a phone and a small tablet the review sheet slides up from the bottom;
its width settles before the slide ends, so waiting on width alone still
compared fields from different frames. The spec now waits for every
running animation to finish and for the surface to hold its position and
width across two frames.
Main measured 3718 KB gz after v1.39.7 and v1.39.8, almost all of it new
copy in the seven lazy locale catalogs; the workout form, its sheet, the
delete action and their copy add about 7 KB (3725). The cap moves from
3724 to 3749, the same 24 KB headroom as before; the eager routes stay
inside their drift allowances.
Refs #1085