Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.
The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.
Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
A workout logged by hand could only be deleted and logged again. The
detail page now offers an edit control beside delete for a MANUAL workout
carrying the form's `manual:` id, in one's own record. It opens the log
sheet on the stored values and saves by re-posting that id through
`POST /api/workouts/batch`, which already applies such a re-post as an
overwrite of that one row. No new route.
The overwrite nulls every overwritable column a re-post leaves out, and
the form has no fields for heart rate, steps, elevation or pauses. The
edit therefore sends those back as stored. A field left as shown sends the
stored value rather than one re-parsed from its display, so saving an
untouched edit keeps the start's seconds and a distance shown in miles at
its exact metres, and answers `duplicate`.
The detail read's `avgHr` / `maxHr` can come from a same-session twin;
the read now also serves the row's own `storedAvgHr` / `storedMaxHr`,
which is what the edit sends back.
The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.
The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.
`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.
The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.
Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.
The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.
scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.
Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.
HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.
Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.
The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.
Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.
When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.
The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.
The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.
Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
remember_fact and propose_plan wrote their rows during the tool round. An
answer that was then blocked, failed or abandoned left the fact in memory
without its note and Undo, and left the plan listed, activatable and fed
into later turns and the briefing. Both are now staged under an id minted
up front and written in the transaction that stores the assistant message,
only when its provenance carries them. A background proposal counts as
offered only once an answer carrying it is stored.
Plan text is model text: ifCue, thenAction and target pass the outbound
dose and risk screen in the person's locale, with their scheduled doses and
medication names, and the injection screen; a trip declines as "unsafe".
The trail route serves recalled facts and the proposal's fact to the owner
whatever the AI capability (only the model-written entries are gated), and
withholds medication facts while the medications module is off.
Accepting a fact proposal is decided under a per-message advisory lock, so
concurrent taps write one fact. Editing a fact re-reads its wording: text
naming a medication moves to "medication", other health wording moves a
non-health fact to "condition", and a health fact the person edited counts
as confirmed.
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.
The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
The usual-range strip on a metric page placed the latest single reading
against a band built from whole-day means. For glucose the day mean climbs
with every meal, so at nine in the morning a fasting reading sat under a
band of whole days and the strip said "below your range" on an ordinary
morning; the MCP baseline read reported the same.
Glucose is now placed by its day mean. A day still in progress, in the
reader's own zone, is held against the earlier days cut at the local time
of today's latest reading, the comparison the health status already makes;
with fewer than seven earlier days that have readings in those hours there
is no verdict. The baseline carries `basis: "sameHours"` on the coach-read
route and the MCP read, and the strip names the range as the one for this
time of day, in all seven locales. Other metrics keep their placement.
GET /api/auth/me gains coachReasoning: the level a turn runs at, the
person's own pick, the highest level the settings may offer, whether
reasoning can run at all, whether the lowest option is a real off, and
the layer that decided. Resolved on the server so the web settings and
the native client read the same answer.
The web reads it through useCoachReasoning. The Coach preferences route
names the stored level on its wide event, and a unit test round-trips
the level through the account-settings backup section.
What a person told the Coach never reached a tool-mode turn: facts and plans
rode the snapshot's memory block, which tool mode does not send, and the
extraction ran only past twenty turns and failed to parse on every
non-Codex chain, because the status chain runs in JSON-object mode while the
prompt asked for a bare array.
- Memory block: WHAT YOU KNOW ABOUT THIS PERSON, built per turn behind the
wire egress check (nothing is read on a refusal), fenced as data, at most
1,500 characters, never trimmed before the inventory. Confirmed facts
first, then the newest; active plans with a server-computed progress line;
reminders the person asked for. Medication facts stay out while the
medications module is off. Building it stamps last_used_at without moving
updated_at. buildCoachToolRequest and buildCoachProviderPrompts take it as
an optional memoryBlock; the snapshot memory keeps only the narrative and
trend recall.
- remember_fact: preferences, goals and context are saved at once
(source coach, source_message_id set); anything that names a condition,
limitation or medication, by category or by wording, is only proposed.
A fact must come from the person's current message; one note per answer.
- The person's answer reads the proposed fact from the encrypted trail on
the assistant message, never from the request.
- propose_plan writes a proposed plan; the tap activates it with its review
window counted from the tap. One per answer, three open at most.
- Progress: one sentence per active plan from the person's own day series
and units, also offered to the briefing (two lines at most, behind the
briefing capability). The plan review and the progress line read a step
plan as daily totals instead of per-sample means.
- Background extraction asks for {"facts":[]} / {"plans":[]} and reads
either shape, runs once a conversation has been quiet for 30 minutes, and
stores health facts as proposals offered once at the next turn.
- Unanswered plan and fact proposals lapse after 14 days in the reminder
sweep.
- Memory list API: POST saves one of the caller's own messages (the
remember button), PATCH edits a fact's wording, GET reports source, origin
and last use and never lists a waiting proposal.
- A structural guard keeps the egress check ahead of every read and the
callers of both builders to the turn and the briefing.
Only the digest applied briefingForToday. The dashboard snapshot (the
web dashboard, its server prefetch and the iOS cold-launch read) and
the /insights advisor payload still served an earlier day's text and
the raw model deltas ("+33.72 bpm").
The snapshot read now resolves the briefing after the cache, beside
the capability decision, from the payload's generation moment and the
snapshot's own newest reading per type; a withheld briefing reads
"preparing", never "ready" with nothing in it. The digest takes the
resolved briefing from that read on its own clock instead of resolving
again. The advisor route resolves dailyBriefing on every branch it
returns, reading the newest reading per signal type in one grouped
query only when a reading-backed signal is present; text generated on
an earlier day no longer counts as fresh on the read GET (so the warm
is enqueued) and no longer short-circuits a provider-backed POST.
The OpenAPI descriptions of both advisor responses say so.
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.
A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
The digest line is the body of the morning push and lands on a locked
phone. It took the briefing's first sentence, the top signal's headline
or the deterministic signal sentence verbatim, so a resting heart rate or
a blood pressure average could sit on the lock screen, and its score
floor stated the score.
A candidate with a figure in it is now passed over for the next, and the
floor says the score is ready instead of what it is. The hero's lead in
the app keeps its numbers. Tests that pinned the numeric line are turned
around, and the push builder is checked on a digest whose lead carries a
reading.
The steady line compared the newest stored score with the number on
screen but not with its band, version or composition, so a score whose
band moved overnight at the same number was still called steady. A run
that reached back past the 120 days read was also told as if it started
there.
The newest stored day now has to match the ring in value, band and,
where the digest carries them, algorithm version and composition. A run
the read cannot see the start of is marked as open (steadyAtLeast on the
digest score) and the hero says "at least" in seven locales.
The vault's filter row gains a procedure facet beside the condition
facet. It lists the record's procedures and surgeries that hold at least
one live document, headed the way the visits page heads them (the
reason, else the practice, else the kind) with the body site and date
beneath, and narrows the list through the existing `encounterId` filter.
Same control, placement and clear behaviour as the condition facet; the
choice rides the URL as `?encounter=`, and an upload dropped into the
filtered view is filed against the procedure.
The usage read publishes the choices as `linkedProcedures` (additive),
decrypting only the label columns, and returns none when a sharing
grant does not cover the visits section.
On a phone an active facet now gives up label width before the search
field does, and the search keeps room for its icon and padding.
The hero led with the first sentence of the cached briefing, which was
often a greeting ("Good morning.") while the sentence with content was
never shown. Without an AI provider it fell back to "Your health score
today is N.", which the hero then removed because the ring already
shows the number, leaving a ring and "Nothing needs your attention".
The daily digest now publishes the lead resolved on the server and a
short list of facts about the day:
- lead: the reaction line or the first briefing sentence that is not a
greeting and does not repeat the score while AI text is available,
otherwise a deterministic sentence about the strongest signal (a vital
outside its personal range, a night an hour off the usual, vitals in
range, last night). A fact the lead was built from leaves the list.
- today: up to five statements in priority order (Rest Mode, doses taken
today, an appointment today or tomorrow that is not already on the
rail, last night's sleep against the usual, vitals against their
personal range, cycle day). Module-gated on the server, never padded,
never asking for anything. A phone shows the first four.
- restMode: passed through while an illness episode is active.
- score.steadyWeeks: how long the score has held, read off the stored
daily scores, shown under the ring in place of a delta.
briefingLead skips greetings and score sentences the same way, so the
morning push improves with it. Visit times are rendered in the profile
timezone and hour-cycle preference; the cycle day comes from the same
verdict the cycle page uses. The ring, the rail and the charts are
unchanged. OpenAPI regenerated; additive fields only.
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.
- Every exported resolver binds the entry's value onto the client, the
connection test included, keyed on the instance type so the operator
provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
compatReasoningEffort and GET reports them. A provider absent from
the chain holds its value on a disabled entry; restating Default on a
chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
openai-compatible entries, refuses it on any other, and keeps the
stored value when the body leaves it out. Its schema is now shared
with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.
Refs #1126
The replay cell was keyed on (user, key, method, path). A client that sends
two different writes under one key got the first write's cached response for
the second, and the second was never saved. Native clients up to 1.1.0 post
the two halves of a manual blood pressure exactly that way, so the diastolic
value was silently dropped.
Each cell now stores a SHA-256 fingerprint of the request body (JSON with
recursively sorted keys, otherwise the raw bytes; scoped by method and path;
at most the first MiB is read). Lookup replays, or refuses as in flight, only
when the fingerprint matches. A different body, or a row written before the
column existed, runs the handler without claiming or caching and leaves the
existing cell for its own retries; a lost claim race re-checks the winner's
fingerprint the same way. The body itself is never stored.
Migration 0371 adds the nullable idempotency_keys.request_fingerprint column.
The OpenAPI parameter description, the client notes and the Home Assistant
guide say what a replay now requires. docs/ops gains a read-only query that
lists systolic readings left without their diastolic half, held to the
schema by an integration test.
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.
- Migration 0368 adds medication_courses and backfills one course per
medication window (an end-only window starts on the creation day, or
on its end day when that lies earlier); a medication without a window
stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
.../courses/{courseId} (owner-only); create seeds the first course
from a window; the medication PUT edits the latest course and refuses
a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
between them; every compliance read selects the courses. A rolling
cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
the detail Lifecycle group lists the courses with start, edit and
delete. The Coach compliance block, the MCP resource, the doctor
report window and table, the clinician view and FHIR (one
MedicationStatement per course with effectivePeriod and status) read
the courses.
- Backup carries courses per medication and the restore writes them and
re-projects; an older file derives the one course its window
describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.
- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
admitted at MANAGE like the cycle symptom vocabulary) and owner-only
PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
medication filed under the category to OTHER in the same transaction
and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
the create and update bodies refuse a key that is not the caller's
(422 medications.category.unknown), and list, detail, the insights
medication tiles and the MCP medications resource carry categoryLabel.
A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
built-in rows with an inline add row; the list page gains a category
filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
restore writes them before the medications; wipe plan, encrypted
column registry, rotation script, sharing guards and OpenAPI follow.
The extract route accepts `Prefer: respond-async` for all three inputs
(text, stored, vision) and queues a DOCUMENT_EXTRACT run (added to
migration 0370). The run's result is the count of staged facts and the
document status; the facts themselves are read from the document.
Without the header the route answers with the document detail as
before; both paths run one shared body.
A background read can take minutes, and a review the person finishes in
the meantime must not have its approved facts replaced. Staging now
locks the document row and asks again whether it may stage, refusing
with the route's own alreadyConfirmed / alreadyPartlyConfirmed codes.
The synchronous path gets the same re-check.
The vault's stored-text extract uses the background path and shows the
shared waiting line in place of its hint while the read runs.
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.
- Migration 0369 (idempotent): symptom_definitions and symptom_events,
intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
eight active definitions per record (a hidden one frees its slot,
showing it again re-checks), hide or purge, events create/list/edit/
delete, a new link must name an open episode of the same record
(422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
plan; both carried at both backup ends with occurrences nested in their
definition, plaintext in a portable file and ciphertext in a recovery
file, and a link to an episode the file lacks dropped and named
(symptomEpisodeReference).
- OpenAPI regenerated.
The summary and suggest routes accept `Prefer: respond-async`: after the
same quick refusals they answer 202 with a run id, and the background
document AI worker runs the read under two new run kinds
(DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header
both routes answer synchronously as before; both paths run one shared
body, so they cannot drift.
The worker asks the capability for the record and re-checks the wire for
the provider it picks before every read. A summary that is to be stored
marks the document PENDING while queued, READY when it lands, and
UNAVAILABLE when the run fails or the write does not land. A second
request attaches to a live run only when it would produce the same thing.
The vault asks for both in the background and shows the same calm
waiting line the index uses, worded so a transient read never promises
that leaving the page keeps it. The egress guard and the route inventory
now hold every run kind to its dispatcher re-check and its capability.
The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.
Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:
- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).
The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.
Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.
Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.
Refs #1108
/api/dashboard/summary sent the glucose card's value in mg/dL with
unit null and a unitKey that reads mg/dL, so a reader on mmol/L saw
the mg/dL number. The card now carries latestValue, secondaryValue
and sparkline in the record owner's glucose unit and names it in
unit, the field that wins over unitKey, as the sleep card already
does. Applied after the summary cache read, so a unit change shows
on the next request.
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.
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 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".