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 drill section said the drill needs nothing beyond the uploader's
GetObject and ListBucket, but the documented uploader grant is PutObject,
GetObject and AbortMultipartUpload. The drill lists the bucket to find the
newest object, so ListBucket is an addition, not something already granted.
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.
Name the Paperless-ngx webhook fields as the workflow editor shows them,
note that Paperless does not follow redirects, and date the double-brace
placeholders correctly: only {{doc_id}} needs 3.0. Say what a dry run
does not check, which re-sends count against which allowance, that Index
all leaves held-back documents alone, and that a workflow can hold AI
reading back with aiRead=defer.
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.
Ask HealthLog whether a document is already held before downloading it
from Paperless-ngx or Papra, and send the source key in the query string.
Pin Paperless API version 9 (Paperless-ngx 2.16 and later), apply --since
locally for Paperless too, fall back to the file name for a blank title,
stop with a clear message when the HealthLog address redirects, and say
only that storage is full when the figures are withheld. The guide names
the minimum versions and the shared OTHER namespace.
Refs #1038
A keyed upload that matched a stored document by its bytes never recorded
its key, so deleting that document let the next import run store it again.
Such keys are now kept as aliases of the document, the source-key check
reads them, and the purge carries them into the ledger with the row's own.
A source key may ride the query string and is then answered before the
body is read or the hourly allowance charged; an upload answered as a
duplicate hands its slot back. GET /api/documents/inbound/source answers
whether a key is held, under the same documents:write scope, so an
importer can skip a download.
aiRead=defer is stored on the row. The summary catch-up, the summary job
and lab auto-staging skip such documents until an explicit Read with AI
or Generate summary clears the marker; backup and restore carry it.
A documents:write caller no longer sees the quota figures on a full vault.
Refs #1038
A delegate who may keep the illness journal but not read the visits
would be refused by GET /api/body-sites on every open of the condition
form, and each refusal writes an access-denied row. The field now asks
only when the grant covers the visits' section, and the condition page
shows the site as text rather than a link in that case.
Refs #1025
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.
- migration 0354: two nullable columns on illness_episodes, reusing the
laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
keeps the stored value, so an edit from the shipped iPhone app, which
never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
conditions, folded after the decrypt and bounded per kind; with site
(and laterality) the visits and conditions there, one hop to their
documents, lab results and visits. Read in the profile section;
conditions only under an illness grant with the module on, and links
into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
one body-site field pair that suggests sites already used; a
condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
name the site on conditions; the Coach illness block carries it
sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
Document tokens, the Paperless-ngx workflow recipe, the import script for
Paperless-ngx and Papra, scheduling for Papra, storage, and
DOCUMENT_UPLOAD_LIMIT_PER_HOUR.
Refs #1038
A single dependency-free Node 22 script that copies tagged documents from
Paperless-ngx (REST, original files) or Papra (API key, /file) into the
vault through a document token. Serial, one file in memory, waits on 429
Retry-After, and resumable through the server's source-key dedup. Supports
--since, --kind / --kind-map, --dry-run with sizes, and holds back automatic
AI reading unless --ai-read is given.
Refs #1038
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
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.