9745 Commits
Author SHA1 Message Date
Marc-André Bombeck b42b0d5fee fix(whoop): refuse a profile without a user id instead of storing "undefined"
String(profile.user_id) turned a missing id into the literal "undefined",
so every such connection would have looked like one WHOOP account and the
unique account id would have refused the second record. The callback now
treats it like a failed exchange (reason=token), as the Polar callback
already does for a token body without x_user_id.
2026-10-31 09:34:25 +01:00
Marc-André Bombeck 5306ba7614 fix(google-health): read the account id back for connections that lost it
Migration 0491 cleared the shared "profile" placeholder to null. Those
connections sync normally, but the one-account-per-record check cannot see
them, so the same Google account could still be connected to a second
record until the first one reconnected.

After a clean sync cycle, a connection without an id now reads it the way
the callback does (profile, then identity) and stores it, guarded on the id
still being null so a reconnect landing meanwhile wins. An id another
record already holds is not written, and a failed read leaves the id for
the next cycle without touching the cycle's verdict. The outcome is on the
wide event as googleHealth.accountId.heal.
2026-10-31 09:34:25 +01:00
Marc-André Bombeck eb47f04a66 Merge pull request #1305 from MBombeck/release/v1.46.5
v1.46.5: Azure OpenAI reasoning deployments
v1.46.5
2026-10-30 17:44:07 +01:00
Marc-André Bombeck 685cb2fa01 fix(ai): learn the max_tokens refusal from the field the request carried
A call that ran beside the one that learned the lesson read today's
selection instead and failed with the 400. The matcher now also wants the
refusal to name max_tokens, so a refusal of another field that echoes the
request does not switch the token field.

Refs #1304
2026-10-30 17:13:09 +01:00
Marc-André Bombeck 31ecb75901 test(backup): golden fixture for v1.46.5 2026-10-30 17:09:12 +01:00
Marc-André Bombeck 2676f2fce0 chore(release): v1.46.5 2026-10-30 17:09:01 +01:00
Marc-André Bombeck 21af344054 fix(ai): send max_completion_tokens to Azure OpenAI reasoning deployments
An OpenAI-compatible gateway entry pointed at an Azure OpenAI v1 endpoint
was treated as a generic gateway and sent max_tokens and temperature, which
a reasoning deployment refuses with a 400. An Azure v1 endpoint now takes
the api.openai.com contract for a reasoning model, and any gateway that
refuses max_tokens for a model is asked again with max_completion_tokens
and remembers it, which covers deployments named something of their own.

Refs #1304
2026-10-30 17:08:43 +01:00
Marc-André Bombeck 0462c23841 Merge pull request #1303 from MBombeck/release/v1.46.4
v1.46.4: Google Health connects on every profile of an instance
v1.46.4
2026-10-30 13:51:10 +01:00
Marc-André Bombeck 29b5bc78a0 chore(release): v1.46.4 2026-10-30 13:18:16 +01:00
Marc-André Bombeck 0d8bcbc52b test(backup): keep the 1.46.4 backup file as a golden 2026-10-30 13:18:15 +01:00
Marc-André Bombeck cd6cbc06f5 fix(google-health): store the account's id, not the word "profile"
The profile's resource name is `users/{user}/profile`, and the connect
stored its last segment, the same constant for every account. Since the
one-account-per-record check, a second record on an instance could not
connect or reconnect Google Health: it was refused as `already_linked`.

The connect now stores the `{user}` segment, reads `users/me/identity`
when the profile answers through the `me` alias, and stores the id as
unknown when neither names the account. A migration makes the column
nullable and clears the placeholder, so the next connect stores the
real id.
2026-10-30 13:17:54 +01:00
Marc-André Bombeck 8f8f98eac7 Merge pull request #1302 from MBombeck/fix/google-health-sleep-sweep
fix(google-health): protect only a night's own segments in the sleep sweep
v1.46.3
2026-10-29 23:56:59 +01:00
Marc-André Bombeck a79a04f3ef test(dawarich): take clock times out before looking for coordinates
The privacy check looks for the fixture's latitude 52.52 in every stored
row, and a timestamp written at :52.52x seconds holds the same digits. It
failed once in CI with no coordinate stored. Clock times are masked first;
a coordinate in any field is still found.
2026-10-29 23:36:32 +01:00
Marc-André Bombeck cd98db82eb test(insights): date the comparison's sleep readings by calendar day
The readings sat at now minus n times 24 hours, which keeps the wall-clock
hour only between two clock changes. Run late in the evening after the
October change, two of them fell into the same night and the average read
525 minutes instead of 420.
2026-10-29 23:16:00 +01:00
Marc-André Bombeck e3ebc01e34 docs(changelog): note the Google Health sleep sweep fix in 1.46.3 2026-10-29 23:13:43 +01:00
Marc-André Bombeck eed83befa3 fix(google-health): protect only a night's own segments in the sleep sweep
Every night's replace-by-window clean-up carried every fresh segment id of
the fetch. On a first sync that reads years of sleep that is one query per
night with tens of thousands of bound values, over Postgres's limit, and
the process spent minutes building them before each one failed. The sweep
can only reach rows inside the window, so each window now protects the
fresh rows inside it, from whichever session reported them.

Refs #1195
2026-10-29 23:13:43 +01:00
Marc-André Bombeck cb6c725a8c Merge pull request #1301 from MBombeck/release/v1.46.3
Release v1.46.3
2026-10-29 23:06:46 +01:00
Marc-André Bombeck 0e57247b29 chore(release): v1.46.3 2026-10-29 22:18:59 +01:00
Marc-André Bombeck 1bdeae5cc6 test(backup): keep the 1.46.3 backup file as a golden 2026-10-29 22:18:58 +01:00
Marc-André Bombeck 1f0b05f141 Merge branch 'fix/v1463-review' into release/v1.46.3 2026-10-29 22:18:02 +01:00
Marc-André Bombeck 3674c1757d fix(timeline): leave hidden lab markers out of the values menu
Hiding a marker takes it out of the labs catalogue and the entry
pickers, but the timeline still offered it among the lab values.

The timeline now offers only markers that are not hidden. A hidden
marker that was chosen before keeps drawing its line and stays in the
menu as a ticked entry, named from that line, so it can be unticked,
the way the entry form keeps a hidden marker that is already selected.
Once unticked it is no longer offered.
2026-10-29 22:01:43 +01:00
Marc-André Bombeck 5dec158c9d fix(ai): keep the reasoning-field day for what a 400 taught
The dialect cache gave a day to every value that says the reasoning
field is refused, and that includes the combined "field refused, reasons
regardless" value, which one answerless reply writes. One reply could
therefore stretch a lesson to a full fresh day, against the rule that a
lesson read from a single reply keeps the hour.

A lesson now says what taught it. A 400 refusal gives a refused field
its day. A reply that upgrades a refused field to the combined value
keeps the refusal's remaining lifetime rather than a new day or the
hour, so the refused request does not come back early either.
2026-10-29 21:58:40 +01:00
Marc-André Bombeck c920266f32 fix(backup): explain a second restore of one file into another account
Restored rows keep the ids they had in the file. Once an erased
account's copy had gone into one account, the same file uploaded into a
second account restored into a primary-key conflict, reported only as
the generic "the database rejected a record" failure.

For a copy put into a chosen account, a primary-key conflict now ends
the restore with backup.restore.rows_exist_elsewhere and a sentence that
says the file was most likely restored into another account already.
The transaction still rolls back, so neither account changes. The code
is in the catalogue, the console names it, and the runbook says a file
goes into one account only.
2026-10-29 21:50:34 +01:00
Marc-André Bombeck 94854bd8f7 fix(backup): trace an account made for an upload when it is made
The upload creates a passwordless account before it reads the file and
deletes it again when the file is refused. The only audit row was
written after a successful store, so a refused upload whose delete also
failed left an account nobody could sign into and no record of where it
came from.

The account and its admin.user.create row (outcome "created") now
commit together, and the discard writes admin.user.delete with outcome
"deleted" or "failed", so a leftover account always has a trace. The
runbook says how to remove one by hand.
2026-10-29 21:42:18 +01:00
Marc-André Bombeck 16d1b542a5 fix(backup): keep an erased account's copy out of admin accounts
A restore read the account settings under the operator's AI endpoint
policy whenever the receiving account was an admin. For a copy put into
a chosen account, the settings were written by the erased account's
owner, not by the operator, yet an admin account could be chosen as the
target, and an ordinary target promoted later got the same trust.

Admin accounts are now refused as a target, in the check and the
picker, with backup.target.admin_account. A copy uploaded into a chosen
account is stored as RETARGETED_UPLOAD_<ms>, and its restore never reads
its settings as the operator's, whatever the account's role is by then.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck dfe34386be fix(ai): read a Codex sign-in's memory only under its own account
Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.

The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck 648810745a fix(backup): let an account fresh from setup take an erased account's copy
The empty-account check counted every backed-up model, and signing up
and answering the setup questions always leaves a setup row, plus a
cycle profile when the cycle area is chosen. The account of someone who
has signed up again, the case the restore target is for, was therefore
always refused.

Those two models are one row per account and are deleted and re-created
from the copy by every restore, so they no longer count. A reading or
medication entered on setup's last screen still refuses the account,
because the restore would delete it. A guard holds the exempt models to
that shape, and the erased-account integration file now drives the real
setup routes before the restore.
2026-10-29 21:14:25 +01:00
Marc-André Bombeck 21f6f5796b fix(timeline): cap lab markers instead of refusing the request
A values list naming more than 24 lab markers answered 422. The list is a
device preference, so once a 25th marker was ticked every later visit
failed: the page rendered its error card, and the values menu that could
untick it only renders on success.

The server now keeps the first 24 markers and leaves the rest out, as it
already does for a marker the record no longer has; a malformed key still
answers 422. The page applies the same cap before it asks, and the values
menu holds further unchosen markers back at 24 with a line saying why,
while a chosen one can always be removed.
2026-10-29 21:06:52 +01:00
Marc-André Bombeck 00d129ad42 Merge branch 'feat/v1463-restore' into release/v1.46.3 2026-10-29 20:46:23 +01:00
Marc-André Bombeck 169a0f1df4 Merge branch 'feat/v1463-labs' into release/v1.46.3
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-29 20:43:40 +01:00
Marc-André Bombeck 77a76a9e33 refactor(backup): keep the upload flow owned by the backups section
The section that renders the recent-proof dialog is the one that hands
the proof to each gated flow, which the recent-proof callers test reads
from its source. The upload hook goes back there and the upload
component takes the flow it returns.
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 8d765139d3 test(timeline): walk a lab marker through the timeline journey
The journey picks a lab marker from the values menu, sees its readings
drawn with the range behind them and the one below it as a triangle,
reads that reading's card by keyboard with its report, checks the page
with axe, reloads to find the choice kept, and on a phone finds the
readings on their lab days without a sideways scroll before unchoosing
the marker again. The mocks answer lab markers only when a test asks for
them, so the other value-line steps keep counting what they counted, and
the two values triggers (the toolbar's and the phone's) are addressed by
their surface.

Refs #1299
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 46350b6461 refactor(backup): one success line for a stored upload
Both upload paths now announce a stored copy through one function, so
the upload hook keeps the single success affordance it is pinned at,
and the upload card and target dialog are wired in their own component
rather than in the backups section, which stays under its size line.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck 8ed6eb5793 refactor(backup): publish the target codes and keep the upload handler short
The target refusals answer with eight new meta.errorCode values; they
are now constants the catalogue sweep reads and entries in the
published catalogue, so a client can branch on them without triggering
each one. The upload handler had grown past its size baseline with the
target branch, so the target checks, the file read and the schema check
move into named functions beside it, unchanged. The target check route
holds its params to a strict schema like every other route.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck b8a983bdec feat(timeline): draw lab markers as lines under the values
With the server naming lab markers as value lines, the page offers them:
the values menu lists the markers the record has numeric readings for,
under the groups the labs list sorts them into, and a choice is stored
and requested like any measurement line. The phone toolbar gains the same
menu as an icon button, since the chronicle shows the chosen lines too.

A lab row sits under the value lines in their rhythm, on its own scale.
Each reading is its own point on the day of the draw, joined in time
order by a thin stroke and never averaged. The reference range is painted
behind the points as the labs page paints it, a faint band with dashed
edges, and the scale holds it so the band is always in view. A reading
outside its range is a triangle pointing the way it left and is worded as
outside wherever it is written out, so colour is never the only sign.

A point reads on its own by pointer, keyboard and tap: the day, the value
with every digit it was stored with, the verdict, the range and the
report it was read from (a link once pinned). The selection bar lists
the bucket's readings with their report, a lab day's row in the phone
chronicle lists that day's readings, a screen-reader table lists them
all, and a marker with readings in a unit that does not convert says how
many were left out. Nothing else on the timeline changes.

To make room without growing the large timeline files, the legend, the
screen-reader tables and the value-line geometry move into modules of
their own, and the chart, the chronicle, the point cards and the page
each hand a part to a helper; the size baselines are written back lower.

Refs #1299
2026-10-29 20:31:36 +01:00
Marc-André Bombeck 42b2c72e28 feat(timeline): serve lab markers as value lines
The timeline showed a lab report as an event on the axis, but the lines
under the lanes could only be measurements. A lab marker can now be named
in `values` as `lab:<biomarkerId>`, beside the measurement types and
`MOOD`, so one choice, one request and one stored list carry every line.

`GET /api/timeline` answers with `labSeries`, one entry per named marker
with its readings in the window as single points (lab values come a few a
year, so they are never averaged into buckets), each dated by the record's
local day, judged against its own window and linked to the report it was
read from when the caller may see documents. `availableLabs` lists every
marker the record has a numeric reading for, so a client offers only
those.

A reading stored in another unit than its marker's is converted through
the unit-conversion registry where the pair is known, and otherwise left
out and counted in `excluded`, never drawn on a scale it is not in. A
marker id that is not in the record yields no line instead of a 422, so a
stored choice survives a deleted marker; ids are capped at 24 a request.

Refs #1299
2026-10-29 20:31:27 +01:00
Marc-André Bombeck 646ad3c5d8 Merge branch 'feat/v1463-codex' into release/v1.46.3 2026-10-29 20:27:15 +01:00
Marc-André Bombeck b1bbffc69b docs(backup): restoring the copy of an erased account
The runbook said a restore writes into an account that exists under the
same id, which left an operator with an erased account's file and no
way in. It now says where such a copy can go, how the new account is
signed into, why an account with records is refused, and what does not
come back.
2026-10-29 20:20:55 +01:00
Marc-André Bombeck a40bd53d45 feat(backup): offer an account when an upload's owner is gone
An upload refused with backup.owner.missing no longer ends in an error
toast. The backups console keeps the file and opens a dialog: a new
account by default, which cannot hold anything yet, or an existing one,
which the server checks as soon as it is picked and which the admin
confirms by typing its username. Managed profiles and accounts pending
deletion are not offered. The file is then sent again with that target,
and a refusal stays in the dialog beside the choice it refuses.

The new-account path says the account has no password yet and where to
set its first one. Copy in every app language.
2026-10-29 20:20:28 +01:00
Marc-André Bombeck 2220855c8d docs(ai): describe the Codex model list read and per-sign-in memory
The protocol spec now documents the /models request, the fields read
from it, the order the walk is built in, the per-sign-in cache and the
codex.models.* annotations (the codex.slug.* keys it named never existed).
The operator env comments say that CODEX_MODEL still goes first and that
CODEX_MODEL_FALLBACK_CHAIN replaces the order and skips the list.

Refs #1287
2026-10-29 20:17:48 +01:00
Marc-André Bombeck 5ab1fb782e feat(ai): pick Codex models from the account's live list, per sign-in
A fixed preference list goes stale at every retirement: when GPT-5.5
retired for ChatGPT sign-in on 2026-10-14, every model the shipped list
named was refused and Codex answered 503 until a release changed it. The
working model also sat in one slot for the whole process, so two sign-ins
on different plans overwrote each other's model and the vision gate read
whichever had answered last.

The client now reads the models the account is offered the way the Codex
CLI does (GET /backend-api/codex/models?client_version=..., same sign-in
as the responses call; shape from codex-rs/protocol/src/openai_models.rs)
and walks: CODEX_MODEL first; our preference order kept to what is
offered; the other offered models in the list's priority order, leaving
out hidden, specialist and frontier-tier entries; the rest of our order
last, so the walk never holds fewer models than before. With
CODEX_MODEL_FALLBACK_CHAIN set the order is the operator's and the list is
not read.

The list is a hint, never a gate. The read goes through safeFetch with a
5 s bound and a 4 MiB cap, is shared by concurrent requests, cached per
sign-in for an hour, and a failed read waits ten minutes. Any failure
leaves the static walk, one ai.codex.models_unreadable warning per sign-in
reports it, and each request that builds the walk annotates a pinned
codex.models.* shape.

The working model, refused models and the list are kept per stored
credential (a person's connection, or the operator-shared central one),
bounded by TTL and a 1000-entry LRU, with a digest of the account id so a
credential reconnected to another account starts empty. A model the list
offered that then answers model_not_found goes to the back of that
sign-in's line for six hours. When the list names a model's input
modalities, the Codex vision gate reads them; otherwise the model family
decides, as before.

Refs #1287
2026-10-29 20:17:42 +01:00
Marc-André Bombeck 8014fd4aae feat(backup): restore an erased account's copy into a chosen account
After an account is erased the operator still has its disaster-recovery
file, and the upload refused it because its owner was gone. The refusal
now carries backup.owner.missing, and the file can be sent again with a
target in the query: an existing account that holds no records yet,
confirmed by typing its username, or a new account created for the copy.
The file's owner is rewritten to that account as it is read, so the
stored copy, its preview, its download and its restore are that
account's, and the restore that follows is the ordinary one.

The new account has no password and no email; an admin sets its first
password with the existing reset. A file whose own account is still here
is refused with a target, and an account made for a refused file is
removed again. The refusal of an unknown owner no longer writes the
owner's id into the audit trail, since the erasure took every row that
named it.
2026-10-29 20:11:14 +01:00
Marc-André Bombeck 9baeb95bab feat(backup): say whether an account can take an erased account's copy
A restore replaces the account's records and has no merge, so the copy
of an account that no longer exists may only go into an account that
holds none of the records a backup carries. The check reads every
backed-up model by its userId, reaches the owner-less children through
their parent, and is held to the schema so a new model cannot be read
as empty. Managed profiles and accounts pending deletion are refused as
well. The admin console asks GET /api/admin/backups/targets/{userId}
before it sends a file again, so a refusal costs no upload.
2026-10-29 20:11:08 +01:00
Marc-André Bombeck 973bb8e5fc feat(backup): rewrite a backup file's owner as it streams
A backup names its owner once, in the top-level userId, and the restore
writes every section under that account and no other. Pointing the copy
of an erased account at another account is therefore one member, not a
walk over every section. This changes that member on the bytes' way in,
without holding the document, and reports the owner the file declared.
Nested userId values and every other byte pass through unchanged.
2026-10-29 20:11:02 +01:00
Marc-André Bombeck 1d374dd4d5 refactor(ai): move the Codex reasoning wire into its own module
The reasoning-field planning and the refusal learning are a self-contained
part of the Codex wire. Moving them out of codex-client.ts leaves room for
the per-sign-in model selection that follows without growing a file the
size ratchet already holds at its limit.

Refs #1287
2026-10-29 19:55:20 +01:00
Marc-André Bombeck b1fe1f0597 fix(ai): hold a refused reasoning field for a day
A gateway that refuses the reasoning field does so by its configuration,
so the lesson now holds for 24 hours instead of one. At the hour every
expiry cost one refused request, a few a day on a gateway that runs the
briefings unattended. Lessons read from a single reply keep the hour.

Refs #1279
2026-10-29 19:39:38 +01:00
Marc-André Bombeck f17ad1f7b3 Merge pull request #1300 from MBombeck/release/v1.46.2
Release v1.46.2
v1.46.2
2026-10-29 12:11:46 +01:00
Marc-André Bombeck a587be009d test(backup): report a broken pipe in the dump test's docker wrapper
Under a loaded full run the wrapper around pg_dump and pg_restore crashed
with an unhandled stream error, and the dump under test reported only the
tail of that stack. The wrapper now leaves the exit code to the tool and
writes the reason to stderr.
2026-10-29 11:51:39 +01:00
Marc-André Bombeck 20585f3b3d chore(release): v1.46.2 2026-10-29 11:28:49 +01:00
Marc-André Bombeck 14eaa5c7c5 test(backup): keep the 1.46.2 backup file as a golden 2026-10-29 11:28:48 +01:00