The clinical category a person picks for a medication lives in a side table
created at runtime, outside the schema. The backup builder and the restore
only walk schema models, so no backup ever carried it: after a restore every
medication read as "Other".
Both backup formats now carry the category inside its medication, and the
restore writes it back in the same transaction as the medication. A file
from before this change restores as before; a category this server does not
know reads as Other instead of refusing the file.
The backup plan gains a list for tables created with raw SQL, and a check
that every such table in the source either maps to a schema model or has a
verdict there, so the next one cannot drop out of backups unnoticed.
A week or month point of a cumulative metric is the average daily total,
which keeps the axis comparable with the 7, 30 and 90 day tabs. The
caption said "Weekly avg", which reads as an average week's total. Steps,
energy, distance, flights and daylight now say "Daily avg per week" or
"per month" on the caption and in the tooltip, in all seven languages;
level metrics keep the plain average.
The All range is read from the WEEK or MONTH rollup tier once a history is
long enough, from DAY rows for a short one, and from the live table on a
coverage miss. The chart folds every path into the average day of each
week or month, but a tier row carried a cumulative metric's whole total
and a level's reading-weighted mean. A month of steps therefore read about
thirty times higher for a long history than for a short one, under a
caption that said average.
A tier row now carries the average day of its bucket on every tier: a
cumulative metric's total over the days that hold a reading, a level's
mean of its daily means. The rollup fold counts the bucket's days and adds
up their means for it. Only the web chart's series reads (the chart
endpoint and the dashboard batch) use these rows.
The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.
An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.
The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
The release branch alone measured 3699 KB gz against the 3700 cap; the
review fixes added 1.3 KB gz across the whole build, nearly all of it new
copy in the seven lazy locale catalogs. The four eager routes measured
the same on both builds and stay inside their ceilings. The aggregate cap
moves to 3724, the same headroom as the earlier re-measurements.
A medication whose end date had passed still showed taken and skip in the
table and on its card, because the action row only looked at whether the
medication was active. The schedule engine already stops minting doses
after the end date, so reminders, the doses card and the today list were
quiet, but the medication itself kept inviting a dose that is no longer
part of the course.
The server now resolves one rule and publishes it on the list and detail
reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the
user's clock, both ends inclusive) and `intakeActionable` (active, intake
tracked, course includes today). The table and both card variants render
their actions from it, the take-all flow skips such a medication, the card
and table name an ended course with an "Ended" badge, and no overdue
escalation is raised for it. A course that ends today keeps its actions
for the whole local day.
The detail header's "Ended" status now follows the same field. It compared
the end date's UTC midnight with the clock and so called the whole last
course day ended. Logging a past dose from the history is unchanged.
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.
`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
The persisted copy of a streamed reply used to mount as a new bubble, so
what the reader had done on the streamed one (the chart or table view, the
open steps list, the evidence panel) reset when the conversation refetch
landed. A turn now keeps one key from its first streamed frame to its
persisted copy, both render in the same wrapper, and the copy keeps the
live tables instead of refetching them, so React keeps the one bubble.
The wrapper stops being a live region once the turn is settled. The
150 ms twin-hiding window this replaces is gone.
"As a table" used to drop the table's chart, so the reply offered no way
back to it. The copied table now keeps its chart and carries view: table,
which the panel opens on the table (the chart one tap away on the toggle)
and the chips read to offer "as a chart". show_result with view table
does the same. The field is optional on the result metadata; a client
that ignores it shows the chart first as before.
The Coach's recent nights carried the wake instant in UTC as their date,
while the sleep table under the answer keys a night by the local day it
ends on. East of UTC the two named different days for the same night.
The snapshot now gives the night's own key.
The recheck and follow-up rules now ride the tool-mode prompt only once
the conversation holds a table, or from the round after the turn produced
its first one. The clarifying-question rules are shorter and English for
every locale, like the other instructions to the model; the German copy
of them and of the recheck rule is gone. A turn without tables sends
about 1 300 characters less on every round.
The golden transcripts change only in the system prompt's hash and
length (4 509 to 3 187 characters; 4 728 to 3 280 for the German turn).
Table refs, step ids, chip ids, a chip's anchor ref, clarification choice
ids and a reused table's source are now validated against their exact
server-minted patterns when a stored reply is read back. Some of them are
written into the model's context on a later turn (the earlier-tables list,
the "keep looking" fetched list), so a stored row edited by hand can no
longer carry text into a prompt through them.
A restore now leaves out measurement tombstones deleted longer ago than
the tombstone retention. The nightly purge removes exactly those rows,
the delta feed answers cursorExpired for every cursor that could still
be served one, and the consolidation repairs look only inside the same
horizon. Younger tombstones keep their readers (a lagging paired client,
the undo of a recent delete, the repairs, the duplicate guard on
re-uploaded samples) and are written as before. The backup file is
unchanged. On a seeded account of 1.89 million readings whose 1.68
million tombstones were past retention, the restore on a slowed database
took 5.4 min instead of 15.
The rollup fold after the commit rebuilt five years of buckets under the
60 s request statement limit and could be dropped on a slow disk, leaving
the charts empty until the next boot. `recomputeUserRollups` takes a
`statementTimeoutMs` that scopes each aggregate and persist transaction
with SET LOCAL, and the restore passes its after-commit allowance.
Fixture tombstones in two round-trip tests were dated in July 2026 and
had aged past the retention; they are now relative to the run.
The test diffed the shared system tmpdir before and after the extraction,
and other test files extracting in parallel stage their files there too,
so it failed on their files rather than its own. TMPDIR now points at a
directory of the test's own for the call.
show_result now passes the same scope gate a fresh read does before it
decrypts a stored table: a metric the person has since excluded from the
Coach, or one outside the conversation's scope, answers
unavailable_in_scope and nothing is sent. The list of earlier tables in
the context drops those metrics too, so the model is not told they exist.
The exclusion (the person's own list plus switched-off modules, and no
adherence when medications are excluded) is the snapshot builder's, now
one exported helper both paths use.
"Keep looking" no longer copies the unfinished question into the
server-written context lines. The question stays in the transcript as the
person's own turn; the line only points at it. Lifted into the system
role, whatever the question said would have carried the system's
authority.
A turn now reads its conversation's newest messages once and shares the
rows between the chip, clarification, "keep looking" and repeat-question
checks, instead of three identical reads.
Earlier tables keep their m<k> names once a conversation outgrows the
window a turn loads: the index counts the assistant messages before the
window too, so a name the model read on an earlier turn still points at
the same table.
The backup validation now reads the per-message table ceiling from
results/refs, which imports nothing but types. Reading it from
results/project pulled the tool executor and the message store into the
snapshot builder's import graph, and the free-text sanitiser guard then
walked files that never build a prompt.
A restore is refused before it deletes anything when its transaction limit
would run past three quarters of the job's expiry. At two hours that refused
a same-size replacement of about four million readings. The expiry is now six
hours, which admits ten million over ten million; a dead worker still frees
the account within minutes through the heartbeat, so the longer expiry does
not hold an account longer.
The refusal now names both counts, the readings in the backup and the ones the
account holds now (removed readings included until the nightly clean-up),
since clearing the latter costs time too.
The server offers no chips under a clarifying question. The second
streamed turn now carries the card alone, and axe runs once with the
chips under the first answer and once with the card.
run-case.ts now calls a provider for the live scenario run, and judge.ts
reads COACH_EVAL_SCENARIO_REPEATS. Both belong to the offline evaluation
harness, like the judge and its model knobs.
A turn never stores more than 128 KB of table JSON, but a restore accepted
any size in resultsJson or resultsEncrypted and sealed it into the database.
Both are now bounded: the readable form by its UTF-8 size, the sealed form at
twice that, above the ciphertext's measured expansion. A file this release
writes always fits; a larger value is refused like any other malformed field.
The grounding ledger derives differences only between figures of one
payload, so a correct "up 4 mmHg on last week" drawn from two separate
table reads was withheld as unverified. A table of the period before or of
a year ago now reads the current window too, and its summary carries the
current figures and the change (delta and percent change) beside the
earlier ones. A wrong delta is still flagged.
The live evaluation now captures the tool calls a generation makes, with
their arguments, and hands them to the judge. runScenarioLive puts a
scenario to the model in the chat turn's own context (system prompt,
tool-mode and dialog addenda, DATA INVENTORY, EARLIER TABLES, transcript)
over the real tool catalogue, answers each call with a stand-in result,
and reads the reply through the turn's clarification and chip parsers.
runScenarioJudge grades each scenario a few times with the same
evaluateScenario the deterministic suite uses and reports a pass rate per
scenario; the server-only scenarios and checks are left out.
Like the existing judge it runs only with COACH_EVAL_API_KEY, from the
nightly workflow's non-blocking step, and never throws.
A tapped follow-up chip goes away and a clarification choice disables as
the turn starts, which dropped focus to the page. Both now hand focus to
the composer, which stays focusable while a reply streams (read-only with
aria-disabled rather than disabled). The clarification question is
announced through a live region that is mounted before the question
arrives; the card itself no longer carries one.
The encryption backfill now also removes the two copies of a practitioner's
contact details that outlived the sealing. An appointment reminder's copy of
the address is cleared, since the reminder resolves it from the practitioner
when it fires. An address-book edit's audit row written before the sealing
kept the old phone number and address in its previous values; those values
leave the row and the changed fields stay named. Rows of a deleted account,
which no per-account job reaches, are scrubbed by the discovery pass itself.
A route whose readable geometry is a JSON null rather than SQL NULL had
nothing to seal but matched discovery every night. The pass now sets it to
SQL NULL so it drops out.
Sealing the practitioner's address left the appointment arm reading the old
readable column, which is now empty, so a booked visit's push lost the address
on booking, rescheduling and restoring a deleted visit.
The reminder no longer keeps its own copy of the address. The reminder tick
resolves it from the owning visit's practitioner when it builds the push and
decrypts it there, so an edit of the address also reaches a nudge that is
already booked. MeasurementReminder.location stays NULL on appointment rows:
booking writes none, a re-anchor clears one an earlier release wrote, and a
backup neither carries nor restores one. A checkup's own free-text location is
unchanged.
A withheld table now leaves a line saying why (module off, or the stored
tables could not be read) and a failed read shows an error row with a
retry, so the Data used count matches what is listed. A table copied from
an earlier answer names that answer's date under its title. The preview
shows the latest twelve periods of a time series, and the truncation note
says the latest rows are kept, which is what the server keeps. The title,
copy and view controls sit above the scrolling table with the title kept
as a hidden caption, and a reply with tables takes the full column width.
The step headers and the related-metric chip read as label: value in all
seven locales, so a metric name no longer sits capitalised mid-sentence.