Commit Graph
543 Commits
Author SHA1 Message Date
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 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 dbbff339f6 test(symptoms): register the new surfaces with the export, destructive and success guards
The full-backup route tests mock the symptom definition read the payload
now makes; the destructive-control registry names the symptom hooks (an
entry or a whole symptom, confirmed by an AlertDialog); the symptom quick
entry's save toast is pinned beside the measurement and mood forms it
mirrors.
2026-10-03 21:38:03 +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
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
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 9ecdda6358 fix(dates): show a stated calendar date as that date east of UTC+12
Day keys anchored at noon UTC went through the zone-aware
dateShortSmart, which shows them as the next day in UTC+12..+14. A
dateShortSmartCalendar formatter reads the key in UTC with the same
year rule, and the predictions panel, the illness timeline, the
intraday pulse day label, the nutrient chart and the coach result
chart use it. The lab chart dates a date-only reading by its calendar
date and a timed one in the reader's zone, as the lab lists do. The
timezone formatter guard now fails on a noon anchor handed to a
zone-aware formatter.
2026-10-03 18:44:06 +02:00
Marc-André Bombeck 51e2d68a88 i18n(measurements): say how a duration entry was read
Refs #1101
2026-10-03 18:35:26 +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 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 0f6669847c Merge remote-tracking branch 'origin/main' into fix/ai-timeout-everywhere 2026-10-02 23:59:38 +02:00
Marc-André Bombeck 6207101006 test(ai): guard that every model call runs under the response-timeout setting
Pins the four ways the setting could be skipped again: a wire client
reading params.timeoutMs itself instead of callTimeoutMs, an exported
resolver in provider.ts returning a provider it did not bind, a client
constructed outside provider.ts (the offline evaluation harness is the
named exception), and a surface ceiling not listed with its reason.
Each matcher asserts a non-zero match count.

Refs #1090
2026-10-02 23:03:45 +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 75361d0666 fix(glucose): state band edges in the reader's glucose unit
The time-in-range labels in the doctor report, the target caption on
the glucose panel, the glucose page description and the fasting
explainer printed fixed mg/dL figures, so a reader on mmol/L saw
"Low (< 70 mg/dL)" beside values in mmol/L.

The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`,
`GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints,
the analytics engine counts its bands from the same constants, and
every sentence takes the band edges and unit as parameters through
`glucoseBandParams`, formatted with the reader's number format.

The unit-preference guard now also reads the locale bundles: a string
that names mg/dL or mmol/L fails unless it is allowlisted with a
reason.

Refs #1067
2026-10-01 22:07:07 +02:00
Marc-André Bombeck 661378bfa9 Merge remote-tracking branch 'origin/main' into fix/v1397-units2 2026-10-01 13:47:10 +02:00
Marc-André Bombeck 615f5a8070 test(units): sweep the AI and Coach producers for unit literals
The unit sweep covered the display trees and the insight library; the
prompt and snapshot builders, the text-writing jobs and the target and
digest producers were outside it, which is where a band in mg/dL, a
threshold in kg/week and a distance in metres reached the model. The
sweep now walks those trees too and matches prose units ("kg/week",
"5 metres") as well as symbols. The four allowlist entries for prompts
that stated weight in kilograms are gone with the literals; the new
entries each name why the literal is not a reader-facing unit.

A line scanner cannot see a canonical number printed with no unit at
all, so the guard also names every producer that carries a
preference-dependent figure into a prompt, a snapshot or a stored
sentence and requires each to call a reader-unit resolver. The canonical
weight signal of the day loses its kilogram label; the reader's unit is
attached where the signal is converted.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 0028290117 fix(cycle): read basal temperature and the unit-bearing chart titles in the reader's units
The basal body temperature field, its chart and its tooltip were fixed
to degrees Celsius whatever the reader had chosen: the placeholder said
°C, the field accepted only 30-45, the chart plotted Celsius and the
tooltip printed "{temp} °C". Two chart titles carried a fixed unit as
well ("Wrist temperature (°C)", "Walking speed (km/h)") above an axis
already in the reader's unit.

The field now reads and writes in the reader's unit and stores Celsius;
a reading the person did not touch is saved back unchanged rather than
through a round trip of rounding. The chart plots, pads and labels in
the reader's unit, the two titles drop the unit the axis already
names, and the cycle contrast's fixed kg, °C and mg/dL labels, which
the transform had already replaced, leave the bundles.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck d731a1b8fe feat(backup): classify every User column and carry the account settings
Every column of the account row now has a backup verdict in
USER_COLUMN_BACKUP_CLASS: SETTING, CREDENTIAL, IDENTITY or OPERATIONAL. A
schema-derived guard fails on an unclassified column, and the setting columns
each get a codec in account-settings-backup.ts, so a column classified as a
setting without a way to travel does not compile.

The restore end checks each value the way the settings route that writes it
does: AI endpoints against this host's private-origin policy, languages, units
and time zones against the known vocabularies, threshold bands against their
bounds, the avatar against the upload's sniff and limits. A refused value is
left out and named; credentials, identity and the host's own cursors are never
admitted.
2026-10-01 13:04:36 +02:00
Marc-André Bombeck 383323b542 Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:29:08 +02:00
Marc-André Bombeck 03eced171e Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:25:06 +02:00
Marc-André Bombeck ada490c7e9 Merge remote-tracking branch 'origin/main' into fix/v1397-tz 2026-10-01 10:59:51 +02:00
Marc-André Bombeck f3bf65fb4e test: fail any structural guard that cannot fail on an empty sweep
guard-floor-guard reads every *-guard(s).test file under src/ and every
test that walks a directory, parses it with the TypeScript compiler, and
fails unless it asserts a floor on what it found: requireFloor,
scanSourceMatches, toBeGreaterThan, an exact non-zero count, or an exact
non-empty set. A floor on the walk alone does not count; the Bearer-scope
guard had one and still matched nothing for weeks.

Files that are not scanners (unit tests of runtime functions named
"guard", fixed file lists with a positive match per file, temp-directory
listings) are named with a reason. An exemption that goes stale fails the
run. The reader's own shapes are pinned by probe sources, including the
ones it must not count: comments, strings, .not, zero floors, it.skip.

It answers per file, not per matcher, and says so.
2026-10-01 10:52:48 +02:00
Marc-André Bombeck b4577f5c31 test: give every scanning guard a floor on what it found
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.

- requireFloor(label, found, floor) throws below the floor and refuses a
  floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
  and floors both the files read and the hits found.

Floors added, pinned at the current counts:

- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
  (399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
  are equal.

The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
2026-10-01 10:52:48 +02:00
Marc-André Bombeck 27ac4c1d63 Merge origin/main into fix/v1397-tz 2026-10-01 10:39:26 +02:00
Marc-André Bombeck da8cc7a6ee Merge remote-tracking branch 'origin/main' into fix/v1397-derive 2026-10-01 10:34:17 +02:00
Marc-André Bombeck 533b75fdcf Merge remote-tracking branch 'origin/main' into fix/v1397-derive 2026-10-01 10:30:10 +02:00
Marc-André Bombeck 4a012be37e Merge branch 'main' into fix/v1397-units 2026-10-01 10:12:47 +02:00
Marc-André Bombeck 414b099972 test(guard): freeze client reads of server-resolved fields
A per-field registry of the answers the server resolves for the record on
screen (the blood-pressure target band, intakeActionable and courseStatus,
the AI capability, the module surfaces) and the ingredient shapes that
rebuild them in the browser: getBpTargets() on the viewer's age, a dose
offered from active and trackIntake, course state from endsOn against the
clock, the raw disableCoach column, server resolvers imported into client
code. A match in client code fails unless the file is allowlisted with the
reason it is not a rebuild; the switches that edit a stored value are the
allowlisted readers.

The walk carries a floor, every pattern is checked against a sample of the
shape it catches, a stale allowlist entry fails, and the total match count
must be non-zero. Putting back each of the three rebuilds this release
removed fails the guard.
2026-10-01 10:09:28 +02:00
Marc-André Bombeck 8821fdeb54 test(tz): run core day paths with the host at both edges of the clock
The suite and the production image both sit near UTC, so a UTC day and a
local day agree for most of every day and day-boundary defects kept
passing. The harness sets the host zone in code to Los Angeles and to
Kiritimati, gives the account the same zone, and checks a schedule slot,
a course's last day, a mood entry, the dashboard summary's mood instant
and a document date round trip at the instant where the local and the
UTC day differ. Unit tests cover the new date-only and key helpers.
2026-10-01 10:02:50 +02:00
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00
Marc-André Bombeck 1934b97663 test(guard): declare every queue's runtime and hold long ones to it
Every queue bound with createAndWork is now declared in
src/lib/jobs/queue-runtime.ts as short (cannot come near the default
fifteen minutes, with the reason), budgeted (stops itself at three
quarters of the default and resumes) or long (an explicit expiry, a
stop on the job's budget, and a lock or claim per identity; or a
declared one-shot with no retries).

The guard reads every binding, every boss.send and every schedule tuple
in the tree: each names a queue in the table, every entry is bound, a
long queue's sends and schedules all carry its expiry, its handler
stops on jobBudget / jobDeadline where declared and holds lockedPass or
the named marker, and a short or budgeted queue sets no expiry of its
own. Floors pin the counts (112 bindings, 64 sends, 74 schedules), and
planted long queues missing each protection in turn are caught. Run
against the tree before the previous commit it reports 59 findings;
dropping lockedPass and the cron expiry from pr-detection reports both.
2026-10-01 09:24:15 +02:00
Marc-André Bombeck 9a282ed698 fix(units): keep converted values on the rounding helpers
The narrative, the workout distance and the elevation chart converted
through the unrounded escape hatch, and the trend caption scaled its
noise floor off the raw factor. They now take the rounded helpers, so a
narrated pound or mmol/L figure reads at the grain every screen shows
it, and the caption's floors are declared canonically (the distance
floor in metres). The trends row joins the chart-feeding surfaces that
may hand a raw scale to a chart.

Refs #1067
2026-10-01 09:17:44 +02:00
Marc-André Bombeck b788b5b8b2 test(units): sweep every display tree for unit literals
The unit-preference guard covered the dashboard, the insights pages and
the target surfaces, and the class kept turning up beside them. It now
also walks src/components, src/app, src/lib/insights and the doctor
report and export modules for the glucose units anywhere in code and
for kg, km and °C in display strings, with comments stripped. Each file
that legitimately carries one is listed with its exact line budget and
the reason: the conversion and the pickers, input parsing, display-kind
tags, canonical wire and registry units, the SI policy for clinician
artefacts, and four prompt texts still to move. The sweep asserts a
match floor, so a broken walk cannot pass as a clean tree.

Refs #1067
2026-10-01 09:10:17 +02:00
Marc-André Bombeck 0f31fd199b test(guard): police dense measurement reads in request handlers and raw SQL
The dense-read guard walked the worker and insight directories only,
and the same class kept turning up beside them: the targets page, the
cycle insights, the per-kind series and the doctor report all lived in
app/api or a helper next to it.

The findMany check now also covers app/api, import, export, the targets
and the doctor report. It reads the type from the where object rather
than the first type key in the call, accepts a list of sparse literals,
and counts a key-bounded where (an id, externalId or measuredAt list,
or an exact instant) as bounded. Every remaining unbounded read is
listed with its reason.

A second check covers every raw statement in the tree that reads from
measurements: it must GROUP BY, use DISTINCT or LIMIT, or select
aggregates only, or be listed. Both checks carry floors and run their
matchers over a planted offender. Restoring the old targets builder
fails the findMany check on the year of glucose and the heart-rate
month; dropping the GROUP BY from the resting-pulse fold fails the SQL
check.
2026-10-01 09:02:23 +02:00
Marc-André Bombeck e7166cb3c6 feat(tokens): mint a workouts:write token for headless workout ingest
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.

Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.

- POST /api/tokens/workouts mints it, mirroring the measurement and
  document mints: cookie session plus a fresh proof, a literal permission
  array, 10 mints a minute, at most 10 live tokens (409
  tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
  behave as before. A scoped caller's workouts are stored as EXTERNAL,
  resolved from the credential, and a batch in which any entry names a
  source is refused 422 (workout.batch.source_not_permitted), the same
  rule the measurement batch applies. EXTERNAL is on no source ladder, so
  when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
  and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
  routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
  error codes; docs/api/README.md lists the workout and document mints.

Refs #1054
2026-09-30 22:21:46 +02:00
Marc-André Bombeck add6d1fadc fix(restore): order the restore and the consolidation passes by one lock
A restore on a freshly booted instance failed its clearing step with a
deadlock (40P01) while the boot-time step consolidation was folding the
same account. A consolidation day updates or mints the day's total and
then soft-deletes the day's per-sample rows; the restore deletes every
reading of the account in one statement. When the restore reached a
day's samples before its total, each side held what the other needed
next.

The restore already took an advisory lock per account before its first
delete. Every day transaction of the passes built on runConsolidation
(step and mean consolidation, the per-sample cumulative drain, the dense
intraday retention) now takes the same lock in shared mode as its first
statement. A restore that starts mid-day waits for that day to commit;
a day that would start while a restore runs is refused at once, and the
pass leaves that account for its next run instead of folding rows the
restore is about to replace. The passes still share the lock with each
other.

A new integration test builds the lock order that deadlocked, with a
row trigger holding the consolidation on the day's total while the
restore starts; before this change the consolidation was cancelled with
a deadlock. A guard test requires every transaction in a pass on
runConsolidation to open with the shared lock.

Refs #1031
2026-09-28 00:34:11 +02:00
Marc-André Bombeck 6f37cbdb23 fix(backup): carry each medication's category through backup and restore
The clinical category a person picks for a medication lives in a side table
created at runtime, outside the schema. The backup builder and the restore
only walk schema models, so no backup ever carried it: after a restore every
medication read as "Other".

Both backup formats now carry the category inside its medication, and the
restore writes it back in the same transaction as the medication. A file
from before this change restores as before; a category this server does not
know reads as Other instead of refusing the file.

The backup plan gains a list for tables created with raw SQL, and a check
that every such table in the source either maps to a schema model or has a
verdict there, so the next one cannot drop out of backups unnoticed.
2026-09-27 14:17:42 +02:00
Marc-André Bombeck 8342d2337b test(coach): register the live scenario run with the egress and env guards
run-case.ts now calls a provider for the live scenario run, and judge.ts
reads COACH_EVAL_SCENARIO_REPEATS. Both belong to the offline evaluation
harness, like the judge and its model knobs.
2026-09-27 13:11:52 +02:00
Marc-André Bombeck 02801bdfda Merge branch 'fix/restore-large-account-clearing' into release/v1.39.4 2026-09-27 12:14:04 +02:00
Marc-André Bombeck 47efdd6606 feat(coach): result table with copy for a spreadsheet or as text
The table shows its caption, scoped headers, right-aligned tabular
numbers and a dash for a missing reading, twelve rows before Show all.
Copy writes TSV and an HTML table together, falling back to TSV, with
formula starts neutralised and separators flattened inside cells.
2026-09-27 11:54:31 +02:00
Marc-André Bombeck 93b6c086b4 fix(restore): let a large restore outlast the request statement timeout
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).

Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.

The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.
2026-09-27 11:24:41 +02:00
Marc-André Bombeck cde7af3cf8 feat(coach): wire contract for steps, result tables, method, follow-ups and clarification
Adds the TypeScript types for the four additive stream frames and their
provenance fields, a Zod mirror for the published document held equal to
the types by a type test, and a guard that no newer frame reuses a key the
shipped native client decodes. The chat request accepts followUp and
clarification, inert for now.
2026-09-27 10:33:13 +02:00
Marc-André Bombeck ed05772a8e test(e2e): run the delegated-writes journey and fail on silent skips
The delegated-writes spec checked for the invitation form's level control
right after page load, before the form had painted, found nothing and
skipped every test on every run, CI included.

- Give the journey its own account pair, seeded and reset in global setup,
  so it no longer invites the same pair as the account-sharing journey
  (a second live grant between two accounts is refused with 409).
- Wait for the form through its data-slot and fail when the WRITE level
  control is missing instead of skipping.
- Revoke the journey's grant in afterAll.
- Keep it out of the mobile project in the config rather than through a
  runtime skip.

Add two checks so a skip cannot hide a spec again:

- e2e-conditional-skip-guard.test.ts requires every conditional
  test.skip/test.fixme in e2e/ to carry a reason and refuses a bare
  test.skip().
- scripts/check-e2e-skipped-specs.mjs reads the Playwright JSON reports of
  both shards in the e2e gate job and fails when a spec file skipped all of
  its tests across every project, apart from an allowlist with reasons.
2026-09-27 04:48:08 +02:00
Marc-André Bombeck 357b742637 test(sharing): follow the invite form's reset through the re-proof flow 2026-09-27 00:14:32 +02:00
Marc-André Bombeck 7d05f68f23 fix(http): say when proxy headers go unread, and trust X-Real-IP only at one hop
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every
client address resolves to nothing and all anonymous sign-ins share one
rate-limit bucket, with nothing in the log to say why. The first request
that carries a forwarding header the setting does not let HealthLog read
now logs one warning naming the header and the setting to change.

X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a
single address, so it is now read only with TRUST_PROXY_HOPS=1; with two
or more hops the chain the count describes is X-Forwarded-For.

The upload, import and native batch routes that bypass the proxy now carry
Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every
other response, and so do the proxy's early exits.

The env examples described an unset TRUST_PROXY_HOPS as right for a
directly exposed host; unset means one hop, and that host wants 0.
2026-09-26 22:45:51 +02:00
Marc-André Bombeck 995bd2385a fix(auth): wrong passwords from elsewhere can no longer lock the owner out
The per-account password limit refused every attempt once ten failures
had landed in fifteen minutes, counted before the password was checked.
Ten guesses from two addresses therefore locked a password-only account,
the app's password sign-in included, and repeating them kept it locked
for as long as anyone cared to.

A place the account has signed in from before is now neither counted nor
held back: a remembered browser, a phone presenting the device id a login
of this account was issued to, or an address a session or device login of
this account came from in the last thirty days. Anywhere else gets five
free failures a day and then a wait that doubles with each failure, from
thirty seconds to at most fifteen minutes, instead of a wall. The per-IP
limit is unchanged, and an unknown identifier is limited the same way on
its hash.

Under an SSO-only policy, a password sign-in to an account other than the
one a pending link names is now refused before the password is checked,
so that exception no longer tells right passwords from wrong ones.
2026-09-26 22:42:40 +02:00