Commit Graph
642 Commits
Author SHA1 Message Date
Marc-André Bombeck dd72634791 Rebuild the Coach thinking row as one calm line
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.
2026-10-07 16:55:25 +02:00
Marc-André Bombeck 5361239ae5 fix(coach): keep a turn's fact and plan only with the answer that carries them
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.
2026-10-06 21:49:27 +02:00
Marc-André Bombeck 072c1e9394 Compare glucose signals by the hour and date stale strip readings
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.
2026-10-06 20:53:39 +02:00
Marc-André Bombeck 3e968f03a9 fix(insights): place a glucose day in progress against the same hours
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.
2026-10-06 20:29:57 +02:00
Marc-André Bombeck 85ebdad5c9 Merge remote-tracking branch 'origin/v141/integration' into v141/d-settings 2026-10-06 19:54:43 +02:00
Marc-André Bombeck 8e7fd0b873 chore(coach): publish the trail route, list it among the mixed reads, and ship its strings in every locale 2026-10-06 19:53:43 +02:00
Marc-André Bombeck 656d8b8563 Merge remote-tracking branch 'origin/release/v1.41.0' into v141/d-settings 2026-10-06 19:44:56 +02:00
Marc-André Bombeck df0689ffbe feat(auth): publish the resolved Coach reasoning on the account payload
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.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck acec87f02c feat(coach): give every turn its memory and let the Coach remember and propose plans
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.
2026-10-06 19:32:31 +02:00
Marc-André Bombeck f6b0131731 fix(daily): serve the briefing as it stands today on every surface
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.
2026-10-05 22:03:58 +02:00
Marc-André Bombeck 2a3bd67b68 fix(insights): cut glucose days at today's latest reading and 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.
2026-10-05 13:10:42 +02:00
Marc-André Bombeck 524975d5f3 fix(daily): decide the line under the Today lead on the server
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.
2026-10-05 12:19:41 +02:00
Marc-André Bombeck bfcf71b7eb fix(dashboard): keep numbers out of the morning push line
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.
2026-10-05 08:34:28 +02:00
Marc-André Bombeck 9e647051d5 fix(dashboard): tell a steady score only when its band held, and say at least at the read's edge
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.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 03c43ef70a Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2 2026-10-05 07:17:17 +02:00
Marc-André Bombeck 0219a85e92 feat(documents): filter the archive by procedure or surgery
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.
2026-10-04 21:59:22 +02:00
Marc-André Bombeck 5cfacba16a feat(dashboard): add a Today overview to the hero
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.
2026-10-04 20:00:18 +02:00
Marc-André Bombeck 24786f6549 Merge pull request #1130 from MBombeck/feat/provider-reasoning-effort
feat(ai): per-provider reasoning effort for Local and gateway models
2026-10-04 11:29:44 +02:00
Marc-André Bombeck befac59576 feat(ai): send a per-entry reasoning effort to Local and gateway providers
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
2026-10-04 10:42:59 +02:00
Marc-André Bombeck b55850967d fix(idempotency): replay a key only for the same request body
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.
2026-10-04 10:14:22 +02:00
Marc-André Bombeck db9618ee7b Merge remote-tracking branch 'origin/main' into wip/v140-H 2026-10-04 00:43:10 +02:00
Marc-André Bombeck d644db1975 Revert "feat(dashboard): send the glucose card in the owner's glucose unit"
This reverts commit 9455b64375.
2026-10-03 23:03:02 +02:00
Marc-André Bombeck 1494409209 Merge remote-tracking branch 'origin/main' into wip/v140-H
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/v137-wave-zero-fixtures.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-03 22:52:49 +02:00
Marc-André Bombeck a14174fe4d Let a medication have several courses
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.
2026-10-03 22:39:25 +02:00
Marc-André Bombeck ef0028c020 Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 22:16:29 +02:00
Marc-André Bombeck 9c2b57df33 Merge remote-tracking branch 'origin/main' into wip/v140-I 2026-10-03 22:00:18 +02:00
Marc-André Bombeck 8068626aa8 Merge remote-tracking branch 'origin/main' into wip/v140-K 2026-10-03 21:48:26 +02:00
Marc-André Bombeck 57a54061be Merge pull request #1112 from BernhardBuckel/fix/series-source-ladder
fix(series): follow the source priority ladder in the dense and raw series reads
2026-10-03 21:41:33 +02:00
Marc-André Bombeck 4cb8118802 Let a person name their own medication categories
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.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck bb58b40b2a Run fact extraction in the background, re-checking under a row lock
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.
2026-10-03 21:14:47 +02:00
Marc-André Bombeck d6571c4e9c feat(symptoms): add person-defined symptoms and timestamped occurrences
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.
2026-10-03 21:13:45 +02:00
Marc-André Bombeck eda5776886 Run document summaries and filing suggestions in the background
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.
2026-10-03 20:51:57 +02:00
BernhardBuckel bb1acfab64 fix(series): follow the source priority ladder in the dense and raw series reads
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
2026-10-03 20:18:50 +02:00
Marc-André Bombeck cd3c0b7de2 Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 20:06:16 +02:00
Marc-André Bombeck 816eabb3c3 Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 19:22:56 +02:00
Marc-André Bombeck 37793298a8 Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 19:22:01 +02:00
Marc-André Bombeck f3ccc809a8 Merge remote-tracking branch 'origin/main' into wip/v140-E 2026-10-03 18:56:39 +02:00
Marc-André Bombeck 9455b64375 feat(dashboard): send the glucose card in the owner's glucose unit
/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.
2026-10-03 18:50:35 +02:00
Marc-André Bombeck facae940b9 Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 18:49:04 +02:00
Marc-André Bombeck 20470d99f4 Merge remote-tracking branch 'origin/main' into feat/document-ai-runs
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 18:41:55 +02:00
Marc-André Bombeck b100c73a62 fix(auth): refuse a sign-in over plain http before it spends a factor (#1097)
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.
2026-10-03 18:21:19 +02:00
Marc-André Bombeck dd7636e147 feat(labs): refuse a biomarker unit change while readings are linked
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.
2026-10-03 18:19:50 +02:00
Marc-André Bombeck 0acf3da575 feat(documents): read documents and lab scans in the background
"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.
2026-10-03 18:18:56 +02:00
Marc-André Bombeck 308470f229 feat(encryption): back up the key, and refuse to serve when it cannot open the database
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.
2026-10-03 18:06:28 +02:00
Marc-André Bombeck 916f918674 feat(workouts): save an edited resubmit of a hand-entered workout
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".
2026-10-03 17:59:20 +02:00
BernhardBuckel d3916036c2 fix(labs): refuse a reading whose unit differs from its marker's
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
2026-10-03 15:44:51 +02:00
Marc-André Bombeck 0f6669847c Merge remote-tracking branch 'origin/main' into fix/ai-timeout-everywhere 2026-10-02 23:59:38 +02:00
Marc-André Bombeck 5ee1b3e28f fix(ai): run every model call under the response-timeout setting
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
2026-10-02 23:01:36 +02:00
Marc-André Bombeck 6eb79a1d47 fix(workouts): report manual saves through the outcome module and register the delete
A repeated submit that wrote nothing reads as a neutral notice rather
than a success. The delete control is entered in the destructive-control
registry, and its 409 code in the published error catalogue.

Refs #1085
2026-10-02 21:31:25 +02:00
Marc-André Bombeck 5e6fd327a5 feat(workouts): delete a workout entered by hand
DELETE /api/workouts/{id} removes a MANUAL workout outright, with its
child rows and the personal records keyed on it, then queues a silent
detection pass so the previous best comes back. Any other source answers
409: the next sync would write it back. Own record only, 404 for another
user's row.

Refs #1085
2026-10-02 21:22:56 +02:00