Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.
The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.
A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
ChartErrorState took no error, so a chart whose read was refused or
whose session had ended offered a retry that could not help. It now
takes the error and branches the way QueryErrorCard and QueryErrorRow
do, the chunk boundary hands it the error it caught, and the error-prop
guard covers it.
QueryErrorRow gains an inset form for a failure inside a card body, and
its frame takes the md:p-6 step. QueryErrorCard drops its fixed px-6 for
the card's own inset and both icons move onto the size scale.
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
The linear-fit guard floored only its walk; it now also requires the
implementation's call sites it expects. The per-type readings query
aliased its driving relation t, which the i18n call-site scanner read as a
translation call; it is mt now, as in readLatestEver.
`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.
The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
The briefing's last-seen read and the MCP search presence probe grouped
every live row of the account to learn the newest instant or whether a
type exists at all. Both now use readLatestEver, one index lookup per
type. The latest-per-type guard also catches the Prisma spelling: a
groupBy by type with nothing but _max, or no aggregate, and no window.
Six readers fitted a line by hand, four in the determinant form that
loses digits on a large x axis, and the two charts carried the same trend
function twice. linearFit / fitFromSums in lib/stats is now the one
implementation (centred sums, so a fit from points and one composed from
stored accumulators are the same arithmetic), trendLineByTime serves both
charts with the same two-decimal output, and a guard keeps hand-rolled
slopes out of the tree.
Five queryFns caught the ApiError and threw a plain Error with a
translated line, so the read-failure primitives always saw a generic
failure: an ended session offered Retry instead of Sign in, and a
refused share drew an error card. The queryFns now pass the error
through; the message lives on the primitive. The ECG list also says a
failed read instead of rendering nothing.
The guard now also reads every queryFn and refuses a catch that
re-throws a plain Error.
The Health Connect import stored its unit factors' binary noise as is
(grams to kilograms, millis to minutes, the day sums) and rounded the
pulse means and nutrients privately. Every measurement value now takes
importValue at the write boundary, and importValue takes a precision for
the two places that need another one. The rounding guard now covers the
import directory and the scaled Math.round form.
Four per-metric status snapshots read mood context without the tombstone
filter, and the blood-pressure snapshot read measurements the same way, so
a deleted entry kept shaping the text written about it. The breathing
screen and the HRV widget probe had the same gap.
One reader now serves the shared mood context, and a guard derives the
soft-deleting models from the schema and fails on any unfiltered read in
the analysis, insight and model-input directories.
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
The setup file runs once per test file and imported all eight message
catalogs, several hundred kilobytes each, which made setup the largest
share of a component-suite run. It now primes EN, the fallback floor of
the t() chain. A test that renders another locale primes it at module
scope through primeLocales(), which loads the catalog via the production
loader in a file-level beforeAll.
users.insights_cached_text held the model's briefing, readings and
recommendations about the account in the clear. Migration 0411 adds
insights_cached_text_encrypted (AES-256-GCM, binary layout, purpose-bound
label) and a partial discovery index; the free-text encryption backfill
seals the existing rows one at a time, fail-closed, carrying updatedAt and
guarded on the value it read. Every reader, writer and clear goes through
cached-text-cipher.ts, and a guard keeps the column names there. The
column joins the encrypted-column registry and the key-rotation script,
stays out of backups as operational, and is reset by the data wipe.
The page-walk guard flagged the backfill progress codec's page > 0 check, a
validity test rather than a ceiling, and missed the list walk's own loop
(pageCount - skipped < maxPages). The matcher now reads a counter offset and
ignores a literal bound of 0 or 1. Strava and the rollup chunks walk through
walkPages, so their pending entries go. The dataPoints.list walk stays outside
as a named exception with a match allowance: it is bounded by progress rather
than by a cursor ceiling, streams pages, stops on a slice budget and keeps its
place, and its backfill decides with a sliced verdict.
The side-effect taxonomy imported its enums from the Prisma client entry,
and the recurrence engine the medication wizard previews schedules with
imported the logging context, which brings node:async_hooks, node:crypto
and node:os along. The taxonomy reads the enums entry now, and a rule that
does not parse is reported through an observer the server registers, the
pattern the source-priority parser already uses. A guard walks the import
graph from every client component and fails on a server-only module.
The mapper kept its own parseFloat(n.toFixed(2)), which rounds a half at
the third decimal down whenever the double sits just below it: a weight of
72 345 g stored as 72.34 kg, a distance of 1 000 005 mm as 1000 m. The
shared importValue rounds on the decimal the value prints as. The rounding
guard drops its last pending entry and also catches a round2 declared as a
const.
Correlation fixtures wrote minute-long midday sleeps, which the nap
rule now reads as naps; they write nights. The analytics test mocks the
user read the empty recovery reason makes, and the control-byte guard
asserts the size of its sweep.
The WHOOP, Oura and Withings list walks and the Fitbit exercise walk each
kept a page loop that, at its ceiling or on a cursor that did not advance,
returned what it had read and said nothing, and the backfill around it
stamped the history complete. `walkPages` keeps a ceiling but reports a
walk that stops with a page pending, or on a cursor it already followed,
as truncated. A backfill reads that through one verdict: a failure
retries, a truncated collection leaves the marker unset without a retry,
a clean run stamps it. The Fitbit exercise walk may read a hundred pages
during a backfill instead of twelve. A guard keeps page-counting loops out
of the provider directories, with Google Health and Strava listed until
their next change.
The workout list, the workout detail header and the dashboard's recent
workouts tile each rebuilt their own date formatter from the shared
cache, with a private copy of the current-year check, and so ignored
the reader's date-order preference. They now use useFormatters(), and
the copies are gone.
The timezone formatter guard now refuses any two-argument
getDateTimeFormat call in src/components, so a component cannot build
its own display formatter again. The medication card's day bucketing,
the one other such call, reads userDayKey instead.
`/api/medications/[id]/cadence` computed its adherence chip from a
private copy of the compliance ledger that ignored pause eras, courses
and auto-missed doses, and read only the chart's window of intakes. A
medication paused for a week read 77 % there and 100 % on the detail
page. The chips now tally `trailingComplianceLedger`, the ledger the
compliance payload builds, over the same window, with the medication's
pauses and courses; the copy is removed and a guard freezes the set of
files that build dose rows.
Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.
The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
About a dozen hand-rolled spreads each picked population or sample
variance without saying so, and the two surfaces a person compares
picked differently: the series API reported the population SD of a
blood-pressure series, the MCP tools and the AI payload the sample SD of
the same readings, about 22 % apart at three readings.
`lib/stats/spread` holds `variance` and `standardDeviation` with an
explicit `population | sample` kind and `READING_SPREAD`, the kind every
surface uses to report the spread of a person's readings. The series
route and the feature payload both report it; the spreads that feed a
verdict (the normal-swing checks, the changepoint threshold) and the
clinical glucose SD stay the sample estimate, now said out loud. A guard
fails on a new hand-rolled spread and on the two surfaces drifting apart.
The zone-shift and single-Pearson guards now assert that their matchers
find the correct shapes before concluding nothing else matches, and the
dense-read guard lists the streak's per-day existence probe with the
reason it stays bounded.
Three files carried invisible control bytes in string literals, one of
them a production join key. They are escapes now, the same strings, and
a guard fails on any raw byte below 0x20 other than tab, newline and
carriage return.
The /insights envelope (weight x BP, mood x BP, mood x weight, mood x
pulse, BP medication continuity) and the AI feature payload computed
their coefficients with an ungated Pearson that banded |r| above five
pairs, so five days of noise sloping the right way read as a strong
correlation there while every other surface refused the same points.
Both now use `significantPearsonCorrelation` (n >= 20 and p < 0.05,
through the engine's exact p-value), and the ungated function is gone.
The allowlist guard that tracked its remaining callers becomes a guard
that there is one Pearson implementation.
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.
QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.
The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
Fitbit, WHOOP, Oura, Polar and Withings each kept a private
`parseFloat(n.toFixed(2))`, which mis-rounds halves on the double below
the decimal. `importValue` keeps the same two places, rounds half away
from zero on the printed decimal and returns the nearest double, so a
value prints cleanly. A guard keeps `toFixed` and private `round2`
helpers out of the provider directories; the Google Health mapper is the
one listed exception until its next change.
Web and worker share one process and one heap limit, and the jobs that
hold a large working set (the Apple Health and Health Connect imports,
restore, the weekly and off-host backups, the restore drill, the rollup
backfill, the nightly status batch, the pre-generation walk and the
narrative walk) ran side by side whenever their schedules or an upload
met, so the peak was their sum. `heavyLane` queues them in the process,
first come first served. A waiter leaves the line when pg-boss expires it
and its retry takes the place; a one-shot whose input lives outside
pg-boss keeps its place. The runtime guard binds every heavy queue in the
lane, keeps `lockedPass` outside it, and allows the one-shot rule only
where the send options forbid a retry.
`measured_at` and `scheduled_for` are naive UTC timestamps. The dashboard
streak and the medication-compliance backfill keyed them with a single
`AT TIME ZONE tz`, which reads the value as local time and shifts it the
wrong way: in Berlin a reading at 23:30 UTC counted for the day before
instead of the day after, so a streak could break or stretch near
midnight and the backfill recomputed the wrong day.
The streak's measurement days now come from `readActivityDayKeys`: one
existence probe per local day instead of a DISTINCT over a year of
readings. The DAY rollups cannot answer this because their buckets are
UTC days. The two compliance-rollup readers had no caller left and are
removed with the coverage probe that carried the same shift. A guard
fails on any single shift of a naive column into a bound zone.
pg-boss polls each idle worker slot every two seconds, so the hundred-odd
bindings asked the database for work about fifty times a second while
most of them wait for one tick a day. `createAndWork` now gives a queue
that a registrar schedules and nothing else sends to a 30-second poll.
Scheduled queues that also take sends keep the default, because with one
job per fetch a slow poll would drain a burst at one job per interval;
the runtime guard holds that list to the send sites in the tree,
including sends through a variable.
The latest canonical reading per type ran as `DISTINCT ON (type)` over
every live row of the account in five places (summaries slice warm and
cold path, comprehensive aggregator warm and cold path, targets). With no
skip scan in Postgres 16 that sorted the whole account to keep one row
per type. `readLatestCanonical` drives the read from the type list
instead: one index probe for each type's latest instant, then a closed
one-day window ranked by the source ladder. The closed window also keeps
the plan under `jit_above_cost`. The targets read reuses `readLatestEver`
with a floor.
The summaries slice was built inside three cache cells (the tile-strip
route, the thick analytics body, the dashboard snapshot). It now has one
cell, read through `readSummariesSlice`; composing callers read it
fresh-only so a refresh after a write cannot cache the pre-write slice
again, and the snapshot folds the water tile into a copy instead of the
cached object.
The derived envelope types `value` as an open record, so the sleep
score's `rhythmNights` and per-sub-score `gap` were on the wire but in
no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced
components slot, the envelope's `value` description names it, and a
contract test holds the published field set to the one the engine
emits, for a scored rhythm and for one withheld for too few nights.
Refs #1204
"Since the last visit" was wrapped in a card by the visits section, so
the card stayed on screen empty while the window loaded, when it held
nothing and when the read failed. The component now owns its card and
draws it only with a row; a failed read shows a compact error row.
The same shapes elsewhere: the caffeine card and the mood forecast no
longer open a loading card that most often collapses, and a failed read
on them, the mood tag catalogue, the usual-range strip, the cycle ring
and the measurement drill-down now says so with a retry instead of
passing for no data. The scores strip keeps the ring's cell on a failed
calendar read. The mental-health card no longer draws an empty action
slot for a viewer without the right to start.
The frame guard now also flags a frame whose only content sits behind a
condition.
Refs #1241
The client fetch-limit guard took any `offset` within three lines as a
pager. The day-grouped measurement list passed it with `limit` 5000
between a comment about pagination and `offset` pinned to "0", while it
drew a whole history on one page. Comment lines, trailing comments and
inline block comments no longer count, nor does an offset pinned to a
literal zero in any of the three spellings. The old list shape is a
test case and fails the guard.
Refs #1245
Raw NUL bytes in source files make grep and ripgrep treat the file as
binary and skip it, so sweeps silently miss it. The affected string and
regex literals use the escape now; behaviour is unchanged.
The workout list, the workout header and the dashboard tile formatted
instants through the shared formatter cache without a timeZone, so they
followed the browser's zone. The year comparison in the list used the
browser's calendar as well. All three now take the profile zone, and the
timezone guard also matches getDateTimeFormat(locale, options) calls that
leave the zone out.
Adds messages/nl.json (AI-initial, informal je/jij register, full key
parity with en.json) and registers nl everywhere the locale list lives:
config, loaders, Intl/date-fns mapping, zod enums, OpenAPI spec, scripts,
the managed-profile picker and the notification locale guards.
Dutch follows the Korean pattern for the AI surfaces (English body plus a
Dutch reply-language footer, no safety-contract matrix) and gets its own
banks in the outbound safety screen (dose, risk, causal, continuation),
the prose-grounding and citation-coverage tables, the reference-range
prose and the no-key fallback copy, each with Dutch test fixtures.
PHQ-9 / GAD-7 / WHO-5 are not marked validated for nl yet: the Dutch
wording has not been read against the published forms, so Dutch readers
get the English items plus the existing note until it has.
A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.
The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.
- requireFreshMfa names the proofs that clear a stale stamp in
meta.methods (the possession proofs the account holds, never a
password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
open the dialog, post the proof to /api/auth/reproof, send the request
once more. The flow moves into a React-free module so it can be driven
in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
every caller passes recentProof.run. The other surfaces wrap their
requests in recentProof.run; disabling the second factor asks before
its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
factor) is told to sign in again when adding a factor, instead of being
offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
a web request to one of them runs outside the flow.
An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.
Refs #959