The runbook now says that key rotation converts copies still stored as one
value into pieces a slice at a time, that a backup skips an account whose
copy is being restored, that two backups of one account run one after the
other, what a reader answers when its copy is replaced mid-read, and that
migration 0352 cannot be undone by going back to v1.39.1.
Key rotation re-sealed a single-value backup in place, which holds the
stored string, its bytes, the plaintext and the new ciphertext at once; a
copy can be over a hundred megabytes and the in-app rotation walked 25 of
them per page, enough to run a 1 GB container out of memory. Rotation now
converts each such copy into pieces under the active key instead, one row
at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte
at a time with its tag checked at the end, inside one transaction, so a
copy that fails the check is left untouched; the copy keeps its date. The
scan reads only the first characters of each value. Measured on a copy of
a 2.6 million-reading account (126 MB stored): converted in 25 s with the
heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out
of heap.
The weekly and manual backup leave an account alone while a restore of
its current copy is queued or running, and count it as skipped, not
failed. A reader whose copy is replaced mid-read answers 409
backup_changed, "replaced by a newer one while it was being read", rather
than the undecryptable error; a restore rolls back with the same code.
Pieces are sealed with a fixed associated-data label, so a value the same
key sealed for another purpose never opens as a piece; rotation re-seals
under the same label. A row holding both a single value and pieces, which
only an older release writing after a downgrade produces, is refused with
a message saying so, and the next weekly run replaces it. Rotation treats a
row deleted between its read and its write as gone rather than an error.
The weekly-backup section still described one stored value capped at a
fifth of the heap and advised more memory. It now says how a copy is stored
and checked, that its size no longer depends on memory, what
BACKUP_MAX_STORED_MB limits and how to raise it, how older copies are read,
and how key rotation handles both forms. It also states that a 512 MB
container is below the app's own working set during a backup.
A stored backup was one value in data_backups.data. v1.39.1 wrote it a
piece at a time but still joined the pieces into that one value, and one
value has to pass through the app whole, so a copy was capped at a fifth of
the heap: 105 MB in the default 1 GB container. An account with 1.75
million readings is past that and could not be backed up at all (#1031).
A copy is now kept as ordered pieces of about a megabyte in the new
data_backup_chunks table (migration 0352). Each piece is sealed on its own
with AES-256-GCM, and its sealed header names the copy (a random stream id
kept on the row), its position and whether it is the last. A reader checks
every piece before it releases a byte, so a piece that is missing, moved,
altered, taken from another copy, or a copy cut short is refused with
backup.payload.undecryptable before a restore deletes anything, and the
second read during the restore checks each piece again.
The write is one transaction, so the previous copy stays whole and readable
until the new one commits. Pieces are written through the model API: a raw
query with a large parameter is kept alive by Prisma's query plan cache,
which left 160 MB of heap behind per hundred one-megabyte inserts.
Restore, preview, download, the restore job and the admin list read both
forms; copies written before this change keep their single value and still
restore. The heap-derived limit is gone. The one limit left is on storage,
BACKUP_MAX_STORED_MB (default 2048), and its message names the setting. Key
rotation covers the pieces, the wipe and account deletion take them with
their backup, and the backup plan lists the table as not carried.
Measured on an account of 2.6 million readings in a 1 GB container with web
and worker in one process: backup 87 s, 97 MiB in 96 pieces, preview 20 s,
download 25 s, restore 264 s with every reading back exactly, peak 567 MiB,
no restart.
A weekly or uploaded backup written by v1.39.x is a single ~hlgcm1. stream
in data_backups.data. Its key id sits behind the stream marker, which the
string codec's parser does not read, so key rotation filed every such row
as legacy, failed to decrypt it and counted it as an error. On a host that
ran v1.39.x that was every weekly copy, and the rotation never finished
clean.
Rotation now reads the key id of a streamed value and re-seals it as a
stream under the active key, without touching what it holds.
A current disaster-recovery backup writes aggregationProvenance for every
measurement, null included. The restore treated null like a missing field
and marked every Apple Health stats: row without a provenance as
LEGACY_UNKNOWN, which is meant only for backups too old to carry the field.
The nightly daily-mean fold writes exactly such rows, so on a large Apple
Health account a restore changed tens of thousands of readings it was
supposed to put back as they were (#1031).
Only an absent field now takes the legacy path.
A commit that reaches main without a pull request, such as an asset
uploaded through the web UI, was never analysed until the weekly run.
Run on push to main as well. Pull-request runs still cancel their
superseded heads; push runs are grouped per commit and never cancelled,
so back-to-back merges each keep their own analysis.
Bump @simplewebauthn/server 13.3.3 -> 14.0.2 and @simplewebauthn/browser
13.3.0 -> 14.0.0, and keep React on an exact pin at 19.3.0 with react-dom
and both type packages moved alongside.
SimpleWebAuthn 14 changes one default the server relied on: the verifier
used to accept every algorithm the library knew (EdDSA, ES256, ES512,
PS256/384/512, RS256/384/512, RS1) and now accepts only the three it
offers. Pass both lists explicitly so a registration that succeeded
before still succeeds, and so the offered list stays EdDSA/ES256/RS256
on a runtime that would otherwise prepend ML-DSA-44. The option JSON of
all four ceremonies, and verification against stored credentials, match
the 13.x snapshots unchanged.
Every existing passkey test mocks @simplewebauthn/server, so a library
upgrade could change the options a browser or the iOS app receives, or
the algorithms the verifier accepts, without any test noticing. Drive
the real library with a software authenticator: snapshot the option
JSON of all four ceremonies, register and authenticate with each of the
ten algorithms the verifier has accepted, and verify an assertion
against a credential row stored the way earlier releases wrote it.
On the rollup path the all-time count, min, max and mean are composed
from DAY buckets plus one raw aggregate of the readings older than the
fold window. The bucket half read every DAY bucket the account had, the
raw half every reading before now minus five years, so a reading on any
day that had a bucket before that instant was counted in both. Such
buckets are common: the per-write hook folds whatever day it touches,
the Apple Health import folded whole spans until this release
clamped it, every fold snaps its start down to a whole day, and
nothing removes a bucket once the moving window has passed it. The
count came out too high and the mean leaned towards the doubled days,
while the live fallback reported the true figures for the same account.
Both halves now meet at the start of the window's first UTC day:
buckets from there on, raw readings before it. A reading lands in
exactly one half whichever buckets exist, so buckets already written
before the window need no repair.
The snapshot's second phase (blood pressure in target, the per-context
glucose tiles, the Health Score) waits until weight, both blood-pressure
halves and steps have DAY buckets. The fold writes buckets only inside
the five-year window, so a type whose every reading is older than that
never gets one and the phase stayed null for good. Since the Apple Health
import now folds only inside the window too, an account that imported
years-old blood pressure or steps and nothing since lost those tiles and
its score with no explanation.
An uncovered gate type now holds the phase back only when it has a
reading inside the window, which is the case the backfill will still
reach. The extra query runs on the miss path alone and asks one indexed
EXISTS per uncovered type.
The restore route answers 202 with a job id and leaves the work to the
backup-restore queue. The journey still expected the synchronous 200 and
read the record straight after the click. It now asserts the queued
answer and its status route, polls that route until the job reports
succeeded (a failed job ends the wait with its code), and only then
checks what the restore put back and dropped. The job runs on the
pg-boss worker inside the server under test, as the manual backup pass
earlier in the same file already relies on.
The projector floored today's slots only on an empty archived era, the
reminder worker and the next-due reads on the newest revision boundary,
and the auto-miss pass on the creation instant alone. Switching tracking
back on and then moving a dose time the same afternoon therefore put the
morning dose on the card, and the next day's pass stamped it a miss; a
same-day times edit on any medication did the same.
One helper now names the live era start (the newest active revision's
boundary) and every path floors on it: the projector, the worker, the
doses card, the medication list, the MCP schedule tool and the auto-miss
pass, which leaves a placeholder minted after the switch for a slot
before it.
Three readers compute a rate straight from intake rows without reading a
schedule: the weekly compliance nudge, the adherence series behind the
general status, and the BP-versus-compliance correlation. They now read
tracked medications only. The structural guard covers intake-row readers
as well as schedule readers, with a whitespace-tolerant matcher, a floor
on the match count and a reviewed allowlist.
A medication's units per dose, a schedule's override and a restored
backup now accept any value above 0 and at most 100 with at most four
decimal places, which is exactly what the Decimal(10,4) column stores.
Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3,
3/4 passed, so one and a half tablets could not be recorded. A restore
now refuses a value no write route would accept.
The medication editor keeps its buttons and adds an Other field that
reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not
read, and previews the value it will save. The supply tab's packaging
dialog uses the same reader; it refused every non-whole value before,
so a medication at half a tablet could not have its packaging saved.
Cards and the supply tab show a whole number plus a fraction as 1½.
The rule, the input reader and the display format live in one module
without imports so the editor and the validators share them.
Refs #1034
The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
The corpus walk and the rotation script paged with Prisma's cursor and skip.
Prisma resolves that cursor by reading the page's last row again, so when
that row was hard-deleted before the next page (a backup replaced, a cached
response expired), the next page came back empty and the walk ended early.
The scan then counted fewer rows than the database holds, which could read
as nothing left on the old key while rows it never reached still were.
Both walks now ask for the rows whose id is greater than the last one read,
which needs no row to still exist.
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.
The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.
A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
The detail page leads the schedule tab with the switch and a one-line
explanation of each state; with tracking off it drops the reminder card,
the next-dose and adherence rows, and shows the stored schedule as the
record. The wizard summary carries the same switch. Cards, the table and
the detail header mark a record-only medication, which leaves the take and
skip buttons, the take-all set, the log-dose dialog and the dashboard quick
add. Strings in all seven locales.
Portable files exported before v1.39.1 carry no creation date for a
medication, so the restore stamped the restore time. The dose history reads
that date as the day the medication began to exist, and every miss before
it, including the day before the restore, dropped out of the record.
When the file has no creation date, the restore now uses the earliest thing
the file records for the medication: its first dose (scheduled, taken or
written), the start of its first schedule revision, its course start, its
first pause, dose change or side effect, and never a date after the restore.
A file that records nothing about the medication keeps the old default.
An uploaded file whose encrypted copy passes the size one stored backup may
have on this host (a fifth of the heap limit) stopped the store with
BackupBlobTooLargeError, which the route let through as a 500 and reported
to the error tracker. It is now a 413 with the numbers and the remedy in the
message and meta.errorCode backup.upload.too_large, and the refusal is
audited like the others. The store rolls back, so nothing is kept.
A restore commits its transaction and then rebuilds the chart tiers. A
worker that stopped during that rebuild left the job running with a stale
heartbeat, and the boot sweep and the admission check treated it like any
interrupted job: the sweep queued it again, so the second run deleted and
re-wrote the account and lost whatever was written in between, and a second
interruption closed it with "rolled back, nothing was changed", which was
false.
The job now records `committedAt` (added to migration 0347, which has not
shipped) and awaits that write before the rebuild starts. The sweep and the
admission check close a committed job as `failed_after_commit`, never queue
it again, and evict the account's caches. The rebuild itself is left to the
nightly and boot passes that already rebuild missing tiers.
The module comment now says what an interrupted restore does (restarted
once, then failed) and what happens to other writes while a restore runs:
new rows are written at once, updates to rows the restore already deleted
wait on their lock until it commits or rolls back.
In the procedure history every row carried the kind as its heading and
again as its badge. A procedure now leads with its reason, and the
practice moves to its own line beneath the body site.
Refs #1025
New structured leaf SURGICAL_HISTORY in the history group and in the
standard template: every procedure that took place, oldest first, with
its date, what was done (the visit's reason), the body site with its
side and the outcome. Lifetime reference data like the immunization
record, so it ignores the report window. The PDF prints it as a table
and the clinician share page renders it; saved selections do not widen.
The FHIR bundle carries no Procedure resource, on the same terms as
visits: no REST route or capability entry exists for it.
Refs #1025
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.
- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
searched after the decrypt over site, side and reason, with body-site
choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
form offers the site for a procedure and keeps it in view on any
visit that carries one
- backup and restore carry both fields; the body site is registered as
ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
procedures when no window is named; the Coach visit block names a
procedure's site
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
The named-stage arm took the body's source and so wrote MANUAL under a
scoped credential, while every other arm of the route resolves such rows
to EXTERNAL. It now resolves the source from the credential as well, and
a bridge segment stays apart from the same reading from Apple Health.
Decoders written against the shapes the shipped iPhone builds read still decode every payload this release changes, and the old shapes still satisfy the new document: the new batch-entry fields and the error code stay optional, the profile time zone stays a required string, and the named sleep-stage answer sits beside the old create response rather than in place of it.