E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a
minimum of one day and put it on the config object, where no caller read
it. Neither annotation fits: it is not deliberately internal, because
there is no internal use, and no consumer is pending. The design forbids
one outright — the worker's grant excludes DeleteObject on purpose, so
nothing here can act on a retention window without giving a compromised
worker the ability to wipe the history. A clamp that guards nothing only
read as an enforcer the worker is not.
So the field and the clamp go, and the reason sits where the parse was.
The variable keeps its place in the two example files and on the compose
whitelist: it is the number the operator sets the bucket's own lifecycle
rule to. The docs table said "defaults to 30", which implied the app had
an opinion; it now says the app does not read it.
U1. The new `unit` description on the batch entry said `value` is read in
the unit HealthLog stores the identifier in. Sixteen mappings disagree:
`convertToDbUnit` runs on every entry, and a waist circumference arrives
in metres to be stored in centimetres, an oxygen saturation and a body fat
arrive as a fraction to be stored as a percent. A client generated against
that sentence sends 98 and stores 9800.
The description now says what happens: `value` is read in Apple's own
HKUnit for the identifier, HealthLog applies its storage scaling
afterwards, and `unit` is recorded for audit rather than validated
against. Two worked examples, because the percent-shaped ones are the pair
that bites. YAML regenerated.
The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.
All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.
An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
Post-publish verify proves the published image boots. Nothing proved that
on a running instance a person can sign in, write a reading and read it
back — which is exactly the shape of the defects this line keeps
rediscovering: a value written and never read.
`scripts/synthetic-journey.mjs` walks that journey in plain Node 22 with no
dependencies, so it runs from a workstation, a runner or the deploy host.
It warms `/`, `/api/health`, `/api/version` and the measurements list first
and discards those timings (the first hit on each route after a deploy is a
cold start), then asserts five legs in order: the served version equals the
expected one, the password flow signs in through a cookie jar, a weight
reading is written with an Idempotency-Key, the same row reads back with
its value, unit and source, and the reversible delete leaves the account
clean. One line per leg with the elapsed time; the first failure ends the
run non-zero with the status and a body excerpt that has every token shape
redacted out of it.
The workflow is `workflow_dispatch` only, with `base_url` and
`expected_version` as inputs and the credentials read from repository
secrets. It is deliberately not wired into the deploy path: the target
instance and the secrets are the maintainer's call.
Mutation proof, against a production build served from
`.next/standalone/server.js` on a throwaway Postgres.
Green:
warm 35 ms (discarded)
ok version 2 ms serves 1.38.13
ok sign in 78 ms session cookie held
ok write 64 ms wrote cmtup9lwu0007zhovn0mxajdz
ok read back 8 ms 77.7 kg (MANUAL)
ok delete 27 ms account left clean
synthetic journey green
Read leg broken deliberately — the same server behind a throwaway forwarder
that empties the `GET /api/measurements` list and changes nothing else:
ok version 4 ms serves 1.38.13
ok sign in 38 ms session cookie held
ok write 40 ms wrote cmtupa4ck000gzhovwnij64fz
FAILED read back 8 ms read back: the list came back without the reading
status 200
body {"data":{"measurements":[],"meta":{"total":0}},"error":null}
synthetic journey RED on "read back"
Version leg broken the same way, by asking the instance for the previous
tag:
FAILED version 3 ms version: instance serves "1.38.13", expected "v1.38.12"
synthetic journey RED on "version"
`--self-test` carries that proof in the repository: it runs the journey
against an in-process instance nine times, once with nothing broken and
once per broken leg, and fails if a broken leg still comes back green. The
workflow runs it before it touches the real instance.
Gates: typecheck, `node --check`, lint (the three known warnings, no
errors), prettier, the workflow and CI-determinism guards.
The runbook said the worker prunes objects older than BACKUP_RETENTION_DAYS. It never calls DeleteObject; its grant is PutObject, GetObject and AbortMultipartUpload, on purpose, so a compromised worker cannot wipe the history. The sentence now says what expires objects and what the variable is for.
The rollups and the cached status insights are recomputed after the
transaction, so a throw there left the rows repaired and every chart on
the pre-repair numbers — and no later run would notice, because the
audit row makes the account skip from then on.
The tail is wrapped now. The audit row is written with
`rollupsRefreshed: false` and only flipped once the recompute returned,
so a re-run can tell "repaired, rollups pending" from "done" and says so.
A run that could not finish the tail names the account, prints the
backfill to re-run — `scripts/backfill-rollups.ts --user <userId>` — and
exits non-zero.
Review finding M2.
The route was exempt from the published contract as a browser handoff. It
is not one: Nightscout has no OAuth step and no authorise page, and the
route is an authenticated JSON POST that Zod-parses a URL and an optional
token, probes the instance and encrypts the pair onto the row. It was the
only POST among eight `connect` exemptions, and its three siblings —
status, test, disconnect — have been published all along, so a client
could read every part of the Nightscout flow except the one that starts
it.
Register it from `nightscoutConnectSchema` so the request shape stays
single-source, document the 400 / 413 / 422 / 429 arms the handler
actually answers with, and drop the exemption. Removing the registration
now fails the coverage guard by name.
An in-place multiplier with no reverse operation was documented with only
a dry run in front of it. Point at the backup runbook where the write
happens.
Review finding M1.
The provenance criterion proves where a row came from, not which build
imported it. An account re-imported on the fixed build carries the same
`EXPORT_XML_SOURCE_MAX` stamp and the same `stats:` external id with the
right numbers and no audit row, so it is selected exactly like a broken
one — and its rest days, under 200 m because the phone stayed at home,
survive the plausibility check after another thousandfold multiply.
So one candidate row that would leave the range now refuses the whole
account: nothing written, no audit row, the offending rows and a printed
reason instead. The idempotency guard only ever held against the
script's own prior run; this holds against the case it could not see.
The runbook says the same in words — a re-imported account must not be
repaired, the re-import already did it — and says what the dry run is
for: it is where the decision is made, not a formality in front of one
already taken.
Integration coverage for the re-imported account: correct rows, an
`--apply` run, nothing changes and no audit row is left behind.
Review finding H1.
The record-unit conversion is now opt-in per caller and only the
`export.xml` archive path opts in. There the attribute is authoritative:
Apple stamps every quantity `<Record>` with the account's own display
unit, so `km` means kilometres and the reading is meaningless without it.
`POST /api/measurements/batch` keeps its documented contract — `unit` is
captured for audit and never read. Nothing in the tree pins the native
client's unit strings for the seventeen convertible identifiers, and a
client that reads a sample in metres while stamping the person's display
unit into the field would have had every walking distance multiplied by a
thousand on ingest. Reading the field there is a wire change and needs
the client's strings pinned first.
The mapping-table audit and the shared conversion module stay. The batch
path's identity behaviour is written down where it is decided and pinned
twice: every convertible identifier round-trips a sibling unit unchanged
by default, and a walking-distance entry sent as `km` is stored as it
arrived. The OpenAPI description says what the server actually does.
Review finding H2.
Tile eligibility filtered the four named contexts against the per-context
summaries and asked whether any of them held readings. An account whose
source records no meal time matched none of them, so the tile was omitted
with the module on, the layout toggle on, and a reading from today. Nothing
warned; the strip was simply short one card.
Move the gate out of the page into `resolveGlucoseTiles`, which walks the
shared bucket list — untagged included — and hand the caller the label key
with the summary. Both summary producers group through the shared resolver,
so `UNSPECIFIED` reaches the wire with its own count, latest value and
trend, and a tagged account still gets its per-context breakdown.
Refs #943
The unit fix only helps the next import. An account that imported an
archive before it carries a decade of days understated 1000x, and the
authoritative repair — re-importing the archive on the fixed build —
needs an archive the account may no longer have.
`scripts/repair-apple-health-distance.ts` is dry-run by default and
selects rows only by the stamp the archive fold itself writes: the
EXPORT_XML_SOURCE_MAX provenance plus the `stats:` external id minted
for that fold. Never by "the value looks small", so a row a native sync
has since overwritten is left alone. `--apply` multiplies one account
per transaction and writes the audit row in the same transaction, which
is what makes a second run a no-op. The archive's own unit is named by
the operator (`--unit=mi`) because the discarded attribute is the defect
itself, and a row whose repaired value would leave the plausible range
is reported rather than written.
Refs #944
Every accountAccess entry on GET /api/auth/me carries writableDomains
and manageableDomains: the sections the grant opens, intersected with
the sections whose routes accept a delegated write at that level. A
READ grant gets two empty lists, a WRITE grant gets writable sections
only, a MANAGE grant gets both, and the vault appears in neither.
The client parser checks the structural invariants (no repeats, within
the grant's sections, manageable within writable, nothing below the
level) and requires the two published views of the active record to
agree on the lists. Regenerate the OpenAPI document and extend the
payload consumer guard with the active-record readers.
Version anchors, the OpenAPI document, the changelog entry, and the
README sentence that still declared immunization records out of scope
while the vaccination record has shipped since v1.37.3.
The off-host job was said to benefit from the streaming work the weekly
in-database backup got, and it did not. Only the payload builder was
shared: `runOffhostBackup` called `buildFullBackupJson` for the whole
document, `gzipSync` over that whole string, a whole-buffer
`createCipheriv` pass over the result, and handed the finished buffer to
a single `PutObject`. Four full copies of the record alive at once, on
the same arithmetic the weekly pass had already been fixed for.
Configuring an S3 target on a long-lived record therefore restarted the
container. On an account of 445 000 measurements the JSON alone is
242 MB, and against a 1 GB container's 524 MB heap the run died of heap
exhaustion seventeen seconds in — taking every signed-in session with
it, because the job shares the app process.
The write path is a stream end to end now: `streamFullBackupJson`
produces the document a page at a time, gzip and an incremental
AES-256-GCM writer consume it as it arrives, and `@aws-sdk/lib-storage`
puts it up in two 8 MB parts. What the process holds is fixed by the
pipeline's shape rather than by the record. Measured on that same
445 000-measurement account under `--max-old-space-size=450`, with the
heap pre-loaded to what a warm server holds: the old path dies with
`Ineffective mark-compacts near heap limit`, the new one finishes with a
peak 79 MB above its baseline and writes a 9.1 MB object.
The stored envelope gains version 3, which moves the GCM tag from in
front of the ciphertext to the end. That is the only change the stream
required — the tag exists only once the last block is in, so a leading
one means the whole object must exist before its first byte can be sent.
Nothing about the authentication moves: the tag still covers every
ciphertext byte and is verified before a byte of plaintext comes back.
Versions 1 and 2 still read, so every object already in a bucket
restores through the same reader with no flag to tell them apart.
The incremental writer is the crypto module's, split into its byte-level
half so the base64 form the in-database blob uses and the raw form an
object needs are one implementation under two framings. The off-host key
handling is unchanged: a separate `BACKUP_ENCRYPTION_KEY`, 64 hex or
32-byte base64, passed in rather than looked up.
A run that uploaded nothing for anybody is a failed job now, not
`ok: true` with an empty bucket behind it. Wrong credentials, a missing
bucket and an unreachable endpoint all fail every account rather than
one, and the target's own sentence rides out as the cause. A run where
some account got a copy still succeeds; retrying the whole cohort over
one object would re-upload everybody's.
The one ceiling left is structural rather than a memory bound, and it is
counted rather than discovered: a multipart upload carries 10 000 parts,
so an object past 80 GB is refused for that account with a clear message
instead of failing halfway through with an SDK error about part numbers.
`tests/integration/offhost-backup-streaming-memory.test.ts` pins the
budget from both sides — what the streaming uploader holds, and that the
materialising path does not fit the same budget on the same fixture in
the same process, so the number cannot pass by measuring nothing.
A gateway can answer a failed generation with HTTP 200 and put the reason
in the body: a top-level `error` object and no `choices`, or
`choices[0].error` beside `finish_reason: "error"` when a generation dies
mid-flight. Both fell through to the empty-content throw, so a dead credit
balance, a data-policy block, a rate limit and a model that is down all
recorded the same hop: status 0, reason "returned empty content", no
excerpt. Nothing on the provider health card told them apart.
Raise the shape a non-2xx reply raises instead, with the gateway's own
message leading and its `error.code` as the status when that code is an
HTTP error status. The chain classifier, the health ledger and the card
then see 402 / 403 / 429 / 503 and treat each of them the way they treat
the same status on the wire. Message and excerpt are bounded and
key-redacted by the same helper the non-2xx path uses, so remote text is
no more trusted here than it is there.
Gated on the gateway tag: api.openai.com signals every failure with a
status, so the pinned tags parse exactly as before, and a genuine empty
reply stays the empty-content throw it was. The local-endpoint provider
had the same hole on its buffered path, its buffered-reply fallback and
its stream, and gets the same treatment. The body classifier learned the
two account-side refusals a gateway words in its own vocabulary rather
than the vendor's.
The mint endpoint's description and its new 409 landed in the OpenAPI source
without the generated spec following, which CI's drift check would have
caught. No behaviour change; the two hunks are the reworded limits sentence
and the ceiling response block.
Only CHANGELOG.md conflicted: v1.38.7 released while this branch was open,
so the new release heading landed where the branch's [Unreleased] section
sits. The HRV entries stay under [Unreleased] above 1.38.7; everything from
1.38.7 down is upstream's file verbatim.
docs/api/openapi.yaml auto-merged. Upstream's only change there was the
`info.version` bump to 1.38.7, which the merge took while keeping this
branch's DashboardLayoutReadResult, and package.json now reads 1.38.7 to
match — so the merged file should equal a fresh generate. It is a generated
file, though, so confirm with `pnpm openapi:generate` rather than trusting
the three-way merge.
The writer added in v1.38.6 compared the process's live heap usage against
80 % of V8's heap limit and aborted above it. That reading is not the
backup's footprint; it is the whole process's, garbage included. A
long-running app sits at 400 MB of largely collectable heap, so on a 1 GB
container (a 419 MB budget) the weekly pass aborted the first flush of every
account: four out of four in seven seconds, the smallest of them a demo
record whose entire stored copy is 1.2 MB, each told its record was too large
and its operator to buy memory.
Drop the gauge. What the writer holds is bounded by its shape and needs no
reading to prove: the three unbounded tables are pulled a page at a time,
every other section is released as soon as its JSON is in the sink, and at
most one flush of text is pending. The one copy that does grow with the
record is the stored blob, so the bound moves there — counted in the
ciphertext bytes actually produced, against a fifth of this process's heap
limit, which is what two copies of the answer plus a live set have to fit
inside. The message names both numbers.
A pass that wrote nothing for anybody now fails its job instead of reporting
`ok: true` with `backed: 0`, so the failing-queue panel and the backups page
read the same thing an operator would. A pass where some accounts were
written keeps passing with the failures as counts.
Measured under --max-old-space-size=450 from a heap deliberately dirtied
first: 445 000 measurements peak at 28 MB held by the backup, a one-row
record at under 1 MB.
`DataBackup.data` holds every weekly disaster-recovery snapshot and every
uploaded pack, AES-256-GCM at rest like everything else, under a column
called `data`. The rotation registry was keyed on the `*Encrypted` naming
convention, so the script never read a backup, reported zero rows
remaining, and the runbook read that zero as permission to retire the
previous key. Following the documented procedure destroyed every stored
backup — the copy an operator reaches for precisely when nothing else
works.
Register it, and register the idempotent-replay response cache the same
sweep turned up. The backup column walks in bounded batches (one row is a
whole compressed account) and re-seals the ciphertext without reading the
plaintext, so both stored envelopes come back byte-identical and so will
any later one. The cache is marked disposable: a row it cannot read is
dropped rather than counted as an error, because keeping it only
guarantees the next replay gets a body it cannot parse.
Running the script against a seeded database rather than reading it turned
up two more. It died on the mood day-context note, whose table is keyed on
the entry rather than on an `id`, and abandoned every column after it —
the backups included. And the document index's verbatim text rotated
without ever being named in the summary.
Make the naming assumption stop carrying weight. A new guard traces the
encryption helpers through their wrappers, walks every Prisma write
payload, and fails when a column receives ciphertext without being
registered. It asserts an anchored, non-zero match count so a matcher that
stops matching fails instead of passing. A second check pins each model's
primary key against the schema.
The script now ends by naming the registered columns it did NOT walk and
exiting non-zero when any is missing: a zero that means "nothing left" and
a zero that means "never looked" must not print the same. The runbook's
retire-the-key step was rewritten around that signal, and tells an
operator who already rotated on an older release how to get their backups
back.
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.
This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.
Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.
The contract gains the endpoint and nothing else; the account payload
already carried the field.
The compression that landed last release was real and was measured in the
wrong place. On a development machine with a multi-gigabyte heap the pass
finished; the container the job runs in is capped at 1 GB, which gives V8
a 524 MB limit, and there the same pass died of memory about fifteen
seconds in. Because the job shares the application process, that was not
one failed backup — it was a restart, and every signed-in session on the
host went with it.
Reproduced before touching anything, under `--max-old-space-size=450`
against a seeded account of 445 000 measurements, 30 000 mood entries and
60 000 intake events: `FATAL ERROR: Reached heap limit` at 34 s. The same
fixture doubled to 890 000 measurements now completes inside a 296 MB
limit, holding 16 MB of live heap where the old writer held 168 MB.
The document is written incrementally now. `buildFullBackupPayload` takes
a `deferBulk` mode that declares the three unbounded tables instead of
reading them, and `streamFullBackupJson` walks the payload in key order,
pulls those tables through page by page, and releases every other section
as soon as its JSON reaches the sink. `packBackupBlobStreaming` feeds that
into gzip and an AES-256-GCM stream, so nothing between the database rows
and the stored value ever exists whole. Both writers go through the same
per-row serialisers, and the round-trip suite pins their output byte for
byte rather than trusting that they agree.
The intake and mood reads are ordered totally now. A keyset cursor needs
it, and a partial order also let two exports of one account differ over a
tie, which every multi-dose day has.
The streamed ciphertext is a new shape at rest, because GCM only produces
its tag once the last block is in and a leading tag is what makes the old
layout unstreamable. It carries a marker no older writer can produce, so
the two families are disjoint by construction rather than by sniffing.
All three stored shapes — plain, compressed, streamed — restore, proved
both ways.
The writer also watches its own live heap and stops at 80 % of V8's
limit. An account too large for its host is now one failed backup,
counted in the run's meta, instead of a restart for everybody on it.
The stored column is the ceiling that is left: `data_backups.data` is a
single `text` value, so the finished artifact has to exist as one string,
and that is the only part of the job still growing with the record. The
operator runbook says so, with the number at which it starts to matter.
Three pieces of code answered "what hour is it" for the salutation. The
snapshot builder computed a `greetingHour` in the user's zone and put it on
the payload so clients would not have to; no client ever read it. The
insights hero derived one through the shared `hourInTz`. The dashboard header
derived one through a local helper that fell back to the DEVICE clock
whenever the runtime could not resolve the configured zone, which is what
happens to an IANA entry the browser's data does not yet carry. At one
instant, for one account, those three returned 20, 21 and 05.
Keep the client end and drop the server one. The hour is a clock reading, not
a fact about the record, and the snapshot body serves stale for up to an hour
past its fresh TTL, so a stored hour can name a salutation the clock has left
behind. What the server IS authoritative for is the zone, and that already
travels on the same payload and is already what both surfaces read.
Both greetings now share `hourInTz`, so an unresolvable zone falls back to the
same default the rest of the app uses instead of to the device. The header's
private helper is gone; it had no other caller.
Two tests pinned the old shape by asserting the payload carried an hour.
Inverted rather than deleted: the payload must now be shown NOT to carry one,
which is the property that was missing when the field went unread for months.
`greetingHour` leaves the response contract, so a native client decoding it as
required needs the field made optional or dropped.
Carries the night's audit work: the weekly backup ran out of memory
rather than out of time and now completes, two dashboard reads stopped
scanning the largest tables, six webhook verbs compare a secret behind a
rate limit, three global channel switches actually stop delivery,
thirty-five settings reach the container, four guarded helpers are used
where their naive twins were, and three guards now see what they claim.
Six WEB_PUSH_* names and NEXT_PUBLIC_VAPID_PUBLIC_KEY sat behind the three
real VAPID_* variables in the env fallback. None was ever on the compose
environment: whitelist and none appeared in .env.example, .env.production.example
or the env manifest, so under the bundled stack a value set under any of them
could not reach the process. Remove them and the self-hosting note that
advertised them.
With the switches now enforced at dispatch, a user could still flip a
channel on, see the toggle stick, and get nothing. Refuse the enable
instead, with 403 and a message that names the reason: the ntfy and
Telegram config endpoints on both accepted body shapes, and the Web Push
subscribe endpoint, which is the enable step for that channel because it
creates the channel row.
Telegram is checked before the webhook registration, so a refused enable
makes no outbound call and stores nothing. Disabling a channel keeps
working while the switch is off — a user must always be able to turn a
channel off, whatever the operator has done.
The three refusals are published in the contract; `/api/settings/global-services`
already exposes the switches so a client can hide the control instead of
provoking the refusal.
The admin AI key now goes to the API its base URL names, seven code
scanning findings are closed at their source, and the guard that was
meant to hold the image's two dependency pins in step can see scoped
packages for the first time. Two accessibility markers and one audit
read no longer race their own page.