Commit Graph
906 Commits
Author SHA1 Message Date
Marc-André Bombeck c79fc6cc7b feat: lay down the day view and timeline contract
Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).

Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.

src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.

The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.

The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
2026-10-08 13:03:37 +02:00
Marc-André Bombeck 861ba21086 Merge tickets part 1 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:35:07 +02:00
Marc-André Bombeck 7eb5659ab9 Merge iOS server asks and client work into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/auth.ts
2026-10-08 11:27:42 +02:00
Marc-André Bombeck 920bd19617 Merge managed profile handover into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:21:25 +02:00
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00
Marc-André Bombeck 7bcb6f71d9 Merge environment data into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 10:04:45 +02:00
Marc-André Bombeck 3c98d95a40 feat(environment): air quality, pollen and UV per day, locations sealed at rest
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather:
17 hourly variables folded into local days by fixed rules (means and maxima
only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only
with the midday hours), a value the feed did not cover stored as null, never
as zero. Requests are cut into 90-day chunks, newest first, and chunks before
mid 2022 ask for the eight pollutant and index variables only. An air-quality
failure never costs the weather row; the day stays unfetched and the nightly
gap fill (up to 90 days per account, newest first) asks for it again.

Every Open-Meteo request asks an instance-wide budget kept in Postgres first
(500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing
rule). A refused request is not sent and the run ends as done with
budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED
are honoured; the account switch lives on PATCH /api/environment/preferences.

The home, each dated location period and each stored day keep their coarse
location sealed as one value under healthlog/environment-location/v1. The
writers write the sealed value only, the readers open it and fall back to the
readable columns for rows the free-text encryption backfill has not reached,
and the backfill now seals all three and clears the readable columns. The
backup carries the location readable in a portable file and sealed in a
disaster-recovery file; a restore seals either under the receiving key.

Three correlation channels join the environment family (PM2.5, the ozone
8-hour high, the day's highest pollen count), read through the averaged
same-day window and left empty while air quality is off. The Coach and MCP
gain get_environment: module-gated, clamped to the lookback limit, no
coordinate or place name anywhere, with provenance and the attribution
lines. The settings section gets the switch, the coverage and the newest
day's values; the dashboard shows a quiet chip on a day with high pollen,
a warm night or very poor air. Copernicus and Open-Meteo are credited next
to the values.
2026-10-08 10:02:39 +02:00
Marc-André Bombeck 321ed1241c Merge Health Connect import into release/v1.42.0
# Conflicts:
#	src/lib/jobs/apple-health-import-worker.ts
2026-10-08 09:59:05 +02:00
Marc-André Bombeck 996d2519d1 feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.

The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.

Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
2026-10-08 09:54:42 +02:00
Marc-André Bombeck 370270bbc1 feat(apple-health): ingest RMSSD heart-rate variability from iOS 27
iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
(Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`,
available from 27.0). The live batch and the export.zip import now store it
as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in
milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The
accepted range is 1 to 300 ms.

RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a
day holding it from Apple Health and a strap resolves to one source
instead of a name tiebreak.

SDNN and RMSSD are different statistics and are kept apart once one
account holds both. The HRV page charts each on its own, titled with its
measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section
carries one timeline per measure under `byMeasure` (same section key, so
every reader still finds it) rather than one daily series averaging the
two; the budget pass condenses and sheds each measure's timeline like a
glucose context.
2026-10-08 09:37:04 +02:00
Marc-André Bombeck 2731c46399 feat(workouts): edit a hand-entered workout from its detail page
A workout logged by hand could only be deleted and logged again. The
detail page now offers an edit control beside delete for a MANUAL workout
carrying the form's `manual:` id, in one's own record. It opens the log
sheet on the stored values and saves by re-posting that id through
`POST /api/workouts/batch`, which already applies such a re-post as an
overwrite of that one row. No new route.

The overwrite nulls every overwritable column a re-post leaves out, and
the form has no fields for heart rate, steps, elevation or pauses. The
edit therefore sends those back as stored. A field left as shown sends the
stored value rather than one re-parsed from its display, so saving an
untouched edit keeps the start's seconds and a distance shown in miles at
its exact metres, and answers `duplicate`.

The detail read's `avgHr` / `maxHr` can come from a same-session twin;
the read now also serves the row's own `storedAvgHr` / `storedMaxHr`,
which is what the edit sends back.
2026-10-08 09:28:56 +02:00
Marc-André Bombeck ba99551faa feat(apple-health): show when each type last arrived and how
The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.

The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.

`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.

The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
2026-10-08 09:22:49 +02:00
Marc-André Bombeck 5c23f9ed63 Merge AI operations and error-log discipline into release/v1.42.0 2026-10-08 09:06:58 +02:00
Marc-André Bombeck 27342ee734 fix(ai): name the real cause of failed briefings and stop retrying what cannot heal
The nightly briefing warm completed every night and read "all-failed",
because one run event held every account's annotations and the last
account won. Each candidate now runs in its own wide event and the run
reports every outcome through `reportJobRun`, so ok, dead sign-in,
withheld and failed accounts show side by side.

A dead Codex refresh token threw a bare error with no status, so the chain
read it as a network failure: the health ledger recorded hard_failed, no
"reconnect" hint appeared, and every night tried the same token. The
refresh now throws `CodexRefreshError` with a 401 for a dead grant
(invalid_grant / invalid_refresh_token on a 400 included), as do a missing
or undecryptable stored credential. The ledger records auth_failed, the
chain marks the primary credential expired, and the nightly pass reports
the account as auth_failed without queuing a retry.

A briefing the outbound screen withheld (a causal claim) failed outright,
and the 45-minute provider retry then asked the same question eight times
a night. The generator now makes one bounded repair that names the broken
rule and keeps the repaired payload only if it passes the same parse,
grounding and screen; a withheld outcome queues no retry.

A provider that keeps failing is paused: from five consecutive hard
failures the skip window starts at 30 minutes and doubles per further
failure, capped at a day. A paused provider is walked last and tried again
when the window lifts; one success resumes it. The operator card shows how
many accounts it is paused for and until when. After a 5xx from a
non-canonical OpenAI-compatible endpoint the client asks the free model
listing, and a configured model the endpoint no longer offers is named in
the error, on the hop line and on the card.

Also:
- The tiered context's live day band read `measured_at AT TIME ZONE tz`,
  which treats the stored UTC as local time; a far-from-UTC reader's
  evening readings landed on the next day. It converts from UTC first now.
- Google Health intraday heart rate re-reads two hours per sync instead of
  a day; daily summaries and sleep keep the day (#1023).
- The first filtered daily read that answers 400 and triggers the
  snake_case retry is recorded as a note, not an error (#1023).
- The single derived-score read goes through the derived cache with
  stale-while-revalidate, like the batch beside it.
2026-10-08 09:06:09 +02:00
Marc-André Bombeck 2210f823d6 feat(logging): give failed runs and caught failures their own error lines
A wide event carries one action and the last annotation wins, so a hop
that failed before the next provider answered, one failed account among
six, or a caught error the code carried on past was either a meta key on
someone else's info line or nothing at all. `emitSignal` writes one extra
line under a stable action and level, joined to the surrounding event by
request and trace id; `logCaught` / `caughtAs` are its warn-level shape
for a catch that degrades on purpose.

Every failed pg-boss run now writes `job.run.failed` at error with the
queue and how many runs of it in a row have failed, counted from pg-boss's
own rows (`readConsecutiveRunFailures`). `reportJobRun` takes a fan-out
pass's whole candidate list: an all-failed run is `job.run.failed`, a
partial one `job.run.partial` at warn, and the run reports `all_failed` in
its facts so a completed night with nothing done still counts toward the
streak. The repeated-failure alert keys on that one action and number.

Retention is per queue now. `failureReaderRetention` refuses a queue
retention under 96 hours (the 72-hour reader window plus a day), and a
guard refuses any `deleteAfterSeconds` that is neither that helper nor a
literal at or above the floor.

A refusal the request was meant to get (any error carrying a 4xx status)
is recorded at warn instead of error. Expired-token 401s were most of the
error lines and hid the real failures between them.
2026-10-08 09:05:54 +02:00
Marc-André Bombeck 900a5c4f1f perf: stop keeping folded samples and re-reading duplicates
The nightly folds soft-deleted every raw Apple Health sample they folded
into an hourly or daily stats row and kept it for 75 days. Nothing ever
read those rows as deletions; they only held a re-upload of the sample
off, and they were most of the measurements table and its indexes.

Ingest now refuses such a re-upload directly: a raw sample of a dense or
mean type, old enough to have been folded, whose hour or day a live
stats row covers, is a duplicate with reason folded_window. The batch
route and the Apple Health export import apply the same rule. With that
in place the dense retention and the daily-mean consolidation delete the
raw rows outright, and a new compaction-tombstone-purge job removes the
backlog: 5,000 rows a batch, one account at a time under the restore
lock, a short pause between batches, resumable because the predicate is
idempotent, queued at every boot and continuing itself while it makes
progress. A person's own deletions are never in its predicate. The sync
feed leaves compaction tombstones out and a restore does not write them
back; all three classify through the same function.

The batch route answers an exact re-sent sample without a transaction:
one read over the batch's sample ids, live or deleted, decides what the
reconciler would have decided, at the same point in the loop, so every
status, reason, counter and the cross-source merge stay as they were. A
batch of duplicates only no longer queues PR detection, a reminder sweep
or the detection audit row. Caches are evicted only for a foreground or
manual sync or a batch that wrote a hand-entered reading, and marked
stale otherwise; the targets cache follows the same split.

Also:
- POST /api/admin/maintenance/measurements queues VACUUM (ANALYZE) and a
  REINDEX INDEX CONCURRENTLY per index, largest first, on a connection of
  its own; it refuses while the purge runs, drops invalid leftover copies
  and stops at the first failed rebuild. Runbook in docs/ops.
- The pure volume queues keep their finished jobs for 96 hours instead
  of seven days, never less than the failure window plus a day.
- work_mem is set per app connection from DATABASE_WORK_MEM (16MB), also
  when the statement timeout is disabled.
- The PR-detection fallback tick re-scans only accounts whose readings
  or workouts changed since the previous tick.
- Type discovery for the status refill walks the live-rows index as a
  loose index scan instead of grouping every live row.
- The host metrics chart polls every five minutes, like its sampler.
- The batch route records per-type HealthKit arrivals.
2026-10-08 08:41:25 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck 15b2f6f4de Declare every MCP output field and read live rows in discovery
list_metrics declares availability, the Coach-backed reads declare
searchedWindow and available, and get_intraday_pulse declares min and
max per bucket. An integration test validates every tool's real output
against its advertised schema under a strict 2020-12 validator.

The metric-status discovery rows and the search clinical-signal probe
filter deletedAt, guarded for every measurement read in the MCP and
Coach tool trees.
2026-10-07 22:48:49 +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 c7d659eb11 fix(coach): ask the capability before deferring a memory refresh, and pin test fixtures to the new quiet time 2026-10-06 21:02:41 +02:00
Marc-André Bombeck 11bafd68b5 feat(coach): one quiet status line, reply pills and memory notes in the thread
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.

Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
2026-10-06 20:47:17 +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 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 f46e0960fb Merge remote-tracking branch 'origin/v141/e-memory' into v141/b-loop 2026-10-06 19:39:10 +02:00
Marc-André Bombeck 8e74bc05c8 test(coach): cover the budget, the brake, the trail, questions and comparisons
Unit tests for the turn budget and the no-progress brake, the budgeted
loop (every stop reason, the round-by-round ledger, repeated calls,
questions, comparisons, reasoning and provider state), the trail recorder
and its screen, clarifications through the tool and the brake, the
comparison table and chart, the trail column and its owner-only read, the
reply frames, and the turn's frames end to end through the route. An
integration test proves the trail against a real database. Eval
scenarios cover a multi-round why question, a period comparison with its
chart, a question asked through the tool, and keep the charts the
existing table scenarios already showed.

Take the effective reasoning level from the route rather than resolving
it in the turn; the turn only caps it when the operator pays.
2026-10-06 19:38:57 +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 210becb28b feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
2026-10-06 18:39:50 +02:00
Marc-André Bombeck 63b395915e test: seed cached briefings with their generation moment
The today check now reads briefingGeneratedAt from the cached payload
and treats a payload without it as not from today. The integration
and e2e fixtures that seed a fresh briefing write it, as every real
generation does.
2026-10-05 22:28:10 +02:00
Marc-André Bombeck ac48e0d0ed fix(coach): give a series read its numbers when the shared build cut its block
Inside a chat turn every tool read lands on the turn's shared full-source
snapshot when its window matches. On an account with many synced series
that build passes the prompt budget, and the budget pass collapses blocks
to a bare unit and then to an `{ omitted }` marker. get_metric_series
sliced the marker out and answered `present: true` with it, so the model
was told the readings exist and was given no figure. The same held for
sleep, glucose, compliance and workouts.

- The snapshot result names the blocks the budget pass cut. A tool whose
  block was cut in the shared build reads it again from its own source at
  the same window.
- A read scoped to one source condenses its own blocks last and never
  swaps them for the marker: it keeps a summary (first, last, min, max,
  mean, change) taken before anything is cut, the newest daily values,
  weekly means folded into monthly means, and names each cut.
- get_metric_series never answers present with a block holding no figure;
  that read reports retrieval_failed instead.
- A chat series read carries the readings it counted, so the step shows
  them, and on a long or condensed window a note on what it covers. The
  tool description and a new tool-mode rule tell the model to call
  get_metric_table itself for longer history instead of asking.
- The screen-reader comma between step parts is unselectable, so a copied
  row no longer reads "Weight · ,all time".

MCP output is unchanged: the source-snapshot equality suite stays green.
2026-10-05 21:29:04 +02:00
Marc-André Bombeck 130ace74ee fix(crypto): keep updatedAt when key rotation re-seals a value
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
2026-10-05 13:07:22 +02:00
Marc-André Bombeck 7836610358 fix(coach): move bumped conversation timestamps back to the last message
Earlier rewrites of conversation rows stamped updated_at with the run time,
so the Coach panel listed untouched conversations under Today. Migration
0375 moves updated_at back to the newest message's created_at where it is
more than a minute later. A rename or attachment shortly after the last
turn keeps its stamp, conversations without messages are left alone, and a
second run changes nothing. One set-based statement; the per-conversation
maximum reads from the (conversation_id, created_at) index.
2026-10-05 12:53:40 +02:00
Marc-André Bombeck a6d841d3ae fix(insights): compare a glucose day in progress with the same hours
Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 7d42cf7122 fix(coach): keep updatedAt through storage-only rewrites
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.

Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 359203d09a Merge remote-tracking branch 'origin/feat/coach-lookback-limit' into release/v1.40.2 2026-10-05 07:31:34 +02:00
Marc-André Bombeck 929c0ebaa4 Merge remote-tracking branch 'origin/main' into feat/coach-lookback-limit
# Conflicts:
#	src/lib/ai/coach/snapshot.ts
#	src/lib/ai/coach/tools/executor.ts
#	src/lib/insights/features.ts
2026-10-05 07:31:03 +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 98ac9c3d80 Merge remote-tracking branch 'origin/feat/dashboard-today-overview' into release/v1.40.2 2026-10-05 07:17:17 +02:00
Marc-André Bombeck 4769502a4b fix(backup): check the weekly copy's preview against the schema section by section
Taking the weekly preview from the writer dropped the schema check the
old scanner ran at the end, so a copy that violates the schema was
described as restorable. The collector now checks each section in its
JSON form as the writer shows it, an array section and a bulk table one
element at a time, plus the schema's one whole-document rule (a
canonical copy names every measurement by id). A copy that fails gets
no stored preview, and the preview route reads the copy itself, as it
did before. Nothing is kept beyond one element.

Measured on the preview memory test under --max-old-space-size=524:
held after the producer 3 MB with and without the check, raw peak
heapUsed 293 MB with it (335 MB without). The test now loads the
backup schema before its baseline: a module loaded once per process,
not memory that grows with the record.
2026-10-04 22:17:27 +02:00
Marc-André Bombeck 48ed149ef0 fix(import): leave out a marked Apple Health sample only when this account holds it
A sample carrying the HealthLog origin marker was always dropped from an
export import. After a move to a new instance without a backup, the
export is how the mirrored values come back, and every one of them was
silently left out. A marked sample now counts as written by HealthLog
only when its HKExternalUUID names a row of this account of the same
kind, deleted rows included; a marked cycle sample only when the
account holds a day-log on its day. Every other marked sample is
imported, and still meets the manual-value match. A correlation's own
HKExternalUUID now covers records that carry none.
2026-10-04 22:15:48 +02:00
Marc-André Bombeck 090e3d9a7e perf(measurements): weigh pulse days with window functions over one scan
The day weight joined its source to an hour grouping of itself. With
stale or missing planner statistics, as after a new account imports a
year of pulse below the autoanalyze threshold, the planner ran that
join as a nested loop and the all-time pulse aggregate ran into the
60 s statement timeout. The weight is now two window passes over one
scan: the readings in a row's hour, and one marker per hour summed over
the day for the hour count. The weights are unchanged.

A new integration test seeds over 100k pulse rows with autovacuum off
and no ANALYZE, bounds the all-time reader and a zoned per-source mean
well under the timeout, and compares every row's weight against the
earlier self-join after ANALYZE. The ANALYZE the metric-series snapshot
test needed for the same trap is gone.
2026-10-04 22:14:14 +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 393ac955f6 Make the Coach lookback a limit on every read
The saved Coach window only seeded the snapshot and each tool's default.
A tool could still read back to all time, the availability probe reported
counts and means for history outside the window, and several blocks read
fixed windows of their own (labs 12 months, correlations 180 days, the
coarse tail, the all-time extremes) whatever the setting said.

coachPrefs.defaultWindow is now enforced as a limit through one module,
history-reach.ts: the turn context clamps the client window, the snapshot
clamps its window and gives every fixed-window block either the shorter
window or, when the block's own window cannot fit, leaves it out. Every
retrieval tool clamps its window, an earlier period or a stored table that
reaches past the limit answers outside_reach, and the probe reads only
inside the limit and names older history as outside_reach without any
figure. Follow-up chips that would read past the limit are not offered.
The default (allTime) is no limit and reads as before. MCP clients pass no
limit and keep their own window arguments.

Adds the twelve-month option, rewrites the picker text to state what is
read with the numbers the readers use, shows the same options in the
managed-profile settings, counts cluster members with a singular, and
drops the opacity on switched-off rows that put their text below WCAG AA.
2026-10-04 20:45:21 +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 016278ba24 test(mcp): analyze the seeded rows before the metric-series golden test
The first case spent 14 s per full-snapshot build in the all-time pulse
aggregate, planned against a table with no statistics, until autovacuum
analyzed it; on a loaded runner that took it past its 60 s budget. Analyze
measurements and rollups after seeding, as a production table would be.
The cases and the comparison they make are unchanged.
2026-10-04 19:21:01 +02:00
Marc-André Bombeck 13ea849188 perf(backup): load the backup payload schema on first use
backupPayloadSchema is built when validations/backup.ts is evaluated and
holds about 12 MB of heap. The restore job, the stored-backup readers and
the export writer import that module, and they sit on the boot path, so
every server process built the schema at start whether or not it ever
touched a backup.

The schema version, the compatibility check and the per-section summary
move to validations/backup-summary.ts, which never builds the schema. The
call sites that validate a file (upload, restore, the summary and download
routes, and the streamed reader's element check) load backup.ts with a
dynamic import at that moment; everything else takes only types. The
OpenAPI registry keeps its static import, since only the spec generator
loads it.

After warm-up on a production build under --max-old-space-size=524, the
used heap drops by about 29 MB, and the schema no longer appears in a heap
snapshot. A structural test refuses a new static value import of the
module outside the registry.
2026-10-04 18:23:14 +02:00
Marc-André Bombeck 546e67475f perf(mcp): build single-metric reads from the metric's own rows
get_metric_series (and get_metrics, the metric resources and the review
prompts, which fan out over it) answered with one section of a Coach
snapshot, but built the whole snapshot to get it: the all-type feature
extraction, the derived wellness layer, trajectory, correlations, memory,
GLP-1, cycle, illness, labs and visits. On a seeded account with about
215k measurements a resting heart rate read ran 208 SQL statements and
blocked the event loop for 175 ms to return under 2 KB.

The MCP surface now asks the executor for a source snapshot. It runs the
same prelude (prefs, module gates, admitted sources, cutoffs, now shared in
snapshot-prelude.ts) and the same block builders over the requested
source's reads only. The core clinical aggregates come from extractFeatures
scoped to the one block, whose bulk read keeps its all-type cap so the rows
a block sees do not change. A section large enough for the full builder's
prompt-budget pass to trim, and the environment cluster, go to the full
builder, so their results are the old ones by construction.

The integration test compares the tool's serialised result with the full
builder's section and grounding for nine sources over four windows, the
get_metrics batch and the metric resource, on a seeded account dense enough
to put the feature row cap into play.
2026-10-04 18:17:24 +02:00
Marc-André Bombeck 1cb50ed21d Merge pull request #1144 from MBombeck/fix/restore-key-probe
fix(backup): restore opens legacy values, drill checks every account, backups hold less memory
2026-10-04 17:44:57 +02:00
Marc-André Bombeck 72ca843fd5 Merge pull request #1147 from MBombeck/perf/dashboard-server
perf(server): one Prisma client, indexed coverage probe, memoised timezone offsets
2026-10-04 17:33:57 +02:00