Commit Graph
3300 Commits
Author SHA1 Message Date
Marc-André Bombeck 5345c8be1e fix(backup): check every account of the newest date in the restore drill
The drill read only the alphabetically last object of the newest date, so
one account stood in for all of them. It now checks every object of that
date one at a time, records a failure against the account it belongs to,
keeps going, and pages once per failing object with its key and how many
accounts were checked.
2026-10-04 15:56:55 +02:00
Marc-André Bombeck f67006783c fix(backup): open legacy text ciphertext as a string and probe only counted sections
A text column written before key ids existed holds the bare base64 of
nonce, tag and ciphertext. The key check read such a value as an encoded
Bytes column, decoded it once too often, and accused the v1 key of not
opening a copy it opens. The instance's stored AI key has exactly that
shape and is the shortest v1 value in every disaster-recovery copy, so it
was picked as the probe and refused every restore of an account with v1
values, even with the instance settings left out of the verdict.

A legacy value now reads as a string unless its decoded head is base64
text. Samples are kept per section and only the sections the verdict
counts are probed; a key is unreadable when none of its probes opens.
Binary values open with the label their column is sealed under, taken
from the encrypted-column registry, and are not probed when no label is
known. A preview stored by an earlier release is still read, and its
sample's layout is derived again from the value.
2026-10-04 15:56:55 +02:00
Marc-André Bombeck dee00613d8 Merge remote-tracking branch 'origin/main' into release/v1.40.0 2026-10-04 12:23:40 +02:00
Marc-André Bombeck 32de7ecf37 Merge pull request #1135 from MBombeck/fix/cycle-backup-and-med-put
fix(backup): carry encrypted free text readable in portable backups
2026-10-04 12:23:35 +02:00
Marc-André Bombeck 4e88243075 Merge remote-tracking branch 'origin/main' into release/v1.40.0 2026-10-04 11:29:47 +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 ad0f35eadd Merge remote-tracking branch 'origin/main' into release/v1.40.0
# Conflicts:
#	CHANGELOG.md
2026-10-04 11:13:49 +02:00
Marc-André Bombeck ecddcb3fa5 Merge pull request #1128 from MBombeck/fix/pdf-raster-ocrmypdf
fix(documents): hoist the pinned canvas binary and render PDFs with pdfjs font data
2026-10-04 11:13:29 +02:00
Marc-André Bombeck a0fa4783c0 fix(ai): keep JSON mode when an endpoint rejects only reasoning_effort
An endpoint that does not know reasoning_effort answers with an unknown
parameter error, which the JSON-mode probe read as a refusal of
response_format and switched that base URL to plain prompts.
2026-10-04 10:48:35 +02:00
Marc-André Bombeck 9cb06ec44d fix(idempotency): read a rebuilt multipart form as the same request
A client that rebuilds a form for a retry picks a fresh random boundary,
so the raw bytes differ and a repeated document upload ran a second time
instead of replaying. The boundary named in Content-Type is replaced by a
fixed token before hashing.
2026-10-04 10:43:00 +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 8cf1210bad fix(documents): log why a document got no thumbnail
Refs #1124. The thumbnail job opened no wide event, so every annotation
it made went nowhere: one instance ran it hundreds of times, left sixteen
PDFs without a preview, and the log never said so.

Each run is now a `job.document_thumbnail` background event. A run that
should have produced a preview and did not is raised to warn and names
the reason; a type that never has a preview stays at info.
2026-10-04 10:37:24 +02:00
Marc-André Bombeck 4b73099e75 fix(documents): render PDFs with pdfjs font data and refuse blank pages
Refs #1124. The rasterizer opened pdfjs without its standard font, CMap
and wasm directories and told it to use system fonts. The runtime image
has none, so any text pdfjs could not draw from an embedded program (a
non-embedded standard font, or an embedded one it cannot parse) came out
as nothing, and an image-only provider was sent the page background. A
workstation hides this because its own fonts stand in.

pdfjs now gets the directories from its own package, resolved at runtime
from the working directory, and Liberation Sans from that package is
registered as the canvas' sans-serif, serif and monospace faces for the
text pdfjs draws with a substitute.

A document whose every rendered page is blank now reports render-failed,
so the read records raster-failed instead of indexing a transcript of an
empty page as a success.
2026-10-04 10:37:24 +02:00
Marc-André Bombeck b36190b2df Restore custom mood tags without mood entries
The account's own mood categories, tags and hidden set were written only
inside the restore's mood-entry branch, so a vocabulary set up before the
first entry did not come back. They are written whether or not the file
carries an entry.
2026-10-04 10:19:32 +02:00
Marc-André Bombeck 8ac3fddc3b Carry visit and vaccination free text readable in a portable backup
A practitioner's note, a visit's reason, outcome and body site and a
vaccination's note travelled as stored ciphertext in a portable file, the
same defect the cycle and mood sections had. They now go through the
shared sealed-text helpers: readable in a portable file, sealed under the
receiving host's key on restore, ciphertext kept only where this host
opens it and otherwise reported as visitCiphertext or
vaccinationCiphertext. The practitioner address and phone ciphertext of a
disaster-recovery file gets the same check, since the key preflight now
leaves these sections to it.
2026-10-04 10:19:31 +02:00
Marc-André Bombeck 2798cc953f refactor(workouts): drop the unused clock arguments of the blank draft 2026-10-04 10:18:16 +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 17b653b819 feat(workouts): read a blank start as now minus the duration
Refs #1085. Logging a workout right after finishing it meant changing the
start first, or the form refused it as ending in the future. The start field
now opens blank; left blank, the workout is saved as starting the duration
before now. A start the person enters is validated as before.
2026-10-04 09:07:39 +02:00
Marc-André Bombeck 43a53255c9 Carry custom mood labels readable in a portable backup
The labels of the mood tags and mood categories an account created
travelled as the stored ciphertext in a portable file, so a restore onto
a host with another key refused the file or wrote labels nobody could
open. They now follow the cycle note: readable in a portable file, sealed
under the receiving host's key on restore, ciphertext kept only where
this host opens it and otherwise reported as moodLabelCiphertext.

The sealing helpers move to one module both sections share.
2026-10-04 08:31:11 +02:00
Marc-André Bombeck 6bc764fa28 Write a medication PUT and its course in one transaction
The row update (name, schedule, oneShot) committed before the course
write. When a concurrent course made the course write refuse, the client
got the refusal while the rest was already written. The schedule replace,
the row update, the pause era and the course write now commit together,
so a refusal leaves the medication unchanged. The rollup recompute runs
after the commit.
2026-10-04 08:17:34 +02:00
Marc-André Bombeck 8121935642 Carry cycle free text readable in a portable backup
A portable file carried the day-log note, the sensitive-category envelope
and custom symptom labels as the stored ciphertext, so a restore onto a
host with another key either refused the file or wrote values nobody
could open. The portable file now carries them readable and the restore
seals them under the receiving host's key; a disaster-recovery file keeps
the ciphertext.

A file written before this still restores: each ciphertext value is
opened with this host's keys first, and one that does not open is kept
out of the row and reported in the skip summary as cycleCiphertext. The
key preflight leaves the cycle sections to that per-value check.
2026-10-04 08:17:34 +02:00
Marc-André Bombeck 2f3da10317 Ask the newest values before the key check refuses
The probe decided on the eight oldest values alone. An install
reinstalled under a fresh key long ago holds old rows sealed under a key
that is gone and all current data under the key it runs with; it works
today, and the check would refuse it with 503 on every request.

When every oldest value fails, the newest values of the same columns are
now sampled in reverse order. One that opens makes the verdict
inconclusive (mixed): the process serves, warns and probes again at the
next start. Only old and newest values that all fail are a mismatch. A
newest-only open never seals; sealing stays with the oldest-first rule.

Every probe query now runs in its own transaction under a
transaction-local statement_timeout (2 s), so a large or locked table
ends that column with an error and the probe as incomplete instead of
holding up the boot. The newest order is the exact mirror of the oldest,
so an index on the timestamp serves both directions.

The module comment lists the three conditions that still yield a
mismatch.
2026-10-04 01:58:04 +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 4317eb91ca Make the rolling course anchor opt-in
The rule that keeps an earlier course's intake from anchoring a rolling
cadence applied to every caller unless one opted out, which is how a
booster reminder picked it up. It now applies only when the context
declares rollingAnchor: "courseStart"; the four medication context
builders declare it, and every other caller keeps the previous
behaviour, where the last intake always anchors. A guard enumerates
every RecurrenceContext builder in src, requires each to be listed as a
medication or a non-medication builder, and checks the declaration on
both sides.
2026-10-04 00:42:54 +02:00
Marc-André Bombeck df85fd62c9 Drop the unused built-in category list export 2026-10-04 00:33:34 +02:00
Marc-André Bombeck b930ab80ff Keep booster and measurement reminders anchored on the last satisfaction
The course rule that stops an old intake from anchoring a rolling
cadence read a reminder's anchor date as a course start, so a booster
fourteen years past on a ten-year cadence came due later today instead
of staying overdue. Measurement and booster reminders now opt out with
rollingAnchor: "lastIntake"; the rule stays on medications.
2026-10-04 00:33:34 +02:00
Marc-André Bombeck 363070540b Merge remote-tracking branch 'origin/main' into fix/key-check-hardening 2026-10-04 00:01:07 +02:00
Marc-André Bombeck 69bae8bdb4 Add ENCRYPTION_KEY_CHECK=warn as an escape hatch for the boot key check
At a first start there is no recorded key yet, so an operator facing a
refusal they are certain is false has no row to delete. With
ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs
its full outcome, /api/health names a mismatch as a warning field without
failing, requests are served, and no key is recorded unless every key id
passes.

The variable is on the compose whitelist, in both example files, the env
manifest, the Unraid and Portainer templates and the published-env guard.
The log block, the mismatch page and the self-hosting pages offer it as the
last resort after the three real fixes, and say that it disables a safety
check.
2026-10-03 23:46:40 +02:00
Marc-André Bombeck 2c2151ae70 Keep the rolling anchor when a start only moved forward
An intake before startsOn stopped anchoring a rolling cadence, which
also moved the next dose of a single-course medication whose start had
merely been pushed forward a few days. The intake is now ignored only
when the dose it implies would fall before the course starts (a new
course after a gap); otherwise it anchors as before.
2026-10-03 23:42:29 +02:00
Marc-André Bombeck eada82f61e Derive a backfilled course in the account's time zone
The backfill and the restore of an older file read the creation day of
an end-only window in UTC while every reader of the window uses the
account's zone; both now use the account's zone (the default zone when
the stored one is unknown to Postgres). A window whose start lies after
its end, which the API refuses but older rows hold, becomes a one-day
course on the end day: every reader treated it as empty and ended, and
a swap would have invented expected doses. One shared rule
(courseFromWindow) serves the restore and the course writer.
2026-10-03 23:36:08 +02:00
Marc-André Bombeck 4783711651 Refuse MCP and its OAuth endpoints while the encryption key does not match
apiHandler answers 503 encryption.key_mismatch before auth, but the remote
MCP endpoint and the OAuth authorize, token and register routes wrap in
withBackgroundEvent instead and never checked. An MCP write tool could seal
new rows under the wrong key, and those rows would open under it at the
next boot.

The refusal moves into one helper used by apiHandler, /mcp (same envelope)
and the OAuth routes (RFC 6749 temporarily_unavailable with the error code
as an extension member). A structural test fails when a route outside
apiHandler, or a server action, lacks the refusal in every exported handler
and is not on a short allowlist of routes that never touch the database.
2026-10-03 23:32:07 +02:00
Marc-André Bombeck 2c295bbea8 Probe the oldest stored values before recording an encryption key
The boot key check recorded whatever key was configured whenever its probe
found nothing to try. It only read string columns, so an install whose
ciphertext sat in Bytes columns (notes, documents, ECG, symptoms) read as
empty; a probe that ran out of time did the same; and rows written by a
process that served under a wrong key could open on the next boot and get
that key recorded as the right one.

The probe now reads every registered column, string and Bytes, in all four
stored layouts, and tries the oldest values first, ordered by the row's
updatedAt (else createdAt, else primary key). A key is recorded only when
the oldest value that opens is preceded by at most one that does not, or
when no column holds a single value under the key id. A time-out, an
unreadable table, oversized values or mixed results are inconclusive: the
process serves, records nothing and checks again at the next start.

When the first insert loses a race, the stored record is read back and must
open under this process's key, else the outcome is a mismatch.

A rotation that walked every column without errors removes the record of
each previous key id that no longer holds any value. The refusal log block
and the self-hosting pages explain how to reset the check for a key id.
2026-10-03 23:29:55 +02:00
Marc-André Bombeck 6c262f2099 fix(documents): compare a confirmed lab fact's unit with the shared spelling normaliser 2026-10-03 23:29:13 +02:00
Marc-André Bombeck 510d4b1d04 Write a medication's window and its course in one transaction
The PUT wrote startsOn/endsOn on the row and then rewrote the latest
course in a second transaction, so a failure between the two left the
row and its courses disagreeing. The window now reaches the row only
through the course writer, which writes both under one lock.
2026-10-03 23:29:12 +02:00
Marc-André Bombeck 9f865a9456 Show every course in the dose history
The history minted bands only from the latest course, so misses in an
earlier course counted in the rate and appeared nowhere in the history.
It now mints over the span of every course and keeps the slots on a
course day. The compliance ledger and the cadence missed tally drop the
gap slots before the reconstruction instead of after it, so a dose
logged between courses stays visible as an off-schedule take in both.
2026-10-03 23:24:23 +02:00
Marc-André Bombeck 8aa2e33435 Walk every course in the streak and the cadence timeline
The rate counted misses in earlier courses while the streak and the
cadence chart's missed tally minted only the latest course, so a miss
in a previous course never broke the streak. The cadence engine context
now carries the courses and expands over their span, keeping only course
days, the same rule as the compliance ledger.
2026-10-03 23:16:16 +02:00
Marc-André Bombeck 16c16000e7 Refuse a first course beside a medication taken continuously
A medication with no course rows and no window is running since its
creation; a course added beside it projected the medication into ending,
which stopped its reminders and dropped its history from compliance. The
create now refuses that (medications.course.currentOpen; End course
writes the first course). A window the rows never recorded is
materialised as the first course before the new one is checked.
2026-10-03 23:11:28 +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 1564e7c396 Add the sick note document kind
A certificate of incapacity for work (in Germany the
Arbeitsunfähigkeitsbescheinigung) is now its own document kind,
SICK_NOTE, instead of Other. The enum value rides migration 0370.

Uploads, edits, the kind filter, bulk edits, the import script and the
backup format accept it; the vault files it with a thermometer glyph
between prescriptions and referrals; the filing suggestion can propose
it and names the words a sick note carries. The extraction's own kind
field keeps its three values.
2026-10-03 21:40:01 +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 bcbfd87ae9 feat(coach): name the person's own symptoms in the illness block
The Coach illness block now carries the last 14 days of the person's own
symptoms (name sanitised for the prompt, count, highest intensity, last
occurrence; at most ten, most frequent first), and exists when there are
symptoms but no episode. The prompt describes the field in English and
German. The MCP get_illness_recovery result leaves it out in this
release.
2026-10-03 21:24:28 +02:00
Marc-André Bombeck 9272daa59c feat(symptoms): log a symptom in one step and show it on the illness pages
- A new capture kind "symptom" (illness module, illness write grant) in
  the bottom bar's picker and the dashboard Add menu. The sheet follows
  the measurement and mood forms: the person's symptoms as chips with the
  cycle sheet's dashed "new symptom" chip, the 0-10 slider with its
  anchors, the time prefilled with now, an optional note, and a "during"
  selector only while an illness episode is open.
- "Your symptoms" on the illness page: one line per symptom (how often in
  30 days, how strong at worst, when last), the last 30 days grouped by
  day, edit and delete per entry, rename, icon, hide and delete per
  symptom. Renders nothing until a symptom exists.
- "Symptoms logged" on an episode's page: the entries filed against it or
  logged while it ran, the filed ones marked. This is the reader of the
  episode link.
- Query keys live under the illness root, so either side's writes
  refresh both.
2026-10-03 21:24:12 +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 9d9e1f153c feat(insights): make each person-defined symptom its own correlation channel
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
2026-10-03 21:14:08 +02:00