Commit Graph
9302 Commits
Author SHA1 Message Date
Marc-André Bombeck 8aab59cdc1 Merge branch 'fix/analytics-live-regression' into fix/review-meds-docs 2026-10-25 06:28:23 +01:00
Marc-André Bombeck 4e79098cc0 perf(measurements): fold per-source days without joining a regrouping back
Three readers grouped the same rows twice and joined the second grouping
back to the first: the live buckets joined each hourly-mean day's hour
means to the per-source days, the all-time extremes joined the day
weights the same way, and the doctor report's dense buckets joined each
bucket's latest reading and hour means to the bucket aggregates. On a
measurements table without planner statistics (a new install, or an
import under the autoanalyze threshold) the planner estimated one row on
each side and ran every one of those joins as a nested loop that re-ran
the inner grouping for each outer row.

On ten years of readings over six types (51,000 rows, no statistics) the
dense buckets and the every-type live buckets ran into the 60 s statement
timeout and the all-time extremes took 11 s. With statistics all three
answered in 0.1 to 0.2 s.

Each is now one chain of folds over one scan. The live buckets fold the
readings per local hour and the hours per local day, carrying the day of
hours beside the counts. The all-time extremes take the day weights from
the same windowed rows they count, the shape the pre-fold remainder
already has. The dense buckets carry each bucket's latest reading and each
hour's count and mean on the rows as window values, and one grouping folds
them all. Same fixture without statistics: 0.2 s, 0.1 s and 0.1 s, the same
as with them.

A parity suite runs the joined statements as the reference against the
readers across ladders, zones with whole, half-hour and negative offsets,
grains, overlapping and off-ladder sources, glucose contexts and device
types, tombstones and both DST changes; sums and means agree to
floating-point summation order, everything else exactly. The
unanalysed-table guard now runs the three readers under EXPLAIN ANALYZE:
the joins measured 200 to 3,000 row visits per row stored, the folds about
10.

The dense-bucket loader drops under the 200-line function cap, so its
baseline entry goes.
2026-10-25 06:27:44 +01:00
Marc-André Bombeck 6d338760d8 perf(series): pick the blood-pressure source with one window over one scan
The blood-pressure series chose each UTC day's source with DISTINCT ON
over the day's floor-passing systolic rows and joined the choices back
to the readings. That is the shape the analytics pick had, with the
same weakness: on a measurements table without planner statistics the
planner estimated one row on each side and ran the join as a nested
loop that rescanned the pick for every reading.

The ten-year series over 6,000 freshly written rows from one source
took 0.57 s for the statement alone, 0.86 s with a second source on
every third day; the plan visited about 4,900 rows per row stored, so
the cost grows with the square of the history.

The pick is now one window over one scan: the day's rows ordered
systolic first, then by ladder rank, then by source name, and the first
row's source taken only when it is a systolic one. The scan starts at
the window's first UTC day, so the pick still sees the whole edge day,
and a day without a floor-passing systolic still shows nothing. Both
fixtures above read in about 10 ms, with or without statistics.

A parity suite runs the old statement as the reference against the new
one across overlapping and off-ladder sources, half readings, sub-floor
rows, tombstones, both DST changes, window starts on and either side of
UTC midnight, and four session zones. The unanalysed-table guard now
covers the series' ten-year, quarter and week reads.
2026-10-25 05:57:31 +01:00
Marc-André Bombeck 2b8eb2c1c2 perf(analytics): pick the canonical source with one window over one scan
The canonical-source pick grouped the window to (type, day, source),
chose each day's source with DISTINCT ON, and joined the choices back to
the rows. Whether that join ran as a hash join or as a nested loop that
rescanned the whole pick for every row was the planner's call, made from
table statistics. On a measurements table without them (a new install,
or an import still under the autoanalyze threshold) it estimated one row
on each side and took the nested loop.

Binding the request's clock as a parameter instead of reading NOW()
flipped which side of that loop the pick landed on: the analytics read
over 6,000 freshly written blood-pressure rows went from 1.6 s to 8.4 s,
and the demo seed's dashboard headline to 20 s. On a ten-year account
without statistics the 90-day read took 4 s with either clock and the
all-time read ran past two minutes.

The pick is now one window function over one scan: FIRST_VALUE of the
source per (type, UTC day) in ladder order, keeping the rows of that
source. Same rows, same order of preference, so every day keeps its
source; the parity suite against the DISTINCT ON statement holds that.
The analytics read on the fixture above takes about 0.1 s, the headline
0.4 s. On the analysed ten-year account the 90-day and all-time reads
are as fast as the join was, and they no longer depend on statistics.

The rows carry one extra column, canonical_source; every reader selects
its columns by name.

A new integration test runs every reader shape under EXPLAIN ANALYZE on
a table with no statistics and bounds the rows the plan produces or
discards per row seeded. The statement count never moved, so a statement
budget could not see this; the join measured 200 to 8,600 row visits
per row seeded and the window stays at 4.
2026-10-25 05:46:08 +01:00
Marc-André Bombeck 4d4a83fb7d chore(bundle): measure the Recently deleted page's first load
The page is new in this release and had no baseline, which the budget check
refuses; 290.8 KB gz as the CI build measured it.
2026-10-25 05:03:54 +01:00
Marc-André Bombeck 240a135b88 refactor(ai): keep one name for one completion's ceiling
A status run is one completion, so its ceiling was the completion ceiling
under a second name. The Local client also re-exported a JSON-mode helper
nobody imported from it.
2026-10-25 04:50:25 +01:00
Marc-André Bombeck 8fee138256 fix(backup): type the alias order as Prisma's mutable order list 2026-10-25 03:35:14 +01:00
Marc-André Bombeck d07f7f5bbf Merge branch 'docs/postgres-major-upgrade' into fix/review-meds-docs 2026-10-25 03:19:55 +01:00
Marc-André Bombeck 2556da645b test(jobs): wait for the expired import's retry instead of a fixed window
The retry is fetched about a second and a half after the overrun ends, later
on a loaded machine, and the twelve-second window turned that lateness into a
red run. Both cases now wait for the state they assert, bounded by a
deadline, and read the call count once the retry's own run has finished.
2026-10-25 03:19:54 +01:00
Marc-André Bombeck ffd06ce289 docs(ops): describe the move to a newer Postgres major
The compose file stays on 16. Changing only the image line stops the stack:
the 18 image refuses a data directory from an older major and expects its
volume one level up. The runbook walks the dump and restore into a fresh
volume, keeps the old one as the way back, and is linked from the update
section and the compose pin.
2026-10-25 03:06:37 +01:00
Marc-André Bombeck 84064ea045 Merge branch 'fix/bound-sync-lab-reads' into fix/review-meds-docs 2026-10-25 03:02:57 +01:00
Marc-André Bombeck 698ed87c22 test(parity): a reading or writing delegate is not offered End
End needs MANAGE over medications, so the course flag now reads false for a
delegate who may only read or write.
2026-10-25 03:02:57 +01:00
Marc-André Bombeck 3853ce7f1a fix(backup): order the source aliases a deleted document's keys are read from
The backup export is totally ordered so two exports of the same account are
byte-identical; the alias relation the import-key ledger reads was the one
nested read without an order.
2026-10-25 03:02:56 +01:00
Marc-André Bombeck fd299cad61 fix(labs): plan every report read against a schedule
The lab scan runs only in the background worker, which hands it the
run's deadline, but its body still took the schedule as optional and
planned a missing one without a bound: a twenty-page report as forty
model calls. Nothing calls it that way today; the signature allowed the
next caller to, and so did every read planner beneath it.

The scan and the planners (maxReadsFor, planTextReads, planVisionReads,
planInboundTextReads, planInboundReads) now require a schedule. A run
with room for every read says so with an unbounded one; a request uses
SYNCHRONOUS_READ_SCHEDULE. No route, wire or response changes.
2026-10-25 02:58:35 +01:00
Marc-André Bombeck 474fc08649 fix(documents): bound the lab staging an index request continues into
The index route without Prefer: respond-async, which the iPhone app
calls, continues into the lab staging with no run schedule. The staging
then planned a long report's text as up to twenty reads with a retry
each: forty model calls inside one HTTP request, the same class the
extract route was bounded against.

The staging now plans a missing schedule as one request does
(SYNCHRONOUS_READ_SCHEDULE, at most three reads): a longer report has
its text shared out between them, and what does not fit is left out and
reported as extractionIncomplete on the document. The request bound
moves into ocr-plan beside the schedule it bounds, so the extract run
and the staging share one definition. Background runs and the automatic
index job keep planning against their own deadline.
2026-10-25 02:52:10 +01:00
Marc-André Bombeck 2b27b4b501 chore(ratchet): keep the version-stamp baseline in the order the writer emits 2026-10-25 02:45:31 +01:00
Marc-André Bombeck 048407756f Merge branch 'chore/pg18-check' into fix/review-meds-docs 2026-10-25 02:45:24 +01:00
Marc-André Bombeck d6c2760415 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/review-meds-docs
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-25 02:45:23 +01:00
Marc-André Bombeck 4390614463 fix(migrations): rebuild an index a failed concurrent build left invalid
A CREATE INDEX CONCURRENTLY that fails half-way leaves an INVALID index,
and the migration re-run after migrate resolve --rolled-back finds it
through IF NOT EXISTS and does nothing: recorded applied, with an index
the planner never uses. 0477 has this shape.

The boot now drops an INVALID index that a not-yet-applied migration
builds concurrently before it runs migrate deploy, with a bounded lock
wait, so the re-run builds it again. An INVALID index whose migration is
recorded applied is named on stderr with the rebuild that mends it. The
deploy runbook says both.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck b2c99e5c8d fix(migrations): bound the lock wait of 0475 and 0480, and of every file after
0475 and 0480 added their columns with a bare ALTER TABLE, which waits
for its table lock behind every open transaction while every write to
the table queues behind it. Both now run in a DO block with a five
second lock_timeout and carry the boot's retry marker; the effect is
the same and both stay safe to run again.

Neither has shipped in a release. A database that already applied them
is unaffected: prisma migrate deploy and migrate status ignore the
checksum of an applied migration (checked against a database migrated
with the old files), so nothing is re-run and nothing is reported.

The lock-timeout guard now refuses, from 0475 on, any statement that
takes a table lock outside such a block.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck 205d8f257c fix(backup): carry the import ledger, and the keys of deleted imports
The backup carried neither the import ledger nor the tombstones of
deleted documents, so after a restore onto a fresh instance a token
importer stored every imported document the person had deleted on
purpose again.

The document filing section now carries documentImportKeys: the
ledger's rows plus the own and alias keys of the documents waiting in
Recently deleted, which still do not travel as documents. The restore
writes them back, minus any key a restored live document holds, and
counts them in the restore summary and the preview. DocumentImportKey
moves from the excluded models to the two-ended ones, and the round
trip, the column audit and the parity goldens carry a ledger row.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck 988af10a34 fix(documents): keep module-off accounts from filling the index retry boot
The boot discovery for the documents 0479 marked took the first thousand
by id and skipped those whose account has the documents module off.
Such an account keeps its marks, so with enough of them it held every
place of every boot and the accounts after it never got their retry.

The marks are walked by id past the last row read, an account found
with the module off is left out of the query from then on, and the
boot stops at a thousand documents enqueued rather than read.

Refs #1277
2026-10-25 02:14:17 +01:00
Marc-André Bombeck ea3bdf42b7 fix(documents): settle an index backfill read once and ask the wire before each
The index-all backfill billed the read and wrote the index in one try:
an index write that failed after the read was settled again in the
catch, handing the reservation back twice. The read is settled once,
and a failed write charges nothing back.

The backfill also checked consent and the operator's switches once, at
the pick, then read up to two hundred documents. The wire is asked
again before every read; a refusal hands that document's reservation
back and ends the walk with no-consent or unavailable.
2026-10-25 02:10:17 +01:00
Marc-André Bombeck da4056ce05 fix(documents): key an undated fact on its report's date in the merge
The extraction merged facts across reads on a key whose only date was
the fact's own effective date. Facts without one collapsed across reads
of different reports, so the same value on two days staged once. An
undated fact is now keyed on its read's report date, falling back to
the document's, as the lab scan keys a row.

Refs #1257
2026-10-25 02:03:27 +01:00
Marc-André Bombeck 41acdbfd54 test(integration): let the suite run against another Postgres major
INTEGRATION_PG_IMAGE overrides the container image, so the same suite can
prove a major the compose file does not ship yet.
2026-10-25 02:00:57 +01:00
Marc-André Bombeck 875a2a98d6 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/ai-chain-review 2026-10-25 01:11:30 +02:00
Marc-André Bombeck a172e377c1 fix(ai): close the notes' windows on a fresh Codex sign-in
A save of a person's provider settings and of the operator's admin AI
settings forget the health ledger's record and close the status notes'
negative windows. The two Codex sign-ins, the operator's central one and a
person's own, forgot the ledger record only, so a fixed connection still
had every note wait out the window the refused one earned, up to two hours.

The central sign-in now closes every record's windows, since every account
may fall back on that connection, and a person's own sign-in closes theirs.
Proven against the real rows in the Codex sign-in integration suite.

Refs #1279
2026-10-25 01:03:50 +02:00
Marc-André Bombeck c9481b672c fix(documents): bound an extraction that runs inside the request
Without Prefer: respond-async the extract route ran the read with no
schedule, so a twenty-page report read a page at a time, with a retry
each, could make forty model calls in one HTTP request. The iPhone app
sends no Prefer header and reads the document detail from the answer,
so the request keeps its 200 and its body.

An extraction without a run deadline now plans at most three reads (six
calls): a longer document has its pages or text shared out between
them, and what does not fit is reported as extractionIncomplete on the
document. The background run keeps reading it a page at a time.

Refs #1257
2026-10-25 01:01:13 +02:00
Marc-André Bombeck 958d230b9f fix(documents): keep an incomplete extraction on the document
A long document read in parts can end with a part cut at the provider's
output limit, a part never read, or more facts than one review takes.
That was told once, as a notice when the run finished; a review opened
later looked complete.

InboundDocument.extractionIncomplete (migration 0483) is written with
the facts by both stagings, the extract route or run and lab
auto-staging. The document detail publishes it and the review shows the
same note whenever it is opened. The disaster-recovery backup carries
it; an older file restores it as false.

Refs #1257
2026-10-25 01:01:13 +02:00
Marc-André Bombeck 8b47d033e7 fix(ai): learn a refused reasoning field set by the entry itself
A gateway or local entry can carry its own reasoning setting (#1126), sent
on every call that has no level of its own. An endpoint that refuses
reasoning_effort answered each of those calls with a 400 that was never
learned, because only the call's own level counted as sent: every status
note, every summary and every other level-less call failed, for as long as
the setting stood.

The refusal is now learned whichever level put the field on the wire, by
the same path as any refused field: the call is asked once more without it
inside its two-request budget, later calls send no field for that endpoint
and model, a model seen reasoning unasked keeps its thinking room, and one
ai.provider.reasoning_field_refused line says so per lesson instead of a
failure per call.

Refs #1279
2026-10-25 01:01:02 +02:00
Marc-André Bombeck 05cb5cf7e9 fix(insights): stop a briefing's model calls once its bound gives up
The nightly warm, the forced warm and the morning refresh bound a
comprehensive generation and abort it when the bound fires, but the abort
only reached the generator's final check before the cache write. The
provider calls never saw it: the call in flight ran on detached, and a
reply that landed after the bound could still start the JSON-shape,
grounding or screen retry, each a full generation nobody was waiting for.

runBriefingCompletion now takes the caller's signal, hands it to the
provider call, and reserves and sends nothing once it has fired. The
generator passes it at all five of its calls (the generation, its three
retries and the phrasing re-roll). The on-demand route passes none on
purpose: it finishes and writes its briefing after the browser stopped
waiting.

Refs #1279
2026-10-25 00:54:00 +02:00
Marc-André Bombeck 0671cb6e0a chore: hold the size and version-stamp ratchets
Tighten the warm-up comments in the summaries slice and the comprehensive
aggregator so neither file grows past its line baseline, drop a release
opener from the sharing-surface note, and record the version stamp that
left the settings section list.
2026-10-25 00:51:33 +02:00
Marc-André Bombeck 0f9ee08a48 test(sync): give the module-gate double the mirror walk's token lookup
The walk now asks the token table first; the route test's Prisma double
answers it with no switched token.
2026-10-25 00:51:32 +02:00
Marc-André Bombeck cc284c4307 fix(jobs): log a failed preload report instead of swallowing it
The sweep keeps going when reporting a load failure itself throws, and
now says so on stderr; the error-level guard refuses a silent catch in
src/lib/jobs. The preload test reads the worker source through
readSource with anchors.
2026-10-25 00:51:32 +02:00
Marc-André Bombeck 2ae23f4ca8 refactor(settings): remove the preview-feature opt-ins
The per-account preview switch was kept for the topic overview pages,
which were dropped before they shipped. Its one flag was stored, served
and backed up, and read by nothing; the column-reader audit listed it as
a pending consumer that will not come.

Removed completely:

- `/api/settings/preview-features` (GET, PATCH) is retired: it answers
  410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`,
  and the contract publishes it under Retired. The route, its validation
  schema, the `PreviewFeatures` OpenAPI schemas and the settings module
  are deleted.
- Migration 0485 drops `users.preview_features_json` under a bounded lock
  wait, with the retry marker.
- The backup no longer writes the field. A file from v1.44 or v1.45 that
  carries it still restores: the schema accepts it and no codec reads it,
  so nothing is refused or written for it. The backup-parity goldens
  lose the field and apply one setting fewer.
- The data-wipe plan entry and the sharing-surface actor entry go with
  the route (frozen count 273 -> 272).

No client build reads the route: neither iOS repository nor any local
checkout references it.
2026-10-25 00:44:15 +02:00
Marc-André Bombeck 402a4a546c fix(admin): lift the bench when the operator fixes the shared provider
The health ledger records failures per account and provider, and since
background work leaves out a benched provider, an operator who fixed the
instance-wide provider in the admin AI settings still saw every account's
background work sit out the pause the broken setup had earned, up to a day.
Only a person's own provider settings and the Codex sign-ins forgot the
record.

A successful save of the admin AI settings now forgets what the ledger
recorded for admin-openai on every account and closes the status notes'
negative windows, the same reset a person's own save makes for theirs
(clearStatusNegativeWindows without an account covers every record). The
ledger comments that still said a benched row only deprioritises now say
what it does for background work.

Refs #1279
2026-10-25 00:42:20 +02:00
Marc-André Bombeck f1163d5efb fix(insights): do not count a status run the bench sent nothing for
A status run whose whole chain was benched for background work sent no
request, yet the caller opened the note's negative window as for any
failure and raised its failure streak. The streak doubles the window up to
two hours, so a note kept waiting long after its provider recovered.

AllProvidersFailedError now says when no hop sent anything (sentNothing:
every provider benched, or refused by the operator cost cap). The status
chokepoint then holds the note for the first window without touching the
streak (holdStatusNoteWhileBenched), and the caller's own failure write
lands inside that window and neither moves nor counts it. A run that
reached a provider counts as before.

Refs #1279
2026-10-25 00:40:23 +02:00
Marc-André Bombeck 5cfaf4869d fix(medications): give back the doses an End took when the End is undone
End course tombstones the pending placeholders at or after its instant.
Moving or removing the end clears the instant, but the tombstoned rows
kept their unique slots, so the today projector treated them as present
and minted nothing: the later dose of that day was neither listed,
reminded nor missed.

The window's one writer now revives the placeholders the End stamped
with its instant once that instant is gone, when the slot is inside the
courses as they stand and no live row holds it.

Refs #1280
2026-10-25 00:37:35 +02:00
Marc-André Bombeck f3b9329fb2 fix(sync): mark the read caches stale after a provider backfill
`afterProviderBackfill` refolded the rollups over the touched range and
re-warmed the status cards, but never marked the analytics caches stale,
so after a Fitbit or Google Health history backfill the dashboard and
analytics reads served the pre-backfill body until its TTL ran out. The
per-write tail has always done this through `afterMeasurementMutation`.

The backfill tail now calls `invalidateUserMeasurements` with
`touchedFrom` set to the first touched day, after the fold and also when
the fold fails, since the rows changed either way.
2026-10-25 00:35:40 +02:00
Marc-André Bombeck c6bd27f06f fix(jobs): load every job handler once at worker boot and report failures
Since handlers load on their queue's first job, a module the image
cannot load no longer fails the worker's boot. The worker comes up
healthy, the queue burns its retries when it first has work, and a
one-shot job (a restore, an import tail) ends failed with nobody told at
deploy time.

After binding every queue the worker now runs `preloadHandlers` in the
background, without awaiting it, so readiness is unchanged. It loads
each handler once through the same memoised loader its queue is bound
to, so a module that loads stays loaded for the first job. Each failure
is reported on the `job.run.failed` line at error, with the queue's name
and `reason: "handler_load_failed"`, plus the worker error report
(stderr and GlitchTip). A summary line on stderr names every queue that
failed. The sweep never throws, and a failing report does not stop it.

docs/ops/deploy.md says what to look for after a deploy.
2026-10-25 00:35:36 +02:00
Marc-André Bombeck f7b89f96e1 perf(sync): walk the mirror domain from the switched tokens
`/api/sync/changes` read the `measurementMirror` domain with one query
that ordered every token-linked reading by the joined token's
`mirror_changed_at`. No index on `measurements` can serve that order, so
every poll read and sorted every row of every token ever switched, even
when nothing had changed past the client's watermark.

The walk now reads the account's tokens with `mirror_changed_at` at or
past the watermark first. Nearly every poll finds none and stops there.
Otherwise it walks each change stamp's readings by
`(ingest_token_id, id)`, the order the index holds, with tokens that
share a stamp walked together so rows interleave by id as before. Rows,
order, page boundaries and the cursor are unchanged; the wire contract
is untouched.

EXPLAIN (ANALYZE, BUFFERS) on a seeded Postgres 16: one account, a
token with 300,000 readings switched at T0, a second token with 50
readings switched at T1, 900,000 untokened readings, page size 200.

  steady-state poll (watermark past every switch)
    before: 53 ms, Merge Join over ~300,000 index rows, 10,827 buffers
    after:  0.07 ms token lookup + 0.02 ms resume probe, 10 buffers
  first page after the switch
    before: 51 ms, top-N heapsort over ~300,000 rows, 10,948 buffers
    after:  0.08 ms + 0.17 ms index scan on (ingest_token_id, id)
  resume in the middle of the big token
    before: 53 ms
    after:  0.02 ms + 0.20 ms, Index Cond on (ingest_token_id, id)

A new integration file pages both forms side by side (tied stamps, a
watermark inside a tie, soft-deleted rows, another account's token,
page sizes from 1 to 100) and asserts identical pages, and that a poll
with nothing switched past its watermark reads no reading at all.
2026-10-25 00:35:10 +02:00
Marc-André Bombeck da2cd62876 fix(rollups): register read-path warm-ups with the background registry
The mood rollup warm-up on the dashboard summary was started with a bare
`void`, so `settleBackgroundTasks()` never saw it. On a stale account the
warm-up started a full recompute that could still be writing when the
statement counter closed its window, and the hot-route budget read 30
against 28 depending on where it landed.

Every read-path start of that shape now goes through
`trackBackgroundTask(runDetached(...))`: the measurement and mood rollup
warm-ups (seven call sites), the dashboard's today-briefing warm, and the
two delegated-access bookkeeping writes in the record guard. A guard
refuses a bare `void` on any of them.

`trackBackgroundTask` now wraps its handle in `Promise.resolve`: a test
double that returns nothing used to leave `undefined` in the set, and
settle spun on it at full CPU.

The budget fixture mints the mood rollups in its seed, as it already did
for the measurement rollups. Eight serial runs of
query-budget-hot-routes read the same counts every time (dashboard
summary 28, snapshot 99, day 47, health-status 59, correlations 43).
2026-10-25 00:33:55 +02:00
Marc-André Bombeck b0ea3b1654 fix(ai): charge what failed hops spent when a later hop answers
A chain whose first provider reasoned without answering and whose second
one answered billed only the answer: result.tokensUsed is the answering
call's alone, and the failed hop's spend, already reported on its
FallbackHop, was dropped by every caller that reconciles the day's ledger.

The raw runners now report tokensSpent on success too, the sum the failed
hops before the answer reported. The briefing, status, Coach (no-tools path
and every tool-loop round) and medication-extract reconciles charge it on
top of the answer. On failure, the Coach turn ledger's close keeps what the
failed chain reported spending instead of refunding everything, and the
medication-extract and document-chat routes charge it too, as the status
and briefing paths already did. Document indexing resolves a single provider
and already charged its failed call.

Refs #1279
2026-10-25 00:33:18 +02:00
Marc-André Bombeck 186fc30fcc test(insights): build the benched re-run request without casts
The benched-provider case for "Re-run analysis" passed its ledger row and
its request through `as never`, which the test-realism ratchet counts. It
now seeds a complete provider_health row and sends a real NextRequest.

Refs #1279
2026-10-25 00:32:02 +02:00
Marc-André Bombeck f0b6854a91 fix(medications): offer a new course only on a day the start accepts
canStartCourse answered true on the day a course was ended with End
course, while a course starting that day shares its last day and the
start refused it with 422 overlap. The start is now offered from the
day after the last course's end, the first day validation and the
engine accept.

A course ended at an instant is over for every rule that asks about
today, whatever the zone it is read from: after a move west the course
rows read CURRENT while the medication's own status read ENDED. The
course rows, the coach summary, the overlap rule and the previous-end
read take the instant-aware reading.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck 8b66f647cb fix(compliance): carry endedAt into the streak and per-window timelines
calculateCompliance and buildTimelineForWindow built the cadence
engine's context field by field and left out endedAt, so a dose slot
after End course counted as missed in the streak and the timeline while
the rate left it out.

Both now take the context from one builder, endedAt is required on
ComplianceMedicationContext and CadenceEngineContext, and the course
anchor guard holds every builder of either type to naming it.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck 8fd1d543a6 fix(medications): let a manager end a course and serve End only to who may press it
POST /api/medications/{id}/courses/end resolved the caller with
requireAuth(), so a delegate managing the record got 403 while the
medication PUT that used to carry the end date admits MANAGE, and the
reads still served canEndCourse true. The action now resolves the record
at MANAGE over medications and files the previous end (C4) with the
count of tombstoned placeholders.

The medication reads narrow canEndCourse and canStartCourse to the
caller: End needs MANAGE, starting a course stays owner-only. The list
cell stays per record and the narrowing runs after it. Create and edit
answers pass the grant they were resolved under instead of null.

Registered in the sharing surface guard, the MANAGE handler matrix and
the activity feed's verb lines.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck bdfcea5e7c fix(insights): let "Re-run analysis" reach a benched provider
The on-demand briefing (POST /api/insights/generate) runs through the
briefing chokepoint, which named its chain walk "job" for the background
share of the day's ceiling. Since background work leaves out a provider the
health ledger benched, a person pressing "Re-run analysis" got no request at
all: the call failed with a connection refusal that never happened, and a
refused credential was never reported back.

The chain runners now take the walk apart from the budget surface: a call
can be rationed as a job and still have a person waiting on it
(interactive). runBriefingCompletion takes the flag and the on-demand
route's calls (the first pass and the grounding retry) pass it, so they keep
the job share and walk a benched provider at the tail, as background-bench
promises for interactive walks. Every other job-surface caller is a worker
or is enqueued; the status, narrative and warm routes only read and queue,
and document reads and lab scans resolve a single provider without a chain.

Refs #1279
2026-10-25 00:24:51 +02:00
Marc-André Bombeck b90980f2a2 fix(ai): keep a cut answer from a model never seen reasoning
A reply that ran into the token ceiling with unlabelled text was read as
reasoning the server did not separate whenever the ceiling held room for
reasoning, which is every call that carries a level, Off included. A model
that does not reason at all (llama3.1) lost its own truncated answer: the
call failed as reasoning_only, the briefing never reached its JSON-shape
retry, the Coach named the wrong error, the failure counted toward the
bench, and the client learned to ask with "none" for an hour.

Such text is now reasoning only once the endpoint and model have shown that
they reason: a reasoning field or a think tag in an earlier reply, or a
lesson that says so (knownReasoner). A sighting on a request that asked to
reason speaks only for requests that ask too. Otherwise the text is the
answer, cut off, with finishReason "length".

A lone closing </think> now splits a reply only where the chat templates put
it, at the start of a line, or anywhere once the model is known to reason. An
ordinary answer that mentions the tag in a sentence stays whole.

Two expectations encoded the old rule and move: the Coach loop case whose
first reply is unlabelled template-opened text from a model never seen
reasoning, and the Local case that read a never-seen model's cut text as a
thought. The streamed think-block splitting moved into local-reasoning.ts.

Refs #1276
2026-10-25 00:20:35 +02:00
Marc-André Bombeck 6e4d26c0ce test(ai): find the wire clients that dial through the request budget
The call-timeout guard found its wire clients by a direct safeFetch call.
The Local and the OpenAI-compatible gateway clients now send through the
call's request budget (hops.fetch), so the guard looked at two clients and
failed its floor. It finds both spellings again.

Refs #1279
2026-10-25 00:17:10 +02:00