Commit Graph
1521 Commits
Author SHA1 Message Date
Marc-André Bombeck 0cc73d3c88 fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.

A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.

The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck a7682a9fed fix(measurements): name the batch validation refusal and the band an entry broke
The validation 422 from `POST /api/measurements/batch` now carries
`meta.errorCode: "measurement.batch.invalid"`, beside the existing
`too_large` and `source_not_permitted` codes, so a client can file the
rows as permanently refused instead of holding its anchor on a guess.

A `value_out_of_range` entry now also carries `convertedValue`, the
reading after conversion into the stored unit, and `range`
(`min`, `max`, `unit`), the band it was checked against. Both appear
only on that skip. A fraction sent where a percent was expected is now
visible in the response instead of looking like a sensor glitch.

Status codes, the envelope and every existing field are unchanged.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck d81518b5bf Merge branch 'fix/large-account-jobs' into release/v1.39.1 2026-09-25 06:49:16 +02:00
Marc-André Bombeck 237acc0002 fix(backup): export, upload and restore a large account as streams
An account of 1.25 million measurements could not export its data or be
restored from any backup (#1031). Measured in a 1 GB container:

- The full export and the passphrase export built the whole payload and
  its JSON string in memory; the app ran out of heap and restarted.
- The weekly backup's JSON is 662 MB, longer than any string V8 can hold,
  so `unpackBackupBlob` threw on every read. Restore, download and the
  restore preview all answered "could not be decrypted".
- Upload accepted 10 MB, a fraction of a large export.

Every reader and writer of a backup file now streams it:

- `scanBackupJson` reads a backup's bytes once, keeps every section but
  the named bulk arrays, and hands their elements out one at a time.
  `readStreamedBackup` uses it twice: a first pass that validates each
  measurement against the element schema and keeps the rest of the file,
  and a second pass that hands the measurements to the writer in batches.
  Stored copies and off-host objects are authenticated whole on their
  compressed ciphertext before any plaintext is read; only the
  decompression is streamed.
- Restore writes the measurements with one `INSERT … FROM unnest` per
  batch. Prisma caches a query plan per `createMany` row count, and one
  1 000-row plan kept about 145 MB alive, which alone put the restore past
  the heap limit. The transaction budget grows with the measurement count.
  The awards and ECG sections are handed the ids they reference that were
  written, not every written id.
- The export routes write the file into the response as it is produced.
  The passphrase export spools its ciphertext to a temporary file because
  the HLX1 format puts the tag first; the format is unchanged, so existing
  archives open as before.
- Upload accepts the raw body, JSON or gzip, up to the 512 MB request
  ceiling, checks it as it arrives and stores it in the same pass; the admin
  page compresses the file in the browser before sending it. Multipart stays
  for small files.
- Download streams the stored JSON; the preview counts measurements from
  the stream. The monthly restore drill and `scripts/restore-backup.ts`
  read the off-host object the same way; the script can write `.json.gz`.

On the seeded account in a 1 GB container: export 25 s at 389 MB peak,
passphrase export 25 s, both uploads 19 s, restore of the weekly backup
94 s at 554 MB peak, and export, upload and restore round-trip to exactly
the same rows. The encrypted-column guard now also recognises the raw SQL
statement that writes `DataBackup.data`.
2026-09-25 02:05:19 +02:00
Marc-André Bombeck fe7cccfd46 fix(medications): attribute doses to slots from before the medication existed
A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its
creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards
on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00
row with no time appeared as off-schedule too.

The history route minted slots only from the creation instant, so the 14:00
slot never existed for the dose to land on, while the write path, which
mints the whole day, had filed it there. The 09:00 row was the pending
placeholder the today projector mints for every slot of the day, which the
auto-miss pass stamped a day later; with no slot to bind to it surfaced as
an off-schedule row.

The ledger now mints slots over the requested window and takes the
creation instant as the floor of expected slots: a slot before it appears
only when a recorded dose claims it (a take by its window or its pin, or a
skip), and a pending or auto-missed placeholder on it is dropped. The first
archived schedule era also covers the stretch before the creation, so an
edited schedule behaves the same.

Every adherence surface tallies the same ledger with the same floor, so the
detail rates, the calendar and the ledger bar agree on that day. The doctor
report, the Coach and the adherence chips previously read the slots before
a medication existed as missed whenever their window started earlier; they
now read nothing there.

Refs #1028
2026-09-24 19:40:27 +02:00
Marc-André Bombeck 8a09adc1c0 fix(cycle): answer a delete of an already deleted cycle without moving anything
The cycle delete now runs the shared removal, which finds live starts only.
A second delete of the same cycle read nothing to remove; the route now says
so explicitly and answers 204 before the transaction, and the tombstone the
first delete left stays as it was.
2026-09-24 12:43:41 +02:00
Marc-André Bombeck e822154936 fix(cycle): give back a start that a mis-tapped earlier start folded in
A start logged within one period before an existing start folds that start
into itself and soft-deletes it, since the two are one period entered out of
order. Nothing recorded which start was folded, so taking the earlier start
back could not bring the later one back: a mis-tap on the 5th with a real
start on the 10th, then deleted, left the 10th gone and the cycle before it
reopened.

Migration 0345 adds menstrual_cycles.absorbed_into_id. The folded row names
the start that absorbed it; removing that start restores it, re-derives its
own end and hands its days back to it. The column is needed because a
soft-deleted row alone cannot tell a folded start from one the person
deleted, and the audit trail is not state a removal can read. Deleting a
cycle directly now runs the same shared removal as deleting its day-log.
2026-09-24 12:30:39 +02:00
Marc-André Bombeck f28153c832 fix(tz): refuse bare UTC offsets as a timezone
The runtime accepts "+05:30" as a zone id, so isValidTimezone let it through,
but Postgres reads a bare offset in AT TIME ZONE with the POSIX sign (east
of UTC is negative), and the day-grain SQL aggregates would cut every day on
the wrong side of UTC.

isValidTimezone now accepts named zones only: whatever the runtime resolves
to a bare offset is refused, while named zones, their aliases, UTC and the
Etc/GMT family stay accepted. Every write that already validated through it
refuses offsets now, and the screener and mood linked-context request schemas
validate their zone too. A value stored before this change can still sit in
the database, so the SQL call sites that read the stored zone directly (the
dashboard summary, achievements, the doctor report, the tiered AI context)
fall back rather than splice it, as the resolver and the day aggregates
already did.
2026-09-24 12:23:00 +02:00
Marc-André Bombeck 091e4b84cb fix(backup): fold a pre-0343 Coach availability key on instance restore
Migration 0343 moved the operator's Coach answer from module availability
into the Coach switch and folded every live row. Restoring the instance
settings from an older file wrote the dead coach key back into module
availability and left the Coach switch on, so a Coach the operator had off
came back.

The restore now applies the same fold: coach false in the file's module
availability switches the Coach off, and the key is dropped either way.
2026-09-24 12:18:00 +02:00
Marc-André Bombeck 6e45acfbc6 feat(coach): keep stored reminders readable and erasable with the Coach unavailable
Reminders are the person's own record, like the stored facts,
conversations and plans. The list and delete routes no longer ask the
Coach module; creating, confirming, resolving or rescheduling one stays
Coach use and is refused by POST and PATCH as before.

Settings -> Coach now always mounts the reminders card (shown with Hide
Coach on only when reminders exist), and Settings -> AI lists them through
the stored-memory block while Settings -> Coach is out of reach. With the
Coach unavailable the card is read-only: every reminder can be read and
deleted, while keep / done / dismiss are not offered.
2026-09-24 11:57:42 +02:00
Marc-André Bombeck 5e5d33ecf8 feat(coach): keep stored plans readable and erasable with the Coach unavailable
Plans are the person's own record, like the stored facts and
conversations. The list and delete routes no longer ask the Coach
module, and the plans page turns read-only instead of redirecting to
Insights: every plan can be read and removed, while confirming or moving
one stays Coach use and is refused by the PATCH route as before.
2026-09-24 11:48:15 +02:00
Marc-André Bombeck 00f6eb6dcb test(measurements): mock the status re-warm where the mutation tail imports it
The bulk-delete, restore and by-external-ids suites mocked
`invalidateStatusInsightsForTypes` on the comprehensive-generate barrel,
while the shared post-mutation tail imports it from status-invalidation.
The mock was never called, the real re-warm ran the capability loader
against a stub database, and the "no invalidate" assertion could not
fail. The mock now sits on the imported module; the positive cases
assert the re-warm with the distinct types, the zero-row cases assert
its absence.
2026-09-24 10:52:04 +02:00
Marc-André Bombeck 2c4dec08f1 test(ai): answer both switch readers from one source in flag mocks
Eight files mocked `@/lib/feature-flags` with `getAssistantFlags` alone.
The capability loader reads `loadAssistantSwitches`, so wherever it ran
it threw and every capability resolved to `check_failed`. A shared
factory now points both readers at the function the test drives.

Only the config-change chain reached the loader. Its fixture means AI
switched off by the operator; it now supplies the loader's other inputs
and asserts `operator_disabled` on the digest, so a starved loader turns
it red instead of passing on the failure path.
2026-09-24 10:52:03 +02:00
Marc-André Bombeck 9c7ddc13b3 Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0
# Conflicts:
#	src/app/api/insights/correlations/__tests__/route.test.ts
#	src/app/api/insights/correlations/route.ts
2026-09-24 10:26:56 +02:00
Marc-André Bombeck 2143445da2 feat(ai): show stored model text inside a shared record as the owner would
Serving stored text and starting model work are separate questions. A
delegate may not start model work on somebody else's record, but reads
the owner's stored briefing, period narrative, status notes, workout
notes and Coach unread signal exactly when the owner would: those
readers now resolve the record's own capability state, not the
viewer's. Generation, warms and enqueues still ask as the viewer, so a
delegate's read of a stale narrative queues nothing. A hidden
model-written narrative is no longer overwritten by a deterministic
warm while AI is unavailable.

The sharing front door reads the digest's briefing lead again, and an
integration case pins both halves.

Refs #975
2026-09-24 10:17:00 +02:00
Marc-André Bombeck c509afeb2e Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
2026-09-24 10:16:53 +02:00
Marc-André Bombeck ab0d1b4b71 Merge branch 'feat/module-surfaces' into release/v1.39.0 2026-09-24 10:10:11 +02:00
Marc-André Bombeck 8ae0f75ecd fix(insights): leave switched-off modules out of the correlation statistics
The correlation scan still paired mood, sleep, glucose, adherence,
symptoms and weather with everything else after their modules were
switched off. The discovery matrix now drops every channel whose module
is off, read through the surface map, and the correlations route passes
the record's module map, skips the lab draws with labs off, and keys its
cache on the switched-off set.
2026-09-24 09:54:25 +02:00
Marc-André Bombeck e89f41710a Merge branch 'feat/v139-targets-and-links' into release/v1.39.0 2026-09-24 09:53:41 +02:00
Marc-André Bombeck 15d6251ad2 feat(vaccinations): show the planned booster's renewal state on the log
Whether a vaccination is still current was known only on the checkups
page. The vaccination log now shows it on each antigen's heading:
the booster the person planned when they logged a dose, marked as
overdue, due soon (within 30 days) or scheduled, tinted only in the
first two cases.

The state is resolved on the server from the person's own confirmed
booster reminder and published on the list response as renewals, so
the web page and the native client read the same answer. Nothing is
worked out from age, history or the catalogue: an antigen without a
planned booster shows no state. Renewals are null for a grant that does
not reach the measurements section the reminders live in.

Refs #1005
2026-09-24 09:44:51 +02:00
Marc-André Bombeck b480ac4543 feat(documents): link a document to visits and vaccinations from its own sheet
A document could show its visits read-only and its vaccinations not at
all, so a childhood record covering a dozen doses had to be attached
from a dozen dose forms. The document's detail sheet now lists the
visits and vaccinations it is filed against and, for someone who may
manage the vault, links and unlinks them in one place, with the records
dated near the document on top. Both ends write the same link tables
through the link service, so the dose's form and the document's sheet
cannot disagree about what is filed where.

The document detail response carries the linked doses, named by
catalogue slug or the person's own wording. It is null rather than
empty for a grant that opens the vault but not the health background,
so a shared page never claims to record no doses. The update route
takes a replace-set of vaccination ids, up to 100 so one record can
cover a whole primary schedule; ownership is re-checked, a foreign id
answers 404 and the change lands in the audit log beside the other link
fields. Linking from the dose suggestion now refreshes the sheet.

Refs #1024, #1005
2026-09-24 09:39:27 +02:00
Marc-André Bombeck 1f6a13e2f7 feat(ai): answer the status notes with a null note when AI is unavailable
The status family (the five metric cards, medication compliance, the
generic metric status and the biomarker assessment) asked the retired
switch and the `insights` module and refused with 403. The note is model
text and the card around it is data, so they are mixed reads now: while
the `statusText` capability is available the note is served and warmed
as before, with `ai` beside it; otherwise the answer is 200 with the
note null, `preparing: false`, `hasProvider` as provider presence only
and `ai` naming the reason, and neither the cache nor the warm queue is
touched. The generic metric status still reports `insufficient` from the
data. The mood card keeps its mood module gate.

Kept apart so it can be held back while the shipped iPhone app's
rendering of a 200 without a note is confirmed.

Refs #975
2026-09-24 09:30:23 +02:00
Marc-André Bombeck c9534b343e Merge branch 'feat/ai-jobs-stored-text' into release/v1.39.0 2026-09-24 09:29:54 +02:00
Marc-André Bombeck ce72fe226e refactor(ai): re-check every chokepoint through the one egress helper
The status chokepoint, the comprehensive briefing, reaction lines and
AI-composed nudges now ask `aiEgressRefusal` about exactly the chain they
are about to send to, instead of each pairing the operator-key check
with a surface receipt lookup of its own. The consent kinds come from the
capability table, so the wire and the published capability answer from
the same rule.

The document summary, its catch-up pass and the index backfill stop on a
closed switch or module before anything is picked, and leave "no
provider" and "consent needed" to the document pick, which re-checks the
wire for the provider it chose. A closed switch still only heals PENDING
to NONE; a withheld pick records UNAVAILABLE. The per-pick document
consent helper is removed, since nothing calls it any more.

The auto-read toggle mints the shared `AI_EXTRACTION_CONSENT_KIND`, and
the web grant helper goes back to minting the master grant only.

Refs #975
2026-09-24 09:29:00 +02:00
Marc-André Bombeck f158f01f18 Merge branch 'feat/ai-extraction-routes' into feat/ai-jobs-stored-text
Brings in the extraction routes and the capability re-check at the wire
(`aiEgressRefusal`), so the jobs and stored-text work can share it.
Conflicts in the document routes, the fenced chat, the auto-read toggle
and its contract text resolve to the extraction branch: those files no
longer call the per-pick document consent helper.
2026-09-24 09:27:43 +02:00
Marc-André Bombeck 9f1d1341f9 test(ai): state the mutation checks the AI route tests were run against 2026-09-24 09:22:50 +02:00
Marc-André Bombeck 3309ac177c feat(ai): serve Insights and Coach data whatever AI says
Data never depends on AI. The Insights data reads no longer ask an AI
switch or the `insights` module (now only the AI analysis opt-out): ECG
list, detail and upload, rhythm events, derived scores, rule cards,
correlations and patterns, the baseline strip, health status, intraday
pulse, and the Today digest with its dismiss. Breathing screening moves
to the sleep module and lab changes to the labs module. Stored Coach
facts, conversations, message ratings and the seen stamp stay readable
and erasable while the Coach is unavailable. Cards now say
`provider: "rules"`.

Reads that mix data with model text answer 200, null the model text
and publish an `ai` state from the capability resolver, without warming
anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider`
from the presence probe), the derived assessment override, the period
narrative, nudge status, the seeded opener, about-me questions, the
workout paragraph, the pregenerate warm (`queued: false`), the Today
digest (lead, reaction line, check-in) and the dashboard briefing,
which is now applied per read after the snapshot cache. Hiding the
Coach no longer hides the briefing.

`insights/generate` GET and POST ask the `briefing` capability and
`insights/chat` POST the `coach` capability, with the typed refusal
envelope; a missing provider keeps the chat's `coach.provider.none`
frame and adds `reason`. The /coach page, the MCP ECG and intraday
tools and `aiAvailable` on the provider route follow the same answer.

An inventory of AI and data routes replaces the Coach gate inventory:
every route that asks a capability names it, and no data route asks.

Refs #975
2026-09-24 09:19:36 +02:00
Marc-André Bombeck 3917a2face feat(ai): resolve the AI capability before any background model work
Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.

The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.

Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.

Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.

Refs #975
2026-09-24 09:07:16 +02:00
Marc-André Bombeck 0c3195fd41 Merge branch 'feat/ai-extraction-routes' into release/v1.39.0 2026-09-24 09:05:54 +02:00
Marc-André Bombeck 8325f3964d feat(consent): add the extraction consent kind and purge on withdrawal
A new receipt kind, ai_extraction, covers reading documents, lab report
scans and typed medication text with an external model. Switching the
document auto-read on now mints ai_extraction instead of ai_full, so it
no longer grants the Coach or the analysis as a side effect. Existing
ai_full receipts keep covering everything.

DELETE /api/consent/ai/latest now runs through withdrawConsent: the
revoke and, when it leaves no receipt covering the analysis, the
deletion of the text a model wrote under it commit in one transaction.
That is the status notes, the cached briefing, model-written period
narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries stay. The purge is audit-logged
as consent.ai.purge with counts only.

Refs #975
2026-09-24 09:05:09 +02:00
Marc-André Bombeck d239d31311 feat(ai): narrow the auto-read consent and refuse the connection test with AI off
Turning document auto-read on now mints an ai_extraction receipt instead
of ai_full, so it covers reading documents, lab reports and medication
text and no longer opens the Coach or the AI analysis. Receipts minted as
ai_full by earlier releases stay valid. The catch-up over stored
documents is only scheduled while documentAi is open for the record.

The AI connection test is an AI call, so it is refused with
assistant.disabled.enabled while the operator's master switch is off, and
fails closed with ai.unavailable when the switches cannot be read (403,
since the route never answers 5xx). A missing provider carries
ai.provider.none and a spent daily budget ai.budget.exceeded.
2026-09-24 08:45:03 +02:00
Marc-André Bombeck bd8ea9d86b feat(ai): gate document, lab and medication extraction on their capabilities
Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.

The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.

The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
2026-09-24 08:44:57 +02:00
Marc-André Bombeck c3ec437cad Merge branch 'fix/v139-server-bugs' into release/v1.39.0 2026-09-24 08:21:55 +02:00
Marc-André Bombeck 16ef75e729 fix(charts): cut days in the account's zone when a mount passes none
Several chart mounts (the medications page, the coach chat, the
recovery section) pass no zone, and the chart then fell back to a fixed
Europe/Berlin, so those charts cut and labelled days in Berlin for
every user whatever their profile said.

The chart now falls back to the signed-in account's zone, and only
before that has loaded to the shared DEFAULT_TIMEZONE the server's own
resolver ends on. /api/auth/me reports the zone the server resolves:
the stored one, or the instance default when the stored one is
unusable, so the client cuts days where the server does.

Refs #1026
2026-09-24 08:15:16 +02:00
Marc-André Bombeck 352de95854 Merge branch 'fix/v139-ui-bugs' into release/v1.39.0 2026-09-24 08:12:51 +02:00
Marc-André Bombeck 3170a622d1 fix(medications): report the dose-history window doses were read over
The dose-history response reported the slot window: `from` clamped
to the medication's creation, `to` the request's end. Recorded doses
are now read from the requested start and, for a dose recorded ahead
of its slot, up to that slot, so the response reported a narrower
window than the rows it carried.

`from` and `to` now report the window recorded doses were read over.
Both fields keep their shape; the OpenAPI descriptions say what they
mean.

Refs #1028
2026-09-24 08:12:23 +02:00
Marc-André Bombeck 7d25bb7352 fix(cycle): log past dates as themselves, in any order (#1004)
The log sheet took its "Day N" header and its "period ended" button
from the verdict, which is the answer about today. A January date opened
in September read "Day 2", and a back-dated period could only be closed
on dates where today happened to be a bleeding day, so the button came
and went as unrelated months were filled in.

The calendar read now carries, per date, the cycle day of the logged
cycle that date belongs to (null before the first start, after today, or
past the verdict's ceiling for an open cycle) and whether a one-tap
period end can land there. The sheet reads both off the grid day for the
date it opened on.

Saving a new entry with nothing picked used to write an empty row: the
request succeeded, the sheet closed and the calendar had nothing to
draw, which read as a save that did nothing. Save now needs something to
store and says what, and an emptied existing entry still saves. The
sheet also stops writing until the opened date's own entry has loaded,
because until then it held the previous date's row id and a quick Save
patched the wrong day.

On the server, a start recorded a few days after an earlier one is the
same period entered in the other order: opening the earlier start now
folds the later one into it instead of leaving a one-day cycle in front
of it, and a bleed too far before the next start to be its first day and
too close to be a cycle of its own no longer infers a start. Opening or
removing a start re-attributes the day-logs whose cycle changed.
2026-09-24 08:02:00 +02:00
Marc-André Bombeck f434ba7d29 test(ai): follow the retired Correlations switch and the closed default
The Coach gate inventory lists the correlations route with the routes
no assistant switch owns. The seeded-question test gives the switch
read a settings row, since a read that fails no longer opens every
switch.
2026-09-24 07:59:49 +02:00
Marc-André Bombeck 5a0b6ee153 fix(insights): fold dense measurement reads per day in SQL
A single account syncing heart rate from a watch took the worker down
within half an hour. The status cards' graded series read the last 91
days of raw rows, bounded in time but not in rows, so a stream sampled
every minute or faster materialised six or seven figures of objects on
every refresh and the read ran into the statement timeout.

Add readDayAggregates, which folds one type into per-day sum, count, min
and max in Postgres (optionally split into age segments and filtered to
the metric's plausibility domain), and move the readers that folded raw
rows into days in memory onto it: the graded series (recent, weekly and
the cold-tier fallback, for every type), the vitals day-mean fallback
behind the personal band, the tiered context's recent days and the plan
review. Recent, weekly and monthly buckets are identical to the raw
fold; the cold-tier yearly slope now runs over day means.

On 91 days of per-minute pulse the graded series now allocates 1.6 MB
where it allocated 62 MB, while the raw read alone holds 22 MB.

A structural guard freezes the remaining untaken measurement reads in
the worker and insight directories: each must name a sparse literal
type or carry a listed reason.

Refs #1023
2026-09-24 07:56:39 +02:00
Marc-André Bombeck 88cea09c7b fix(measurements): cut chart day buckets at the user's midnight
Switching a chart from 7 to 30 days moved steps, active energy,
flights and distance totals onto neighbouring days. The 7-day range
buckets raw rows by the user's calendar day; the 30-day range read the
DAY rollup tier, whose buckets are UTC days, and the chart labelled each
bucket's UTC-midnight start in the user's zone. East of UTC a sample
shortly after local midnight counted on the previous day; west of UTC
every bucket read one day early.

The daily series (the chart read and the batched dashboard read) and the
aggregate=daily|weekly|monthly list now fold live in the user's zone and
return the instant of local midnight, so every range and every summed
metric shares one day boundary. The fold runs in Postgres and returns
one row per day; about 0.1 s for 90 days of per-minute samples. Windows
longer than the DAY cap still step up to the coarser rollup tiers.

Refs #1026
2026-09-24 07:56:33 +02:00
Marc-André Bombeck 464522f315 feat(ai): publish capabilities on the account payload
GET /api/auth/me gains ai: the state of every capability and the
provider block, resolved for the active record and masked to the
grant like moduleAccess. The authority comes from the switch the
payload already resolved, so a delegate reads not_permitted_for_record
and a guardian is limited to the operator's key.

useAiCapability is the web reader and fails closed until the payload
arrives. GET /api/feature-flags becomes a deprecated projection of the
operator switches with a Deprecation header and a link to its
successor.
2026-09-24 07:49:14 +02:00
Marc-André Bombeck a7888a9011 feat(ai): replace the Correlations switch with Reading documents
The operator switches become master, Coach, Daily Briefing, Status
notes and Reading documents. Correlations goes: it gated statistics,
not anything a model writes. Reading documents covers the document
vault, lab scans and medication extraction.

The Coach module's operator availability now follows the Coach
switch, so there is one place to turn the Coach off server-wide. The
migration folds a stored Coach availability of false into the switch,
and gives everyone who hid the Coach the AI analysis opt-out, since
hiding the Coach no longer stops nightly briefing and status notes.

A switch read that fails now fails closed. The insights module is
described as AI analysis in every locale, and the switch guard now
keeps both retired switches out and freezes the callers of the old
operator-only gate.
2026-09-24 07:36:47 +02:00
Marc-André Bombeck 01cd755de6 fix(medications): keep recent doses in the trailing history window
The Verlauf ledger dropped doses the full history showed. Three windows
ended too early or started too late:

- The server clamped the window floor to the medication's creation
  instant and filtered recorded doses by the same floor, so a dose
  logged for earlier the same day on a medication added afterwards
  fell out of "Last 90 days". The creation floor now bounds minted
  slots only; recorded doses use the requested window.
- A dose taken inside its window before the slot's own time is stored
  on the slot anchor, which still lay past the window end. The read
  horizon now reaches the latest recorded anchor within a day, without
  minting later upcoming slots.
- The client pinned from/to at mount and reused them on every refetch,
  so a dose added after the tab opened stayed hidden until a reload.
  The window is now resolved when the request fires and only "from"
  travels; the query key carries the window length instead of instants.

Refs #1028
2026-09-24 07:22:06 +02:00
muhdusama 24ff031f7a fix: preserve legacy compliance response contract 2026-09-21 07:17:13 +10:00
muhdusama 41d47c3184 fix: mark mirror adherence not applicable 2026-09-20 21:48:20 +10:00
muhdusama c223b6d77d fix: scope Apple Health schedule exemption 2026-09-20 20:46:47 +10:00
muhdusama 30e454fb83 fix: allow scheduled Apple Health mirrors 2026-09-20 00:26:05 +10:00
Marc-André Bombeck c7f7a2925f fix(insights): keep the overview reads open with the assistant switched off
With the assistant switched off on the server, the Insights overview did
not load. Its main read, /api/insights/comprehensive, was gated on the
Coach surface, and the derived tiles, the ECG list, strip and live ingest,
and the rhythm events were gated on the status-card surface. The master
flag forces every surface off, so all of them answered 403 and the page
replaced the whole overview with an error.

None of these handlers calls a provider or returns assistant prose. They
now gate on the insights module only, like health-status, labs-changes and
patterns already do. comprehensive had no module gate of its own, only the
Coach gate, so it gains the insights module gate the other five already
call. Routes that serve generated text keep their gate.

The route-gate inventory moves the six files onto the not-Coach-owned
list, four OpenAPI descriptions drop the gate they no longer have, and a
comprehensive test pins a 200 with the master flag off.

Refs #975
2026-09-16 12:55:43 +02:00
Marc-André Bombeck ed8a93afc2 feat(labs): make the lab-scan limit operator-tunable
A self-hoster scanning a stack of lab reports ran into a fixed six scans
an hour with no way to raise it. LABS_OCR_LIMIT_PER_HOUR now sets the
per-user ceiling (default 6, clamped to 1-1000, falling back to the
default when unset or not a number), the same shape as
DOCUMENT_AI_LIMIT_PER_HOUR. It is on the compose whitelist, in the
production example, the env manifest and the AI provider guide.

The route also charged the slot before it had looked at the request, so
a malformed body, an unsupported or oversized file, a PDF that could not
be rendered or a scan the daily budget refused still used up the hour.
Those failures hand the slot back now; a scan that reached the provider
keeps it. The 429 carries meta.retryAt. Refs #985
2026-09-15 23:01:10 +02:00
Marc-André Bombeck 864566d882 fix(mcp): let the consent screen return to the connecting client
After Allow on the MCP OAuth consent screen, Chromium-based browsers
stayed on the page although the grant was issued. The page carried the
app-wide CSP form-action 'self', and Chromium applies form-action to the
redirect that follows the form post to the client's redirect_uri.

The authorize route now owns the CSP of its responses and the proxy
leaves that exact path alone. The consent page allows form-action for
its own origin plus the origin of the redirect URI the route has just
validated against the resolved client, and only https or http origins
qualify. Every other response from the route keeps form-action 'self'
with default-src 'none'. The redirect after the decision is now 303,
which tells every browser to follow a form POST with a GET. Refs #983
2026-09-15 14:06:27 +02:00