Under a loaded full run the wrapper around pg_dump and pg_restore crashed
with an unhandled stream error, and the dump under test reported only the
tail of that stack. The wrapper now leaves the exit code to the tool and
writes the reason to stderr.
The count rides out in the job's outcome facts, whose vocabulary is an
allowlist, and is kept as a failure tally so the catch that feeds it is
not a silent one.
The check whether an account's full rollup backfill is queued or
running answered false on any error, so a failed read let the dense
fold delete raw samples the backfill still had to read. A missing queue
table still answers false, since a web-only deployment has no backfill;
any other failure now counts as pending and is annotated. A backfill
that failed for good is still not pending, as the comment now says.
The admin recompute for one account called the full fold without a
statement budget, so on the large accounts it is meant for each yearly
statement ran under the database's ordinary limit and timed out, as the
boot job had before it got its own. It now passes the same per-statement
budget.
The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
The re-proof accepted an auth_time up to five minutes old and then
stamped the session at callback time, so the proof held for close to
ten minutes after the person signed in at the provider. It now stamps
the authentication instant, clamped to now for a provider clock that
runs ahead, and the gates' five-minute window runs from there.
One staged fact that would not decrypt threw out of the whole repair, so
the job failed, retried, and was enqueued again on every boot. It is now
skipped and counted as undecryptable.
The candidate query read an account zone Postgres does not know as UTC,
while the repair resolves it to the instance default and then
Europe/Berlin. A value confirmed near midnight stayed a candidate the
repair always left alone, so the set never converged. The query now
falls back the same way.
The alert added every attempt of a failed job to the streak, so one job
with two retries read as three failures. It also judged a queue by its
newest job across all accounts: on a queue that runs one job per
account, one account failing every hour beside accounts that succeed
never reached the streak, or flipped between failing and recovered.
The streak now counts jobs, and is kept per account (the job's userId,
or the whole queue for jobs without one). A queue is failing while any
account has failed three jobs in a row, and recovered only when a
failing account has itself run successfully since. A reminder needs a
failure newer than the notice before it, so a one-off job that failed
and does not run again is announced once.
The dense fold deleted Google Health and Oura samples outright, on the
reasoning that no client pulls them. The change feed serves every
source, so a client mirroring it kept those samples next to the hourly
means with nothing to tell it they were gone.
The nightly increment of a server-pulled source is tombstoned again: the
feed reports each sample and the tombstone cleanup purges them after the
retention. A backlog day, such as an account's first fold, is still
deleted outright, and the account's delta cursors expire instead, since
tombstoning millions of rows would undo the point of the fold. The
compaction-tombstone class leaves these sources out, so the feed, the
backlog purge and the restore treat their tombstones like any deletion.
Apple Health and Health Connect are unchanged.
The status prompts carry the unattended flag for background calls, the
queue list holds the lab date repair, and an account whose only way to
confirm is its identity provider is now offered that way.
The reading delete found the cuff's pulse by owner, source and identical
instant. The dense fold anchors an hour of heart rate at the local HH:30
under the samples' source, so a reading taken at half past took that hour
with it. Aggregate (stats:) rows are never part of a reading now, for the
delete and for the restore.
readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
420 000 heart-rate readings, one every five minutes over about four
years, written through the real Google Health backfill with Google's
API faked at the safeFetch seam. The account's DAY rollups lose their
reading extremes, as rows folded before 0417 have them; the boot
discovery finds it, and the windowed full backfill refills every bucket
in the fold window with the raw readings' own extremes. The dense fold
then thins everything older than 90 days to hourly Google Health rows
(420 000 rows to about 59 000) while the DAY tier keeps its figures,
and a second full-history backfill over the same samples inserts
nothing. Without the ingest guard it re-inserts about 394 000 of them.
Each step reports its duration on stderr. On a developer machine:
backfill about 85 s, discovery 0.2 s, full backfill 4 s, fold 40 s,
second backfill 4 s.
Nothing covered the operator's position after an erasure: a backup file,
an admin session, and a server that no longer knows the account. Export
the shared backup seed, delete the account through the settings route
under a 14-day window and run the purge job past its end, then:
- scan every text and JSON column for the account's id; only a
rate-limit bucket that expires within a day may remain;
- upload the file as an admin: refused as owner_not_found, nothing
stored (the documented contract);
- recreate the account row under its id, upload and restore: every
section's count matches the export;
- point the file at a freshly created account, upload and restore: every
count matches and nothing names the erased account afterwards.
Every backup test exports and restores with the same code, so a change
that moves both ends at once passes all of them and still breaks a file
a self-hoster downloaded last month. Commit the disaster-recovery file
the 1.46.1 exporter writes for the shared backup seed, with a manifest of
its counts and SHA-256, and restore each committed file through the admin
upload and restore routes into an empty account, comparing the re-exported
counts section by section.
The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the
current one; the README says when). The seed's own illness-symptom
catalogue key is swapped for a shipped one so every link resolves on a
fresh instance. Ciphertext is sealed under the suite's fixed key. A
measurement tombstone that ages past retention may only lower its count.
A translated label that runs a little longer than the English one wraps,
and a wrapping header grows; every English-only check stays green while
one locale ships a taller dashboard header. The new sweep switches its own
account through each locale the app ships (via the locale API, with
<html lang> asserted before measuring), then compares the top bar and the
dashboard's page-header row on a 390 px phone and a desktop window with
the height English gives them, within 1 px.
The locale list is read from the i18n config, so a new language is swept
without editing the spec. It runs in the desktop project only because the
account's language is one value shared by its tests.
What an account with tens of millions of readings looks like when it
runs into the per-statement limit (empty cards, job.run.failed with
57014), what the full backfill, the hourly heart-rate fold and the
admin alert now do about it, and when raising
DATABASE_STATEMENT_TIMEOUT_MS still makes sense. The variable was only
named in the compose file and the example env until now (#1298).
A full rollup backfill that failed on every boot showed only on the
admin page's card of failing background jobs. From the account's side
the only symptom was empty dashboard cards, and nothing pointed the
operator at the card (#1298).
A new hourly evaluation reads the newest terminal runs of every queue
whose failure has no account-facing surface (the system audience in
subsystem-surface.ts) from pg-boss's own ledger. A failed run adds its
attempts, retries included, and the first successful run ends the
streak. Three failed attempts in a row send every admin a SYSTEM_ALERT
once, a reminder at most once a day while the queue keeps failing, and
one notice when it runs successfully again: the decision the backup
health notices use, with the same compare-and-set claim, so two workers
never send a notice twice.
The memory is one row per announced queue in system_job_alert_notices
(migration 0490). The notice names the queue, the streak and the last
error, and opens the admin status page.
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
The full rollup backfill refills each day's figures from the raw
readings, and the dense fold deletes those readings once it has their
hourly means. On a large account the backfill can run longer than the
ten minutes the fold's boot job waits, and a year it reached after the
fold had taken it would compute its days from hourly means: the day
count, minimum and maximum of every folded day would change.
The consolidation walk now asks a per-account deferUser hook before it
reads an account, and both entries of the dense fold (the per-account
boot job and the nightly drain) pass isRollupFullBackfillPending, which
looks for a queued or running full fold of the account in pg-boss. The
account is folded on a later run, once its rollup tier is rebuilt.
The French bundle mixed the familiar and the formal address: about a
third of the strings said "tu", "ton" or "Réessaie", the rest "vous",
sometimes both on one screen and once in one string.
Every string that addressed the reader as "tu" now uses "vous" with the
matching conjugation, possessives and agreement; placeholders, plural
branches and punctuation are unchanged. The Coach prompts a person
sends address the Coach the same way, the casual "Salut" greeting of
the nudges becomes "Bonjour", and "après que" takes the indicative.
A Dutch reader whose screener answer raised the safety path saw only
the international set, because no Dutch set existed.
The Netherlands gets 113 Zelfmoordpreventie (113, the free 0800-0113
and the chat on 113.nl), Belgium the Zelfmoordlijn (1813,
zelfmoord1813.be), both behind 112. The app's one Dutch bundle is read
in both countries and the account keeps no country, so a bare "nl"
lands on a combined set that names each line with its country, the way
the German and Portuguese sets already do; an explicit nl-NL, nl-BE or
region "be" gets its own country's set.
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.
All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.
/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.
DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.
The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
From a HealthLog container with its stored ChatGPT sign-in, gpt-6.1-sol,
gpt-6-sol and gpt-6-luna each answered text and read a lab-report image,
and the account's own model list offered them with image input.
gpt-6.1-sol refuses effort none and names low as its lowest. The spec said
the gpt-6 rungs had never been probed live, and still listed gpt-5.4,
gpt-5.4 mini, gpt-5.3-codex and gpt-5.2 as accepted, all of which OpenAI
has retired for ChatGPT sign-in, with the exact refusal the backend sends.
Also note why a fixed chain goes stale: once GPT-5.5 retired, every model
on the chain shipped at the time was retired with it.
OpenAI retired the 5.x line from Codex for ChatGPT sign-in in steps: GPT-5.2
and GPT-5.3-Codex on 2026-05-26, GPT-5.4 and GPT-5.4 mini on 2026-08-31,
and GPT-5.5 on 2026-10-14. The backend answers a retired model with 400
model_not_found and "not supported when using Codex with a ChatGPT
account".
Pin that exact refusal as a reason to try the next slug, pin that an
account refused the head of the default list walks on to the next rung
and caches it, and keep the long-retired models off the default list.
gpt-6.1-sol, a rung on the default list, answers reasoning "off"
(effort none) with 400 unsupported_value and lists low as its lowest
effort (seen 2026-10-24). The client must stay on the slug, retry with
low and remember it, not walk on as if the model were retired.
Background jobs ask for medium reasoning at most, and status cards ask for
none. A model that cannot be switched down (GLM behind a vLLM gateway, for
instance) ignores both, spends the whole room thinking and returns only
reasoning, so the daily briefing and the status cards fail as
reasoning_only and the chain pauses. The Coach on High works because it
leaves 12k tokens to think in.
The chain runner now marks every call of a job walk as unattended, in one
place rather than at each call site. The gateway and local clients give
such a call at least the High thinking room once its model has answered
with reasoning alone. The room has to ride on the #1267 repeat with none:
one call may send two requests, and a once-a-day job meets an expired
lesson every morning, so a room that waited for the unswitchable lesson
would never reach the first briefing of the day. It is a ceiling, not a
charge: a model that does stop at none never touches it. Calls a person is
reading, the Coach included, keep the room of their own level, and the
requested effort is unchanged.
The room calculation both clients shared is now one function.
Reported by @Tio61 in #1276.
The dense hourly fold only took Apple Health and Health Connect samples,
so heart rate from the provider syncs that store one row per sample
stayed raw forever. One self-hosted account held about 25 million
Google Health pulse rows after a full-history backfill, and every
reader and fold that falls back to the raw table paid for it (#1297).
The fold now walks a map of sources to the dense types each stores
sample by sample: every dense type for Apple Health and Health Connect,
heart rate for Google Health and Oura. Their daily SpO2 values are no
intraday stream and stay as they are; Fitbit does not sync intraday
heart rate, and WHOOP, Withings and Polar write dense types only once
per recovery, night or spot check. Each source folds into its own
hourly stats rows, and the DAY rollup is still taken from the raw
samples before they go.
The fold deletes the samples it averages, and the provider syncs write
first-write-wins on the externalId, so a backfill or full sync would
have put every folded sample back. Google Health would even have
re-keyed an hourly row into a sample that fell on its anchor through
the natural-key rescue. The folded_window rule now has one hour rule
per fold source, and both syncs drop the samples of an hour the fold
already took before they write. Recent samples cost no lookup.
The boot discovery of the fold probes each account and source for the
first raw sample it would take instead of grouping every such row, and
the one-time fold repair stays on the two sources it was written for.
The provider sent PUT /api/auth/me/locale on mount and on every switch,
signed in or not. Signed out the route answers 401, the once-per-tab
marker was never set, and every signed-out page load repeated the
refused write.
The mirror moves out of the provider into LocaleServerSyncBridge, which
watches the account payload without fetching it and writes only once an
account is known. The choice made on the sign-in page therefore reaches
the account right after signing in, and the marker is keyed on the
account as well as the language, so another account signing in on the
same tab is written for too. The browser test stub no longer treats the
PUT as ambient, which is what lets its test see a stray write.
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).
GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.
recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.
The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.
A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.
Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
On an account with tens of millions of readings the boot full backfill
never finished. It ran four aggregates over the whole five-year window,
each held to the connection's 60 s statement limit, so every attempt
was cancelled with 57014, the reading extremes added by 0417 stayed
NULL, and the readers that need them fell back to the raw table and
timed out as well (#1298).
The full backfill now folds the window one UTC calendar year per pass,
newest first, and every statement runs under its own five-minute limit,
the shape the pulse day-mean rebuild already uses. Calendar-year cuts
write exactly the buckets one pass writes: day, month and year buckets
fall inside one window and a week across New Year is folded whole by
both. Each window commits on its own, so a run that reaches its job
budget hands the remaining years to a follow-up job instead of failing
and starting over on the retry.
The boot discovery read every live reading in the window to find
uncovered days, the same scan that kept the fold from finishing. It
now walks the days per account and type off the live index, so its
cost follows the number of days with readings, and runs under the same
limit. The full backfill code moves to its own module.
Before the confirm step dated an undated value by its document, a lab
value confirmed from a report that prints its collection date only in the
header was stored with takenAt set to the moment of confirmation. The fix
covers new confirms; the values already filed that way stay on the wrong
day until something moves them.
A one-shot maintenance job, sent at boot only while there is something to
repair, moves exactly those values to the document's report date (else its
filing date) at noon UTC, the same rule the confirm step now uses. A value
qualifies only when the chain is certain: written by the document confirm
path, named by exactly one approved observation fact, takenAt still within
five seconds of its own createdAt, no calendar date of its own in the
decrypted fact, and a document day that differs from the day the value is
filed under in the owner's zone. A value re-dated since, one really taken
on its confirmation day, or one whose document has no date is left alone.
A moved value no longer sits at its creation moment, so the pass converges
and a second run changes nothing.
The job definition lives in a new registry file beside maintenance.ts so
that file stays under the size ratchet.
knownSignInSource compared a trusted device's expiry with new Date()
while the throttle around it times its waits with Date.now(). The test
mocks Date.now() only, so it set the device to expire 30 days after its
fixed clock, which the real date passed on 2026-10-26 at noon UTC; from
then on the remembered browser read as expired and the test failed on
every run. One clock decides both now, and the test's expiry is fixed
against it. A further case pins that an expired device gets no
exemption.
The narrow-chart rule lived in HealthChart's body and grew it past its
size baseline. The overlays hook, which already sizes the room beside
the plot, now takes the chart's width, keeps no room on a narrow chart
and marks each target zone whose label sits inside it; the x axis
helper works out its own tick layout. The tiered series unit test mocks
the lookup for readings older than the rollups and covers the head the
read puts in front of them.
A range longer than a year reads its series from the week or month
rollups, which are kept for the last five years. When the rollups held
any rows the read returned them alone, so on a record that goes back
further the All range started where the rollups start: a weight history
from 2015 charted from late 2021. The readings older than the first
rollup day are folded live at the tier's grain and lead the series; the
head ends where the first tier bucket begins, so no month is counted
twice. One indexed lookup when there are none.
1.46 moved the average's and the target zones' labels beside the plot,
in a right margin sized to the longest label. On a phone that margin
took about a third of the plot's width, and the legend, wrapping into
the narrower space, took more of its height: a pulse chart drew at
160 x 66 px instead of 232 x 108. Below 520 px the labels sit inside
the plot again, the average's at the top left as before and a zone's
at the top right of its band, and the plot keeps no room beside it.
The x ticks assumed an 80 px date label. A 90-day chart's month starts
sit about 78 px apart on a phone, so the month grid never fit and the
axis showed one date. The label width now comes from a date in the
reader's format (10/01/2026 draws at 58 px). A yearly grid is labelled
with the year alone, narrow enough for one tick a year, and with the
year it marks when its point falls in the December before.
The y axis shows the decimals its tick step needs: 57, 58, 59 bpm
instead of 57.0, 58.0, 59.0.
Against a real Postgres and the real confirm route: an undated value takes
the report date over the filing date, a value's own date is kept, an
impossible own date counts as none, and a document without a report date
dates the value by its filing date.
A lab report usually prints the collection date once, in its header. The
extraction puts that date on the document (reportDate) and leaves each
value's own date empty, and the confirm step filed every such value under
the moment it was confirmed, so an old report showed up as today's
results.
The confirm route now reads the document's report date and filing date
with the document it already loads, and an undated value takes the report
date, else the filing date, else now: the order the day view places a
document by.
A stated date, a lab value's or a condition's onset, now counts only when
it names a real calendar day. 2021-02-30 used to be stored as 2 March, and
2021-13-01 became an invalid date that failed the whole confirm.
The forwarder ran every tool as docker exec -i with its own standard
input piped through, also for pg_dump, which reads none. With -i and an
input already at its end the docker client may close the stream before
all output has arrived; one CI run saw the second of three nightly dumps
fail where every local run and the two runs before passed. Attach stdin
only for a file fed to pg_restore, and put the run's error into the
assertion so a failure says why.
realisedFamily probed for the closest two slots up to range.to only, so
the answer moved with the range. A Saturday + Sunday plan started the
weekend before the autumn DST change, read around its Saturday dose: the
±24 h range ends at 06:00 UTC on the Sunday, whose 08:00 slot is at 07:00
UTC after the change. Cut off, the closest slots were six days apart
(weekly); the wide re-mint saw the 25 h gap (daily). The two passes
disagreed, no band survived, and the dose was filed as unscheduled, so a
second backdated dose within a minute came back as the first one (200).
Probe the same reach on both sides of the range's middle.
The list ended 24 hours after local midnight. On the 25-hour day of an
autumn DST change that left the last hour out, so a dose due then could
not be offered from it; on the 23-hour spring day it reached into the
next day. Take the day's bounds from getUserTodayBounds, as the dashboard
tile does. Anchor the health-status test's history to local noon: now
minus whole days is still today on the long day.
Node's recursive mkdir retries ENOENT/EEXIST forever below /proc on Linux,
inside one native call, so neither a timer nor a test timeout runs again.
A DB_BACKUP_DIR or GEOLITE2_DIR pointing there froze the process; the
database-backup integration file, which uses such a path for its
unwritable case, never finished on a Linux runner. Walk from the nearest
existing parent with plain mkdir instead.
The new backup, dump and deletion files were unknown to the duration
sequencer and gathered in one shard, which ran past its job timeout
while the other three finished in under fifteen minutes.
With the deletion grace window on, the confirm dialog still asked to
delete the account permanently and its button said so, while the body
promised fourteen days and an admin undo. The title and the button now
match the window, and the success message that names the erase date stays
long enough to read before the sign-in page.
The new account-copy explainer under the Backups heading ran to 99-113
characters in German, Spanish, French, Italian, Polish and Portuguese and
wrapped to a second line at desktop width, so that page's header sat
20 px taller than every other settings and admin page. The German run of
the settings/admin header measurement failed on it. The Korean text was
two sentences in a one-sentence slot.
Each is now one sentence that fits the 1280 px column; English and Dutch
already did and stay as they are.
The kit's check compared keyed SHA-256 digests of the pasted value and the
configured key, which code scanning still reads as a fast hash of a
password. It now compares the scrypt fingerprints in constant time.
API_TOKEN_HMAC_KEY can be a phrase, and its fingerprint is shown on the
admin page and written into dump manifests in the bucket; a fast hash
there lets a guessed phrase be checked offline. Text secrets now use a
scrypt fingerprint; the random encryption keys keep theirs. The kit's
check of a pasted token key compares keyed digests that never leave the
process.
The list reads ordered schedules by their window and id, but the detail,
create, update, compliance, dose-history, GLP-1 and efficacy reads took
them in whatever order Postgres found them, so two schedules created in
the same millisecond could swap places between two answers. Every read
now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every
schedules include, not only the list reads.
The capability gate's check for an account pending deletion added a
point read of users to every request that resolves AI capabilities, one
statement over the budget on /api/auth/me, the dashboard snapshot and the
batch ingest. The usage-level read already loads the record's row once
per request; it now carries the deletion lock too.
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
A background AI run's deadline is armed in model time plus a fixed margin.
Time spent waiting in the document reader's queue, and the read itself, came
out of that margin, so a person's lab scan or document read could be timed
out by the reaper because other work held the reader. When the reader grants
the slot it now moves the run's deadline by the wait plus the most the read
may take, and gives back the unused part when the read ends, never past the
job's own expiry and only while the run is running. The interactive queue
wait stays within the margin, which covers the run until the slot is
granted.
The per-account free-text encryption backfill seals what is already stored
and changes nothing the record holds, which is why its module is exempt from
the frozen-account rule; the new pending-deletion gate dropped its per-user
jobs anyway. A queue can now say it acts for pending accounts
(actsForPendingAccounts), and this one does.
Also types the ingest route test's token fixture to the row it now reads,
and drops an unused variable from the deletion grace test.
The guard asked whether a file that reads the lab table mentions the gate
anywhere, so one ungated read in a file with a gated one passed, and
whenModuleOn for any module counted as the labs gate. It now walks the syntax
tree: each read must sit in a function that asks the labs module, or in a
private helper whose every caller in the file does, or be listed by file and
function with its reason. whenModuleOn counts only with "labs".
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP had been changed to mean signed requests
only, so an operator who set it for a sender that adds a timestamp but cannot
sign would have had every deploy notification refused after the upgrade. It
means the secret header plus a fresh timestamp again. Signature-only, the
setting that stops a replay, is DEPLOY_WEBHOOK_REQUIRE_SIGNATURE, on the
compose whitelist, in the env manifest and examples, and in the deploy
guide.
Forwarding headers were believed from any connection on a private address.
Behind Docker Desktop, OrbStack and docker-proxy every connection to a
published port arrives from the gateway, a private address, so a caller
reaching the port directly could again choose the client address that rate
limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS
(comma-separated CIDRs) now names the proxies: when set, only those peers may
report a client address. The private-range default stays when it is unset,
the boot log states the rule in force in one line, and the reverse-proxy
guide says what to set for a proxy on the same host, on a Docker network, or
on a public address (which, unlisted, puts all clients in one bucket).
An invalid TRUST_PROXY_HOPS threw on every request that resolved the client
address, so every route answered 500, the health probe included. It now
resolves no address, warns once, and is named at boot.
The cross-site write check let through any request that carried an
Authorization header, while the caller is resolved from the session cookie
before any header. A browser attaches Basic or Negotiate credentials on its
own once a proxy or host asked for them, so a cookie-authenticated write from
a sibling subdomain could pass. Only Authorization: Bearer is exempt now, and
only when no session cookie rides along; a request carrying both is checked
as the cookie request it resolves to.
The share-link passphrase POST under /api/c/ is exempt by path: its
credential is the token in the path and the passphrase in the body, and from
the shared view's no-referrer page a browser without Sec-Fetch-Site sends
Origin: null, which was refused.
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.
Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
Three defects in the bounded document reader.
Image uploads were decoded into their thumbnails in the server process. A
header check is all that stood between a decoder and an allocation the file
chooses, and the GIF check showed how that fails. The image is now decoded in
the same child process PDFs run in, under its memory ceiling and time limit;
the header check stays in front of it.
The reader's one slot went to whoever asked first, and a waiting item waited
without end, so a few heavy PDFs from one account held everyone's lab scans
behind them. The slot now goes to a read a person is waiting on before
background work, to accounts in turn, to a few waiting items per account, and
an item that waits past its lane's deadline is answered busy, which callers
treat as retryable.
Any child that ended without an answer counted as a document past its limits,
so a kernel kill caused by the container's overall memory marked a good PDF
as permanently failed. Only a bound the reader measured (the sampled memory,
the time limit, the child's heap cap) is final now; any other end is run once
more and then answered interrupted, which the thumbnail job retries within its
ordinary attempts.
Both resolve a Bearer token outside resolveBearerToken and checked only
revoked and expiry. The deletion revokes every token in its own transaction,
so only a token issued in that same instant could get through, but the rule
should hold everywhere a token is resolved: both now refuse an account
pending deletion.
A restore of the copy a data wipe took replaces the record with that copy,
so anything added after the wipe is gone, and the wipe ended integration
connections, sharing in both directions, Guardian roles and share links,
none of which are in a copy. Neither the person's confirmation nor the
admin's restore dialog said so. Both do now, in every locale.
The dumps now live under database/<host of APP_URL>/, so the rule check reads
a sample key in that folder: a rule scoped to this instance's folder counts,
one scoped to another instance's does not.
Off-host dumps were named by their time alone. Two instances sharing a bucket
(nothing forbids it, and it was safe for the account objects) overwrote each
other's dump at the same second of the same default schedule, and
latest --from-bucket restored whichever instance's dump was newest. Each
instance now uploads under database/<host of APP_URL>/, the object key (and so
the encryption's associated data) carries it, and the restore takes only this
instance's dumps: it refuses another's and names the instances it found,
unless --instance=<host> or the dump's full key says which one on purpose.
A restore killed outright (out of memory, docker kill) left its .restore- or
.download- directory, a whole database decrypted, in the backups directory,
where nothing removed it. A restore now removes them before it starts and on
SIGINT and SIGTERM, and the nightly retention sweeps any older than twelve
hours.
A drill that stopped on its time budget before the first object recorded
ok with zero accounts checked, and the readiness tile said passed. Such a
run now records its time and counts and no verdict, so the last pass or
failure stands, and readiness calls it attention (nothing_checked) in every
locale.
The upsert that keeps a pass and a failure apart was stubbed in the drill's
tests and tested nowhere; it now has an integration test read back through
the readiness rule. The drill test that claimed to keep a failed verdict
when keeping it fails is named for what it checks.
Restoring the copy a data wipe took withdrew every held data_wiped purge of
the account. With two wipes in one window, restoring the first copy also
withdrew the second wipe's purge, and the off-host copies of what the person
entered between the wipes, and wiped, stayed until the bucket's rule removed
them. Only the request written after this copy and before the next one goes
now. The withdrawal had no test; it has one against Postgres.
A lifecycle read that timed out once was cached as unknown for ten minutes,
which kept every key an off-host object ever needed in the recovery kit: the
kit turned due, the admins were pushed attention, and the next good read
pushed recovered. A failed read now stands on the last answer the bucket gave,
for up to a day.
The notes said the other migrations only add columns and one concurrent
index. 0487 drops four app_settings columns the previous image reads on
nearly every request, and 0488 builds its index on users without
CONCURRENTLY. The rollback step now names the columns to hand back, and the
notes say what v1.45 does with accounts pending deletion and held wipe
purges, and how to settle them before going back.
Any error counting pending migrations (a connect or statement timeout, a
database still starting up) exited the step with "no dump before migrating",
and the migration that ran a second later went ahead without its way back.
An unknown count now takes the dump; a database that cannot be reached at
all then fails the dump, and the boot stops naming the switch that skips it.
Pre-migration dumps were kept by count alone, so an install that updates
rarely kept its last two, and every account deleted since, indefinitely,
while the docs and the backups card stated the nightly retention as the
retention of deleted accounts. A pre-migration dump older than the oldest
nightly dump kept now goes, except the newest one, the way back from the
latest update, which stays until the next update. The card, the disaster
recovery guide and its variable table say so.
The recovery kit, "Keys the backups still need" and safeToDropRetiredKeys
read only the stored copies and the off-host account objects. After a
rotation the page called the old key safe to drop while every dump taken
before it, the pre-migration dumps that are the way back from an update
among them, could only be restored with that key: db-restore refuses a dump
whose active key is missing.
A dump now counts for the key its rows were written under, the manifest's
active key: a dump on this server while it is kept (read from the manifests),
an off-host one until the bucket's rule for database/ expires it, or
indefinitely when that rule cannot be read. The nightly run records the key
on its ledger row (migration 0489, database_backup_runs.active_key_id). The
encryption view and the kit list the dumps per key, in every locale, and the
rotation and backup runbooks say so.
The enumeration guard read any query that named userId as scoped, including
an orderBy tiebreak, so the nightly recovery, strain and stress score jobs
grouped every account's measurements, wrote scores into frozen records and
spent cap slots on them. It also looked only at findMany and groupBy under
lib/jobs.
The guard now reads a scope from a query's filter only (orderBy, by,
distinct, select and include are dropped first), covers count, aggregate,
updateMany, deleteMany and executeRaw, and walks the boot discoveries and
sweeps that live beside their domain: the rollup and mood rollup discovery,
the Fitbit jobs, the cumulative record rederivation, the intake slot dedup,
the inventory expiry and the climb backfill. What it then found is filtered
(the score cohorts through excludePendingOwners, which filters before the
cap; Fitbit sync, backfill and sleep repair; coach proposal lapsing and
reminder nagging; expired coach reminders; snooze clearing; inventory
expiry) or says why it needn't be (retention and expiry sweeps over rows,
writes scoped by id).
Jobs queued before a deletion ran anyway, since handlers act on the userId
they carry without asking again. The binding of every queue now completes a
job whose payload names an account pending deletion without loading its
handler (pending-deletion-gate). The silent medication intake push, which
does not go through the dispatcher, refuses a pending account itself.
The weekly and off-host passes skip an account pending deletion on purpose,
and the readiness read counted it anyway: within days of any deletion the
weekly copy read failing and the off-host copies stale, every admin was
pushed a problem and then a recovery after the purge, and a real failure
hid behind a signal already red. The read now filters with ACTIVE_ACCOUNT,
and the enumeration guard covers the file.
A handover link stayed claimable after its profile was deleted: the claim
gave the profile credentials that every sign-in then refused, and the purge
erased the account someone had just taken over. Locking an account for
deletion now revokes the open links for that profile and the links that
account minted, and creating, previewing and claiming a link refuse a profile
pending deletion, the claim under the record lock.
The last-Guardian rule counted Guardians pending deletion and profiles
pending deletion. A Guardian who had deleted their only profile could not
delete their own account; two Guardians sharing a profile could both leave,
and the second one's erasure was then refused on every run. The rule now
counts only Guardians whose account is not pending deletion, ignores profiles
pending deletion, and is decided under the record lock for account deletion,
data wipe, revoking and renouncing a Guardian. An admin's undo of a managed
profile with no such Guardian left is refused (409
account.deletion.no_guardian).
Demoting an admin counted every admin, pending deletion included, so after
one admin deleted their account the other could step down and leave the
instance with no admin who can sign in or undo the deletion. Both the
demotion and an admin's own deletion now count the other admins not pending
deletion, inside the transaction that makes the change, under one roster
lock, so two at the same moment cannot each count the other.
The hourly purge rethrew any error that was not the Guardian refusal, so the
oldest due account, failing on every run, stopped every later erasure and the
sweep of expired pre-wipe copies for good.
Each account is now its own try: a failure is counted, reported through the
worker error path with its first error, and told to the admins once per
request (user.account.erasure_failed), and the run goes on. The pre-wipe
sweep and the off-host kick run in a finally. Every account the run starts is
marked in backup_pass_attempts under the purge's queue name and the mark
closes only on erasure or when the account is no longer due, so an account
that failed or was refused goes behind every other due account next time.
The run stops starting accounts once its job budget is spent, since fifty
erasures of up to two minutes each did not fit the queue's expiry.
The header check before a thumbnail decode read only the GIF logical screen.
A decoder sizes its buffer from the frame's image descriptor, so a file with a
small screen and a huge frame passed the pixel cap and allocated the full
frame. Walk every block, read each image descriptor, size the image by the
largest of screen and frames, and refuse a file that ends inside a header or
descriptor, carries an unknown block, or has no frame.
A run that kept its dump on the volume and failed the upload is the newest
good dump and the newest failure at the same instant. Compared with ">", the
card showed no failure and readiness said fresh, night after night, while no
dump left the host.
The health read now carries the off-host leg separately: when the newest dump
reached the bucket, and the newest one that did not. One pure reading of it
(databaseDumpProblems) feeds both the backups card and the readiness model.
A configured bucket that missed the newest dump is attention (offhost_failed)
with the upload's error, and failing once the newest dump there is older than
36 hours (offhost_stale) or none ever arrived (offhost_never). The card shows
the refused upload and when the bucket last got a dump. A failure at the
instant of the newest dump for another reason, retention throwing, is now
shown too.
pg_restore reads the table of contents and exits; the read stream piped into
it stayed open on the dump. The long-lived worker checks one dump a night, so
every dump retention deleted stayed allocated until the worker restarted and
the backups volume filled. runTool no longer takes a stdin stream at all.
The reproductive-health page promised that deleting all data or the
account erases at once. Both wait for the grace window now, the nightly
dumps keep a record until they age out, and the old pgdata volume is a
full copy until it is removed; the page says so and how to make deletion
final. The README, the getting-started note on majors, the move guide and
the Unraid and Portainer templates catch up with Postgres 18, the backups
volume and the recovery kit. The demo seed's spread bound leaves room for
the seed's own one-in-150 spread.
Going back to Postgres 16 after a move that stopped half-way left the
moving marker, and the next start emptied the live 16 database and
started a second server on its data directory. db-upgrade now waits for
the database service and does nothing unless it runs Postgres 18.
Using 16 again after a finished move and then updating started the app
on the move-day copy without a word. The marker now records the old
database's last checkpoint, and a later start refuses when it moved on;
the guide says how to move the current database instead. The disk check
leaves room for the first pre-migration dump, and the move check seeds
the old database with v1.45's own migrations.
grep -q stops at the first match, and with pipefail the SIGPIPE that then
ends docker logs read as a miss, so the drill failed on a line that was
there. The Dockerfile notes that the dump directory keeps nothing without
a mount.
The resource-limit cases and two older mocks now pass typed values, so the
file carries fewer as-never casts than before; the outcome list's comment
is a line shorter to keep its file at its length. Both ratchet baselines
are written down, the source size one also for offhost-backup.ts, which
had shrunk without its number following.
The fourth rollback step had been joined onto the paragraph after the
list; it is a list item again, with the way back to the data after it.
The disaster recovery page now says how the server tells admins about
its copies: the recovery readiness card, the recovery kit and the hourly
Backup health notification. The support page put GlitchTip under Admin,
General; the card is under Admin, Integrations, as the deploy runbook
says. The pre-migration dump notes say that without the backups volume
no dump is taken, also where the image's own directory is writable.
The restore refuses a file that carries any document as metadata only
(findIncompleteDocument), whatever the account holds; the page said it
refused when the account held a document.
The compose comment still called DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP a
requirement for a fresh timestamp header, which the route stopped
accepting in strict mode once requests could be signed, and the env
manifest named only the secret header and lacked the strict switch. Both
now say what the route checks: the secret header, which has no replay
protection, or X-Deploy-Webhook-Signature over the timestamp and body,
and that the strict setting takes signed requests only. The development
example says the same and points at the deploy runbook instead of a page
that no longer exists.
The flag makes the app believe CF-Connecting-IP from every connection,
before the peer check and whatever TRUST_PROXY_HOPS says, which is safe
only when nothing but Cloudflare can reach the origin. The reverse-proxy
page now says so with the ways to get there, the compose comment and both
example files carry the same condition, and the variable is in the env
manifest. The page's list of peers whose forwarding headers are read now
matches the code: loopback, 0.0.0.0/8, private, CGNAT, link-local,
unique-local and site-local, the reserved blocks and multicast. Those last
two stay trusted, since no internet host can connect from them; a test
pins every listed range and the Cloudflare precedence.
A text read that crossed the isolated reader's memory or time bound came
back as a plain read error, which the index job records as decrypt-error,
so the document said its stored file could not be opened. The local
extractor now answers resource-limit for PdfResourceLimitError, the index
records it under that name, and the detail view says the PDF is too large
or complex to read here, in every locale. Like the thumbnail's refusal of
the same bytes it is final: nothing retries it on its own.
Only the backups page said when the bucket's lifecycle rules left
database/ out, so every dump, and every deleted account inside it, stayed
in the bucket forever without anyone being told. Readiness gains a
dump_retention signal that reads the same cached lifecycle probe: attention
when dumps go off-host and no rule covers database/, ok with the expiry
when one does, quiet when the bucket will not say, inactive without a
bucket or without off-host dumps. The hourly evaluation pushes it to the
admins like every other signal.
The image creates DB_BACKUP_DIR owned by the app user, so an install that
runs it without the backups volume could write there, and every nightly and
pre-migration dump landed in the container's own layer. The next update
threw them away while the admin page, the boot line and the readiness card
called them kept on this server.
A dump directory now counts as kept only when it survives the container:
inside a container (Docker, Podman, Kubernetes, a container cgroup or an
overlay root) the directory must sit on a mount of its own that is not a
tmpfs, read from /proc/self/mountinfo with a device-number fallback;
outside one, a writable directory is a disk. Otherwise no dump is written
there: the nightly run uploads off-host only and records not_persistent,
the pre-migration dump is skipped with a warning, the boot line says the
directory is inside the container, the backups page stops promising local
dumps and ignores local-only ledger rows, and readiness fails with no
bucket and notes the bucket-only setup otherwise.
The Unraid template gains a path for the dumps, the Unraid and TrueNAS
pages say why it is needed, and the restore drill first runs the stack
without the volume and checks that nothing is kept or claimed.
The deletion revokes every session, refresh token and API token, so they
answer like any revoked credential; only new sign-ins get the 403
auth.account.deletion_pending, through the shared login response.
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
With the real dump health wired in, an empty test database also reports
a missing dump. The file follows the weekly copy alone; the dump signal
is judged in the readiness unit tests.
When the grace window ends for an account that has become the last
active guardian of a managed profile, the purge job refuses to erase it
and the account stays locked. Until now only the worker error log said
so. Every admin now gets one localized notification naming the account
and the ways out (another guardian, deleting the profile, or undoing
the deletion). An audit row on the account records that it was reported,
so the hourly retries stay quiet; a deletion undone and asked for again
is reported afresh.
The undo confirmation in the admin users list now says that sign-in
works again but API tokens, share links and trusted devices stay revoked
and have to be set up again (for a managed profile: its share links and
tokens).
The backups page stated "Retention: 90 days", from a constant whose
comment said it matched a backup-prune job. There is no such job: nothing
deletes a stored copy by age. The weekly copy is replaced in place, one
per account; the copy taken before "Delete all data" is deleted by the
account-deletion purge when the grace window ends; an uploaded copy stays
until its account is erased.
The list now carries preWipeCopyDays (the grace window, 0 when no such
copy is taken) instead of retentionDays, and the page says exactly the
three facts above, in every language.
Also drops a re-export of the JSON import row limit nothing imports.
The shared backup reader kept documents without their stored bytes but
kept every ECG strip whole, trace included. A long watch history is
hundreds of strips of tens to hundreds of kilobytes each, so an upload,
preview or restore of such a copy held the whole history in memory.
The first read now keeps each strip without its trace (a short stand-in,
after the key it was written under is recorded and its form checked),
and forEachEcgTrace reads the traces again one strip at a time. The ECG
restore writes each strip from that second read, as the document restore
already did, and refuses a file whose two reads disagree on the count.
The upload memory test gains an ECG-heavy file: 150 strips, about 200 MB.
The old reader held 197 MB of it and failed the 48 MB bound; the new one
holds about 1 MB, also under a 524 MB heap.
A switched-off scheduled dump is attention now, sent once and never
reminded, with how to turn it back on (DB_BACKUP_ENABLED). Dumps are on
by default, so off is a choice that leaves no copy able to rebuild the
host. An install that never wrote a dump and has nowhere to write one
stays quiet.
The out-of-date kit notice says what changed: the kit now also lists a
key the last confirmation did not cover, for example API_TOKEN_HMAC_KEY
on an instance upgraded from the one-key step, and that nothing broke.
The French kit and readiness strings use vous, the form the rest of the
French bundle uses.
The Postgres major-upgrade guide suggested removing the old pgdata volume
"when you are sure". That volume is a full copy of the database as it stood
before the move: account deletions, trash purges and key rotations after it
never reach it. The guide now says so and makes removing it part of the
move.
The browser error relay forwarded anything posted to it into the operator's
GlitchTip without a session, limited only per address, so anyone could
fill the incident list. Separately, a Prisma client error prints the
failed call into its message and stack, with the arguments object (a
reading's value, a note's text) when no source map is at hand, and the
server sent that to GlitchTip and the logs after a credential scrub that
does not look for it.
The relay now needs a signed-in session and is limited per account; errors
on pages before sign-in go unreported. The central redaction cuts a Prisma
call's printed arguments, keeping the call, a reason line that names a
field, and the stack frames, and the GlitchTip sender applies the
redaction to every event at the egress boundary, whichever caller built it.
The deploy-status webhook authenticated with a static secret in a header,
and the timestamp the docs called replay protection was covered by nothing:
whoever captures one request holds the secret and can send a fresh request
with any content. The strict setting, documented as closing the replay
window completely, only made that timestamp mandatory.
A sender can now sign instead: X-Deploy-Webhook-Signature is the
HMAC-SHA256 of the timestamp and the exact body under the shared secret,
with the timestamp within five minutes, and the secret never travels. The
strict setting accepts only signed requests. The secret header still works
by default for senders that cannot sign, and the docs describe its
timestamp as the staleness hint it is.
safeFetch with requirePublicHost vetted the first URL and pinned DNS
answers in the connector, but Node never looks up an IP literal, so a
followed redirect to a literal private or metadata address was dialled
unchecked. No caller follows redirects to a user-chosen host today; the
floor did not hold for the next one that would.
With requirePublicHost, redirects are now followed one hop at a time and
every location goes through isPublicUrl before it is dialled, at most five
hops. isPublicIp also refuses the benchmarking, protocol-assignment,
multicast, reserved and broadcast IPv4 ranges, site-local and multicast
IPv6 and the IPv4-translated spelling of a private address, and isPublicUrl
strips a trailing dot before the hostname denylist.
The quick-answer route charged one per-address bucket before it read the
token. Where the client address cannot be read, every caller shares that
bucket, so two junk requests a minute switched off every person's
notification quick answers.
The token is an HMAC and is now checked first, which costs one hash and no
database read. A genuine token is limited per account; anything else is
limited per address in a separate bucket that genuine answers never draw
from.
The proxy matcher left out every path ending in an image or text extension
so static files skip it. The exclusion was not anchored, so an API path
whose last segment the caller chooses (a custom tag name ending in .png)
skipped the proxy too, and with it the demo-mode write refusal, the
worker-only refusal and the security headers. Authentication was unaffected.
The extension exclusion now applies outside /api/ only.
The MCP server instructions promise that the person's free text comes back
wrapped in USER_TEXT markers, but get_medication_schedule returned
medication names and doses as plain strings, and get_labs returned analyte,
panel and unit names that can come straight out of an uploaded document.
Text written to read like an instruction reached the connecting client's
model unmarked. The marker scrub also removed only the exact uppercase
spelling, so a lower-case or spaced marker survived inside fenced text.
Both tools now fence those fields, and the scrub removes a marker in any
case and with any spacing inside the brackets.
A token scoped to one medication got 404 for a name that matched none of
the owner's medications and 403 for one that matched outside its scope, so
it could confirm which medications exist by name. Its idempotency replay
also ran before the scope check and returned any of the owner's doses
stored under the key.
A token without the wildcard now gets the same 403 for both, the replay is
looked up only after the scope check, and a key already spent on another
medication answers 409 without that dose. The wildcard token keeps its 404.
A share link gated its report on the doctorReport module but listed and
served its attached documents unconditionally, although the comment beside
the gate said the documents kept a gate of their own. An owner who switched
the documents module off still had every attached file decrypted for anyone
holding the link and its passphrase.
The share page lists no document while the module is off, and the document
route answers 404, the same as for an unknown document, before it looks the
membership up.
Dynamic client registration is open by specification and the client names
itself, so anyone could register under a well-known assistant's name with a
callback on a host of their own and send a signed-in person the authorize
link. The consent page showed the borrowed name and "Unverified
application", never where the code would be sent.
Every consent page now says where the access goes: the callback's host, or
an application on this device for a loopback callback, and the Allow button
names the host. For a self-registered client whose callback leaves the
device, a warning that names the host leads the page, above the
self-reported name. Registration stays open: the hosted assistants register
this way with their own https callback.
The session cookie is SameSite=Lax, and SameSite compares the registrable
domain, so a page on a sibling subdomain of the instance could send a
CORS-simple POST with the person's cookie attached. safeJson accepted any
Content-Type containing "application/json", so a text/plain body that
mentions it in a parameter, which needs no preflight, was parsed as the
person's own request: readings added, entries deleted, integrations
disconnected, a delegate invited within the re-proof window.
apiHandler now refuses a POST, PUT, PATCH or DELETE before authentication
when the browser marks it Sec-Fetch-Site same-site or cross-site, or, from
a browser that sends no fetch metadata, when its Origin names a host other
than the one the request went to or APP_URL. A request with an
Authorization header, or with neither header (the phone app, scripts,
webhook senders), is unaffected, as are the webhook and URL-credential
routes that authenticate with their own secret. The JSON media type is
compared exactly, with parameters allowed, in safeJson and in every route
that branched on a substring of it.
The Coach snapshot attached its labs block regardless of the module, a
decision from before Labs could be switched off, while the snapshot's own
prelude says a disabled domain never reaches the model. With Labs off, the
Coach prompt, its get_labs tool, the daily briefing's flagged-marker block,
the biomarker status note, the reaction line to a new panel and the MCP
lab_trend_brief prompt all still read lab values for a model.
Each of those reads now asks the labs module first and reads nothing when it
is off: the Coach tool answers module_disabled, the briefing omits the
block, the status note neither writes nor serves text, the reaction line
gets no value, and the MCP prompt refuses like get_labs and the doctor-visit
prompt already did. The data routes stay as they are. A structural guard
holds every lab-table reader under the model-input paths to the gate or to
a listed reason its rows never reach a model.
The per-account password throttle skipped every attempt that came from an
address one of the account's sessions or device logins had used in the last
thirty days. An address is the easiest thing about a request to share or
claim: in a household or an office the guesser has the owner's address, and
a direct caller could name it in a forwarding header. Either way the
throttle switched off and guessing was bounded only by the hash cost.
A remembered browser (the trusted-device cookie) and a phone presenting the
device id of one of the account's logins still pass; everything else gets
the growing wait.
The client-IP resolver trusted one forwarding hop by default and read the
rightmost X-Forwarded-For entry. Next fills that header with the socket's
address only when the request carries none, so a caller reaching the
published port directly could send its own and choose a fresh address per
request: every per-IP limit (sign-in, second factor, passkey, share unlock,
webhooks) became per-request, and sessions, the audit log and sign-in
notices recorded the chosen address.
The server now writes each connection's own peer address into an internal
header from Node's http.server.request.start diagnostics channel, installed
at boot and overwriting anything a caller sent under that name. The resolver
reads forwarding headers only when that peer is loopback, private,
unique-local or CGNAT, where a reverse proxy sits; a public peer is its own
client address, and a log line says once that its forwarding headers were
ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so
a directly exposed host no longer shares one bucket across all anonymous
callers. Proxies on the same host, Docker network or tailnet need no change.
The thumbnail's header walk took the byte after any 0xFF as a marker. A
single 0xFF fill byte after the start-of-image marker, which the JPEG
standard allows and every decoder skips, threw it off; it read no size, and
an unread size let the image through to a decode that allocates the full
bitmap. A small JPEG declaring 20000 x 20000 pixels allocated 1.6 GB in the
web process before the post-decode check could see it.
The avatar upload already had a walker that skips fill bytes. Both now use
one reader (lib/image-dimensions) that follows the marker rules: fill bytes,
the length-less markers, and no size when a scan or the end of the image
comes before a frame header. The thumbnail treats an unreadable size as a
refusal (size-unreadable) for every format it decodes, and the backfill
counts that refusal as final.
pdf.js decodes an image at the size the file declares, whatever size the
page is drawn at, and the server opened documents without an image limit.
A small upload declaring a very large image made the web process allocate
gigabytes until the container was killed, for every user at once, and the
thumbnail job ran the same document again after each restart. An image
whose soft mask declares the size does the same past pdf.js' own limit, and
nothing bounded how long a text read could take.
pdf.js is now opened with maxImageSize (20 million pixels, which keeps one
image inside the reader's ceiling and admits a 300 dpi A3 scan), and every
read and render runs in a child process, one at a time: its V8 heap is
capped, its resident memory is sampled and the child killed past 640 MB, on
Linux it volunteers to the out-of-memory killer first, a time limit ends a
read that runs on, and it starts with an almost empty environment. The
loader and the rendering moved into pdf-engine.mjs, which the child runs
with plain Node and which is traced into the standalone image.
A document that crosses a bound answers resource-limit. The thumbnail job
records it as a final failure, so the backfill does not queue it again.
Several surfaces promised more than the per-account copies deliver:
- The full-backup export card said it had the same shape as the weekly
copy. It is a portable export with documents as metadata only; it now
says so, and that it cannot recreate an account on another server.
- The export page offered to "import from a backup". The JSON import adds
measurements and mood entries; it is now named that way, and its help
states the 16 MB and 10,000-entries-per-list limits from the constants
the route enforces.
- The weekly copy is labelled an account rollback on this server, and the
backups page says a copy cannot rebuild a lost server. The upload says
the account must exist here. Two restore refusals pointed to a
"disaster-recovery" copy; they now name the copies that exist.
- The instance-settings restore option was described as being for
rebuilding a host.
docs/ops/backup-restore.md is rewritten short and accurate: what each
copy is, the deletion grace window, that database dumps keep a deleted
account until they age out, how to restore one account, and a link to
the disaster-recovery procedure for a lost server.
The deletion dialogs, the managed-profile delete dialog and the Data &
Privacy page read the window from the privacy summary and say what
happens: the account is locked at once, erased after the stated number
of days, and an admin of the server can undo it until then; a wipe keeps
a copy an admin can restore for the same time. Copies in the server's
backups age out after their retention period, and the text says that
too. With the window set to 0 they keep the immediate wording.
The sign-in page names the erasure date when a sign-in, a passkey or the
single sign-on callback is refused for an account pending deletion. The
admin users list shows a pending account with its date and an "Undo
deletion" action, and the backups list labels the copy taken before a
wipe. Every new string is translated in all nine languages.
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.
ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:
- Account and managed-profile deletion lock the account (migration 0488:
deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
Every session, refresh token, API token, connector, share link and
step-up elevation is revoked in the same transaction, and the record,
its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
erasure date, after the credential was proved and before a second
factor is asked for. The session and Bearer resolvers and the acting-
account resolver refuse a pending account too, so a credential minted
in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
ended, exactly as the immediate deletion did, with the off-host purge
request committed alongside. The Guardian invariant is decided again;
a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
the writer the weekly copy uses, keeps it out of the wipe, and holds
its off-host purge back with a new not_before on the request. The
ordinary restore brings the record back; restoring that copy withdraws
the held purge. The purge job deletes the copy when its window ends.
Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.
The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
The drill now runs an S3-compatible bucket beside the stack (Versity S3
Gateway v1.8.0, pinned by digest; the MinIO images are no longer published
on Docker Hub or quay.io) on a network and a volume of its own, so it
outlives the stack as a real bucket does. The app takes the dump through the
scheduled job and uploads it there; the drill waits for a run that both
kept and uploaded it, destroys every volume of the stack, the local backups
included, and restores on a new stack with only the bucket and the keys:
`docker compose run --rm db-restore latest --from-bucket`. The independent
row count reads the manifest from the bucket with the restore's own reader.
DRILL_SOURCE=local keeps the earlier path (dump files copied off the host,
then `db-restore latest`), and CI runs both. A new mutation,
wrong-backup-key, restores with a different BACKUP_ENCRYPTION_KEY and must
be refused. Ports are free ones picked at start.
The new-server steps lead with `db-restore latest --from-bucket`, the copy
that survives when the host and its local dumps are gone, and keep the
restore from downloaded files beside it. Both backup pages now say that the
database dumps are not purged per account: a deleted account stays in them
until they age out, which is the retention period an operator states to the
people using the instance.
The check failed once with "Can't reach database server at
127.0.0.1:55416": the container reported healthy before its published port
forwarded, and the migrate on the host ran in that gap. It now waits until
the port answers, retries a migrate that could not connect, both within
thirty seconds, and fails with Prisma's own output otherwise. The port is a
free one picked at start unless CHECK_DB_PORT names it, so the check cannot
collide with another stack on the same machine.
A database dump is the whole database at one moment, so an account deleted
since stays in the dumps taken before until they age out. The database row
on the backups card now says so with the numbers that decide it: how many
nightly dumps this server keeps, and after how many days the bucket's
lifecycle rule removes the off-host copies.
The lifecycle probe used to answer with the shortest expiry of any enabled
rule, whatever its prefix, so a rule scoped to the account copies read as
covering the dumps too. It now also answers for `database/` alone, counting
only rules whose filter takes in a dump's key (prefix, legacy prefix, an
And filter; a tag filter never matches untagged objects), and the card
warns when no rule covers that prefix: dumps there, and the deleted accounts
in them, would never be removed. The reading moved into its own module.
A lost host takes its local dumps with it, and the copy left is the one in
the bucket. `docker compose run --rm db-restore latest --from-bucket` now
lists `database/` in the bucket the BACKUP_S3_* variables name, takes the
newest dump whose manifest is there too (or the one named), downloads both
with the S3 client the image already ships, and continues as for any
off-host copy: opened with BACKUP_ENCRYPTION_KEY, checked against the
manifest, restored into an empty database. `list --from-bucket` shows what
is there. The sealed download is removed once it is opened.
The image build now proves the S3 client resolves from the shared module's
place in the tree, so the restore cannot lose it unnoticed.
The key backup sections become "Save the recovery kit": which keys the kit lists and why, the fingerprint of the token key, and the readiness card with its Backup health notification.
Every backup failure was a pull, on a page nobody opens until the day it
matters: the weekly copy stopped for 46 days and showed an ordinary row,
a failed monthly drill was on the failing-jobs card three days in thirty
(job-failures.ts reads 72 hours), and an off-host bucket that was never
set up showed only on the backups page.
One readiness model (src/lib/recovery/readiness-model.ts) judges six
signals as ok, attention or failing, with a reason code, the time it is
about and where to fix it:
- database dump: failing once the newest good dump is older than 36 h
while dumps are on, or when it can be kept nowhere; read through a
provider that reports "not available" until the dump lands;
- weekly account copies: worst account decides, attention past 9 days,
failing past 14; an account without a copy ages from its creation;
- off-host copies: attention when any account is two nights behind;
- restore drill: failing until a later drill passes, with no time
window; the drill now keeps its verdict in restore_drill_outcomes;
- off-host setup: attention 7 days after the first admin exists;
- recovery kit: attention when a needed key is not confirmed, by id,
7 days after setup or as soon as a confirmed kit is out of date.
A "Recovery readiness" card shows them on the admin overview and the
backups page. An hourly job (recovery-readiness, :33) sends each change
to every admin through the localised admin path, under a new
admin-only BACKUP_HEALTH event that the channel preferences route: once
when a signal turns to attention or failing, once a day while it stays
failing, once when it recovers. Each notice is claimed with a
compare-and-set before it is sent, so two workers never send the same
one, and nothing is claimed while no admin exists.
The backups page calls the weekly copy stale at the same instant as the
model (past nine days rather than past ten).
The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.
The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:
- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
(innerKeyIds) or an off-host object inside the bucket's retention
(OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
named by the id their objects record;
- API_TOKEN_HMAC_KEY.
Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.
Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).
The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
scripts/compose-dr-drill.sh plays docs/ops/disaster-recovery.md against
real containers: it builds the image from the checkout, starts the bundled
compose stack with fixed throwaway keys, seeds the demo account and a
Guardian with a managed profile (through the app's own code), writes a
reading with an encrypted note through the API, and waits for the
scheduled database-backup job to take the dump. It then keeps only the
dump files, destroys every volume, starts a new stack, restores with
`docker compose run --rm db-restore latest`, counts every table against
the manifest apart from the restore, and boots the app: health, the demo
login, the note read back, a document opened, the Guardian still seeing
the profile.
DRILL_MUTATION breaks one step on purpose (wrong-key, skip-table,
non-empty-target), and the drill fails on each. The workflow runs it on
pull requests that touch the compose file, the image, the entrypoint or
the dump and restore code, nightly, and by hand.
docs/ops/disaster-recovery.md says what is dumped and where, that local
dumps are unencrypted and why, which keys a restore needs and what each
one costs when missing, how to restore onto a new server or back to the
state before a migration, and how the procedure is tested. The deploy
runbook and the getting-started guide now describe the dump the
entrypoint takes before migrating, and the off-host account copy page
points at the dump for rebuilding a server.
GET /api/admin/backups now carries `database`: the newest good dump (when,
how big, kept on the server, off-host or both), the newest failed run,
whether this server can write its backup directory, and whether off-host
copies are configured. The backups card leads with one row built from it,
with a notice when the last run failed and when dumps can only go
off-host or cannot be taken at all. The route stays an unpublished admin
console route, so the OpenAPI document does not change.
The image now carries postgresql18-client (pg_dump and pg_restore 18.6)
and a /var/lib/healthlog/backups directory owned by the app user, which
the bundled compose file backs with a new `backups` volume for `app` and
the commented `app-worker`; a fresh named volume inherits that owner.
Before `migrate deploy`, when migrations are pending on a database that
has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump
with its manifest and keeps the last two. A failed dump on a writable
directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without
a writable directory it warns and continues.
`docker compose run --rm db-restore <file|latest|list>` runs
scripts/db-restore.mjs in the app image with plain node. It opens an
off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks
the archive against the manifest's SHA-256, compares the keys in the
environment with the manifest's fingerprints and refuses on a different
active encryption key unless --ignore-key-mismatch, refuses a database
that holds any table, restores in one transaction, runs ANALYZE, and
compares every table's row count with the manifest, emptying the database
again on a difference.
DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and
DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files
and the env manifest.
The per-account copies roll one account back on the same server; nothing
the app made could rebuild a lost one. A scheduled job now runs pg_dump
over the whole database (every schema, pg-boss included) into
DB_BACKUP_DIR as healthlog-<UTC time>-daily.dump, with a manifest beside
it: app version and build, newest migration, Postgres version, size and
SHA-256, exact row counts of every table, and the fingerprints of the
keys the database needs.
The counts read the same exported snapshot pg_dump reads, so a dump
taken while the app writes still matches its manifest. The archive is
written as .partial, listed back with pg_restore --list, and renamed
after its manifest; a failed run leaves nothing named like a good dump.
Retention removes only files this job wrote, oldest first, and never
the newest good one (DB_BACKUP_KEEP, default 7; two pre-migration dumps).
With the off-host bucket configured, each dump and its manifest go up
under database/ in the envelope the account copies use, streamed. Every
run lands in the new database_backup_runs ledger (migration 0486), and
getDatabaseBackupHealth() answers the last good dump, the last failure,
whether the directory is writable and whether off-host is configured.
The boot readiness summary says where dumps go, or that none can be kept.
The dump, naming, retention and restore rules live in one plain ESM
module so the image's own scripts can share them without tsx.
The tool module was split into one file per tool on this branch, so the
change lands in tools/get-labs.ts, and the advertised tool list snapshot
gains the two declared fields.
The admin upload streamed only the measurements while it checked a file,
so a document-heavy account copy was held whole in memory and an upload of
a few hundred megabytes could take the process down.
The sections a reader takes an element at a time are now named once
(BACKUP_STREAMED_SECTIONS in streamed-backup.ts): measurements, intake
events, mood entries, documents and ECG recordings. The upload reads its
file through readStreamedBackup like the restore and the preview, and the
restore drill and the restore script use the same key set.
A behavioural test pushes a 271 MB document-heavy file through the real
upload route and fails when the heap grows with the file. The stream guard
now asserts that every reader goes through the shared reader or key set
and that no file declares its own.
Both files had drifted copies of the docs-site guides, which carry the same
sections and more, and nothing in the app, the scripts, the other docs or
the site links to them. Each is now a short pointer to its guide.
Fix two dead anchors (the MCP trust model, the README quick start) and the
statements that no longer match the app: a guardian can connect a managed
profile's services, the environment module is on by default and reaches the
air-quality host too, its readable location columns were dropped in 0409,
settings sections carry their current names, the Withings card lives on
/settings/integrations, the TrueNAS start period matches the image, the
deploy check names HEALTHLOG_PROD_URL, and the docs index describes what
self-hosting/ and integrations/ hold.
The labs block reads the whole record, or up to the lookback limit, not just
recent readings, and a named read also comes back empty when no marker
matches. The header of the labs snapshot and the note at its call site
still described the earlier twelve-month window.
The integration runbooks, the Garmin note, the guardian note on a managed
record's connections, the backups admin link and the README's self-hosting
link all pointed at retired docs paths that only resolve through redirects
(or not at all, on a host without them). They now go to the final guide
pages, and the settings tests assert the new addresses.
The statement already declares `date` and `code` on Observation beside
paging on every search; the route test now asserts that list per resource,
so a parameter the search honours cannot drop out of it unnoticed.
The published contract said the nine health-shaped fields that predate
`health` were removed in v1.44, but every status read still sends them:
older native builds decode them, the current one falls back to them against
a server without `health`, and the web card's copied diagnostics read the
failure streak. They stay deprecated, without a removal release.
The hint shown when a text-only provider needs on-device text recognition
sent people to the AI settings, where no such switch exists. It now names
the switch by its label under Settings → Modules → Labs in every locale and
links straight to that card. Inside somebody else's record the link is
withheld, since Settings there is the caller's own and closed; the sentence
stays.
The turn records up to 64 steps while the read path kept only the first
48, so the tail of a long answer's step list vanished on reload. Both ends
now read one MAX_TURN_STEPS constant, and a unit plus an integration test
round-trip a full 64-step answer through the stored provenance.
The blood-pressure chart scales one axis to fit systolic and diastolic, so
the 12 mmHg the busy stretch added came out as a nearly flat line on a 60 to
140 axis, even with the 7-day trend on.
The stretch now climbs over two days to about 144/91, holds for some ten
days, and comes back down over the following days after ramipril goes from
5 to 10 mg. A dose change and a family-practice visit on the day it turns
carry that step, and the medication now reads 10 mg. The day-to-day walk is
quieter, with a little reading scatter on top, so the 7-day mean draws an
arc rather than jitter.
Two limits keep the rest of the page honest. The lift stops short of
149 mmHg: one more and the padded axis rounds out to 50 to 200 and flattens
the rise again. And the stretch stays well under half of the last 30 days:
the usual range is the median ± 3 MAD of that window, and a longer stretch
moved the median onto it and widened the band to about 87 to 181 mmHg.
The stored texts that describe the last weeks mention the stretch and the
dose increase. The seed-shape test holds the larger swing, the ceiling, the
smaller day-to-day steps, the median of the window and the dose change on
the turn.
The compose file runs Postgres 18. An install from v1.45 or earlier keeps
its Postgres 16 data in `pgdata`, which 18 cannot open, so a one-shot
`db-upgrade` service moves it on the first start: it starts Postgres 16 on
the old volume inside its own container, streams a dump into 18 in one
transaction, compares every table's row count, gathers planner statistics
and marks the old volume as moved. The app waits for it to succeed.
The old volume is never changed apart from the marker, so the way back is
the previous image and volume lines. A move cut short leaves a "moving"
marker and runs again from scratch; an emptied new volume after a finished
move is refused rather than refilled with the old state.
scripts/compose-db-upgrade-check.sh plays all of it against real
containers from the v1.45.0 compose file, and runs in CI on every change
to the compose file. Pull requests run the integration suite and e2e on
Postgres 18; the nightly integration run keeps 16 covered for installs
that bring their own database.
The keys followed each other without waiting for the step to move the focus,
so on a slow run Tab started from the previous answer and the number chose
nothing.
Each step focuses its question, but the first step's question only exists
once the language's wording chunk has arrived; the focus ran while the step
still showed its loading state and was never moved again, so a keyboard or
screen reader user started on the page body.
Scrolling to the end can start a section's load, and its skeleton sat under
the button for a moment on the Insights page in landscape. The check now
scrolls to the end and measures again until the last line is in place;
content that stays under the button still fails it.
A concurrent build that is still running is INVALID with its migration not
yet finished, exactly like a failed one. With the web and worker roles in
two containers, the second boot dropped the index the first was building,
which cancelled the first one's migration and left it failed until someone
resolved it by hand.
The check and the drop now run in one transaction that first takes Prisma's
migration lock without waiting and does nothing when another instance holds
it; an index Postgres reports as being built is never dropped. One
transaction also keeps lock_timeout on the statement it bounds behind a
transaction pooler, and the applied-migrations lookup quotes the schema, so
a mixed-case schema no longer reads as having none applied.
Two sources off every ladder now read at the very same instant on thirty
days: on fifteen the name decides, on the other fifteen the first name's
systolic is under the floor and the second takes the day. A dedicated
case asserts which source each of those days shows, under a UTC and a
Berlin session; the parity cases compare them with the reference as
well. Dropping the name from the window's order turns 26 of 59 cases
red on Postgres 16 and 18.
The guard seeded each day's pair six hours before the clock, with the
second source ten minutes later. Run between 05:50 and 06:00 UTC, the
newest day's second pair landed just past midnight, alone on a UTC day
of its own; the pick rightly chose that source for that day, the old
DISTINCT ON statement chose the same, and the assertion that every
series row is the first source failed for those ten minutes of the day,
on every Postgres major.
The pairs now sit at 10:00 and 10:10 UTC from yesterday back, whatever
the clock. With the clock pinned to 05:55 UTC the old fixture fails the
three series shapes and the new one passes, on 16 and 18 alike.
A later case in the file folds the rollups again, and a fold sums in the
order its plan reads, so the second fold's sums could differ from the
first's in the last bit; the comparison read the table after that fold.
An account counts as done for a run when it succeeded at or after the run's
start, and the follow-up run's start taken with new Date() could share the
millisecond of the retry's last ledger write.
Three readers grouped the same rows twice and joined the second grouping
back to the first: the live buckets joined each hourly-mean day's hour
means to the per-source days, the all-time extremes joined the day
weights the same way, and the doctor report's dense buckets joined each
bucket's latest reading and hour means to the bucket aggregates. On a
measurements table without planner statistics (a new install, or an
import under the autoanalyze threshold) the planner estimated one row on
each side and ran every one of those joins as a nested loop that re-ran
the inner grouping for each outer row.
On ten years of readings over six types (51,000 rows, no statistics) the
dense buckets and the every-type live buckets ran into the 60 s statement
timeout and the all-time extremes took 11 s. With statistics all three
answered in 0.1 to 0.2 s.
Each is now one chain of folds over one scan. The live buckets fold the
readings per local hour and the hours per local day, carrying the day of
hours beside the counts. The all-time extremes take the day weights from
the same windowed rows they count, the shape the pre-fold remainder
already has. The dense buckets carry each bucket's latest reading and each
hour's count and mean on the rows as window values, and one grouping folds
them all. Same fixture without statistics: 0.2 s, 0.1 s and 0.1 s, the same
as with them.
A parity suite runs the joined statements as the reference against the
readers across ladders, zones with whole, half-hour and negative offsets,
grains, overlapping and off-ladder sources, glucose contexts and device
types, tombstones and both DST changes; sums and means agree to
floating-point summation order, everything else exactly. The
unanalysed-table guard now runs the three readers under EXPLAIN ANALYZE:
the joins measured 200 to 3,000 row visits per row stored, the folds about
10.
The dense-bucket loader drops under the 200-line function cap, so its
baseline entry goes.
The blood-pressure series chose each UTC day's source with DISTINCT ON
over the day's floor-passing systolic rows and joined the choices back
to the readings. That is the shape the analytics pick had, with the
same weakness: on a measurements table without planner statistics the
planner estimated one row on each side and ran the join as a nested
loop that rescanned the pick for every reading.
The ten-year series over 6,000 freshly written rows from one source
took 0.57 s for the statement alone, 0.86 s with a second source on
every third day; the plan visited about 4,900 rows per row stored, so
the cost grows with the square of the history.
The pick is now one window over one scan: the day's rows ordered
systolic first, then by ladder rank, then by source name, and the first
row's source taken only when it is a systolic one. The scan starts at
the window's first UTC day, so the pick still sees the whole edge day,
and a day without a floor-passing systolic still shows nothing. Both
fixtures above read in about 10 ms, with or without statistics.
A parity suite runs the old statement as the reference against the new
one across overlapping and off-ladder sources, half readings, sub-floor
rows, tombstones, both DST changes, window starts on and either side of
UTC midnight, and four session zones. The unanalysed-table guard now
covers the series' ten-year, quarter and week reads.
The canonical-source pick grouped the window to (type, day, source),
chose each day's source with DISTINCT ON, and joined the choices back to
the rows. Whether that join ran as a hash join or as a nested loop that
rescanned the whole pick for every row was the planner's call, made from
table statistics. On a measurements table without them (a new install,
or an import still under the autoanalyze threshold) it estimated one row
on each side and took the nested loop.
Binding the request's clock as a parameter instead of reading NOW()
flipped which side of that loop the pick landed on: the analytics read
over 6,000 freshly written blood-pressure rows went from 1.6 s to 8.4 s,
and the demo seed's dashboard headline to 20 s. On a ten-year account
without statistics the 90-day read took 4 s with either clock and the
all-time read ran past two minutes.
The pick is now one window function over one scan: FIRST_VALUE of the
source per (type, UTC day) in ladder order, keeping the rows of that
source. Same rows, same order of preference, so every day keeps its
source; the parity suite against the DISTINCT ON statement holds that.
The analytics read on the fixture above takes about 0.1 s, the headline
0.4 s. On the analysed ten-year account the 90-day and all-time reads
are as fast as the join was, and they no longer depend on statistics.
The rows carry one extra column, canonical_source; every reader selects
its columns by name.
A new integration test runs every reader shape under EXPLAIN ANALYZE on
a table with no statistics and bounds the rows the plan produces or
discards per row seeded. The statement count never moved, so a statement
budget could not see this; the join measured 200 to 8,600 row visits
per row seeded and the window stays at 4.
A status run is one completion, so its ceiling was the completion ceiling
under a second name. The Local client also re-exported a JSON-mode helper
nobody imported from it.
The retry is fetched about a second and a half after the overrun ends, later
on a loaded machine, and the twelve-second window turned that lateness into a
red run. Both cases now wait for the state they assert, bounded by a
deadline, and read the call count once the retry's own run has finished.
The compose file stays on 16. Changing only the image line stops the stack:
the 18 image refuses a data directory from an older major and expects its
volume one level up. The runbook walks the dump and restore into a fresh
volume, keeps the old one as the way back, and is linked from the update
section and the compose pin.
The backup export is totally ordered so two exports of the same account are
byte-identical; the alias relation the import-key ledger reads was the one
nested read without an order.
The lab scan runs only in the background worker, which hands it the
run's deadline, but its body still took the schedule as optional and
planned a missing one without a bound: a twenty-page report as forty
model calls. Nothing calls it that way today; the signature allowed the
next caller to, and so did every read planner beneath it.
The scan and the planners (maxReadsFor, planTextReads, planVisionReads,
planInboundTextReads, planInboundReads) now require a schedule. A run
with room for every read says so with an unbounded one; a request uses
SYNCHRONOUS_READ_SCHEDULE. No route, wire or response changes.
The index route without Prefer: respond-async, which the iPhone app
calls, continues into the lab staging with no run schedule. The staging
then planned a long report's text as up to twenty reads with a retry
each: forty model calls inside one HTTP request, the same class the
extract route was bounded against.
The staging now plans a missing schedule as one request does
(SYNCHRONOUS_READ_SCHEDULE, at most three reads): a longer report has
its text shared out between them, and what does not fit is left out and
reported as extractionIncomplete on the document. The request bound
moves into ocr-plan beside the schedule it bounds, so the extract run
and the staging share one definition. Background runs and the automatic
index job keep planning against their own deadline.
A CREATE INDEX CONCURRENTLY that fails half-way leaves an INVALID index,
and the migration re-run after migrate resolve --rolled-back finds it
through IF NOT EXISTS and does nothing: recorded applied, with an index
the planner never uses. 0477 has this shape.
The boot now drops an INVALID index that a not-yet-applied migration
builds concurrently before it runs migrate deploy, with a bounded lock
wait, so the re-run builds it again. An INVALID index whose migration is
recorded applied is named on stderr with the rebuild that mends it. The
deploy runbook says both.
0475 and 0480 added their columns with a bare ALTER TABLE, which waits
for its table lock behind every open transaction while every write to
the table queues behind it. Both now run in a DO block with a five
second lock_timeout and carry the boot's retry marker; the effect is
the same and both stay safe to run again.
Neither has shipped in a release. A database that already applied them
is unaffected: prisma migrate deploy and migrate status ignore the
checksum of an applied migration (checked against a database migrated
with the old files), so nothing is re-run and nothing is reported.
The lock-timeout guard now refuses, from 0475 on, any statement that
takes a table lock outside such a block.
The backup carried neither the import ledger nor the tombstones of
deleted documents, so after a restore onto a fresh instance a token
importer stored every imported document the person had deleted on
purpose again.
The document filing section now carries documentImportKeys: the
ledger's rows plus the own and alias keys of the documents waiting in
Recently deleted, which still do not travel as documents. The restore
writes them back, minus any key a restored live document holds, and
counts them in the restore summary and the preview. DocumentImportKey
moves from the excluded models to the two-ended ones, and the round
trip, the column audit and the parity goldens carry a ledger row.
The boot discovery for the documents 0479 marked took the first thousand
by id and skipped those whose account has the documents module off.
Such an account keeps its marks, so with enough of them it held every
place of every boot and the accounts after it never got their retry.
The marks are walked by id past the last row read, an account found
with the module off is left out of the query from then on, and the
boot stops at a thousand documents enqueued rather than read.
Refs #1277
The index-all backfill billed the read and wrote the index in one try:
an index write that failed after the read was settled again in the
catch, handing the reservation back twice. The read is settled once,
and a failed write charges nothing back.
The backfill also checked consent and the operator's switches once, at
the pick, then read up to two hundred documents. The wire is asked
again before every read; a refusal hands that document's reservation
back and ends the walk with no-consent or unavailable.
The extraction merged facts across reads on a key whose only date was
the fact's own effective date. Facts without one collapsed across reads
of different reports, so the same value on two days staged once. An
undated fact is now keyed on its read's report date, falling back to
the document's, as the lab scan keys a row.
Refs #1257
A save of a person's provider settings and of the operator's admin AI
settings forget the health ledger's record and close the status notes'
negative windows. The two Codex sign-ins, the operator's central one and a
person's own, forgot the ledger record only, so a fixed connection still
had every note wait out the window the refused one earned, up to two hours.
The central sign-in now closes every record's windows, since every account
may fall back on that connection, and a person's own sign-in closes theirs.
Proven against the real rows in the Codex sign-in integration suite.
Refs #1279
Without Prefer: respond-async the extract route ran the read with no
schedule, so a twenty-page report read a page at a time, with a retry
each, could make forty model calls in one HTTP request. The iPhone app
sends no Prefer header and reads the document detail from the answer,
so the request keeps its 200 and its body.
An extraction without a run deadline now plans at most three reads (six
calls): a longer document has its pages or text shared out between
them, and what does not fit is reported as extractionIncomplete on the
document. The background run keeps reading it a page at a time.
Refs #1257
A long document read in parts can end with a part cut at the provider's
output limit, a part never read, or more facts than one review takes.
That was told once, as a notice when the run finished; a review opened
later looked complete.
InboundDocument.extractionIncomplete (migration 0483) is written with
the facts by both stagings, the extract route or run and lab
auto-staging. The document detail publishes it and the review shows the
same note whenever it is opened. The disaster-recovery backup carries
it; an older file restores it as false.
Refs #1257
A gateway or local entry can carry its own reasoning setting (#1126), sent
on every call that has no level of its own. An endpoint that refuses
reasoning_effort answered each of those calls with a 400 that was never
learned, because only the call's own level counted as sent: every status
note, every summary and every other level-less call failed, for as long as
the setting stood.
The refusal is now learned whichever level put the field on the wire, by
the same path as any refused field: the call is asked once more without it
inside its two-request budget, later calls send no field for that endpoint
and model, a model seen reasoning unasked keeps its thinking room, and one
ai.provider.reasoning_field_refused line says so per lesson instead of a
failure per call.
Refs #1279
The nightly warm, the forced warm and the morning refresh bound a
comprehensive generation and abort it when the bound fires, but the abort
only reached the generator's final check before the cache write. The
provider calls never saw it: the call in flight ran on detached, and a
reply that landed after the bound could still start the JSON-shape,
grounding or screen retry, each a full generation nobody was waiting for.
runBriefingCompletion now takes the caller's signal, hands it to the
provider call, and reserves and sends nothing once it has fired. The
generator passes it at all five of its calls (the generation, its three
retries and the phrasing re-roll). The on-demand route passes none on
purpose: it finishes and writes its briefing after the browser stopped
waiting.
Refs #1279
Tighten the warm-up comments in the summaries slice and the comprehensive
aggregator so neither file grows past its line baseline, drop a release
opener from the sharing-surface note, and record the version stamp that
left the settings section list.
The sweep keeps going when reporting a load failure itself throws, and
now says so on stderr; the error-level guard refuses a silent catch in
src/lib/jobs. The preload test reads the worker source through
readSource with anchors.
The per-account preview switch was kept for the topic overview pages,
which were dropped before they shipped. Its one flag was stored, served
and backed up, and read by nothing; the column-reader audit listed it as
a pending consumer that will not come.
Removed completely:
- `/api/settings/preview-features` (GET, PATCH) is retired: it answers
410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`,
and the contract publishes it under Retired. The route, its validation
schema, the `PreviewFeatures` OpenAPI schemas and the settings module
are deleted.
- Migration 0485 drops `users.preview_features_json` under a bounded lock
wait, with the retry marker.
- The backup no longer writes the field. A file from v1.44 or v1.45 that
carries it still restores: the schema accepts it and no codec reads it,
so nothing is refused or written for it. The backup-parity goldens
lose the field and apply one setting fewer.
- The data-wipe plan entry and the sharing-surface actor entry go with
the route (frozen count 273 -> 272).
No client build reads the route: neither iOS repository nor any local
checkout references it.
The health ledger records failures per account and provider, and since
background work leaves out a benched provider, an operator who fixed the
instance-wide provider in the admin AI settings still saw every account's
background work sit out the pause the broken setup had earned, up to a day.
Only a person's own provider settings and the Codex sign-ins forgot the
record.
A successful save of the admin AI settings now forgets what the ledger
recorded for admin-openai on every account and closes the status notes'
negative windows, the same reset a person's own save makes for theirs
(clearStatusNegativeWindows without an account covers every record). The
ledger comments that still said a benched row only deprioritises now say
what it does for background work.
Refs #1279
A status run whose whole chain was benched for background work sent no
request, yet the caller opened the note's negative window as for any
failure and raised its failure streak. The streak doubles the window up to
two hours, so a note kept waiting long after its provider recovered.
AllProvidersFailedError now says when no hop sent anything (sentNothing:
every provider benched, or refused by the operator cost cap). The status
chokepoint then holds the note for the first window without touching the
streak (holdStatusNoteWhileBenched), and the caller's own failure write
lands inside that window and neither moves nor counts it. A run that
reached a provider counts as before.
Refs #1279
End course tombstones the pending placeholders at or after its instant.
Moving or removing the end clears the instant, but the tombstoned rows
kept their unique slots, so the today projector treated them as present
and minted nothing: the later dose of that day was neither listed,
reminded nor missed.
The window's one writer now revives the placeholders the End stamped
with its instant once that instant is gone, when the slot is inside the
courses as they stand and no live row holds it.
Refs #1280
`afterProviderBackfill` refolded the rollups over the touched range and
re-warmed the status cards, but never marked the analytics caches stale,
so after a Fitbit or Google Health history backfill the dashboard and
analytics reads served the pre-backfill body until its TTL ran out. The
per-write tail has always done this through `afterMeasurementMutation`.
The backfill tail now calls `invalidateUserMeasurements` with
`touchedFrom` set to the first touched day, after the fold and also when
the fold fails, since the rows changed either way.
Since handlers load on their queue's first job, a module the image
cannot load no longer fails the worker's boot. The worker comes up
healthy, the queue burns its retries when it first has work, and a
one-shot job (a restore, an import tail) ends failed with nobody told at
deploy time.
After binding every queue the worker now runs `preloadHandlers` in the
background, without awaiting it, so readiness is unchanged. It loads
each handler once through the same memoised loader its queue is bound
to, so a module that loads stays loaded for the first job. Each failure
is reported on the `job.run.failed` line at error, with the queue's name
and `reason: "handler_load_failed"`, plus the worker error report
(stderr and GlitchTip). A summary line on stderr names every queue that
failed. The sweep never throws, and a failing report does not stop it.
docs/ops/deploy.md says what to look for after a deploy.
`/api/sync/changes` read the `measurementMirror` domain with one query
that ordered every token-linked reading by the joined token's
`mirror_changed_at`. No index on `measurements` can serve that order, so
every poll read and sorted every row of every token ever switched, even
when nothing had changed past the client's watermark.
The walk now reads the account's tokens with `mirror_changed_at` at or
past the watermark first. Nearly every poll finds none and stops there.
Otherwise it walks each change stamp's readings by
`(ingest_token_id, id)`, the order the index holds, with tokens that
share a stamp walked together so rows interleave by id as before. Rows,
order, page boundaries and the cursor are unchanged; the wire contract
is untouched.
EXPLAIN (ANALYZE, BUFFERS) on a seeded Postgres 16: one account, a
token with 300,000 readings switched at T0, a second token with 50
readings switched at T1, 900,000 untokened readings, page size 200.
steady-state poll (watermark past every switch)
before: 53 ms, Merge Join over ~300,000 index rows, 10,827 buffers
after: 0.07 ms token lookup + 0.02 ms resume probe, 10 buffers
first page after the switch
before: 51 ms, top-N heapsort over ~300,000 rows, 10,948 buffers
after: 0.08 ms + 0.17 ms index scan on (ingest_token_id, id)
resume in the middle of the big token
before: 53 ms
after: 0.02 ms + 0.20 ms, Index Cond on (ingest_token_id, id)
A new integration file pages both forms side by side (tied stamps, a
watermark inside a tie, soft-deleted rows, another account's token,
page sizes from 1 to 100) and asserts identical pages, and that a poll
with nothing switched past its watermark reads no reading at all.
The mood rollup warm-up on the dashboard summary was started with a bare
`void`, so `settleBackgroundTasks()` never saw it. On a stale account the
warm-up started a full recompute that could still be writing when the
statement counter closed its window, and the hot-route budget read 30
against 28 depending on where it landed.
Every read-path start of that shape now goes through
`trackBackgroundTask(runDetached(...))`: the measurement and mood rollup
warm-ups (seven call sites), the dashboard's today-briefing warm, and the
two delegated-access bookkeeping writes in the record guard. A guard
refuses a bare `void` on any of them.
`trackBackgroundTask` now wraps its handle in `Promise.resolve`: a test
double that returns nothing used to leave `undefined` in the set, and
settle spun on it at full CPU.
The budget fixture mints the mood rollups in its seed, as it already did
for the measurement rollups. Eight serial runs of
query-budget-hot-routes read the same counts every time (dashboard
summary 28, snapshot 99, day 47, health-status 59, correlations 43).
A chain whose first provider reasoned without answering and whose second
one answered billed only the answer: result.tokensUsed is the answering
call's alone, and the failed hop's spend, already reported on its
FallbackHop, was dropped by every caller that reconciles the day's ledger.
The raw runners now report tokensSpent on success too, the sum the failed
hops before the answer reported. The briefing, status, Coach (no-tools path
and every tool-loop round) and medication-extract reconciles charge it on
top of the answer. On failure, the Coach turn ledger's close keeps what the
failed chain reported spending instead of refunding everything, and the
medication-extract and document-chat routes charge it too, as the status
and briefing paths already did. Document indexing resolves a single provider
and already charged its failed call.
Refs #1279
The benched-provider case for "Re-run analysis" passed its ledger row and
its request through `as never`, which the test-realism ratchet counts. It
now seeds a complete provider_health row and sends a real NextRequest.
Refs #1279
canStartCourse answered true on the day a course was ended with End
course, while a course starting that day shares its last day and the
start refused it with 422 overlap. The start is now offered from the
day after the last course's end, the first day validation and the
engine accept.
A course ended at an instant is over for every rule that asks about
today, whatever the zone it is read from: after a move west the course
rows read CURRENT while the medication's own status read ENDED. The
course rows, the coach summary, the overlap rule and the previous-end
read take the instant-aware reading.
Refs #1280
calculateCompliance and buildTimelineForWindow built the cadence
engine's context field by field and left out endedAt, so a dose slot
after End course counted as missed in the streak and the timeline while
the rate left it out.
Both now take the context from one builder, endedAt is required on
ComplianceMedicationContext and CadenceEngineContext, and the course
anchor guard holds every builder of either type to naming it.
Refs #1280
POST /api/medications/{id}/courses/end resolved the caller with
requireAuth(), so a delegate managing the record got 403 while the
medication PUT that used to carry the end date admits MANAGE, and the
reads still served canEndCourse true. The action now resolves the record
at MANAGE over medications and files the previous end (C4) with the
count of tombstoned placeholders.
The medication reads narrow canEndCourse and canStartCourse to the
caller: End needs MANAGE, starting a course stays owner-only. The list
cell stays per record and the narrowing runs after it. Create and edit
answers pass the grant they were resolved under instead of null.
Registered in the sharing surface guard, the MANAGE handler matrix and
the activity feed's verb lines.
Refs #1280
The on-demand briefing (POST /api/insights/generate) runs through the
briefing chokepoint, which named its chain walk "job" for the background
share of the day's ceiling. Since background work leaves out a provider the
health ledger benched, a person pressing "Re-run analysis" got no request at
all: the call failed with a connection refusal that never happened, and a
refused credential was never reported back.
The chain runners now take the walk apart from the budget surface: a call
can be rationed as a job and still have a person waiting on it
(interactive). runBriefingCompletion takes the flag and the on-demand
route's calls (the first pass and the grounding retry) pass it, so they keep
the job share and walk a benched provider at the tail, as background-bench
promises for interactive walks. Every other job-surface caller is a worker
or is enqueued; the status, narrative and warm routes only read and queue,
and document reads and lab scans resolve a single provider without a chain.
Refs #1279
A reply that ran into the token ceiling with unlabelled text was read as
reasoning the server did not separate whenever the ceiling held room for
reasoning, which is every call that carries a level, Off included. A model
that does not reason at all (llama3.1) lost its own truncated answer: the
call failed as reasoning_only, the briefing never reached its JSON-shape
retry, the Coach named the wrong error, the failure counted toward the
bench, and the client learned to ask with "none" for an hour.
Such text is now reasoning only once the endpoint and model have shown that
they reason: a reasoning field or a think tag in an earlier reply, or a
lesson that says so (knownReasoner). A sighting on a request that asked to
reason speaks only for requests that ask too. Otherwise the text is the
answer, cut off, with finishReason "length".
A lone closing </think> now splits a reply only where the chat templates put
it, at the start of a line, or anywhere once the model is known to reason. An
ordinary answer that mentions the tag in a sentence stays whole.
Two expectations encoded the old rule and move: the Coach loop case whose
first reply is unlabelled template-opened text from a model never seen
reasoning, and the Local case that read a never-seen model's cut text as a
thought. The streamed think-block splitting moved into local-reasoning.ts.
Refs #1276
The call-timeout guard found its wire clients by a direct safeFetch call.
The Local and the OpenAI-compatible gateway clients now send through the
call's request budget (hops.fetch), so the guard looked at two clients and
failed its floor. It finds both spellings again.
Refs #1279
A gateway that refuses reasoning_effort outright (LiteLLM in front of
Ollama: "does not support parameters: ['reasoning_effort']") in front of a
model that reasons without answering sent requests until something outside
stopped it. The answerless rule learned to ask with "none", the refusal of
that "none" was learned as "send no field" over it, the next request came
back answerless again, and each lesson undid the other.
Each call of the Local and the OpenAI-compatible gateway clients now carries
one request budget through every repeat (request-hops.ts): at most two
requests, after which the call ends with the failure it has. A structural
guard holds both clients to sending only through the budget and to handing
it to every repeat.
The refusal is now learned as what it is: an endpoint that refuses the
field, in front of a model already seen reasoning unasked, keeps both facts
(FIELD_REFUSED_REASONS_REGARDLESS), so it is never asked with "none" again
and keeps its thinking room. An answerless reply from an endpoint that
refuses the field is final. When the refused "none" was the repeat of an
answerless reply, the call fails as reasoning_only, which is what it ran
into. The refusal handling of both clients lives in wire-refusal.ts.
A status run that hits its bound now aborts the call it gave up on instead
of leaving it running detached.
Refs #1279
The ledger stamped last_failure_at, last_ok_at, next_retry_at and
updated_at with the database's NOW(), while every reader compares them
with the app's clock: the runner's skip hints and the background bench,
the admin provider-health card, the server-health tri-state and the
support report. A backoff or pause therefore sat wherever the skew
between the two clocks put it. With the app clock pinned, a five-minute
backoff lasted until the database's clock plus five minutes, months away,
or was over before it began.
Both writes now bind the instant they took as a parameter
(`$N::timestamp`, the naive UTC columns), the same one-clock rule the
summary reads follow. The escalating pause arithmetic is unchanged.
The pause integration test pins the app clock years behind and ahead of
the database's and checks the stored instants, a backoff that lifts after
five minutes, a pause that benches background work for its full length,
and a success the server health reads as recent.
Refs #1279
The batch golden lost a card's request about one run in eight. When the
batched call fails, every pending card falls back to its own call at once,
and each failure is recorded on the provider health ledger without being
awaited. Background work leaves a provider out from its third failure in a
row on, so a card that read the ledger after two of its siblings' writes
had landed was benched and never reached the model. Which card, if any,
was scheduling.
The stored notes were the same either way, so only the request list moved.
The parity harness now runs on a ledger that records and benches nothing;
the bench keeps its own tests.
Refs #1279
The GET contract run requires every list operation to answer at least
once with a row, and the new list of deleted documents had nothing to
list. The seed now ingests a second document and tombstones it the way
the delete route does.
The medication reads now carry `endedAt` and `canEndCourse`, and every
course its `endedAt`; the row dumps carry the new columns. Additions only.
Refs #1280
Ending the running course writes the same course rows the other course
routes do, so it follows the same data-layer reasoning: the medications
module gates the surface, not the row store.
Refs #1280
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.
End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.
The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.
Refs #1280
A course's end date is a calendar day and counts in full: the doses of that
day stay due, reminded and expected until local midnight. That is right for
an end date set in the editor and wrong for "End course", whose dialog
promises that nothing more is due.
A course now carries `endedAt` (migration 0482, additive, nullable), the
instant it was ended on its `endsOn` day, projected onto the medication next
to `startsOn` / `endsOn` by the one course writer. From that instant:
- the schedule engine emits no slot, so a dose later that day is not due,
not reminded, not projected onto the today list and never expected by
compliance, while a dose earlier that day stays inside the course;
- the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose
actions, `remindersActive` and the reminder gate stop at once, follow-ups
of an earlier dose included;
- every engine context builder, compliance context, dose history, Coach
snapshot, doctor report and the adherence storyline read it, through one
select and one picker for the medication window.
Any write that moves a course's `endsOn` clears the instant, so an end date
chosen in the editor keeps meaning the whole day. Backups carry it in both
purposes and a restore projects it again. `courseStatusAt` moves next to the
other course rules.
Refs #1280
Drives the status-card path the way an idle instance runs it: a data sync
every minute dirtying five cards, the production enqueue with its queue
name, singleton key and retry options, the queue's consumer, the card's
single call and fallback, the status chokepoint, the chain walker with the
health ledger's in-memory twin, the gateway client and the note store's
negative windows. Only the wire, pg-boss, the clock and the unrelated
Postgres reads are faked; the wire answers every request with reasoning
and finish_reason "length".
Against the code before this series the hour counted 151 requests, 150 of
them sent with "none", none charged to the day's ledger, and 300 when the
reasoning lesson did not survive from one job to the next (the pattern
behind the reported log). Now it counts six, charges all of them, sends
"none" once, and logs the pause once per window.
Refs #1279
pg-boss gives a send that names no retry options the queue's defaults:
two retries, at once, without a backoff. A job that reached a model and
failed then ran again immediately, twice, against the provider that had
just failed it. Two model-bound queues still relied on those defaults:
the Coach memory refresh send and the nudge cron. Both now ask for the
same two retries, a minute apart and backed off.
ai-job-retry-bound-guard holds the rule: every send to a queue whose jobs
reach a model names retryLimit (at most 3) and, unless it is 0,
retryBackoff with a retryDelay of at least 30 s; every such cron carries
the same in its schedule options. The queues come from the model-run
sites the expiry guard already keeps complete (now a shared helper), plus
the job files that call a provider directly, which the new guard keeps
complete itself. A planted case runs the checker over sources shaped like
the tree before this change. The queue golden records the nudge cron's
new options.
Refs #1279
The content-index backfill capped a run at 200 documents, but counted
only the ones it indexed. Against a provider that failed every call (a
model that reasons and never answers) the cap was never reached, so the
walk transcribed every un-indexed document in turn, one provider call
each, until the job's time ran out after more than two hours, and the
refunded failures never reached the day's token ceiling.
The cap now counts provider calls, the walk stops with reason
provider-failing after three failed calls in a row (one document the
model cannot read is still walked past), and a failed read charges what
its reply reported spending. The per-document read moves into its own
function.
Refs #1279
A status note whose generation stalled or failed opened a fixed five-minute
negative window, and only the read path honoured it. The ingest
invalidator counted only a stored note as fresh, so every data sync
re-enqueued each note whose last generation had just failed, and the
worker generated it again whatever the window said. With a model that
never answers, every sync bought another round of calls per note.
The window now grows with the generations in a row that ended without a
note (failure_streak, migration 0480): five minutes, doubling per failure,
up to two hours. A written or re-dated note resets it. A failure inside an
open window neither moves nor counts it.
runStatusCompletion waits the window out before any provider work,
whichever path selected the note again (a sync, a warm pass, a queued
job), and the ingest invalidator no longer enqueues a note inside its
window. A derived-score assessment that fails now opens the same window.
The person's own regenerate closes the record's windows first, and saving
the AI provider settings closes them and forgets the provider's health
rows, so a fixed setup does not sit out a pause the old one earned.
The window code moves to status-negative-window.ts; the status parity
golden records that the mood note screened earlier in the same minute is
no longer generated again inside its window.
Refs #1279
The document picker showed a document deleted in HealthLog as "Deleted
in HealthLog" and would not let it be ticked, so a person who deleted an
imported document by mistake met a dead end exactly where they would look
for it again.
Such a row can now be ticked, and the import sends revive: true for it.
A document still in Recently deleted is restored through the shared
restore, nothing is fetched, and the row reads "Restored from Recently
deleted". One the purge already removed is fetched and stored again, and
only once it is stored does the ledger forget its key, so a failed fetch
leaves the deletion standing. Without revive the route answers "deleted"
as before, and the token upload has no such switch: a Paperless workflow
or the import script re-sending a deleted document is still refused,
whether it is in Recently deleted or long purged.
The search names the document a deleted key still points at (documentId,
null once purged), so the row can say "In Recently deleted" rather than
"Deleted in HealthLog". The outcome gains "restored", sent only in answer
to revive. The row logic moves to picker-rows.ts.
Refs #1275
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.
GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.
The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.
Refs #1275
The health ledger already counted every failed call and paused a provider
that kept failing, with a pause that grows. A paused provider only moved to
the tail of the chain, though, and the tail is still walked: a chain whose
only entry answered every call with reasoning and no answer was called by
every background job regardless.
A walk for background work (surface "job") now leaves out every provider
the ledger has benched until its window lifts; the local floor only once
it is paused, past a brief backoff. One ai.chain.background_paused line
per window says so. A person's own call still reaches the provider at the
tail and reports its own error. The ledger is in Postgres, so the bench
holds across workers and restarts.
The status, briefing and document-index paths refunded a failed call in
full, so background spend on replies without an answer never reached the
day's token ceiling. A failed chain now carries the tokens its hops
reported (tokensSpent) and those paths charge them.
The two clients share one answerless step (askAgainUnreasoned), which
keeps both inside the source size ratchet; its baselines go down.
Refs #1279
The #1267 rule asks a reply that reasoned without answering once more with
reasoning_effort "none", and asks every later call that way for an hour.
That is what Gemma 4 on Ollama needs. gpt-oss behind vLLM accepts only
low / medium / high and ignores "none", and a GLM chat template reads an
effort it does not know as its maximum, so for those models the repeat and
every later "none" were wasted requests or longer thoughts.
When a reply sent as "none" comes back without an answer as well, the
Local and gateway clients now learn that too: later calls go out as the
call asks, with the thinking room #1276 keeps, and an answerless reply is
final. One call costs two requests at most and one after that, and the
log gets one line for each lesson instead of one per call.
A failed answerless call now carries what it cost on its error, and a
repeat that answers reports both requests, so the day's token ledger can
charge them. The learned lessons are pinned on globalThis, so the Coach's
route handlers and the worker's jobs share one copy per process instead
of learning the same thing twice.
Refs #1279
Two guards with the same file name froze the same failure from two sides:
one stopped a runtime import of pdf-parse, the other pinned one pdfjs-dist
in package.json, the lockfile and the checkout, and allowed only the shared
loader to import it. They are one guard now. It strips comments before it
matches, so prose naming a package does not count, walks every runtime
extension and skips __mocks__ as well as __tests__, treats a type-only
import of pdfjs-dist outside the loader as an import, and keeps the import
shapes each side's self-test proved, including the near-miss name that must
not match.
Refs #1277
Two pairs of tests ran a text read and a rasterization in one process,
one file per order. The pdfjs-one-version pair stays; the other pair and
its shared helper go. The kept pair now first asserts that nothing in the
process has claimed the pdf.js worker yet, so a file that lands in a shared
process fails instead of passing on an order it never ran, and it mocks the
failure signal the text read can emit.
Refs #1277
The text read and the rasterizer each opened pdf.js on their own: the text
read with bare options, the rasterizer through src/lib/documents/pdfjs.ts
with the standard font, CMap and wasm directories. The loader now serves
both, so a CID font with a predefined CMap is mapped to characters on the
text path too, and there is one set of options to keep in step.
The loader is typed with pdf.js' own declarations instead of a hand-written
shape. That drops isEvalSupported, which pdf.js 6 no longer has and which did
nothing. Only the render slice the DOM-typed declarations cannot describe
(the native canvas factory) keeps a named local type in the rasterizer.
The run joiner with its named steps moves into the loader as joinTextRuns,
and readPdfText is its one page walk; the second layout copy that mimicked
the previous library's tabs and blank lines is gone. extractPdfText keeps
its outcomes and page cap on top of it, and a failed read still says what
pdf.js threw (documents.local_extract.failed). A test draws a word in two
fonts into a real PDF and reads it back whole.
Refs #1277
LifecycleManageBody takes the medication (id, name, active, course
status) instead of one prop per field. The call site in
medication-detail-tabs.tsx shrinks to one line, so the course status
reaches the controls without growing a file the size ratchet holds.
While two pdf.js versions broke each other, a local text read after a
page render failed and was recorded as decrypt-error, and a scanned PDF
that had to be rasterized for the provider was recorded as raster-failed.
Neither has a content index, and on an instance without an AI provider
nothing offers to index it again.
Migration 0479 clears the attempt time of exactly those PDFs and keeps
their reason, a state no index attempt leaves behind. A new boot
discovery, document-index-retry, enqueues the upload's auto-index job for
each such row, so it runs the same provider-first, local-fallback decision
under the same consent rules; a deferred import stays local because its
row says so. The attempt writes its time back, so every document drops out
after one try, and one that really cannot be read records its reason again
and stays settled. An account with the documents module off keeps its
marks until the module is on.
An integration test seeds both kinds of damage, runs each migration's
statement and only what a boot runs on its own, and sees a preview and an
index appear, while a blank page, a broken ciphertext and a file that is
not a PDF settle after one retry and are not found by the next boot.
Refs #1277
While two pdf.js versions broke each other, every PDF preview rendered
after a local text read failed and was recorded as raster-failed, which
the thumbnail backfill treats as final. Migration 0478 deletes those
failure rows, so the boot-time thumbnail backfill finds each document
again and renders it once. A stored failure cannot tell the clash from a
page that really renders blank; such a page fails once more and settles
with one attempt. Only raster-failed is cleared: the clash surfaced as a
failed rasterization, which the thumbnail path maps to that reason alone.
Refs #1277
The lockfile must resolve exactly one pdfjs-dist, the version package.json
pins, and no package may depend on it except the app; every lockfile line
naming pdfjs-dist is either understood or reported. The checkout must
resolve that version from the working directory, and no production module
may import pdfjs-dist except src/lib/documents/pdfjs.ts, nor pdf-parse at
all. Run against the previous lockfile it reports the 5.4.296 dependency
and the old override; an extra import in the rasterizer fails it as well.
Refs #1277
Nothing imports pdf-parse any more, so its pdfjs-dist 5.4.296 and the
older @napi-rs/canvas that came with it leave the lockfile. The override
that kept that transitive copy past a renderer advisory has nothing left
to move and goes too. The standalone tracing stops naming pdf-parse; its
pdfjs-dist glob now matches the one copy the app pins. The Dockerfile and
canvas guard comments describe the second copy as history.
Refs #1277
Under Node, pdf.js runs its worker in the server process and keeps it on
a global, and whichever copy of pdf.js opens a document first owns that
global until a restart. The local text read went through pdf-parse, which
pins its own pdfjs-dist 5.4.296; the rasterizer uses the app's 6.4.299. A
text read first made every later rasterization fail with "The API version
does not match the Worker version" (thumbnails raster-failed, the vision
read of a scan 422), and a rasterization first made every later local text
read fail, which left the document out of the search index with nothing in
the logs.
src/lib/documents/pdfjs.ts is now the one place pdf.js is loaded and
opened, with one set of options (asset directories, no eval, no system
fonts). The text read walks the pages with getTextContent and lays the
text out the way pdf-parse did with the options it was called with: line
breaks, tabs between far-apart items, a blank line after each page, the
200-page cap. Text extracted from the PDF fixtures and from generated lab
tables, encrypted files and doctor reports is identical before and after.
A failed text read now emits documents.local_extract.failed with the
error pdf.js threw, and annotates the index event with it, instead of
returning a bare error.
The report tests read PDF text through the same reader. Two tests, one per
order, run a text read and a rasterization in one process; both failed
before this change.
Refs #1277
End and its confirmation now live in EndCourseControl, which renders only
while the course runs. When End stops being offered, the open dialog and
its state unmount with it, so the state no longer has to be reset during
render.
The course-status prop uses the shared CourseStatus type instead of a
spelled-out union, and the End confirmation's open state is dropped when
End stops being offered, so a dialog closed by a refetch cannot pop up
by itself once a later course offers End again.
Ending sends today as the end of the medication's latest course. While
the PUT was refused this did nothing; now that it succeeds, pressing End
on a course that had already ended reopened it up to today (doses due
again, the days between counted as missed), and on a course that had not
started yet it became a one-day course today.
LifecycleManageBody now takes the server-resolved courseStatus and shows
End only while the course is CURRENT (a medication with no dates is
CURRENT). Pausing stays available whatever the course.
"End medication" sent new Date().toISOString(), a full instant, against
an endsOn that only accepts YYYY-MM-DD, so every press answered 422 and
the toast said the medication could not be ended. The instant's date was
also the UTC one, a day off around midnight for anyone not on UTC.
The button now sends the person's calendar day in their timezone
(userDayKey), the same day key the medication card uses.
The reasoning separation grew three files past their size ratchet. The Local
client's reasoning collector and the new separation helpers move to
local-reasoning.ts, the gateway client's thinking room and reply separation
become two private methods, and the Coach's error-code resolver moves out of
chat-bubble.tsx into coach-error-key.ts, still re-exported from
message-thread.tsx. Code motion apart from comments that said when a branch
was added rather than what it does. The baselines record the shorter files.
Refs #1276
The lab-scan read asks every provider for no reasoning. On an
OpenAI-compatible gateway or a local server that "none" is a request the
model behind it may ignore, so these wires now add the low thinking budget on
top, the same rule the file already holds for a model that always thinks:
the answer keeps its full allowance either way.
Refs #1276
Every 90-day series in the seed is a mean-reverting walk, and around a
settled target that is flat noise. A 7-day trend line over the last 30
days of blood pressure drew a straight line, which is the view the demo
exists to show.
A busy stretch three to four weeks back now lifts blood pressure and
pulse and lets them settle again, the mood dips with it, and weekend
readings sit a touch higher. Blood pressure also pulls back towards its
target harder, so a seed no longer ends several mmHg above the band its
stored texts describe; today's reading settles on the days after the
stretch. The two stored texts that summarise the last weeks mention the
stretch.
An integration test runs the real seed and holds the 7-day mean to a
visible swing and today's reading to the settled side of it.
When every provider in the chain answered a Coach round with reasoning and
no reply, even after being asked once more without reasoning, the turn ended
with "could not reach a provider, please try again in a moment". The
provider was reached, and trying again in a moment changes nothing for a
model that cannot stop reasoning.
Such a chain now ends with its own code, coach.provider.reasoning_only, and
the drawer says the model only reasoned and never wrote an answer, and
suggests another model. The choice between credential_expired, rate_limited,
reasoning_only and unavailable moved into one function beside the other turn
error rules, where it is tested on its own. An un-wrapped reasoning-only
error maps the same way. Clients that do not know the code keep showing the
generic provider message.
Refs #1276
The Coach loop runs a round's tool calls only when the round finished with
"tool_calls". The Anthropic and Codex clients already derive that from the
calls themselves; the OpenAI-compatible client copied the server's
finish_reason, and vLLM, Ollama and LiteLLM can report "stop" beside a
parsed tool call. The loop then took the round as the answer, the call never
ran, and what the person saw was whatever text came with it.
A reply that carries tool calls now reports "tool_calls", as on the other
wires.
Refs #1276
A gateway or local server in front of a model that always reasons (GLM
behind vLLM, for one) accepts reasoning_effort "none" and ignores it. Two
things went wrong with such a model.
Once it answered a round with reasoning alone, the #1267 lesson asked every
later call with "none", and the token ceiling followed the wire: "none" meant
no thinking room, so a Coach round got its bare 600-token answer budget. The
model spent that thinking and stopped at finish_reason "length" before it
reached a tool call or an answer, for the hour the lesson holds. A turn set
to Off had the same ceiling from the start.
Where the server did not separate the reasoning (no reasoning parser, or one
that falls back to content), the reply's content was the thought itself: an
inline <think> block, the text before a lone </think> when the chat template
opened the block in the prompt, or unlabelled text cut off at the ceiling.
The gateway client passed all of it through as the answer; the Local client
split a leading <think> block, and only for callers that asked to reason.
On these wires the ceiling now follows the call's level instead of the wire
effort: Off keeps the low room, a call sent as "none" keeps its own level's
room, and a call without a level gets the low room once the model has shown
it reasons unasked. It is a ceiling, not a charge; a model that does switch
off never touches it. Both clients now take reasoning out of content for
every caller: a leading or a lone closing think tag, and a reply that filled
a ceiling sized for thinking with unlabelled text and no reasoning field
beside it. What comes out goes to the reasoning channel when the caller asked
for it and nowhere else. A reply left without an answer takes the #1267 path
and ends as reasoning_only, never as the thought.
The loop test drives a Coach turn through the gateway client onto an
upstream that answers like the reported wire capture, with and without a
reasoning parser.
Refs #1276
body-parser 2.3.0 (CVE-2026-111181, high) and fast-uri 3.1.8
(CVE-2026-110180, CVE-2026-110306) arrive under the MCP SDK on a
production path. undici 7.30.0 (CVE-2026-109869, CVE-2026-109841,
CVE-2026-109924) is the development-only copy under shadcn; the app's
own undici 8 is already past the fix. Each bound moves the flagged copy
and nothing else.
The 90-day core series (weight, blood pressure, pulse, body fat, resting
heart rate, sleep, steps) were all tagged MANUAL. The measurements list
badges every row that is not MANUAL, so the newest weight and
blood-pressure rows carried no source while Settings > Sources named
Withings for those metrics (from the older history rows). The body
composition series claimed Apple Health for fat mass, fat-free mass,
muscle, water, bone and visceral fat, none of which HealthKit has.
Weight, body fat, blood pressure and its pulse are now WITHINGS with no
device type, the way the Withings sync writes them; resting heart rate
and the sleep aggregate are APPLE_HEALTH from the watch; steps are the
iOS daily total under its `stats:` key, so the legacy-step consolidation
leaves them alone. The Withings body-composition components move to
WITHINGS; lean body mass and BMI stay APPLE_HEALTH from the scale. The
waist stays MANUAL, since nothing syncs a tape measure.
An integration test runs the real seed and holds every Withings and
Apple Health row against the type maps those integrations ingest
through, and pins a device source on the newest reading of each synced
vital.
Every briefing reader serves `dailyBriefing` only when the payload's
`briefingGeneratedAt` falls on the reader's today. The demo seed wrote the
comprehensive payload without that stamp and without the language tag, so
on a fresh seed the advisor read returned no briefing, the dashboard
snapshot stayed "preparing", and a warm was enqueued that can only fail
against the placeholder key. The /insights card sat empty while the hero
said "Updated today".
The seed now writes the cache the way a generation does: the payload
stamped through `withBriefingGeneratedAt`, the cache stamp from the same
instant, and the `en` tag. The baked paragraph drops its "Good morning"
opener, since the hero already greets by the hour.
The hero's freshness caption dates the subtitle, so it now shows only
when the subtitle is a briefing. The cache stamp also moves when a warm
keeps yesterday's text, which the read then withholds; the caption then
sat under the generic fallback sentence.
`seed()` is exported behind a main-module guard, and an integration test
runs the real seed and reads its output back through the advisor read,
the dashboard snapshot, the status cards and the period narratives.
With the day panel docked beside the blood-pressure page (1280 px and up),
each half of the stat card is a narrow column, and its four cells still
went side by side because the step followed the window's width. Every cell
came out narrower than "112 mmHg": the systolic values broke onto two
lines, the two-digit diastolic ones next to them did not.
The stat strip now joins a value to its unit with a no-break space, and
each series is a container whose cells go four-up only when the series
itself has the room: from 28rem for a half-card column, from 36rem for a
full-width strip (where the 640 px window step put it, so the longest unit
written, mL/(kg·min), keeps a cell it fits in). The loading shell follows
the same steps. Every page with a stat strip (pulse, weight, the
HealthKit, lab and custom-metric pages) shares the component.
A component test pins the no-break pair and the container steps; the
day-view spec measures every blood-pressure value beside the docked day
at 1280 px and fails on the old markup.
The summaries slice and the comprehensive aggregator took their JS windows
(the DAY-bucket bounds, the rollup slope anchors, lastSeen staleness) from
the process clock and their SQL windows (avg7, avg30, the month before, the
median, the 90-day canonical pick, the cold-path slope evidence) from
NOW(), the database's clock. One response mixed two clocks, so a reading
on a window's edge fell in or out with whichever host answered, and with
the process clock pinned the analytics parity walk changed its avg7 with
the hour it ran at.
Each read now takes one `now` and binds it into its statements
(`request-clock-sql.ts`): `$N::timestamp`, which keeps the driver's
offset-free UTC string as written in any session zone. The canonical-pick
helper takes its window as an instant plus an interval instead of reading
NOW(); the series and calendar readers anchor it on their own `since`, the
BP-in-target read on its 365-day start. The window-mean and live-slope
columns the two aggregates each spelled out now come from one builder.
The streak's local day series read its bounds through `::timestamptz`,
which follows the session zone: outside a UTC session today's reading fell
out of the streak. It reads them as UTC first.
The parity walks for analytics, the dashboard summary and the
comprehensive overview pin a fixed day instead of today: their records are
laid out from the anchor day, so the chronotype's work and free nights and
the weight-by-weekday points moved with the weekday the walk ran on. The
snapshots are unchanged. A new suite pins the clock to four times of day,
far from the database clock, with readings ten minutes either side of
every edge, on both read paths, in a UTC and an Auckland session.
Since the lab staging reads a long report in parts, one auto-index could
make the transcription plus up to twenty reads of two calls each at the
person's response timeout, hours past pg-boss's fifteen-minute default,
which failed the job and retried it beside the reads still running. The
staging now takes the run's deadline as a schedule and plans only the
reads that fit before it. The index job is long, with an expiry above the
calls that run before the plan can say no, a stop on jobDeadline and a
lock per document; the document run hands the same staging its own
deadline, which moves the run's expiry as the reads are planned. The
document summary, one call and its reasoning repeat, gets an expiry above
that worst case.
ocrReadCallMs in the read core is the one per-call timeout both deadlines
divide by. The expiry guard now names every importer of the document and
lab read entry points with the queues that reach it.
The runtime guard's planted cases stripped every file's comments again on
each rescan, which took over a second alone and timed out under a full
parallel run; the stripped text is now memoised.
Refs #1274
A least-squares slope per day over readings minutes apart explodes: three
blood-pressure readings in one sitting, the only ones of the week, fitted
thousands of mmHg per day, and the dashboard tile printed seven days of it
as the 7-day trend. The 30-day fallback had the same flaw.
A window now earns a slope only when its readings fall on at least three
UTC days whose first and last lie at least half the window apart (4 days
for the 7-day window, 15 for the 30-day one). The rule lives once in
window-confidence.ts and gates every path that publishes a windowed trend:
trendSlope (summarize and its callers), the rollup composition and the
live REGR_SLOPE fallback of the summaries slice and the comprehensive
aggregator, and the per-bucket slope of folded week, month and year
buckets. Slope, direction and confidence are present or absent together.
The fit itself stays over individual readings, as the window means do.
The parity snapshots change only where the fixture falls short of the
rule: a weekly body-fat series has two days in its 7-day window, and a
ten-day oxygen-saturation series spans nine days of its 30-day window.
An iPad in a Safari tab laid the app shell out 10 px in from the left edge
in portrait. The shell padded that inset, so a strip of page colour ran
beside the rail from the logo band to the account row, and the rail's band
borders stopped short of the edge.
The desktop shell no longer pads the sides. The rail runs to the window's
edge and takes the left inset inside its own background, widening by it so
its content keeps its 4rem / 16rem; the logo band, the footer group and the
account band reach back over the inset so their lines meet the edge. On the
right the top bar and main already run to the edge. The phone shell keeps
padding both sides. The side insets are read through --safe-area-left and
--safe-area-right, so a test can set one.
The account band takes the logo band's height (4rem) with the avatar
centred in it, instead of 61 to 69 px of padding with more room below the
avatar than above; the home-indicator inset stays under the band.
tablet-shell.spec.ts sets a 10 px side inset and a 20 px home indicator and
checks that the rail owns the window's left column of pixels, that its band
lines start at x = 0, that nothing in it sits inside the inset, and that the
account band mirrors the logo band. shell-viewport.test.ts holds the routing.
A status run (a metric card, the batched assessment, a derived score, a
workout line, a Coach memory pass) is one model call plus the one repeat
the Local and gateway clients send when a thinking model answered with
reasoning alone, each with the person's response timeout. The run's outer
bound was a single call, so the repeat was cut off, and the open card
stopped polling after 48 s, so a note from a model that needs minutes
never reached the page. The bound is now the per-call timeout times
STATUS_MODEL_CALLS, and the card's poll ceiling follows the published
response timeout (fast for the first 48 s, every 15 s after), only while
the route reports the note in flight; the fallback text stays meanwhile.
The status queue is exclusive, so the longer poll cannot queue a second
generation beside a running one.
The morning refresh is one comprehensive generation of up to five calls
in a row, with no outer bound and pg-boss's fifteen-minute expiry, so a
large setting had pg-boss fail and retry it while it was still running.
It now runs behind the same bound as the warms, and the job carries an
expiry derived from that worst case. The per-metric note, the workout line
and the Coach memory refresh get expiries derived the same way.
queue-runtime.ts gains a bounded class for these jobs: model time read off
model-time.ts, an expiry above it, carried on every send. A new guard
names every caller of runStatusCompletion and generateComprehensiveInsight
with the queues that reach it, and holds each such queue to bounded above
its runs or long under a run lock.
Refs #1274
The document review now names the two outcomes the extraction can report
since it reads a long document in parts. A read that stopped at the
provider's output limit before a single fact closed shows its own
sentence (try a model that allows longer answers) rather than the
generic "Couldn't read the document". A run that staged facts but had a
part cut or not reached opens the review as before and adds a warning
that some values may be missing, so the list is checked against the
document. Both strings are in every locale bundle.
Refs #1257
Extracting lab values from a stored document still sent the whole text
in one call. A multi-page report produced more JSON than one call's
output ceiling, the answer was cut mid-fact, the parse failed twice, and
the read came back as 422 "Couldn't read the document. Try a clearer
copy." The lab scan had already learned to read the same report a page
or a part at a time, but each surface carried its own copy of the read,
so the document path never got it. It also ran under the shared 60 s
timeout, dropped every fact past 120 (or every fact when one did not
match the schema), and charged a flat reservation.
The read now lives once, in lib/documents/ocr-read.ts and ocr-plan.ts:
the per-read limits, the corrective retry, the salvage of a cut
answer's complete rows, the wire re-check and budget reservation before
each read, and the planning of a PDF a page at a time and text in parts
at line boundaries. The lab scan and the document extraction (the
extract route, its background run and the lab auto-staging) both read
through it, and the background run plans its reads on the same deadline
as a lab scan. The per-read budget moves to a shared metered budget.
The document path validates the answer fact by fact, keeps the lab
scan's row cap (400), and says why nothing came back: 422
documents.inbound.outputLimit when the reads stopped at the output
limit, 429 when the budget refused them, extractFailed only for answers
that were not the schema. A run whose reads were cut or not reached
reports `incomplete` on its result.
A guard holds the class: the output ceiling is set only in the core,
both surfaces read through runOcrReads, both runs share the deadline,
and a lab read and a document read go out under the same limits.
Refs #1257
The TrueNAS page now walks through the catalog install field by field, how
to generate the two secrets, where TrueNAS keeps the key and why that is not
a backup, a dump and restore from the postgres container shell, and updates
with forward-only migrations. The listing is marked as pending review, and
the install-via-YAML values stay for the time until it is live.
The deploy notes list what needs a matching truenas/apps pull request in the
same release cycle: a new or stricter required variable, a port or health
check change, a Postgres major or extension, and any other compose-shape
change.
The TrueNAS app in the Community train takes the key in its own form field,
not in an environment list. The step now names that field and keeps the
environment-variable wording for an app installed via YAML.
The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.
The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.
The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
The browser's abort for a forced regenerate waits three calls at the
response timeout, fewer than the five the background generator may make.
That is correct: the route runs its own pipeline, refuses an unparseable
reply or a screened payload instead of retrying, and never reaches the
background generator, so its longest run is the generation, the
grounding retry and the one reasoning repeat.
The guard only read two of the pipeline's files, so a retry stage added
anywhere else on the route would have lengthened the run past the
browser's abort unnoticed. It now sweeps the route and the whole
briefing-request folder for stages and for any call into the background
generator, and the advisor states why its count is the shorter one.
Refs #1274
The briefing read polls while the warm it enqueued runs, and it stopped
after ten polls (225 s) whatever the response timeout. A briefing that
landed later never reached the open page, which kept the empty card, or
the previous attempt's "timed out" hint, on screen until a reload.
The read's poll ceiling now follows the published response timeout over
the generation's sequential calls, never less per call than the
briefing's 180 s default, plus one interval for the queue to pick the
warm up. A settling regenerate and the overview body keep their ten
polls. A test walks the poll schedule against the warm's own bound for
every setting, and the client-abort guard holds the read to the derived
ceiling.
Refs #1274
The out-of-band warm the Insights page relies on bounded one
comprehensive generation at the account's response timeout plus 30 s.
A generation is up to five model calls in a row: the generation, the
JSON retry, the grounding retry, the outbound-screen repair, and the one
repeat a thinking model gets when it answered with reasoning alone. Each
call honoured the setting, but a local model needing 300 s per answer
under a 600 s setting met the 630 s bound in the middle of its first
corrective retry, and the briefing was dropped.
The bound is now the per-call timeout times COMPREHENSIVE_MODEL_CALLS
plus the headroom, for the nightly pass and the forced warm alike. A
test drives the real warm, generator, chain runner, Local client,
safeFetch and pinned dispatcher against a loopback server that answers
after 300 s; under the old bound it reports a timeout on the third
answer.
The response-timeout guard now names every outer bound around model
work and holds both briefing generators' stages to the call counts the
outer waits multiply by.
Refs #1274
The ChatGPT Codex backend now rejects every slug from gpt-5.5 down, so a
deploy without a CODEX_MODEL_FALLBACK_CHAIN override walked the whole
default ladder into "all configured Codex slugs were rejected". The
default now starts at gpt-5.6-terra, with its luna and sol siblings and
gpt-5.5 as the last rung.
The gpt-6 line stays out of the default: the reasoning, sampling and
vision gates do not recognise that family yet, and a test now holds every
default slug to a family the reasoning gate knows.
Every server render left its request reachable for ten minutes after the
response was sent. Providers builds its QueryClient once per render, and
the app's defaults give queries a ten-minute gcTime. On the server each
entry the render built, disabled observers included, started a timer of
that length, and a Node timer keeps the async context it was created in.
That context is the request's: the response, the React Flight stream and
the compressed body. A heap snapshot after a run of unauthenticated
probes showed one 600 s query-core timer per request and the retained
Flight chunks, ServerResponses and gzip buffers all hanging from them:
about 150 KB of heap and 90 KB of buffers per not-found render.
TanStack Query already defaults gcTime to Infinity on the server for this
reason; an explicit value from the defaults or a hook overrides it.
AppQueryClient folds Infinity back in at defaultQueryOptions, the merge
point every observer, cache build and hydration passes through, so the
server cache goes with the client at the end of the request. The browser
keeps its configured lifetimes.
The retention test reproduces the chain without a server: a response
object held only by an AsyncLocalStorage store must be collectable after
the render's client is dropped, with a positive control on a plain client
that must keep it alive. A guard keeps client modules from building a
QueryClient themselves and allows only option-less clients elsewhere.
step-up-elevation-guard timed out under load in three separate gates
and passed every time on its own. One of its tests imported
@/lib/auth/step-up inside the test body to read the fresh-factor set,
and that module reaches the database client and the generated Prisma
client: the import alone ran against the test timeout, and how long it
took depended on the machine, not on the code under test.
The fresh-factor definitions (StepUpMethod, FRESH_FACTOR_METHODS,
isFreshFactorMethod) move to step-up-factors.ts, which imports nothing;
the guard imports it statically and the test is synchronous. Two more
guards did the same: next-image-optimizer-guard loaded next.config and
its plugins per test, and proxy-relative-redirect-guard loaded the
redirect helper with next/server. Both import statically now.
guard-dynamic-import-guard.test.ts parses every file of the guard
project with the TypeScript parser and fails on any import() call, so
a guard that needs a value imports it with the file or reads the
source; a test that must load late belongs to the isolated project.
A full backup carries the account's own mood tags, rated factors and
tag groups, and the JSON import left them out: a mood entry imported
into another account landed without every link to a tag the person had
made.
Before the entries are written, every key they or the file name is
resolved once. A catalogue tag or one the account owns is used as it
is; an own tag whose label the file states differently is kept, never
overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a
group). A tag the file defines and the account lacks is created in its
group, the group created the same way, within the account's caps of 50
tags and 12 groups. A key another account of the server holds comes
over under a key derived from the account and the file's key, so a
second import of the same file finds the copy instead of minting
another. The links are written through that resolution, own rated
factors included, inside the entry's transaction; anything unresolved
is still dropped and named. The import card names kept keys in all
nine locales.
The field-coverage guard now holds the backup's customMoodTags and
customMoodTagCategories to the import schema. The round trip moves a
backup to another server (the same seeded catalogue, none of the
account's rows), imports it into a fresh account and compares the
re-export, own tags, group and rated factor included; a second case
imports into another account of the same server, and a third keeps a
renamed own tag.
Postgres returns rows that tie on an ORDER BY in no particular order,
and not the same one twice. The medication reads ordered by columns two
rows share all the time: every read that hands schedules on included
them with no order at all, medications were listed by createdAt alone
(one import writes many in a millisecond), intakes by scheduledFor (a
twice-daily medication puts every morning dose on one instant), and
"the latest intake" lookups by takenAt. A client read the same account
in a different order from one request to the next, and a latest-intake
lookup picked either of two rows.
Every findMany / findFirst on a medication table, nested includes
included, now ends its order on the id in the direction of its last key,
and schedules are read by window, then id (MEDICATION_SCHEDULE_ORDER, the
order the backup already wrote). medication-read-order-guard.test.ts
holds the tree to it. medication-read-order.test.ts writes tying rows in
the opposite order to the one each read promises, through the medication
list and today's intakes, and fails without the tie-breakers.
The parity suite's own state reader had the same gap: it sorts rows by
content with the ids blanked, and rows that tie then kept whatever order
the unordered read returned, so their <id:N> labels swapped under a
loaded run. Its reads are ordered by id, which for a cuid is the order
the rows were written in.
The JSON import's schema left the route, so its entry in the inline
schema strictness register goes; the schema module says why its objects
are not strict.
A restore never runs beside a backup pass or another large job: the
heavy lane puts its delivery back in the queue for a minute at a time.
The job's own row says queued for the whole wait, and the console said
the restore starts "as soon as the background worker picks it up",
which read as a stuck worker while a backup ran.
The status routes now ask the queue. A delivery the lane put back sits
in created with a start_after ahead of now, and the active row of a
heavy queue is what holds the lane. The job view carries
waiting: "backup" | "busy" | null, and the console says it waits for
the running backup, or for another large job, and since when it has
been queued, in all nine locales. A queue that cannot be asked answers
no wait and is reported at warn.
The job view and its reads move to backup-restore-view.ts, which keeps
the worker module under its size baseline.
A hold ended only when its handler settled. pg-boss fails a job at its
expiry and fires its abort signal but does not stop the handler, so a
backup that hung held the lane for the life of the process: every heavy
job, a restore an operator started among them, went back to the queue
every minute from then on, and the only trace was one warn line after
two hours.
A hold now carries the longest expiry of the jobs it runs. A job that
finds the lane held past that expiry and one deferral of grace reports
job.heavy_lane.holder_expired at error and takes the lane; the old
handler, should it ever settle, releases only a hold that is still its
own.
The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.
The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.
json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
The restore shares the heavy lane with the backup pass, and a restore
that finds the lane taken goes back to the queue for 60 seconds. The
journey waited exactly 60 seconds for it. On a loaded machine the pass
the journey had just started was still finishing its tail when the
restore came in, so the restore was deferred and started one second
after the wait ran out. The wait now holds one deferral and a second
yield, and the test timeout makes room for it.
Both palette scans ran axe as soon as the dialog was visible. The
dialog fades in, and a scan that landed mid-fade read every colour
blended with the page behind it: on a loaded machine the selected
option's text measured 4.47:1 against the 4.5:1 floor. The scans now
wait, bounded, for the finite animations to finish first.
The journey counted glucose rows on the unfiltered list, which shows
the account's newest page of every type. Other specs keep writing
readings to the same account, so on a database a few runs old the
reading typed for yesterday fell off that page and the count read one
short. The list now opens with `?type=BLOOD_GLUCOSE`, so it holds
exactly the rows the spec owns.
pageText worked on raw text-matrix slots and bare numbers. Each step now
has a name: positionOf reads where a run starts and ends and its font
size, startsNewLine says when the baseline moved by more than half a font
size, and leavesWordGap says when the gap is wider than a tenth of the
font size. The output is the same on every test PDF.
extractPdfText cast the dynamic import to a hand-written shape
(`as unknown as`), so the compiler could not check the call against
pdfjs. It now uses pdfjs' own types: PDFDocumentLoadingTask, TextItem and
TextMarkedContent, all type-only imports that are erased at build.
That check found a dead option at once: pdfjs 6 has no isEvalSupported.
The option went away with the font-program eval path it used to switch
off, so it did nothing here; it is removed.
The test-realism ratchet counts a test that calls readFileSync and names
a source file. The guard now reads source through readSourceText, and the
two one-process tests no longer name their twin file in a comment. Their
doc comments are rewritten in plainer words.
The value chart's axes and labels, as drawn on a 90-day blood pressure
view on a tablet:
- Y ticks were whatever Recharts split the padded domain into, so the
labels read 57, 83, 109. The domain is now widened to a 1/2/5 step
and every tick is handed to the axis, evenly spaced and all drawn.
- X ticks were every n-th point, so their dates drifted with the data.
They now fall on a calendar grid (days, Mondays, two weeks, months,
quarters, years), each on the point nearest the grid line, and the
step is the smallest whose labels fit the measured plot width.
- The "your average" label sat inside the plot over the readings, and a
target zone's label was clipped to its first letter. Both now sit
beside the plot, in a right margin sized to the longest label.
A browser test draws the axes with Recharts and pins the tick values,
the Monday grid, and both labels outside the plot clear of every dot.
On an index or band axis the rug placed every day that holds anything
by interpolating it between the two nearest points. A sparse series
beside a daily one squeezed a week of other days into one gap, and the
dots ran together into bars. A day now gets a dot on those axes only
when it is one of the chart's points; a time axis keeps every day of
its span.
With a 20 px inset set through --safe-area-bottom, at iPad sizes in both
orientations and in Split View: the last row of pixels belongs to the
rail and main, or to the bar, never to the page behind the shell; the
rail runs to the edge while its account row stops above the inset; the
root carries the content colour; and the end of the page clears the
inset, the bar and the Coach button.
The Coach button is also checked against the last line of the nested
layouts (an Insights metric page, a values list, the Insights overview,
two Settings sections) at inset 0 and 20, with an ai block that makes it
render.
The rail, main, the bottom bar and the Coach button each read
env(safe-area-inset-bottom) on their own. They now read
--safe-area-bottom, which is that env() value, so the four always agree
and a test can set an inset that no desktop browser reports.
The app shell's root also states the content colour itself instead of
leaving it to propagate from the body. A WKWebView browser on iPadOS
(Edge, Chrome, Firefox) can keep the strip over the home indicator out
of the page's layout and fill it with the colour it takes from the root;
that strip now meets main in the same colour.
The override comment said pdf-parse's pdfjs 5.4.296 drives local text
extraction. Extraction now reads with the app's own pdfjs; only the tests
still use pdf-parse, which has to stay on pdfjs 5 because its teardown
calls an API pdfjs 6 removed. The range itself is unchanged.
A structural guard fails when any runtime file under src/ imports
pdf-parse again, which would bring its second pdfjs back into the server
process. The next.config comment on the tracing includes now describes the
text path as it is, and the extractor no longer turns an empty
end-of-line run into a blank line of its own.
Local text extraction went through pdf-parse, which pins its own
pdfjs-dist (5.4.296) beside the app's (6.4.299), and both copies take
their worker from globalThis.pdfjsWorker. Whichever ran first in a process
broke the other: after a text extraction every raster failed with "The
API version "6.4.299" does not match the Worker version "5.4.296""
(thumbnails raster-failed, vision reads of a PDF 422 extractFailed), and
after a raster the text extraction failed and the local content index
stayed empty without a trace.
extractPdfText now reads the text layer with getTextContent on the
pdfjs-dist the rasterizer uses, so only one pdfjs runs in the server.
Pointing pdf-parse at 6.4 instead is not enough: its destroy() calls
PDFDocumentProxy.destroy(), which pdfjs 6 no longer has, so every
document would skip its teardown. Two tests run text and raster in one
process, each order in its own file.
Each measurement token row carries a switch, the mint form an opt-in, and
the card a notice when readings exist that no token reaches. Copy in all
nine locales. The endpoint catalogue moves to its own file and the
revoked list out of the token card.
Declare appleHealthMirror on measurement rows and sync upserts, the list
filter, the measurementMirror sync domain, the token setting on the list,
the mint and the new PATCH, and the unattributed-readings summary.
PATCH /api/tokens/{id} sets mirrorToAppleHealth on a measurement token:
cookie session only, the caller's own tokens only, revoked ones included,
any other token refused with tokens.mirror.not_measurement_token. It
stamps mirrorChangedAt and audits only when the value changes. The mint
accepts the setting (off by default), the token list returns it, and
GET /api/tokens/measurements counts the EXTERNAL readings no token
reaches.
Measurement upserts in /api/sync/changes carry appleHealthMirror. A new
measurementMirror domain delivers { id, appleHealthMirror } for the live
readings of every token whose setting changed past the cursor, keyed on
(token.mirrorChangedAt, measurement.id). Switching the setting writes the
token row only, so the readings keep their updatedAt and syncVersion and
nothing keyed on them recomputes. The domain is additive inside the v2
cursor and is left out of the retention expiry, since its watermark is a
setting's age, not a deletion's.
A reading sent with a measurements:write token keeps its EXTERNAL source
and now points at the token that carried it (measurements.ingest_token_id).
Every stored row answers a server-resolved appleHealthMirror: true for
WITHINGS, IMPORT and MANUAL, the token's choice for EXTERNAL, false
otherwise. The plain list accepts appleHealthMirror=true so a client can
page exactly those rows.
Migration 0476 adds the columns and links existing EXTERNAL readings only
where one measurement token provably sent them; 0477 builds the index
concurrently. Backups carry the link and a restore keeps it only for a
token the account still holds. A guard holds every EXTERNAL writer to
setting the link.
The offline persister restores the last dashboard snapshot at boot and
marks it invalidated so the first mount refetches it, but the snapshot
cell read refetchOnMount: false and never did. A page reloaded right
after a write, before the persister had flushed the newer snapshot,
painted the pre-write dashboard until the two-minute poll; the weight
just entered had no tile. A cell with live data still reads as it is on
remount; a restored, invalidated one fetches.
A test that reads source by path counts against the realism ratchet;
the sweep reads every file through readSourceText like the other
guards, which also shares the reads with them.
The vault's layout is account state. documents.spec read the default
while the layout journey in v142-qol turned it on the shared account,
so which one failed depended on the order they ran in. Each now runs on
a vault account of its own; the vault fixture seeds the same corpus
under ids tagged per account, so two vaults never claim one row.
Global setup clears every rate-limit bucket keyed on a fixture account
or on the loopback addresses the suite calls from, not only the login
and share buckets: the check-in, write, export and import buckets
outlive a run by up to an hour, and a second run on the same database
met the first run's counts. A developer's own accounts are left alone.
The layout journey waits for its own write rather than whichever layout
write answers first.
tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet,
in the WebKit job) and on Chromium with an Android tablet profile
(chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in
both orientations, Safari's bars, Split View and Slide Over. It checks
that the shell fills the window and leaves the document nothing to
scroll, that every rail destination scrolls into view on top and takes a
tap, that the scroll cue follows the overflow, that main reaches its
last line clear of the bottom bar, that the page scrolls again after a
sheet and a dialog close, and that a tab strip leaves the vertical pan
to the page.
ui-standards records the shell and rail rules.
touch-pan-x on the tab strip refused a vertical pan that began on it, so
a swipe up or down over a tab strip scrolled nothing. The strip already
pans sideways through its own overflow.
A touch tablet keeps the rail's 44 px rows, so in landscape three to
seven destinations sit below the fold, and the list has no scrollbar at
rest. The edge fade alone did not read as "swipe here", and a swipe that
reached an end chained to the document, which held the next one.
The list region moves into its own module. Under a coarse pointer it
contains its overscroll, keeps a focused row clear of a new scroll cue,
and shows that cue at an edge with more destinations; a tap scrolls the
list most of a screen on. The cue stays out of the focus order and the
accessibility tree: the keyboard and screen readers already reach every
row. The rail takes the home indicator's inset so the account row stays
clear of it. A mouse sees no change.
The authenticated shell was sized by h-dvh. On iPadOS Safari that value
and the area the browser lays out disagree after a rotation and under the
floating toolbars: a short value left a strip of page colour under the
shell with the last card cut off above it, a tall one let the document
scroll by the difference and take the swipes meant for main.
The shell now takes the window's edges (absolute inset-0 against the
initial containing block), and the document under it gets no scroll and
no rubber band (data-scroll="shell": overflow hidden, overscroll none,
height 100% so the box survives a dialog's scroll lock making the body
relative). Not fixed: a fixed box re-rasterises every page in a layer of
its own and traps the bottom bar's z-index.
The desktop shell's main now clears the home indicator of an installed
app, and the page frame clears the Coach button there with pb-24: the
button sits 5.5rem up, so pb-20 left the last 8 px under it. The
sub-shell grid floor follows the new padding.
Every page load mounts the locale provider, and its mount effect sent
PUT /api/auth/me/locale with a value that had not changed. Every
authenticated write is charged to the account's shared write bucket, so
each page view spent one write. The mount write now happens once per tab
session and locale; a switch still writes at once.
Forty-two guard files carried their own comment stripper, block comments
first in one non-greedy pass, so a /* inside a line comment opened a
block that hid the code after it, and the stricter copies hid code
behind a /* inside a string, which every e2e route glob has. They all
read through the shared stripComments now, and it scans the way the
parser does: strings, template literals and regular expressions keep
every character, a comment inside a template expression goes, and line
numbers are kept. A parity test strips every source file both ways,
with the scanner and with the TypeScript parser, and fails on the first
that differs.
No guard reports anything new. Each one fails when the stripper returns
nothing; three of them passed on an empty file and assert that the code
they ban is read at all now.
Thirteen of the table's files had been deleted or renamed since it was
written. The entries were dead weight; nothing reads a timing for a file
that is not there.
The JSON import refused the app's own files: the full backup writes an
empty note, tag list or glucose context as null and the JSON export as
an empty string, and the import schema read either as a value of the
wrong type. The file is now read in the exporter's terms before it is
validated: the export's envelope is opened, every null or empty field
is treated as absent, a backup row carrying a deletion is left out and
counted as skipped, and a unit the app converts on every other way in
is converted to the stored one.
The round trip also showed the portable backup dropping what a reading
itself says: glucose context, sleep stage, range bounds, rhythm and
device. They ride the portable file now, and a portable restore puts
them back. Sync bookkeeping stays out.
An integration test exports an account three ways (full backup, JSON
export, measurement CSV), imports each into a fresh account and compares
what the import carries.
The reduced-motion backstop gives every element a near-zero transition,
and the default transition property is all, so the font size Recharts
writes into its measurement span started a transition and the read in
the same task returned the previous size. Labels were sized at whatever
the last one had, or at the inherited 16 px: the last date label moved
in from the edge and value labels that fit were dropped as colliding,
differently from one visit to the next. Nothing in the measurement host
is ever seen, so nothing in it transitions now. A browser test pins the
read under reduced motion and without it.
The custom-metric create and update refusals and the medication layout
refusal are filed through auditLog now, so a delegate's refused request
reaches the owner's activity feed, where it read as the generic change.
Each gets its own sentence in every bundle; the refusal sentences live in
their own function. The audit write guard states its floor, and the
schedule-reader guard names the medication create and edit, which answer
through medicationDetail.
The create, its mirror replay and the edit answered the written row
without the read-model fields the declared resource carries (reminder
state, stock and runway, actionability, course status, composition,
links), while the detail read answered them. They answer the read model
now, built from the row they just wrote (medicationDetail), so a client
reads back exactly what GET /api/medications/{id} would say.
A dose write that converged onto an existing slot answered the slot
upsert's narrow row, without userId, medicationId, updatedAt,
autoRecorded, autoMissed or attributionSource. The upsert reads and
returns the whole event row, the same shape a write that inserted its own
answers. The parity walks drop their contract skips and are re-pinned.
A mirror create re-posting a pair the caller already holds has always
answered the existing medication with 200, as the description says; only
the 201 was declared.
Twelve routes wrote their <surface>.validation-failed row with a bare
prisma.auditLog.create, so they skipped the two decisions auditLog makes:
an owner's own refused request stays on the wide event
(meta.rejected_attempt) instead of becoming a row, and a delegate's names
the delegate as the actor. The bulk medication and mood ingests, the
layout, overlay, device, health-sync, metric and ingest routes all go
through auditLog now, and a structural guard keeps raw writes to the one
writer and the briefing failure marker. The unit tests assert the auditLog
call, and the bulk walks are re-pinned without the owner's refusal row.
The briefing prompt asks for dailyBriefing, trendAnnotations and
storyboardAnnotations beside the insight fields, and the briefing card,
the dashboard snapshot and the today read consume them. insightResultSchema
was a strict object, so it dropped exactly those blocks from a reply that
validated, while a reply that failed validation kept them. It is a loose
object now: the named fields are checked and the rest of the reply stays.
A key id retired from ENCRYPTION_KEYS, or a corrupt row, made decrypt throw
inside the provider builders, and a briefing generation answered 500. An
unreadable key now drops its entry from the chain like a missing one, with a
warning on the event, so the generation answers the no-provider 422
(ai.provider.none). The connection test reads it as no stored key. The
parity walk holds that case to the contract again.
Both ingests store each route point's sample as { t, speedMs?, hr? }, while
computeSplits parsed every sample as a bare instant and the GPX export
skipped anything that was not a string. Splits were therefore null for
every ingested track, and an exported track carried no times. One reader,
routeSampleInstant, takes either shape. The workout detail walk is
re-pinned: its ingested run now answers its first kilometre split.
10.7.3 and earlier take exponential time on a crafted Address6 zone
identifier. The copy under the MCP SDK's rate limiter reaches runtime, and
the one under the registry client resolves to the same version now.
Every authenticated write is charged to the shared per-account bucket,
and the locale sweep drives one account through eighteen setup runs in
well under a minute, so its last case met the write ceiling. The reset
already cleared the flow's own buckets; it now clears that one too.
Work a request starts and does not wait for runs inside the request's
context, so an annotate({ action }) from it landed on the request's event
whenever it ran before the event was emitted. The measurement tail's
status re-warm logged a hand-entered reading as
insights.status.invalidate.skipped on some requests and as
measurement.create on others.
runDetached starts such work so that its action is dropped for the event
it was detached from; meta, warnings and the auth it reads stay as they
were, and an event the work opens for itself keeps its own action.
fireAndForget now takes a function and starts it detached, and every
trackBackgroundTask handle on a request path is built with runDetached,
which a structural guard holds.
The layout journey left the shared account on the stacked arrangement,
and the month-grid tests that read the default flowing one failed
whenever they ran after it.
The splits on this line took the files over 800 lines from 115 to 96,
under the liveness floor of 100. The floor guards the matcher, so it
moves to 80, below the real count.
A duplicate upload has always answered meta.duplicate (and meta.deleted for
a deleted source key) beside the row, as the description says, but the
envelope schema declared neither. An episode or visit id that is not the
caller's has always answered 404, which no response declared.
Run for real it is detached from the request but inside its context, so
its annotate lands on the request's wide event on some runs and misses it
on others.
The inventory reads a delegated route and its direct imports. With the medication handlers moved into lib/medications, the lifecycle reconciliation a dose write files was two levels from its route and dropped out of the walk. A route's handler-body modules now count at the route's own level, listed in one map whose every entry must be a direct import of its route.
POST /api/medications/intake/bulk keeps authentication, its own bulk bucket, the body with its size and schema refusals, the payload schema and the HTTP mapping. The ingest, one 846-line function, moves to lib/medications/intake-bulk/ in the order it always ran: the ownership read, the Apple mirror gate and the batch pre-reads (ingest.ts), each entry's refusals, site, write bookkeeping and P2002 answer (entry.ts), its slot attribution (entry-attribution.ts) and its slot, replay, probe or standalone write (entry-write.ts), then the audit row, the dose days, the snooze, the tails, the arrival record and the checkpoint. The two identical canonical-slot write blocks of the loop are one function now. The route falls from 1044 to 231 lines; no function in the new modules is over 70.
The parity walk recorded before the move (medication-intake-bulk.parity) is unchanged. The per-entry external-id markers name the module that classifies each entry, the batch-surface and key-scope legs read the moved code, and the intake-tracking allowlist and the dense-read guard follow it.
A '/*' inside a line comment opens a block for every guard that strips
block comments first, and the code after it disappears from that guard's
view. Four server comments and one new one did this; reword them.
The guard stripped block comments before line comments, so a '/*' inside a
line comment opened a block that swallowed the code after it. The delta
feed's intake read sat behind such a comment and was never seen. The
shared stripper handles that order; with it the feed shows up, and it is
listed: it mirrors recorded rows and computes no rate.
One walk through POST /api/medications/intake/bulk: a mixed batch with one entry per per-entry outcome (unknown and record-only medications, unstable keys, band, pinned and refused pins, named-slot skips, pending echoes, a standalone dose with site, override and key and its replay, a disallowed site, Apple doses with an in-batch repeat and an extra dose on a held slot, a skip converging onto the standalone dose), the same batch again, a background and a backfill sync with the arrival rows and checkpoint they write, the batch refusals, and the callers the route admits and refuses.
GET and POST /api/medications/intake keep authentication, the query and body parsing with their refusals, the 404 and the HTTP mapping. The today list moves to lib/medications/intake-today.ts and the cached compliance buckets to intake-compliance.ts. The status update, one 432-line callback, moves to lib/medications/update-intake-status/ in the order it always ran: the event lookup, the record-only rule, the delegated refusals and the injection site (admission.ts), the take in place or onto another slot (take.ts), then the skip or snooze, the audit row, the rollups and the tails (update-intake-status.ts). The route falls from 634 to 149 lines; no function in the new modules is over 70.
The parity walk recorded before the move (medication-intake-status.parity) is unchanged. The sharing and activity-verb guards read the status-update modules beside the route, and the dense-read guard keeps the moved code in scope.
Each month of the million-row seed runs in an interactive transaction with
an explicit timeout. The batch form ran under Prisma's 5 s default and a
month took 9.9 s at a high load average, expiring the transaction before
the heap assertion ran.
One walk through GET and POST /api/medications/intake: today's list with its projection and every status a row reads as, the compliance buckets cold and cached, a take in place, a take that re-binds to another slot, a pin and a pin that is no slot, a skip, both snoozes, the not-found answers, the record-only rule both ways, the injection-site refusal and an allowed site, the schema refusals, and a delegate marking an open dose and refused a snooze and a correction.
POST and GET /api/medications/{id}/intake keep authentication, the intake and ownership guards, the body and query parsing with their refusals, the delegated outcome-change refusal and the HTTP mapping. The dose write, one 607-line function, moves to lib/medications/record-intake/ in the order it always ran: the injection-site check and the slot attribution (attribution.ts), the replay and dedup answers and the slot, probe or standalone write (write.ts), then the inventory, the audit row, the rollup, the lifecycle and the tails (record-intake.ts). The history list moves to list-intake-events.ts. The route falls from 767 to 200 lines; no function in the new modules is over 75.
The parity walk recorded before the move (medication-intake.parity) is unchanged. The activity-verb guard now reads the awaited audit actions of a delegable write's handler modules beside the route, for this route and for the medication create that moved before it, and holds each module to its route's imports; the sharing guard, the intake-tracking allowlist and the dense-read guard follow the moved code.
One walk through POST and GET /api/medications/{id}/intake: band, standalone, named-slot, pinned and backfilled doses, skips onto slots and onto a standalone dose, the dedup window, both idempotency forms and their replays, the delegated refusal on both write paths, the injection-site and pin refusals, a one-shot dose, the schema refusals, and the history list in its filters. The parity step can now rewrite an id carried inside a longer string, and a refusal's unawaited audit row is waited for on the real timer so the pinned clock does not move.
GET /api/measurements/series keeps authentication, the query and its
refusal; readSeries (lib/measurements/series/read-series.ts) picks the
reader in the old order (sleep nights, paired blood pressure, the bucketed
read of a dense kind, the raw rows) and adds the direction, change,
reference lines and stat strip. The kinds, units and point shape live in
kinds.ts. The route falls from 752 to 95 lines.
The parity walk recorded before the move is unchanged. The guards on the
verdict resolver, the spread, the pulse day mean and the unit literals read
the new files, the last two with their scope widened to the series folder.
GET, PUT and DELETE /api/medications/{id} keep authentication, the ownership guard, the body and the HTTP mapping. The read moves to lib/medications/read-medication.ts; the edit, one 886-line callback, moves to lib/medications/update-medication/, split along its stages in the order they always ran: the preflight checks and refusals, the schedule normalisation, the transaction (era archive, tombstones, the row update, the pause era, the course write) and the commit tail with its audit row. The delete moves to delete-medication.ts. The route falls from 1270 to 100 lines; no function in the new modules is over 115.
The parity walk recorded before the move (medication-detail.parity) is unchanged. The sharing guard reads the edit and delete modules for the handler body, the intake-tracking allowlists name the modules that now read the schedule and the open slots, the published-DTO leg reads the read module, and the dense-read guard keeps the moved code in scope.
Some read routes cut their windows in SQL against the database's now(),
which a pinned Date cannot reach; a walk pinned to a fixed date then reads
less of its record every day the calendar moves on. relativeToAnchor writes
every instant and day key of a snapshot relative to the start of the
current UTC day, so a walk can lay its record out from today and still read
the same snapshot on any day.
The series walk uses it with the clock unpinned; the analytics walk pins
the clock to noon of today, keeps its account in UTC and is re-recorded in
that form (its earlier snapshots proved the builder move before this
change).
A parity walk over GET /api/measurements/series through every reader it chooses between: the plain and ladder-picked rows, pulse with its spread, the paired blood pressure (two sources and a corrupt systolic), the sleep nights, glucose raw and day-bucketed, a dense stream bucketed per hour, a refused query and a delegate in another language. The readers cut their windows against the database clock, so the record is laid out from the start of the current UTC day and the snapshot holds instants and day keys relative to it.
The backups section now hands its flow to the download, restore and
upload hooks. Each caller entry may name the file that sends the
request; the component still owns the flow and the dialog and must
import that hook and hand it the flow.
The HealthChart split moved its layers, tooltip and day footer into
health-chart/, so the guard read the composer as a date-axis chart with
no doors. A component and the folder of its name are now one entry, a
tooltip action handed whole to the tooltip counts as wired, and the mount
floor counts the dashboard's one builder mount for its seven charts.
Drop two return-type exports nothing imports.
GET keeps authentication, the module gate and the AI capability, and hands
the rest to readWorkoutDetail (lib/workouts/detail.ts): the stored row, the
canonical winner of its cluster, the enrichment reads and the body, as
named stages. DELETE keeps authentication and the HTTP answers; the
ownership check, the synced-source refusal and the transactional removal
with its records, audit, cache and detection tail move to
lib/workouts/delete-manual-workout.ts.
The parity walk recorded before the move is unchanged; the guards that name
the canonical-identity reads and the track readers point at the service.
A track stored before the object form holds one ISO instant per point, and the detail answers it as stored. The route-sample declaration accepts both forms.
One walk through GET, PUT and DELETE /api/medications/{id}: the read with stock, composition and courses, every edit branch the handler tells apart (schedule replace and echo, the grace bridge, pause and resume, the category rules, the record-only rule and both tracking transitions, the as-needed and one-shot refusals, the course refusals, a composition change), the refusals, and delegated access at each level. The parity step can now wait for a write the route does not await and read state outside the medication tables.
A parity walk over GET and DELETE /api/workouts/{id} on workouts landed through the batch ingest: the canonical row, the compact body, a hidden twin, a ride, another account's row, a missing one and the module switched off; a synced, a missing, another account's and a hand-entered workout with its record for the delete.
WorkoutRouteGeometry.sampleTimestamps was declared as an array of strings. Both track ingests (the workout batch and the Apple Health export) store one object per point, { t, speedMs?, hr? }, and the detail answers them as stored. The response is unchanged; the contract now says what it holds.
The route keeps authentication, the slice refusal, the summaries slice, the
cache read and the headers. buildAnalyticsResponse (446 lines) moves to
lib/analytics/response/default-body.ts and calls one module per block, in
its old order: the blood-pressure in-target windows, the glucose summaries
and clinical panel, the correlation hypotheses with their stored patterns,
the Health Score with its Rest Mode framing, and the sleep stages. The
route falls from 761 to 155 lines.
The parity walk recorded before the move is unchanged; the guards on the
glucose window and the bounded reads follow the code.
A parity walk over GET /api/analytics on an account with a full record
(record-fixture.ts, reusable by the other read routes): an unknown slice,
the summaries slice and the default body cold and warm, the modules off, a
second language with a personal blood-pressure target, an empty account and
an anonymous call. Background work a request leaves running is settled
before the next one. The blood-pressure fast path's rollup row counts are
left out of the pinned wide event: they follow the rollup tier's coverage,
not the code, while the answer they fed stays pinned in the body.
The page-walk exception for the Google Health list walk follows
forEachDataPointPage into data-points.ts, and the workout provenance guard
reads the raw Polar and Strava sport labels where the mappers now live.
The write-step helper's tests assert the result it returns, including a
failed fold and a caller that re-warms the status cards itself.
The goldens held their fields in the order the code assigned them. They are now written with every object's keys sorted, so the text depends only on content and diffs and compresses well; the parsed content of every golden is unchanged. They carry no clock reading or generated id: the clock is pinned and the ids are fixed.
The status routes kept their old resolve*StatusLocale names as import aliases when the per-card locale resolvers were folded into normalizeLocale. They call it directly now.
The dayKey drill-down of GET /api/measurements answered the stored rows as
they were, notesEncrypted ciphertext included and the note itself unread.
It now selects the contract's columns by name and shapes each row like the
plain page: the note decrypted onto notes, the ciphertext never on the wire.
A route test walks the measurement reads over rows with a sealed note
(the page, the drill-down, the day-grouped list, an aggregate, the detail)
and fails on any sealed field; it failed on the drill-down before this
change. The parity walk drops its contract exemption for the drill-down.
callRoute now fails a call whose JSON body carries a key ending in
Encrypted, or bytes serialised as an index-keyed object or a Buffer, on
every route it drives and regardless of the contract exemption. A sealed
column is decrypted or dropped before a response is built; one that leaks
is a defect, whatever the contract says. The kit's own test proves the
check finds both shapes and leaves an hour map alone.
The backfill, the sleep repair, the OAuth-state cleanup and the sync
handlers move from lib/jobs into lib/fitbit/jobs, beside the queue facts
and definitions the registry already spreads. Their tests move with them.
Removing the integration is now the folder, its routes, the registry
spread and the drain's two cases.
The updatedAt backfill guard also walks provider jobs folders, so the
moved backfill stays covered.
The next/navigation shim is reached only through the browser project's
alias, and the helper's render module is a .tsx file under tests/,
which the project globs did not include.
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in
`client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the
pure record-to-row mappers, no I/O). Google Health's 1,600-line client
becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error
detail, retry, rate limits), `data-points.ts` (the paginated walker) and a
small `client.ts` for the profile and roll-up reads; its `export * from
"./mappers"` facade is gone and its importers name the module that defines
what they use.
The code moved verbatim; the mapper goldens written before the move and
the provider integration tests pass unchanged.
The section becomes a composer over backups/: the restore dialog with its
preview, the refusal, skip and schedule notices, the restore jobs and
their request, the upload flow, the run and download actions, and the
two row layouts sharing one action pair. The upload total is a pure
function and tested as one. The read-failure guard's 250-character
window had stopped reaching the restore preview's own failure lines; the
preview now sits in a smaller component, the guard sees it, and it is
listed as a deliberate exception with its reason.
Say which claims belong in *.browser.test.tsx, how to run the project
alone, and what tests/browser/ provides. The browser install guard now
names the browser project beside the hero-geometry check, since both
launch the headless shell the CI slices install.
One question at a time in the source order, Back keeping the answer,
the number keys and arrows, the review with a way back to every answer,
the optional PHQ-9 follow-up, a failed submit, and the note over an
unvalidated translation staying inside the viewport on every step. The
switch to the English original is carried in the answers, and the full
path from the overview posts the instrument, the reader's language and
the item scores. Questionnaire text loads through the production chunk
loader.
The wizard's markup cases are removed from the surface suite; the
loading-frame case stays there, since a browser loads the chunk before
a test can look. The browser project inlines an empty process.env for
Next's client modules.
Each family form moves into managed-record/ beside a small kit (the
coercion helpers, the form footer and the switch row); the section keeps
the record-scoped read, the save and the family switch.
The backup journey restored a snapshot over the account it came from. A
fifth act now follows the runbook's new-host path: a second account writes a
reading and a mood entry and downloads its full backup from the export card,
the account is recreated empty under the same id, the admin uploads the file
in the backups console and restores it, and the person signs in to the same
rows under the same ids. The restore wait is shared by both restores.
`integrations/sync-kit/after-write.ts` is the tail every provider write
ends in: the arrivals go out, the touched days are refolded, the read
caches are marked stale back to the earliest touched reading, and the
touched status cards are re-warmed (through `afterMeasurementMutation`,
shared with the interactive routes); Withings also evicts the dashboard
snapshot. A full-history backfill refolds its whole touched range once
through `afterProviderBackfill`.
Withings, WHOOP, Polar, Nightscout, Google Health, Fitbit and Oura drop
their hand-written tails, and no provider module imports the comprehensive
insight generator any more; a guard holds the provider folders to that.
Differences from the copies: the status cards are re-warmed even when the
fold failed, and the caches are marked stale back to the earliest touched
reading instead of wholesale, so older closed blocks survive a sync.
Several provider unit tests mocked an export the old tails never reached
(their failures were caught and only logged); the mocks now name what the
code calls.
The sheet becomes a composer over detail/: the original-file preview and
download controls, the detail read with its writes, the AI layer with its
gate and review-first apply, and the field, meta and footer sections.
Guards that read the sheet's source now read the detail folder.
Pick a sport, type a duration and distance, press Save and read the
batch body: miles asked of an imperial reader arrive in metres, a blank
start counts back from now, a retry after a failed save reuses the
form's external id, and an edit re-posts the stored instants and the
fields the form does not show. Field errors, the pending state and the
phone layout are measured in the page.
The markup and edit-render cases are deleted from the old suite, which
keeps the save and delete helper checks as manual-workout-save.test.ts.
Type into the form, press Save and read the request body off the fetch
boundary: blood pressure as one batch, a weight typed in lb stored in
kg, a decimal comma, glucose typed in mmol/L stored in mg/dL with its
meal context, a sleep typed as 7:30 stored as 450 minutes. Native
required validation, the pending state, a refused write's localised
message, the module-off type list, the last-used type and the menu's
Reset are covered too.
The unit-label and glucose-unit markup tests are deleted; their source
pins are replaced by the wire assertions. The module-types file keeps
its pure ownership checks and drops its render.
The browser helper gains a Date-only clock pin, clears localStorage
between tests, carries an envelope's meta, and answers the providers'
locale mirror without counting it as the component's request.
The time-format choice is changed through the account settings and read
back after a reload, in a second browser context that shares only the
session cookie, and on /api/auth/me, on the settings journey's own account.
The table and the card list each took nine props per row and passed
them on to the shared row actions. One MeasurementRowContext carries
them, and rowState derives a row's grouping, selection and drill-down
state in one place for both layouts.
The check-in journeys anchor on data attributes so they read the same in
every language, which leaves the words themselves unchecked. Two journeys on
the wellbeing account now read the stem, the first and last item and the
answer labels in German (the validated WHO translation, reproduced exactly)
and in Brazilian Portuguese (the translation written for the app, with its
note), submit, and check the score on screen against the assessment the
server stored, with its language and kind of wording.
A FilterBar over useUrlFilterSync now proves the contract end to end:
a facet picked from the open menu pushes a history entry, typed input
replaces the current one, a pill's clear and Reset leave a bare URL,
two writes in a row both land, Back and an edited address re-seed the
pills, and the open menu rests inside the viewport under its pill.
The markup tests for FilterBar and useUrlFilterSync are deleted; the
popper-placement source pin is replaced by the measured menu position.
The browser project re-bundles its dependencies on every run so a
cache warmed before a new file existed cannot load a second React.
The import journey ran on the shared account in both projects, so one
project's duplicate verdict could rest on the other's import. It now runs on
the labs journey's account, emptied first, checks that the dry run writes
nothing and the import writes exactly the accepted row, and finds the result
on the labs page before the second run.
The checkups file held two components of about 620 lines each. Their
parts move to components/measurement-reminders/checkups/:
- reminder-form: the type groups, cadence presets, the form state and
its mapping to and from a reminder (pure)
- reminder-form-sheet: the create / edit sheet and its cadence fields
- checkup-card: the card in both views, its menu, details, skip and
snooze lines, next / last lines and action row
- postpone-sheet: the postpone chips and date, the honest skip and its
confirmation, and the calendar-day offsets
vorsorge-section.tsx keeps the list, the value capture and the visit
filing. Markup and behaviour are unchanged; the guards name the modules
that now hold what they check.
The mood timestamp spec answered the POST and the list read itself. It now
runs on the mood journey's account: the instant typed into the form is the
instant stored, and the entry is in the list, also after a reload.
The add-measurement flow answered its own POST and list read, so it proved
that the list renders a row the test typed in. It now runs on the
measurement journey's account, emptied first, and checks that the reading
the server stored is the one entered and that the dashboard tile, the weight
chart's data table and the day view all show it.
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.
Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
The login spec stubbed the login route to prove the form shows an error. It
now spends a real 401, and three journeys run on the sign-in account of
their project: a password sign-in that lands on the dashboard as that
account, a sign-out whose old cookie is refused afterwards (with a positive
control before it), and a passkey registered in settings through Chromium's
virtual authenticator that signs the account back in.
Roving focus, Home/End, wrap, toggle mode, disabled and the chosen
segment's fill are now asserted through key presses and computed styles
instead of markup greps. The static render test is deleted; its two
source sweeps move to segmented-single-implementation.test.ts.
The browser setup sets the default theme class so colour tokens
resolve, and pre-bundles the browser files' dependencies so a cold
cache does not reload the page mid-run.
DashboardLayoutSection was a 620-line component. Its parts move to
components/settings/dashboard-layout/:
- layout-model: the reorder and hero-kind helpers and the label maps
- use-dashboard-layout-draft: the tokened read, the draft, save and
reset (with the snapshot and digest refresh) and the draft edits
- hero-settings-fieldset: the top card's switch, lead and highlights
- widget-lists: the sortable web list, the native-only group and rows
The section composes them. Markup and behaviour are unchanged.
Backup and restore, the adherence journey, delegated writes and the offline
page already run against the real server. They carry the @critical tag now,
and a structural guard refuses any /api interception in a file that carries
it, directly or through an imported helper, with a floor on the tagged count.
Global setup seeds one account per critical journey and per project, so the
journeys still to come can run at both widths without writing into each
other or into the shared fixture account. pnpm e2e:critical runs the set.
Run *.browser.test.tsx in headless Chromium through the Playwright
provider. The shared helper in tests/browser/ mounts the app's own
providers, stubs only the fetch boundary, and shims next/navigation
onto the page's real location and history.
The ledger file held two components of 404 and 314 lines. It now reads:
- dose-history/use-dose-history-ledger: the window read, summary, day
groups and the optimistic mark, plus the owner's edits (toggle, pin,
release, delete) and one reconcile step after every write
- dose-history/ledger-row-item: the row and, apart from it, the owner's
row menu
- dose-history/ledger-summary-chart and ledger-confirms
dose-history-ledger.tsx keeps the ledger layout. Markup and behaviour
are unchanged; the guards name the modules that now hold what they
check.
POST /api/medications keeps authentication, the body, the validation
answer and the HTTP mapping; the create (the mirrored-medication dedupe and
cap, the one-shot shape refusal, the initial course, the nested schedules,
the insert and its tail) moves verbatim to lib/medications/create-medication.ts
and answers created, existing or refused. The route falls from 541 to 94
lines. The parity walk recorded before the move is unchanged, and the
sharing guard reads the service beside the route for the handler body.
The size baselines take the lower value of the two sides per entry, and every ratchet baseline is written back to what the tree measures now (file and function sizes, version stamps, source reads, import cycles, route body parsing).
GET /api/measurements keeps authentication and the query refusal and hands
the validated query to measurementListResponse (lib/measurements/list),
which tries the list modes in their old order. POST keeps authentication,
the body, the bridge-source refusal and the three validation answers; the
single and combined writes with their shared tail move to
lib/measurements/create/create-readings.ts, the named sleep-stage write to
create-sleep-stage.ts. The route falls from 938 to 274 lines.
The parity walk recorded before the move is unchanged; the guards that name
the measurement writers, the write tail and the bounded reads follow the
code.
The five hand-written registrars (about 5,500 lines, one 997-line function)
are replaced by `jobs/registry/`: one `defineJob` entry per queue with its
cron, schedule send options, policy, work options, lock, lane and a handler
loader. `registerJobGroup` creates, schedules, retires and binds each group
in the order the registrars used; the worker boot walks the groups and runs
their boot discovery.
Handlers load on their queue's first job. The facts a declaration needs at
boot (names, crons, concurrency, send options) moved out of the heavy job
modules into light `*-queue.ts` siblings, and their importers point there,
so loading the registry evaluates 288 modules instead of the 1,164 the
registrars pulled in. The formerly inline handlers moved verbatim into
handler modules under `jobs/reminder/`.
Fitbit's queues live in `fitbit/jobs/definitions.ts` and enter the registry
through one spread, so removing the integration later is that folder and
that line.
`tests/integration/queue-registration-parity.test.ts` proves pg-boss holds
exactly what the registrars left (queues, schedules, bindings, byte for
byte) and loads every handler once. The queue tests and the runtime guard
assert on the registry objects instead of reading registrar source.
DocumentsView carried 970 lines of state before its first element. The
state moves to components/documents/vault/:
- selection-history: the `?doc=` history entry helpers (pure, tested)
- use-vault-filters: URL filters, the debounced search and the `/` key
- use-vault-library: usage, the paged list and its processing poll,
layout, uploads and their intake paths, the corpus-index affordance
- use-vault-filter-choices: conditions, procedures, visits, years
- use-vault-selection and use-vault-bulk: the selection with ranges and
Escape, the confirmed delete, re-filing, linking and the bulk share
- use-vault-detail: the detail sheet driven by `?doc=`, its close and
the check that the close took
- vault-page-actions, vault-drop-overlay
The page composes them in about 300 lines; markup and behaviour are
unchanged, and the guards follow the code they watch.
A parity walk over GET and POST /api/measurements: every create arm (single, combined, named sleep stage) with its duplicate and refusal, a bridge token, a delegated write, and every list mode. Sealed bytes are recorded by length, since each write carries a fresh nonce.
The sleep list rows of GET /api/measurements carry the main night's stage
minutes, time in bed, time awake and awakenings, and the aggregate buckets
carry the bucket's lowest and highest reading; none of these were in the
contract. The create answer's union tried the plain row before the named
sleep-stage result, so a contract check read a named-stage answer as a plain
row and dropped its status. The responses are unchanged; the contract now
says what they hold.
The route keeps authentication, the body, the base token, the layout schema
(the inline-schema inventory reads it there), the conflict mapping and the
responses. The rest moves verbatim into lib/dashboard/widgets/, one module per
stage: the layout read and the availability probe for GET, the unknown-id
drop, the refusal report and the stored-field merge for PUT, and the locked
reset for DELETE. The route is 317 lines, down from 513; its longest
function is 85 lines, down from 199.
The parity walk recorded before the move (dashboard-widgets.parity) is
unchanged. The tombstone-read guard now also scopes lib/dashboard/widgets/,
so the moved HRV probes stay policed.
MeasurementList was a 1,586-line component with fourteen pieces of
state. Its parts move to components/measurements/list/:
- list-model: the row type, page size, labels and formatting helpers
- use-measurement-list-filters: URL-owned filters, the value-range
draft, page, sort and the page-scoped selection
- use-measurement-list-data: the list read, the soft deletes with undo
and the selection operations
- use-measurement-edit: the edit draft, its unit conversion both ways,
the save and the discard guard
- use-measurement-row-display: how a row shows its reading, as a pure
rule (rowDisplayFor) bound to the account
- measurement-filter-rail, measurement-table (one row component),
measurement-card-list (one card component), row-actions shared by
both layouts, measurement-edit-sheet, day-drill-down
measurement-list.tsx lays these out in about 330 lines. Markup and
behaviour are unchanged. The glucose edit checks call the conversion
functions instead of matching source, and the guards that named the
old file name the module that now holds what they check.
The PUT handler was one 390-line callback. The route keeps the record
resolver, the 404, the body and its schema refusal; the correction moves
verbatim into lib/medications/update-intake-event.ts, split along its
stages in the order they always ran: the start-day floor, the slot
attribution, the in-place edit or the move onto another slot, then the
audit row, the event and the tails. The undo moves into
delete-intake-event.ts. The route is 124 lines; no function in either
module is over 140.
The parity walk recorded before the move (medication-intake-event.parity)
is unchanged. The sharing guard's audit, tombstone and overwrite legs read
the route together with the modules it hands its bodies to, listed in one
map that each route must still import, and the moved-dose provenance leg
now also asserts the original source is carried.
Record the parity walk for GET, PUT and DELETE /api/dashboard/widgets on the
current code: the first and the cached read, a full save with ids outside the
catalogue, a partial save that keeps stored fields and the per-chart range,
the schema, empty-body and base-token refusals, a save on the current token,
the recovery-off availability probe with and without an HRV series, a
delegate's read and refused writes, the reset, and an anonymous call.
postBatch was a 700-line function. The route keeps authentication, the
per-account preference read, the body caps, the rate limit, the parse and
the bridge-source refusal; the ingest moves verbatim into
lib/workouts/batch-ingest/: the first pass (entries), the duplicate probe
and write plan with the manual-correction rules (plan), the transaction
that writes corrections, workouts, tracks and series (write), and the
orchestrator with the audit, record detection, wide event, cache and
arrivals (ingest). The route is 275 lines.
The parity walk recorded before the move is unchanged; the guards that
name the track writer, the Workout writer set and the per-entry
external-id check point at the service files.
A parity walk over POST /api/workouts/batch: a mixed first batch with every per-entry refusal and the cross-source twin, the same batch with an enrichment, a corrected hand entry, a kept track, both whole-batch refusals and a narrow bridge token. Responses, wide events, queue tails and stored workouts with their series and tracks are pinned.
postBatch was one 1255-line function. The route keeps authentication, the
rate limit, the body, the entry cap, the schema and the bridge-source
refusal; the ingest moves verbatim into lib/measurements/batch-ingest/, one
module per stage in the order they always ran: the first pass (prepare),
the measurement writes with their prefilters and set-based insert, the
hourly sums, the sync checkpoint, the audit and wide-event report, and the
cache and arrival tail. The route is 274 lines; no function in the folder
is over 200.
The parity walk recorded before the move (measurements-batch.parity) is
unchanged, as is the set-insert golden. The guards that read the route for
the per-entry external-id check, the plausibility validator, the write
tail, the dense-read bound and the unit audit now read the service files.
Admin, coach, consent, chart-overlay, device registration, insights
feedback and provider chain, ECG ingest, onboarding, search and the AI
provider settings move to parseJsonBody with the same message, error
code, status and wide-event action they answered with. A refusal whose
annotate meta is computed from the issues, or puts its keys in another
order than the helper would, keeps that annotate verbatim in onInvalid.
The routes that still call safeJson do something between the read and the
schema: an optimistic-concurrency token split off the body, a count cap
answered first, a refunded quota slot, a conditional read, a non-Zod
check, or the body handed to a service.
Labs, biomarkers, custom metrics, cycle, anamnesis, illness, measurement
reminders, the measurement edit, medication sub-routes, mood and mental
health assessments still spelled out safeJson, safeParse and the 422 by
hand. Each becomes one parseJsonBody call with the size cap, action,
issue count, error code and status it had. The audit rows and the
unstable-externalId breadcrumbs move into onInvalid, in the order they
ran; where a breadcrumb reads the body, the wide-event annotate moves with
it. A `body ?? {}` or a payload unwrapped from the body becomes `input`.
The medication ingest route reads through the helper too; its Bearer
resolution happens before the read and is unchanged.
A route that reads its body through parseJsonBody has no
`if (!parsed.success)` block, so the guard lost sight of every migrated
site: its floor of 200 failed at 139 and the token inventory reported the
coach message feedback entry as stale while that route still answers
`feedback.body.invalid` in `error`, now through the `message` option.
Each parseJsonBody call is now a site of its own, carrying the issue list
by construction unless it names a status other than 422, and its `message`
option is read as the refused string by the token sweep.
HealthChart was a 1,929-line component. Its parts move to
components/charts/health-chart/:
- series-math: series names, day keys, moving average, personal
baseline, annotation and marker placement (pure)
- use-health-chart-series: the fetch window, the batched-slice decision,
the daily read and the data-ready latch
- use-health-chart-view: the visible slice, bucketing, the average and
trend lines, the prior period, the visible-range stats and the y domain
- use-health-chart-overlays: value bands, target zones, baselines, the
trend read-out, the spoken summary and the table columns
- chart-header, band-overlay, chart-tooltip-content
- chart-layers: the reference layers and series as plain functions that
return the Recharts elements, so the plot's child list is unchanged
health-chart.tsx keeps the component and its path (chart-runtime still
imports it from there) and shrinks to 488 lines. Output is unchanged.
The guards pinned to the file follow the code into the folder, and the
baseline-gating check now calls the layer instead of matching source.
tests/kit/parity.ts pins what a route answers, stores and reports for a
fixed walk of cases, so the code behind it can move under a byte-for-byte
comparison: the response, the rows read back, the request's wide event and
the queue and arrival tails it asked for, with generated ids, server
timestamps and request ids replaced by stable placeholders.
The first walk covers POST /api/measurements/batch: a mixed first sync with
every per-entry refusal, the same sync again, a background day total, the
manual and HealthKit mirror merge, a backfill, both whole-batch refusals and
a narrow bridge token. The clock is pinned, so the snapshot holds on any day.
Most strip tiles differ only in their series, label, unit and icon, and
most chart cards share the reveal report, the batched slice, the time
zone and the day doors. One metric-card and one chart builder carry the
shared props, so each registry entry states only what is its own. The
dashboard route's client code shrinks by 0.2 KB gz; rendering is
unchanged. The tile-destination guard now builds every strip tile from
the registry instead of reading ids out of the source.
132 route handlers spelled out safeJson, safeParse and the 422 refusal by
hand in one of six shapes. Each block becomes one parseJsonBody call with
the same size cap, wide-event action, issue count, error code, message and
status it had, so every response is the one it was. The comments inside
the replaced refusal blocks go with them.
A ratchet (route-body-parse-ratchet) counts the route files that still call
safeJson themselves; the count may only fall.
parseJsonBody composes safeJson, the Zod safeParse, the validation-failed wide-event action and the multi-issue 422 envelope, with the variations routes use (size cap, input derivation, issue count, error code, message, status, an onInvalid hook) as options. The refusals are the existing helpers' bytes, which the unit test compares directly.
The dashboard page held every tile and chart card in one 2,100-line
function: a 1,279-line inline block pushed 24 cards behind 36 show flags.
The cards now live in components/dashboard/tiles/, each with its layout
widget, its own visibility rule (data floor, module, resolved layout) and
its render; the layout toggle and order are applied once by the registry.
- use-dashboard-data: the snapshot or legacy reads, the layout, mood,
thresholds, the batched chart series and the loading gate
- tile-context: summaries, display units, comparison deltas, freshness,
directions and target bands, derived once per render
- strip-tiles / chart-tiles: the two registries and their builders
- dashboard-tiles: the strip, the chart row, their skeletons and the
empty state
The page is a composer of about 170 lines. Rendering is unchanged. The
guards that read page-client.tsx read the whole registry through one
helper; the weight-direction, HRV freshness, BP-in-target comparison and
chart-reveal checks now build the real tiles instead of matching text.
The module gate and the cycle gate imported each other for one pure
predicate. isCycleEnabled moves to lib/cycle/enabled.ts and both gates
import it from there.
The band minter read DOSE_WINDOW_DEFAULTS through the analytics/compliance
re-export barrel, which loads the compliance display and with it the
scheduling modules that import the minter. It now imports the constant from
the module that defines it. Runtime import cycles fall from 6 to 4.
lib/sharing/grants.ts held both the grant transitions and the predicates
every reader asks (state, level, scope, the live grant for a pair). The
transitions reach into the managed-profile lifecycle and the OAuth connect
target, and both of those read a live grant, so grants, lifecycle and
connect-target formed an import cycle.
The predicates, findActiveGrant and touchGrantUsage move verbatim to
lib/sharing/grant-access.ts, which imports only the database client and
the scope vocabulary. Importers and the mocks that replaced those
functions point at the new module. Runtime import cycles fall from 7 to 6.
Reverting the topics preview puts sidebar-nav, bottom-nav and the
/api/auth/me handler back to their v1.45 code; their baselines return to
the v1.45 values, nothing grows beyond them.
auditLog resolves login locations through lib/geo.ts, and the resolver
imported the notification dispatcher for the offline-database notice. The
dispatcher's collaborators write audit rows, so audit, geo, the dispatcher,
channel state, delivery identity, managed-profile lifecycle, the OAuth
connect target and the grants formed one strongly connected cluster.
The notice now lives in lib/notifications/offline-geo-notice.ts. The
resolver keeps deciding when it runs (first public-IP lookup of a process,
again after a runtime database fetch) through a slot the notice module
fills at boot from instrumentation.ts. Runtime import cycles fall from 13
to 7.
api-handler.ts held two modules: the route wrapper and thirteen guards. The
guards now live in lib/auth/guards/, one module per family (the caller,
the acting-account carrier, the record modes, the cookie-only helpers, the
fresh-factor and recent-proof gates, the Bearer elevation, MFA management).
api-handler.ts keeps the wrapper and re-exports every guard, so it stays the
one public entry and no route changes its import.
The bodies move verbatim; only the version prefixes of moved comments are
dropped. The structural guards that read the auth layer now find the module
that declares a guard by name (helpers/auth-guards.ts) instead of a fixed
path, and a new guard holds the entry: nothing outside the folder but
api-handler.ts imports a guard module.
On a phone, arriving on a step scrolled to the question and left the note
and the switch to the English original at the top of the page, out of
view. They now sit at the top of every step's card, above the stem, and
the step scrolls its top block (note, stem, question) into view as one,
so a block already on screen does not move and focusing the question
finds it visible. The review step carries the note the same way.
The phone e2e run asserts the note is in the viewport on the first and a
later step.
A journey on its own account, seeded with 40 days of heart and sleep,
weight, blood pressure, a short run of steps and mood entries, and lab
markers with and without a trend: switched off there is no More tile
and no sidebar entry, the pages leave for Insights and the routes
answer 404; switched on in Settings the More sheet leads with the
Topics tile and the sidebar carries the entry below Insights; every
offered topic renders, with the pair that has 14 paired days stated
and the pairs short of it saying so.
The phone sweep opens every topic page with the preview on, and the
axe matrix scans the overview and two topics in both themes. The
Settings sweep gains the Preview features page.
/topics shows a tile per topic the record holds data for, and names the
empty topics and the module-hidden ones once each. /topics/<topic>
shows the topic today, its signals side by side over 7, 30, 90 days or
a year against their usual range, how they move together, their trends,
its blocks, the single-metric pages behind it and what it is missing.
Every topic links into the metric pages, which stay as they are, and
each page ends on a neutral note that this is a preview.
Settings > System > Preview features is listed again and switches the
area on for the person reading. While it is on, the sidebar carries a
Topics entry with a Beta badge directly below Insights and the phone's
More sheet leads with a Topics tile; the bottom bar does not change.
The pages leave for Insights when the switch is off.
The charts load through the shared chart runtime. The sidebar rows move
into their own module and the More sheet tile into its own component,
so neither shell file grows; the lab trend wording is shared between the
marker page and the metabolism topic. Copy in all nine locales.
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).
Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.
The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
The neutral tone set muted text on the secondary fill, which measures
3.6:1 in the dark theme. The secondary surface's own foreground reads
the same in light and passes in dark.
For German, Dutch, Korean and Brazilian Portuguese, the note and the
switch to the English original appear on an unvalidated translation and
nowhere else, the question carries the language it is written in, and
the result of an unvalidated translation says so. The switch shows the
English original and the result then carries no note, and a Brazilian
PHQ-9 with item 9 above zero leads with the crisis card. Each screen is
scanned with axe, at desktop and phone width.
The report data counts, per screener score type, how many of the
window's results were answered in a translation that has not been
validated (`screenerUnvalidatedTranslations`, read only when a screener
total is in the report). The PDF marks such a row with an asterisk and
says under the vitals table how many of how many read as orientation;
the clinician share view says the same under the row.
To keep the aggregator and the PDF section from growing, the wellness
summary and the per-context glucose rows move into helpers; the size
ratchets only fall.
An administration now stores `wording`: `validated` (the published
validated version of the reader's language, the original for English),
`unvalidated_translation` (HealthLog's own translation) or `original_en`
(the English original, chosen by a reader of another language). The
client sends only `englishOriginal`; the server resolves the rest from
the wording registry, so a client cannot claim a validated wording the
registry does not hold. `locale` keeps the reader's language, which
still selects the crisis resources.
Additive migration 0475: a nullable column with no backfill, since which
text earlier rows showed depended on releases since replaced. Every read
returns the field, the OpenAPI contract carries it and `englishOriginal`,
and the disaster-recovery backup carries and restores it (the parity
goldens gain the column; their pinned timestamps move by one step
because the fill writes one more column).
The PHQ-9 item-9 crisis signpost is answered from the answers alone and
is proven in every wording through the real route.
The PHQ-9, GAD-7, WHO-5 and SCI now have text in every app language
instead of falling back to the English original. Each instrument and
language is one file under src/lib/mental-health/wording/ that declares
what it is: a published validated translation reproduced word for word,
with its source and terms in `source`, or a translation written for
HealthLog, faithful to the English and on the same scale.
Validated versions added: the Pfizer PHQ-9 for France, the Netherlands,
Korea and Brazil, the Pfizer GAD-7 for the Netherlands, Korea and
Brazil, the PRU Dutch WHO-5, the Korean WHO-5 validation (J Korean Geriatr Psychiatry 2010) and
the Polish SCI validation (Sci Rep 2026). The SCI in the other
languages and the WHO-5 in Brazilian Portuguese carry HealthLog's own
translation; each file names any validated version found and why it is
not used.
Where the text is unvalidated, the check-in shows a note that the result
is orientation and a one-tap switch to the English original. Each
language loads as its own chunk when a questionnaire surface needs it.
The item text, answers, stems and the PHQ-9 follow-up leave every message
bundle, so a translator never sees them; the i18n guard now requires that
no bundle carries them, and the registry test requires every instrument
in every language with the item count and anchor scores the scoring
expects. CONTRIBUTING explains where the wording lives and how to propose
a validated version.
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.
@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.
The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
Several export reads sorted only by a column two rows can share (a
creation time, a day, a name), and a few to-many reads (medication
schedules, mood tag links, cycle and illness symptom links) had no order
at all. Tied rows then came back in whatever order they sat on disk, so
two exports of one account could differ, and the parity goldens flapped
once the fixture's child rows shared their pinned timestamps.
Each read now ends its order in the model's primary key: the existing
sort key plus `id`, or the remaining key columns of a compound key
(nutrient `source`, hidden tag `moodTagId`, link `symptomId` /
`moodTagId` / `documentId`). Schedules sort by window start, then id.
The restore re-export reads through the same modules, so it inherits
the order.
The parity goldens are unchanged: they already hold the tie-broken
order, so no regeneration was needed and they still pin the output of
the pre-split exporter.
A new integration test runs both purposes, built and streamed, through
a client that records every findMany and every nested to-many read, and
fails on any whose order does not cover the primary key (keys pinned by
an equality in `where`, or by the foreign key to the parent, count as
covered). A second case writes the same tied rows in forward and
reverse physical order and requires byte-identical files.
A module mock that leaves out an export the code under test reaches throws
at the call, and where that code catches and logs (a best-effort tail, a
fail-soft read) the test went on green while the path it meant to cover
never ran. The Apple Health import tests mocked the rollup module without
its fold window, so the import tail's rollup fold and cache invalidation
never ran in any of them; four route test files mocked the logging context
or the rollups without what the measurement tail reaches.
Both setup files now watch the console and fail the test in which such a
line was logged. The ECG import test keeps the real rollup module and
stubs only the fold, and the four unit mocks gain what they lacked.
The heap test for the pulse day-mean rebuild wrote its million readings in
one INSERT ... SELECT, which outran the 60-second statement timeout on CI's
shared Postgres. The fixture is now written a month per statement, each in
its own transaction with the timeout lifted for that transaction only, and
ANALYZE the same way; the code under test keeps the timeout, and the
production-heap assertions are unchanged. With the timeout at 4 seconds the
old seed is cancelled with 57014 and the new one passes.
The login overview's IP button gained screen-reader text, which is
absolutely positioned. Inside a scroll container that is not positioned,
its containing block lies outside the container, so the span escaped the
container's clipping and every row panned past the edge widened the page:
the admin page scrolled sideways at 390 px. Every overflow-x-auto wrapper
around a table is now relative (the admin sections, the settings token
tables and the Coach result table), and a guard holds that.
The three updates the newest dependency pull requests carry, pinned the way each already was: exact for the query client, a caret floor for the two tools (knip ^6.42.0 resolves to 6.42.1). The dead-code gate is clean under the new knip.
The nightly measurements check reported its bloat factor, a ratio such as
1.3, as a job fact. A fact is a non-negative whole number, so the outcome
validation refused it on every run, after the vacuum and the reindex had
already done their work, and the run failed. It now reports bloat_percent
(130), and the duration goes through the same rounding.
A malformed fact no longer fails a run whose work is done: runJob drops it
from the result and emits job.outcome.fact_invalid at error level with the
queue and the key, where failing would have made pg-boss retry finished
work. A failed outcome is unchanged. wholeFact and percentFact are the
intended way to report a measured value or a ratio, and a guard reads
every outcome producer and refuses a fact that is a division or names a
ratio unless it is rounded on purpose. The other producers were checked:
their numeric facts are counts and integer millisecond durations.
Refs #1271
Recharts measures every label in a span the chart runtime parks under
<html>, beside <body>, and the host copies body's classes to pick up the
app font. With next/font/google the font stack variable came from one of
those classes; since Inter is self-hosted, globals.css sets --font-sans on
body itself, so the host outside it measured in the default font and every
tick was sized for text the chart did not draw. The rule now sets the stack
on the measurement host too, and the dashboard's strings measure the same
inside the host as under <body> again.
The full mood fold upserted every day bucket with a statement of its own,
so the read that refreshed the tier paid one statement per day of mood
history. The dashboard summary in the hot-route budget fixture did exactly
that: 57 to 59 single-row inserts out of its 85 to 87 statements, a count
that moved with the calendar the fixture is dated against, which is why it
crossed its budget of 84 on CI without a new query in the route. The fold
now writes each chunk of up to 500 buckets as one multi-row upsert, and the
route's budget goes to the measured 28.
The rollup-or-fallback read of the per-day mood points moves out of features.ts into mood-daily-points.ts, unchanged, which keeps features.ts under its size baseline after the calendar-day ages.
The Coach's mood block gave its newest and oldest entry ages as elapsed
hours rounded to days. A point from the rollup tier sits at its day
label's UTC midnight, so from noon UTC on this morning's entry read as a
day old; the integration run failed at 19:02 UTC for that reason. The ages
and the span are now whole days between the entries' own day labels and
the reader's today.
Pinning the clock to the edges of the Berlin day showed a second gap: the
open-ended rollup fold stopped at now, and a day label runs up to fourteen
hours ahead of UTC, so between a reader's local midnight and UTC midnight
the fold left their today out and the tier called yesterday the newest
day. The open-ended fold now reaches a day past now.
The number-free dose shapes added for this release blocked harmless
replies: a trailing negation the pattern never reached ("Erhöhe dein
Insulin heute nicht", "Verhoog je insuline vandaag niet zelf"), a
Spanish subjunctive after "antes de que", a question ("Increase your
dose? Not without your doctor."), a clinician-gated condition, and
Polish "dose" of caffeine, sugar or exercise. These shapes now stand
down in a sentence that asks, negates, restricts ("nur wenn", "alleen
als") or names a clinician; shapes with a stated amount still block.
The Korean trial pattern stands down the same way, which also clears a
pharmacist question it already blocked.
The continuation exemption fell back to the whole plan when a sentence
named a drug the plan does not carry, so "Take Eliquis 5 mg as before."
and swapped doses passed. Every stated amount must now sit beside one
medication on the plan, named, at exactly that dose.
A false-positive corpus (at least 15 harmless replies per language)
sits beside the positive one. The Brazil crisis set's comment now says
what reaches it.
GHSA-cg2g-xh64-v4q7 (critical, a SAFE_FOR_TEMPLATES bypass) and GHSA-mrv2-3pqr-j5jr (high, an IN_PLACE hook that removes a parent) cover dompurify up to 3.4.16, which jspdf and jspdf-autotable pull in. jspdf 4.2.1 is the newest release and still allows that range, so the existing override moves its floor. The doctor-report PDF renders as before (unit and integration suites).
sourcesContaining in the guard helpers and the DeferredRows type re-export from full-backup-payload had no importer, which the dead-code gate reports. isDeferredRows stays re-exported; the backup job's test mocks it there.
Opening the conversations beside a docked day dropped focus to the page
in most runs. A day opened on load asks for its heading to take focus;
when the heading had not mounted yet the request waited, and it was
honoured whenever the heading appeared, even after the person had moved
on and opened the conversations. Focus jumped into the folding day and
fell to the page as it went inert. A waiting request is now honoured
only while focus is still where it was asked from, never inside an inert
panel, and it ends with the day.
The timeline range check measured the popover during its slide-in and
read it up to 8 px too high; it now waits for the entry animation.
The label-in-name fixes move their name composition into small helpers
(score ring link, IP copy button, sortable header, period marker, the
day's title focus), so every function ends at or below its old length
and the size baselines only fall.
axe 4.14 checks WCAG 2.5.3 (label in name), and the app frame failed it
on every page: the account link, the user menu and the day strip carried
an aria-label that left out their visible text. The same class sat in
the coverage meter, the score ring, medication links, instrument cards,
filter pills with a value set, cycle calendar days (named by an ISO
key), admin user and login rows, a few sort and action buttons, and the
French, Spanish, Italian and Polish weekday chips, whose abbreviations
were not part of the day names.
Controls with visible text are now named by it, with any extra purpose
added as screen-reader text; the select pill and calendar days spell
out a name that contains what they show. The day and conversation
strips keep their action as the tooltip.
A disaster-recovery restore wrote these rows stamped with the restore's own
time: the medication's updatedAt (moved again by the course window), the
category assignment, custom category labels, courses and composition, the
mood day context and tag links, illness and cycle symptom links, nutrient
days, and a flare's updatedAt after the parent pointer pass. The recovery
file now carries each stamp (additive fields: linkCreatedAt on a mood
entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness
symptom, categoryUpdatedAt on a medication, updatedAt where missing) and
the restore writes it back. A file without them restores as before.
A custom category label, a course note and a marker's context rode the
recovery file as readable text and were sealed again under the active key.
They now ride as ciphertext (labelEncrypted, noteEncrypted,
contextEncrypted) and come back as the same bytes; a file that carries the
readable value instead is sealed on the way in, as before. The portable
file keeps the readable values. The rotation script and the
encrypted-column writer guard already cover all three columns.
The parity golden copies change for exactly this: the new fields in the
recovery file, the restored stamps in place of <now>, and the three values
coming back under the key they were sealed with. The legacy readable coach
title and custom metric note are still sealed on restore, since the row
holds no ciphertext to carry. The round-trip column audit no longer
excludes the four link stamps.
Every instrument now runs the same wizard: the form's own question stem
above each item (the PHQ-9 and GAD-7 stems were missing), the item's
exact answer options as one radio group in source order, progress, Back,
number-key answers, and a review step that lists every answer, lets any
of them be changed and is the only step that submits. The result leads
with the crisis card when it applies, then shows the score on the
instrument's scale, the band, what the band means, the change since the
last check-in against the meaningful-change threshold, and the next
step. Scoring is untouched.
The wording was read against the published versions. German follows the
PHQ-D and Löwe's GAD-7 verbatim, including the follow-up question in its
published formal address. Spanish, Italian and Polish carry the official
PHQ-9 and GAD-7 translations, French the official GAD-7, and the GAD-7
gets its own answer set because the Italian forms word it differently.
Where a version could not be matched to a published form (the French
PHQ-9, every Korean form) the language now shows the English original.
Each locale list names its sources, and spot checks pin the corrected
wording.
The windowed reader is async, so a Promise.all that listed it first held a rejected promise of it whenever a later element threw while the array was built, and nobody handled that rejection. It now comes last in each of those arrays.
The backup payload schema the admin restore routes publish now declares notesEncrypted and deletedAt on a family-history entry, which the disaster-recovery file carries since the previous change.
The ratchet on tests that read a source file by path matched readFileSync
and readFile only, so a guard that called readSourceText with a fixed path,
directly or through a local read(rel) wrapper, read the file without the
anchors readSource demands and was never counted. The classifier now counts
both shapes, and leaves alone a readSource call with anchors and a sweep that
feeds the reader from a walk.
The twelve guards that read a fixed file that way now go through readSource
with an anchor each read asserts on, so the baseline stays where it was.
The floors step piped scripts/mutation-floors.mjs into tee without
pipefail, so it took tee's exit status and a module under its floor could
never fail the run. It now runs under bash with pipefail, as do the other
steps that pipe into tee, and a guard reads every workflow and fails on a
tee pipe without it. The other piped steps were checked: each either
validates what the pipe produced or pipes a command that cannot fail.
The nightly runs set FC_NUM_RUNS, an absolute count that replaced every
property's own: the database backup round trip, which states twelve cases
for its cost, ran a hundred times against a timeout sized for twelve.
FC_RUNS_FACTOR now multiplies each property's count (five for the unit
suite, four for integration), propertyTimeout grows a test's timeout by
the same factor, and the backup round trip uses it.
The unchanged-stats shortcut skips the write when every overwritten column matches the stored row. The kit test covered a new value and a new device; it now also re-posts a step total that only names a new HealthKit source version and a sleep row that only changes its stage, and checks each is written with its syncVersion bumped and the tail started.
Every record with a tombstone (allergies, labs, conditions, vaccinations,
life events, measurements and the rest) rides the disaster-recovery file
deleted rows included, so a restore gives back the account as it was. The
family history read only live entries for both files, so a restore lost
its deletions, and it carried the note as readable text that the restore
sealed again under the active key.
The disaster-recovery slice now carries every entry with its deletion and
the note as the ciphertext at rest, and the restore writes both back
verbatim; the portable file keeps the readable live entries. The parity
golden copies change for exactly that: the deleted entry, its note's key
and the shifted ids after it.
The parity harness normalised any timestamp written during the run to
<now>, and the seeded rows' createdAt and updatedAt were themselves written
during the run, so a restore that dropped a row's createdAt still matched
its golden copy. Every run-time timestamp of the seeded account is now
pinned to a fixed historical value, and the seeded snapshot is asserted to
hold none, so a lost createdAt shows as <now> against a historical value.
The account also carries a tombstone of every model that keeps them, a
flare under its condition and a superseded profile fact, so the second
restore pass resolves real pointers. The fixture is sealed under the
legacy key and a second key is active from the export on; sealed values
render as the key id they name and the text they open to, so a value the
restore re-encrypts instead of carrying over differs.
The golden copies are written from the backup code as it stood before the
export and restore moved into per-domain modules (64fa65b46), and the
current code matches them.
A web-only process opens its queue connection without migrating and refuses a job-queue schema older than its own, so in a web/worker split the new worker has to start, and upgrade the schema from 45 to 46, before the new web containers. The v1.45.0 notes say so, name the one additive migration, state that a single-container install needs no action, and point a forked compose file at the new stop grace period.
The server reads messages/<locale>.json from disk on first use. A missing
or corrupt file threw, and since the matchers iterate every locale, one bad
non-English catalog turned every request that touched them into a 500.
That locale is now served the English catalog, and the failure is logged
once as i18n.catalog.fallback with the locale and the reason. English is
the fallback itself: a process whose messages/en.json is missing or does
not parse refuses to start, with the file and the reason in the message.
The image build checks the catalogs twice with
scripts/verify-message-catalogs.mjs: in the builder against the locale
list in the source, and in the runner stage against the copies the image
carries.
Closed analytics blocks survive a write that says it reached no further
back, so a writer that never invalidated left them on their pre-write
values. The Apple Health and Health Connect imports, the CSV and JSON
imports, the score engines, the WHOOP webhook, the Withings activity, sleep
and ECG legs, the Oura history backfill and the sleep-segment sweep wrote
measurements without touching the read caches; a long-history import
showed the old all-time figures until the entries aged out.
The shared post-mutation tail now marks the caches stale back to the
oldest row it is handed, and the Withings and Oura legs that hand-rolled
the same tail run through it. The two export imports share one tail that
refolds the rollups, marks the caches stale back to the oldest sample
written (all of them when none was) and re-warms the status assessments.
A sweep that tombstones a sleep segment drops every cached block.
The wizard drops its slug alias to stay inside its function-size
baseline, the bare-t() sweep reads the tree through readSourceFolder,
and the version-stamp baseline takes the lower counts the dose-screen
and questionnaire changes left.
With an analyte, the Coach's labs block read the newest 4,000 lab rows of
every marker and matched the asked-for one in memory afterwards. An account
with that many newer readings of other markers lost the marker's older
readings, so its count and first date named a shorter history than the
record holds, or the marker vanished. The match now runs in SQL before the
row cap, on the biomarker's name for a linked row and the row's own name
otherwise, and the history compares units with sameLabUnit, so a reading
stored as "MG/DL" beside "mg/dL" stays in the series.
The no-limit option promised every lab result, and the German detail
sentence ended on a dangling clause. The Coach keeps the latest value
of the most recently measured markers and, for a marker asked about,
its five previous results with older ones summarised per year for ten
years; years before that are only counted. Every language now says
that in complete sentences, with the two numbers pinned to the lab
history constants.
The cross-source picker anchors each duplicate cluster on its first start,
so which cluster a workout lands in depends on the workouts around it. The
detail route still read the old fixed five-minute clock slot, and every
other reader handed the picker only the rows of its own range. The detail
of a twin that straddled a slot boundary declared itself canonical while
the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by
a read starting at 09:55 and another by a read starting at 10:00.
Every reader that decides which workout is canonical (list, detail and its
previous session, activity calendar, day view, doctor report, Coach,
briefing and correlation blocks, strain, per-sport context, the activity
insight's history, the batch write) now reads through one windowed reader.
It reads a duplicate window past the end of the range and reaches back
until every sport in the range starts at a workout that is an anchor
whatever came before it, then picks over everything read and keeps the
rows that start inside the range. The per-sport baseline and the insight
history leave out the whole cluster of the current session instead of the
row alone, which left its twin behind as a session of its own. The batch
write clusters its rows together with the stored workouts around them and
drops only a row that loses to another row of the same batch.
A property test checks the windowed read against a whole-history read for
random dense histories and ranges; route tests pin list, detail, deep link,
previous session and the batch write against the database.
The outbound screen let a halving with no number through in every
language but Dutch ("Reduce your dose by half.", "Reduza a dose pela
metade.", "Tome meia dose."), let a bare change imperative through
("Zmniejsz dawkę.", "복용량을 늘리세요.") and did not know the Polish
spelling of the generic stems, so "Odstaw ramipryl." passed. Each
language's bank now carries a halving and fraction shape and a bare
change shape on a medication noun, and the stems include the Polish
-pryl, -statyn and -glutyd.
The continuation exemption trusted "as before" on any surface that
passed no schedule, so "Take 40 mg as before." went out to people who
had never taken 40 mg. The screen now takes the dose plan (each active
medication with its dose) instead of a bare list of numbers, and
exempts a stated amount only when it is the planned dose of the
medication the sentence names. Without a plan no stated amount is
exempt.
The support report's host-name step only knew a short list of top-level
domains, so saude.minhacasa.br:3000 or vida.example.com.br reached a
report meant for a public issue unredacted, as did hosts under .pt, .be,
.lu, .li, the Nordic codes and the English-speaking ccTLDs. The list now
covers the country codes of every app language's home regions, pinned
by a test against the shipped locales.
The app ships one Portuguese bundle, and pt-PT, pt-AO and bare pt all
fold into pt-BR, so a stored pt-BR does not mean Brazil. A positive
PHQ-9 item 9 still answered it with SAMU 192 as the emergency number
and CVV 188 as the only line, neither of which connects from Portugal,
under a comment claiming pt-PT kept the international set.
With no region signal, Portuguese now gets the DE_AT_CH treatment: 112
first, the worldwide directory, then CVV and SAMU labelled Brazil and
SNS 24 (808 24 24 24, option 4) and SOS Voz Amiga labelled Portugal.
An explicit pt-PT tag gets the Portugal set and br the Brazil set.
The PHQ-9, GAD-7 and WHO-5 are not validated in Dutch or Brazilian
Portuguese, and the SCI is validated in English only, yet the check-in
wizard and the answer breakdown read the item text, answer options and
stems through t() in the reader's language. Dutch and Brazilian
Portuguese readers saw unvalidated translations of every item,
including PHQ-9 item 9, under a note saying the questions were in
English.
Every surface now resolves instrument wording through instrumentText,
which returns the English source for a language outside the
instrument's validated set. The unvalidated wording is removed from
the bundles; the locale-parity check accepts exactly those keys as
absent and fails when a bundle carries one, and a structural test
keeps item text out of bare t() calls. The PHQ-9 follow-up question
regains its original wording. CONTRIBUTING states the rule for
translators.
The check sampled getAnimations() once after the sheet showed, which passes or fails on whether WebKit drew a frame in between. It now reads the sheet and backdrop the moment they are inserted, the worst case, and is deterministic: it fails every time against the old classes. The other measurements in the spec already wait on polls, rendered frames or layout.
data-[state=open]:animate-in carries an attribute selector and outranked motion-reduce:animate-none, so the slide stayed, only cut to 0.01 ms by the global reduced-motion rule. It still existed and ran until the next drawn frame. The none is important now, on the sheet and the backdrop.
Every raised entry is main's own number for a file this branch had not touched since the fork, so no value is above both sides; the version-stamp counts that fell are recorded lower.
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
JavaScript's \b is an ASCII word boundary, so next to an accented
letter it never matches. That left patterns, or alternatives of them,
dead: the French "en bonne santé", "à cause de", "causé" and
"continuez à", "probabilité" and "probabilità" in the French and
Italian risk banks, the Dutch "één procent", the German "Ärzte", and
the Polish "przedział", "średnia" and the verb endings in "-ą". A \w*
stem cannot run through "ł" either, so "doprowadziło do" missed its
causal pattern, and the Polish adult pattern was spelled "dorosli"
without the "ś".
Those boundaries now use lookarounds that count the Latin letters
with diacritics as word letters (WORD_START / WORD_END in the shared
screen module, the same class inline in the regex literals), the
Polish stems take accented letters, and "dorośli" is spelled both ways.
Two guards keep the class out: every pattern of every locale's screen
and population-norm bank has to match at least one realistic sentence,
so a dead pattern fails the suite, and no pattern may put \b next to
an accented letter. Both fail on the old patterns.
Undoes the raise from the pt-BR tables commit. After the screen banks,
population-norm patterns, insights footers, settings labels and
Telegram words moved into their own modules, every file that grew is
below its old line count, outbound-screen.ts leaves the over-800 list
and buildDoseChangeClass the over-200 list.
outbound-screen.ts held every locale's dose, risk, causal, continuation
and change-stem patterns plus the shared units and slot builder, and
grew with each language. The banks now live in
outbound-screen/locales/<locale>.ts, one per language, each exporting
the same LocaleBank shape; the units, target-dose shape, object slot
and generic-name stems are in outbound-screen/shared.ts. The screen
composes them per locale and keeps the matching logic, the
continuation rule and the general rationale.
No pattern changed: compiling the old and the new module and comparing
every bank of every locale, with and without a medication-name list,
gives the same regular expressions (108 of 108 identical), and the
screen tests still block and pass the same sentences in every locale.
The screen is 457 lines instead of 1,597, and buildDoseChangeClass is
a four-line composition.
The reference-sentence exemption's patterns move out of
coach-prose-grounding.ts into population-norms/<locale>.ts, composed
into the same Record the check already reads. The patterns themselves
are unchanged. A test feeds every locale one reference sentence the
English bank cannot match, so a module that is not wired fails.
The per-locale footer table is prompt text, not prompt logic; it moves beside the generator, which drops back under its size baseline after pt-BR joined the table.
The Coach metric and cluster options and the label keys of the managed-record settings forms are data; they now sit beside the forms instead of inside the component file, which drops back under its size baseline after the locale picker started reading the locale list.
The two word lists lived inside the webhook handler and had no entry
for Dutch or Korean. They move to their own module, keyed by locale so
the compiler names a language that has none, and gain "hulp", "hoi",
"goedemorgen", "도움말", "도움", "안녕" and "안녕하세요" beside the
Portuguese words. The bot still matches the union on the folded text.
A Brazilian Portuguese reader who answered PHQ-9 item 9 saw the
international set: 112 and two directories. They now see CVV (Centro de
Valorização da Vida), 188 by phone and the chat at cvv.org.br, free and
answering around the clock, with SAMU's 192 as the emergency number.
Both were checked against cvv.org.br and the Ministry of Health's SAMU
page. A bare "pt" or "pt-PT" keeps the international set, because 188
does not connect from Portugal.
The CVV label is translated in every bundle, and a test pins that every
resource of every locale's set resolves its label in that locale.
Add an inlang project so Fink opens the repository directly:
English as the source, the nine shipped locales, and the
message-format plugin pinned to 4.5.5 reading messages/{locale}.json.
That plugin keeps the nested keys and {placeholder} tokens as they
are; the i18next and next-intl plugins Fink also supports either
rewrite numeric-keyed objects as arrays or misread quotes around a
placeholder as ICU escapes.
Checked against these files with the inlang CLI and SDK: all nine
locales load with no errors and no findings, a load and save without
edits leaves every file byte-identical, and an edit changes exactly
the edited lines. inlang's working files beside settings.json ignore
themselves in git and are kept out of the format check.
CONTRIBUTING gains the browser workflow (fork, push, pull request),
what a reviewer checks, and that machine translation is a draft a
reader of the language goes through before the pull request; the
new-locale steps now name the real locale list. A test keeps the
project's locales equal to the shipped ones and the plugin pinned.
Korean has no singular plural category, so the two ...One strings are
never chosen at run time, but they carried {count} where English writes
the number. They now read "1일 후" and "검사 수치 1개", which leaves no
placeholder that English lacks.
Since the guards read the source tree through the shared index, 35 of
them no longer read a source file by path, and the realism ratchet
failed until the lower numbers were recorded.
The outbound screen, the prose-grounding check and the insights and
Coach prompt files keep one table per locale on purpose: the dual-bank
rule (the reader's bank plus English) reads in one place, and every
other locale lives there. A ninth locale adds its rows, mostly to the
dose, risk and causal banks of outbound-screen.ts and its
buildDoseChangeClass table (+134 and +40 lines). The managed-profile
settings section and the Telegram handler grow by an import and the
Portuguese bare words.
The delete-confirmation, sleep-debt, mood-prognosis, MCP-token and
AI-consent guards match each locale against its own phrases, and the
sharing-boundary guard against the word for account; pt-BR now has
them. The locale-count floors read nine, and the managed-profile picker
test counts the locale list instead of a literal.
Key parity let 38 pt-BR strings through that renamed or dropped a
{placeholder}: a renamed one renders as a raw token, a dropped one
loses what the sentence was for. Every non-English bundle now has to
use exactly the placeholders of the English string, with one
allowance: a singular plural form may spell out or add {count}.
Since the explainer started naming the fasting bands in the reader's
glucose unit, the English, Dutch and Korean text states where normal,
prediabetes and diabetes begin and that one high reading is not a
diagnosis. The German, French, Spanish, Italian and Polish text kept
the older sentence without the bands or the caveat. They now carry
both, with the same placeholders as English.
Two badge and plan strings still carried the English text, and the
Coach plans description escaped its quotes twice, so the reader saw
\"se… então…\" with literal backslashes.
"Dose" stood for both the amount of a medication and the act of taking
it, so "Editar dose" could mean either, and the mechanical toma-to-dose
pass had turned two questions into "Com que frequência você dose?".
Where English says intake, the Brazilian strings now say "tomada"
(tomadas registradas, histórico de tomadas, editar tomada); "dose"
stays for the amount and where English itself says dose.
411 English keys had no Brazilian Portuguese string yet: the 135 that
were already missing when the translation was opened, and everything
v1.44 and v1.45 added since (the activity calendar, source provenance,
the lab and medication CSV import, the doctor-report workout and
medication-effect sections, the segmented medication-effect view, lab
trends and OCR outcomes, the mental-health trend lines, and the new
settings, integration and error strings). The bundle now has exactly
the English key set.
Terminology follows the existing catalogue: medicamento, exame,
treino, faixa de referência, chave de acesso, você.
38 strings used other placeholders than English: {when} for {date},
{provider} for {name}, a {unit} English dropped since, and the
integration-card, document-archive and Apple Health freshness lines lost
{since}, {list}, {relative} or {time} altogether, so the reader saw a
label where the sentence belonged. Most of those lines had been
translated from the key name ("Plano limitado", "Erros do provedor")
rather than from the English text; the whole of those sections is
translated from the source now, along with the Dawarich, glucose
reference, setup, re-confirmation and connector-token lines that had
the same problem, and the 19 strings whose English changed after the
translation was drafted.
The Telegram help documented "tomado <nome>", but the bot only accepts
"taken <name>"; it now shows the command the bot understands.
A sentence the grounding check reads as population guidance has its
numbers exempted from the evidence check. Two of the pt-BR patterns
matched sentences about the reader's own readings: "(entre|de) ...
geralmente" fired on "Sua pressão de 158/97 geralmente fica...", and a
singular "médico ... recomenda" with a 30-character gap fired on "Seu
médico recomenda manter a pressão em 158/97". Either let an invented
figure through unchecked.
Every pt-BR entry now needs a population subject or a reference noun:
plural guideline, doctor or specialist subjects next to their verb,
plural adults needing something, and "a/o <metric> normal" without a
possessive. Portuguese month names join the written-date pass so "21 de
julho" is not graded as a figure, and quilos, por cento and passos
join the unit words.
Negative cases prove the reader's-own sentences are checked again;
positive cases keep the reference sentences exempt.
The outbound screen runs the reader's bank plus English. For a pt-BR
reader that meant English only, so "Pare de tomar a metformina",
"Dobre a dose de insulina" or "Seu risco é de cerca de 12%" passed every
surface unchanged.
Add the pt-BR banks the other locales carry: the dose-change class
(change, take, skip, stop, halve, double and one-more imperatives in
the você form, with the medication nouns, drug classes and stop words a
Brazilian reply uses), the target-dose patterns, the risk-figure and
risk-engine patterns, the causal phrases, and the continuation and
change-stem pair that keeps "continue tomando conforme prescrito"
exempt. UI joins the insulin units, and the count words and dosage
forms gain the Portuguese spellings.
Tests pin each class blocking in pt-BR and the restatements, negated
warnings, referrals, past changes and dietary advice passing.
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.
The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.
PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
The pt-BR bundle carried 420 top-level sections that duplicated the
nested settings and onboarding keys (1,835 strings, of which 538 differed
from the nested copy), wrapped thresholds, gettingStarted and trendHints
in one-element arrays, and kept 48 keys English no longer has.
Rebuild it in en.json's order and shape: unwrap the arrays, drop the
stray top-level duplicates in favour of the nested copies (which match
the terminology of the rest of the catalogue), and drop keys English
does not have. No wording changes.
Dengue: Two doses three months apart and no booster. It's listed with the
travel vaccines.
Tuberculosis: One dose at birth and no booster. It's listed under childhood,
since that's where it appears on most vaccination cards.
Both cite the relevant WHO position paper. The names are translated in
all eight languages, and the OpenAPI spec is regenerated to include the
two new vaccines.
The batch route counted arrivals inline and grew past its size baseline
with the new-sample rule. tallyHealthKitArrivals now owns the fold, the
route hands it each landed entry and hour with whether it brought a new
value, and the size baselines move down to what the route is now.
Refs #1259
The Coach read lab results from the last twelve months only, even with
the lookback set to its widest option, so a slow change in LDL or PSA
across years stayed out of reach. Labs are sparse, so they are no
longer held to the one-year window that bounds the dense readings: they
reach back over the whole record, and a lookback limit the person set
still applies.
The snapshot block keeps the latest value per marker, now over that
wider window, at its old size: it shares the prompt's size cap and is
never shed. get_labs with an analyte adds each matching marker's
history, summarised so it stays bounded: the newest five earlier
readings in full and older ones as one mean / min / max / count per
year, ten years at most, older years counted. With a heavy fixture
(30 markers, quarterly for 20 years) the overview stays at 4.3k
characters and a named LDL read grows from 0.2k to 1.2k characters for
80 readings. The MCP get_labs read and the lab resource follow, since
they run the same tool.
The widest lookback option no longer reads "All": it names what it
reads, twelve months in detail and every lab result, in every locale.
Refs #1263
A batch that carried a type with duplicates only moved its
lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live
delivery had stalled kept reading as freshly arrived for as long as a
replay, a retry or an older client re-sent its old samples.
The arrival ledger now moves those stamps only for a type the batch
brought at least one new or changed row for. An unchanged stats:
re-post and an hour posted again with its stored value count as
duplicates here; the wire keeps answering updated for them. A
duplicates-only type stamps the new last_seen_duplicate_at column on
its existing row and nothing else. The intake bulk route's dose entry
follows the same rule through the shared recorder. lastSyncedAt and
healthKitLastSyncedAt are unchanged.
GET /api/integrations/healthkit serves the stamp additively as
metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a
re-send that came after the last arrival.
Refs #1259
Each of these asked the same question several times or did the expensive
part before the cheap one: the threshold guard parsed every client file
once per rule and twice per run, the i18n reverse check rebuilt its scan
three times and compared every leaf with every literal key, the step-up
caller guard stripped comments and searched every route path in every
file per case, the plausibility guard ran its writer sweep three times,
and the cycle verdict guard parsed every client file to find out whether
it mentions a cycle. Each now works a result out once, and a file is
parsed or searched only when a substring says it can match. A planted
violation still fails the threshold, plausibility, cycle verdict and
backup plan guards.
walkSourceFiles now lists each indexed folder (src, e2e, prisma) once
per worker and filters it by prefix, readSource and readSourceFolder read
through the same memoised file cache, and readSourceText is the cached
read for guards that read files themselves. The guards project shares a
module graph per worker, so a file is read once however many guards ask
for it. sourcesContaining adds the substring pre-filter for sweeps that
look for a few markers. Every guard over 1.5 s now reads through it;
anything outside the indexed folders is read from disk as before.
Each of the guard's scans started its own grep over the whole of src,
the generated client included, five times per run, and the floor case
timed out at five seconds under a loaded unit suite. The tree is read
once now, without the generated client, and a file is kept only when a
plain substring says a pattern could match; each scan's result is
reused by the cases that ask about it. The file runs in 1.9 s instead of
6.5 s alone, and the floor case in 2 ms instead of 2.3 s.
The grant expiry case gave the grant 400 ms and slept 500: on a loaded
runner accepting, signing in and switching took longer than the grant
lived, and the grant expired before it was accepted. The grant now runs
30 seconds and the case moves the clock the resolver reads to the
instant the grant names, so the row still does not change and nothing
sleeps.
The key check's start-up probe is bounded at ten seconds and two per
query. Under load a probe query ran past two seconds and the check
answered inconclusive where the data says mismatch. The verdict tests
now run the check with time to finish; the cases that test the bounds
pass their own budget or sampler and keep them.
/api/auth/me 18 to 13, dashboard snapshot 103 to 99, summary 86 to 84,
the 365- and 3650-day pulse series 6 to 5, the day view 48 to 47,
insights health status 60 to 59, correlations 44 to 43 and a 500-row
batch ingest 31 to 28.
The profile section in docs/contributing/benchmarks.md says how to run
scripts/bench/boot-profile.ts, what the profile of the v1.44 runtime
found (the worker graph as one 753 ms block, a live heap of about
165 MB of which the query compiler holds 50 and the catalogs 33) and what
changed. scripts/bench/results/v1.45.0-*.md hold the before and after
numbers per area, from back-to-back runs on the same machine. The size
baselines take the lowered numbers.
Readers that tag a cell as a closed block no longer import the
invalidation module, which several tests replace wholesale. The cache
route test counts the slice's pre-fold cell apart from the route's two
cells, and the provenance test reads the shared pick's ORDER BY over its
grouped rows.
Re-importing an Apple Health export the account already holds sent one
UPDATE per row the insert skipped, about two million statements for a
large export, nearly all writing back what was stored. One read per chunk
now finds the rows whose stored values equal what the update would write;
they count as updated as before, without the statement. A key the chunk
carries twice is written in order as before. The natural-key rescue moves
into the same module.
A long-window read of a dense series sent two statements over the same
canonical rows, the day or hour buckets and the strip's figures, so a
ten-year pulse read picked the canonical source per day and scanned the
readings twice. Both aggregates now run in one statement over the window
materialised once, with the same SQL each had (dense-series-read.ts).
An integration test keeps the two old statements as the reference and
requires the same buckets and figures for pulse (two ranked sources),
glucose and oxygen saturation, day and hour grains, in UTC,
Europe/Berlin, Asia/Kolkata and America/New_York, and the same empty
answer for a type without readings.
The two-ended checks ask every declared file about every model and each
of its relations, and read the schema and the files again for each
question. With the backup split into more files that took two seconds a
case and timed out on a loaded runner; the reads are memoised now.
The whole-payload tests meet a slice's refinements only by accident. Each
pattern, bound and two-ended free-text rule now has the value just inside
and just outside it, with the issue it raises: the day keys, a long day's
time away, the Coach's table and trail caps, a life event's title and a
symptom's label, a profile fact's readable value and validity. The backup
validation mutation score goes from 25.5 to 41.6 %; the floor follows.
The element schemas move out of validations/backup.ts into one
<domain>-backup-schema.ts per domain, beside its export and restore, with
the shared primitives in backup-schema-kit.ts. validations/backup.ts now
composes the slices into backupPayloadSchema in the same key order, with
the same defaults and the checks that span sections, so the parsed
payload, every validation message and the OpenAPI contract are unchanged.
The lazy-schema guard now covers the slices too: a slice is reached only
through the payload schema or another slice, never from a domain's export
or restore. The external-id guard carries the payload's exemption to its
slices, and mutation testing scores the slices with the payload schema.
buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.
The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
A table read without an order comes back in whatever order its last
updates left on disk, so the column fill could pick a different row of a
two-row table from run to run. The audit rows of a restore are now found
by the copy's id instead of by time, since the database clock and the
test's clock do not agree to the millisecond. The golden copies are
written again from the code before the restore and export moved.
Up to 12.37 pg-boss retried a job whose handler outlived its expiry in
the same worker beside the run it had given up on, and the lane test
pinned that as an executable statement. 12.38 fails the job and fires
the signal as before, but the worker fetches nothing more until the
handler returns, and the retry starts after it. The test now pins that:
one run at a time, the retry starting after the overrun ends, and the
lane's handler importing once per delivery with never two at once. The
lane's lock stays, as the guard for a second worker or instance.
next/font/google downloads Inter from fonts.googleapis.com and
fonts.gstatic.com while the app builds, and when that request fails the
build fails with it, which is what broke the arm64 image build and
several CI jobs. The files it downloaded now live in src/app/fonts/inter
under the SIL Open Font License, and next/font/local declares them.
They are the same bytes: Inter 4.001, one variable file per script
subset (Cyrillic and its extension, Greek and its extension,
Vietnamese, Latin Extended, Latin), each used for weights 400 to 700.
The declarations rebuild the stylesheet Google answered, the twenty-eight
faces with Google's unicode ranges in Google's subset order, swap
display, and only the Latin file preloaded. The local loader emits one
unicode range per call, so each subset is its own call under the
family Inter.
The Arial fallback face and the font-sans stack move into globals.css
with the metric overrides next/font/google shipped for Inter; the local
loader would have computed slightly different ones from the file.
A production build with every outbound request sent to a dead proxy
completes, where the same build with next/font/google fails on the
fonts.googleapis.com request. The content security policy already
allowed fonts from self only. A guard refuses a next/font/google import
anywhere under src.
Strava's token endpoint does not answer RFC 6749 errors. A refresh
token it no longer honours, after the athlete revoked the app or the
token was rotated away, is a 400 with the API's generic Fault:
{"message":"Bad Request","errors":[{"resource":"RefreshToken",
"field":"refresh_token","code":"invalid"}]}. The classifier looked for
invalid_grant, which never arrives, so the failure was booked
persistent and the connection never asked for a reconnect.
The token error now carries the fault reduced to its resource, field
and code (or an RFC error code, should one arrive), and the classifier
lifts a 400 naming RefreshToken to reauth_required, from the error and
from its message after a retry loses the prototype. Every other refusal
is classified by its status as before, including the faults that name
the Application.
Strava documents the Fault and Error model but not these values; the
body is the one several unrelated client libraries captured from live
traffic between 2023 and 2026.
An app boot's /api/auth/me read the AppSettings singleton six times, each
reader with its own few columns: the MFA policy, the Assistant switches,
the module availability, the provider presence, the AI usage bounds and
the reasoning controls. They now take their columns from one read,
memoised for the request and read afresh inside a background job like the
readers themselves. No answer changes.
The day view started about twenty-five section reads at once beside the
readings, scores and movement, more than the pool holds, so a
neighbouring request waited for a connection. The sections now run six at
a time and come back in the same order.
Every sync marked all of an account's analytics cells stale, so the slice
re-read the pre-fold remainder (every reading older than five years,
folded per source and day, the slice's largest statement on a long
history) and the day view re-read its past history blocks, although a
sync of today's readings cannot change either.
A cache key can now name the instant its value's readings end before
(closedBlockKeyPart). invalidateUserMeasurements takes touchedFrom, the
earliest measuredAt a write created, changed or removed, and leaves such
blocks in place when it is not earlier; without it every block goes as
before. The batch route passes it from the identities it wrote. The
pre-fold remainder moves into its own module and is cached as a closed
block; the day view's past history blocks carry their end, with a day of
margin for a night across midnight.
The batch route's two up-front reads move into batch-stored-rows.ts with
the once-per-batch key rule they share.
The rollup parity case writes over half a million readings and folds them
into DAY buckets: about 35 s on a CI runner alone. Run beside two other heavy
files on the same Postgres it took 1.7 times as long and crossed the 60 s
default. Nothing in the case waits on a lock: its advisory locks are scoped
to the worker's own database, and the measurement with the database capped
at two CPUs (14.6 s alone, 25.3 s beside the sharing matrix and the restore
memory test) shows plain contention. The case gets a 150 s budget, twice the
contended estimate. HEALTHLOG_INTEGRATION_PG_CPUS caps the database
container so a CI runner's contention can be reproduced locally.
The 0412 lock-retry case let go of the lock on a 2.5 s timer. On a loaded
runner the migrate CLI took longer than that to start, so the first attempt
found the lock already free, applied cleanly and never produced the 55P03
the case is about. The lock is now released once the attempt is seen queued
behind it in pg_stat_activity and gone again, which is the moment its own
timeout fired, and the case fails outright if no attempt ever queued. It
holds with the CLI start delayed by four seconds.
POST /api/admin/backups/upload stores a backup file as a new copy, the
copy whose id the published restore takes, and it refuses a file for
reasons an operator has to be able to look up: a key this server does
not hold (backup.key.missing, with the key ids), an owner account that
does not exist here, a newer schema, a copy too large to store
(backup.upload.too_large). It was exempted from the contract as console
plumbing while its two siblings were published for exactly that kind
of refusal.
It is registered with the three bodies it reads (raw JSON, raw gzip, a
small multipart form) and every status it answers; the summary is the
three identity fields plus one integer count per class, so a class the
backup format gains later does not break a decoder. The round-trip
test calls it through the contract now.
The six /api/auth/me scalar writes (unit preference, glucose unit,
automatic document reading, local lab OCR, workout routes, the central
Codex switch) read their body through safeJson with a 1 KB cap, so each
answers 415 for a body that is not JSON by content type and 400 for one
that does not parse, beside the 413 they already declared. Their
descriptions said so and the responses did not. All three now come from
one block.
The modules and disable-coach route tests had been skipping the
contract check over a 400 and a 409 the registry already declares; the
skips are gone.
A /api/fhir/* handler answers its own refusals as FHIR: a bad search
parameter is a 400 OperationOutcome, the hourly fhir bucket a 429
OperationOutcome (issue code throttled), both served as
application/fhir+json, the same way the 404 on a read already was. The
contract declared no 400 and the generic JSON 429 for the 429, and no
403 for the doctorReport module gate, which every data route runs.
The searches now declare the 400, every data route the 403
module.disabled envelope and the FHIR 429 with the rate-limit headers
apiHandler attaches to it. The module gate keeps answering the JSON
envelope: it is the errorCode every client already branches on for a
disabled module, and it runs before the FHIR face does. The route tests
drop their skips and assert the 429's content type and Retry-After.
requireAuth answers 403 auth.scope.insufficient to a narrow Bearer token
on a route that names no scope it holds, and requireAdmin answers 403
auth.admin.required to a session that is not an administrator's. Both
run before any route body, and the contract only said so in the prose
of the shared 401, so a client generated from it had no typed branch
for either. The route kit accepted the scope refusal on any operation
that declared a 401 for the same reason.
The 403 is added to the assembled path table rather than to
stdResponses. That set is spread into most operations, about three
hundred of them carry a 403 of their own, and most spread the set after
it, so a shared 403 there would have overwritten every module-off and
sharing refusal in the document. withScopeRefusal adds the shared
response where an operation has none and prefixes an operation's own
403 with the gate's code, keeping the sharing sentence last. Operations
that take no credential, and the three surfaces that authenticate by a
credential of their own, are left as they were.
The kit no longer tolerates the refusal on an operation's behalf, and a
guard holds every operation behind the gate to declaring it.
The guard pinned the npm-installed dotenv in the Dockerfile to a literal,
which is how that install came to trail the app tree by a patch without
anything noticing. It now reads the Prisma and dotenv versions from
package.json and asks the Dockerfile for the same ones, engines included.
The phone re-sends the same daily totals on every sync. Each went through
the reconciler, about ten round trips in its own transaction, to write the
same values back, and as an update it started the DAY refold, the rollup
jobs, PR detection and a cache stale for the account.
One read over the batch's stats: ids now finds the rows whose live stored
row already holds every value the update would write; those are reported
updated exactly as before, per entry and in the summary, and nothing is
written or started for them. A changed column, a tombstoned row, a key
that occurs twice in the batch or a value the plausibility gate refuses
still goes through the reconciler.
The shared canonical-source CTE sorted every raw reading in its window by
a CASE expression no index can supply; on the benchmark account the pulse
pick over ten years sorted 260,000 rows and spilled to disk. Its order
depends only on (type, source), so the pick now groups the window to its
distinct (type, day, source) triples first and runs DISTINCT ON over
those. The picked source per day is the same by construction; an
integration test runs the old statement beside the new one over a fixture
with overlapping sources, sources off the ladder, tombstones, empty days
and readings either side of midnight, under default and reordered ladders,
for every reader shape, and requires identical rows.
Every app session now sets jit=off. Postgres compiled the larger folds to
machine code, which took 100 to 140 ms of a 220 ms statement and saved
nothing for statements that run once and are bound by reading rows. JIT
never changes a result.
Seven matchers built their index over every shipped catalog at module
load (the GLP-1 tag map, the nutrient and metric names of the MCP reads,
the SNOMED and LOINC lab names, the Coach's health words, the lab-name
pattern), so loading the worker graph read all eight catalogs at boot.
Each index is now built the first time it is asked for.
The boot profile records the change: the longest busy stretch of the
main thread at boot fell from 753 ms to 241 ms, the live heap after five
idle minutes from 164 to 152 MB, and the catalog chunk sources (two of
9.7 MB) are gone.
restoreBackup becomes an orchestrator: the checks before the first delete
move to restore-preflight.ts, and the transaction runs the steps listed in
backup-domains.ts in dependency order, emptying the account first and then
rebuilding it domain by domain. The inline restores for measurements,
medications, mood, labs, illness, allergies, family history, workouts,
nutrient days, documents, the instance settings and the cleared
notification channels move into one module per domain, beside the
existing section modules. Cross-domain references travel through one
typed context instead of local variables.
The order of every write, the refusals and their audit rows, the skip
report, the cleared counts and the reported phases are unchanged; the
golden copies of the restored account still match. Guards that pinned
the restore by path now point at the domain files.
Re-resolving Vitest 5.0.5 had carried vite from 8.1.5 to 8.3.4, two
minors nobody asked for: 8.2 resolves the root through real paths,
which moves file ids in a symlinked worktree, and 8.3 warns on every
run about config-loader features the test configs use. 8.1.6 carries
the same five fixes as a backport, so the lockfile takes that and the
floor in the workspace file stays as it is.
GHSA-8c8m-p3m8-cjg8 and two sibling advisories against the address
parser are fixed in 10.1.4; 10.1.5 to 10.1.7 are linear-time and
encoding fixes on the same line. The email channel hands the parser a
recipient address each account sets for itself.
Production: the AWS SDK S3 client and lib-storage 3.1152.0, Prisma
(client, adapter-pg, CLI) 7.10.1, TanStack Query 5.104.3 and Virtual
3.14.21, dotenv 18.0.8, jspdf-autotable 5.0.9, lucide-react 1.59.0,
nodemailer 10.1.2, pg-boss 12.38.0, React and React DOM 19.3.1 (still
exact) and undici 8.12.
React 19.3.1 and undici 8.12.2 are security releases. Prisma 7.10.1
stops a nested interactive transaction from leaking its id into a
later top-level one.
pg-boss moves its schema from 45 to 46; the worker applies the
migration on start and the index builds continue in the background.
Its list and delete helpers now page and batch, which touches only the
queue retirement at boot, and that is idempotent. The retention test
pins the installed version, so it moves to 12.38.0 after re-checking
every source line it asserts.
Development: @axe-core/playwright 4.14.0, Playwright 1.64.1, Vitest and
its coverage provider 5.0.5, shadcn 4.22.0. The Stryker runner patch
still applies; it touches the runner's own name join, and 5.0.5 changes
nothing in how Vitest builds a test's full name. axe-core 4.14 turns on
label-content-name-mismatch for the WCAG 2.1 A tag the a11y specs run.
The S3 client is held on the same release as lib-storage rather than
the newest one its range allows, so the two share one SDK core.
Vite gets a floor at 8.1.6 for GHSA-587h-7wmj-h5pm and four sibling
advisories against its dev server and bundled launch-editor. It arrives
only under Vitest and never reaches the image.
The image's separate Prisma CLI install moves with the app tree to
7.10.1, and its dotenv to 18.0.8, which it had trailed by a patch.
The SETTING verdicts are now the keys of the codec table, so a column
becomes a setting by getting a codec and nothing else has to name it. The
verdicts for every other User column move beside it into
account-settings-classes.ts, and the restore half into
account-settings-restore.ts. The backup plan keeps the model verdicts.
The classification guard now also refuses a column that has both a codec
and another verdict, since the composed table would quietly carry it as a
setting.
Next's signal handler closed the server and called process.exit, which
won the race against boss.stop, so a running job was cut off and stayed
active until its expiry. The shutdown handler now holds process.exit until
the boss has stopped, at most 30 s plus a margin, and compose gives the
container 45 s to stop.
A process that runs the worker gets four pg-boss connections instead of
two for its pollers, cron, supervision and job writes; a web-only producer
keeps two. Both come out of the same per-process budget.
The heap monitor's tick now also reads the cgroup working set (usage minus
reclaimable page cache) against the container limit. At 85 % it writes one
runtime.memory.pressure line with the work in flight, and the heavy lane
sends new heavy jobs back to the queue for a minute and asks a sliced
holder to give way, for at most fifteen minutes per episode. Heap lines
carry the working set and the limit. Outside a cgroup with a limit nothing
changes.
The eight catalogs were static JSON imports, so every server bundle
carried the whole eight-locale chunk as a source string and parsed all of
them: 32 parsed copies and two 9.7 MB chunk sources in an idle process.
The server now reads messages/<locale>.json the first time a translator,
the SSR cache or a matcher asks for that locale, and keeps one parsed copy
per process on globalThis. allMessages keeps its shape; its entries load on
first read. The image copies messages/ next to the server.
foldLabel moves to a leaf module, so the client medication map no longer
imports the catalog index.
The event-loop monitor starts first in register(), so loading the
database client and the worker graph is inside its view, and a stall line
now carries the process uptime, the startup phase, the boot step running
at the time and the oldest work in flight. Each boot step is timed, and
the boot ends with one boot.steps line.
The five registrars and the lifecycle helpers are imported one at a time
after boss.start(), with a turn of the event loop in between, instead of
all at once when the worker module loads. The longest stall is then the
largest registrar graph rather than the sum of all five.
scripts/bench/boot-profile.ts starts the production build with every role
under the production heap ceiling against the seeded benchmark database,
and records the time to a healthy probe and to a ready worker, every
event-loop stall the process logs, the heap lines, the full collections
from --trace-gc, the main thread's busy stretches during the boot with
their self time by source module (resolved through the chunk source maps),
and one heap snapshot after an idle wait, summarised by node type,
constructor, largest strings and largest retained subtrees.
The baseline on the current runtime: a 753 ms busy stretch at 0.7 s, most
of it the worker graph, and a live heap of 164 to 172 MB after five idle
minutes, of which the query compiler holds about 50 MB and the eight
locale catalogs about 33 MB (32 parsed copies and two 9.7 MB chunk
sources).
Seed one account as far as the schema allows (a row of every two-ended
model, every column of those rows and of the account row, the instance
settings, a sharing grant), export it both ways and restore each file
into a fresh account, over the live account, and from a version-1 file
in the old shapes. The files, counts, parsed payload, preview, restore
outcome, reported phases, audit rows, wide-event fields and every row the
account holds afterwards are compared with golden copies after ids,
ciphertext and run-time timestamps are normalised. Refusals, skip reports
and the schema's issue list for malformed files are pinned the same way.
The seed moves out of the round-trip test into a shared fixture module
so both files fill the same account.
Two copies of one session were collapsed only when both starts fell in the
same fixed five-minute clock slot, so a pair thirty seconds apart that
straddled a slot boundary (08:04:50 and 08:05:20) stayed as two workouts in
the list, the batch write, the calendar, strain and the Coach.
Workouts of one user and sport are now clustered by start time: a workout
joins the open cluster while it starts less than five minutes after the
cluster's first start. Wherever no pair within five minutes straddles a slot
boundary the clusters are exactly the old slots, which a parity test checks
against the previous picker on thousands of generated inputs, strain impulses
included. A property test shows any offset under five minutes now collapses,
five minutes or more never does, and a uniform shift of every start keeps the
picked set.
docs/contributing/testing.md says which suite a test belongs in and why,
how to write a route test with the kit and what its contract check proves,
how the settings round trip and the integration database work, how the
shards are balanced and the timings regenerated, when to write a property
test, how the mutation floors are kept, and the flaky-test policy with the
quarantine format. Linked from the docs index.
The mocked route tests moved onto the kit take the ratchets down: route
tests mocking @/lib/db wholesale 321 to 274, route tests stubbing
apiHandler 138 to 133, as never casts in tests 6 014 to 5 496, and version
stamp comments 10 634 to 10 592 with the deleted files.
Stryker runs against encryption at rest, medication compliance, the rollups,
source ranking, unit conversion and backup validation, with the unit tests
that live next to them (vitest.mutation.config.mts). It is too slow for a
pull request and runs nightly and on demand (.github/workflows/mutation.yml).
scripts/mutation-floors.mjs reads the JSON report and fails when a module
scores below its floor in stryker.floors.json, so one well-tested module
cannot hide a weak one. The floors sit two points below the first measured
scores: crypto 46.9 %, compliance 66.8 %, rollups 41.7 %, source ranking
57.0 %, units 87.4 %, backup validation 25.5 %.
@stryker-mutator/vitest-runner 10.0.0 predates Vitest 5, which names a test
"describe > test" where the runner still built "describe test". Every test
inside a describe block was filtered out of the mutant runs, so nearly every
mutant survived (5.5 % across the set). A pnpm patch builds the name the way
Vitest 5 does; the Docker dependency stage copies the patches directory,
which the frozen install needs.
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.
The case now pins Date to an instant well inside a slot. With the pin at
12:04:45Z or 23:59:45Z the case failed under UTC, Europe/Berlin and
Pacific/Kiritimati alike; at the committed 12:02:00Z it passes under all
three.
The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
One run of scripts/bench/run.ts against the seeded account (1,266,549
measurement rows, anchor 2026-10-22), 20 measured requests per row after 3
warm-up requests, web-only production build, 792 MB heap ceiling, Postgres
16.15. The commit adds only lint rules, guards, tests and scripts on top of
v1.44.0, so the server code measured is the v1.44.0 code.
Cold (caches evicted before each request), p95 and statements per request:
/api/auth/me 17 ms / 19, dashboard summary 233 ms / 31, dashboard snapshot
2253 ms / 158, pulse series 30 d 59 ms / 8, 365 d 311 ms / 8, 3650 d
2118 ms / 8, day view 78 ms / 49, insights health status 140 ms / 68,
correlations 105 ms / 45. Warm, the snapshot is 24 ms / 13 and the 3650-day
series still 1506 ms, since that read is not cached. The 5,000-row batch
ingest took 1722 ms and 424 statements over ten requests.
The machine was shared with other work during the run (load average 8 to
20), so latency is indicative; the statement counts do not depend on it.
The results directory is written by the script and left out of prettier,
so a re-run diffs cleanly.
The environment check pinned the database a worker talks to as the single
shared one. Each worker now runs against its own clone, so the check names
the clone and asserts it is not the migrated template the clones copy.
Once a night both suites run with files and tests in a random order, seeded
and printed, so order dependence surfaces on purpose rather than on somebody's
pull request; a failure replays with --sequence.seed. The same run gives every
fast-check property a fresh seed and five times the cases. The integration
reports are merged into a regenerated timing file and uploaded for a
maintainer to commit when the shards drift apart. Not a gate.
Vitest's --shard cut the file list into slices of equal length, which gave
the integration shards 954 s to 1 449 s. A sequencer now deals files out by
their measured duration, plus a fixed per-file cost for import and setup,
longest first to the shard with the least work, and starts the longest files
first inside a shard. Timings live in tests/durations/*.json and are
regenerated from Vitest JSON reports with scripts/test-durations.mjs; every
CI shard uploads its report.
Integration shards run three workers each, one cloned database per worker.
The unit suite runs as three projects decided per file from its source:
guards and unit share a module graph inside a worker, isolated keeps a fresh
one for every file that mocks, spies, stubs or resets. In CI it is three
duration-balanced shards next to a job for lint, format, types and the
OpenAPI drift check.
The required check names stay as they were. "Lint, Typecheck & Test" is now
the job that answers for the static checks and every unit shard, the way
"integration" already answers for its shards. The browser-install guard
recognises a direct, flags-only vitest run as a run of the unit suite.
tests/integration/query-counter.ts counts the statements the app sends
while a function runs, by wrapping Client.prototype.query on the copy of pg
the Prisma adapter itself resolves (so a second pg in node_modules cannot
leave it reading zero; a window with no statement throws). Background tasks
are settled before the window closes, so fire-and-forget writes count with
the request that started them.
query-budget-hot-routes.test.ts seeds a small account (four months of the
main types with overlapping sources, stats rows, sleep, glucose, a
medication, workouts and mood), warms each route once, evicts the user's
caches and counts the next call. Budgets, measured on this fixture:
/api/auth/me 18, dashboard summary 86, dashboard snapshot 103, pulse series
30/365/3650 days 6 each, day view 48, insights health status 60,
correlations 44, a 500-row batch ingest 31. A failure prints every
statement grouped by text.
Proof: one extra `SELECT 1` before every handler in apiHandler failed all
ten budgets by exactly one (18 → 19, 86 → 87, … 31 → 32), which also shows
the counts are deterministic; with the probe removed all ten pass.
scripts/bench/seed-large-account.ts (moved from scripts/, extended) writes
one synthetic account of ten years: 1,265,561 measurement rows across the
main types and five sources (Apple Health dense spot rows, Withings blood
pressure and scale, a weekly manual weight, three years of Oura nightly
values, 90 days of Nightscout CGM), `stats:` and per-sample cumulative rows,
nightly sleep stages, 3,020 workouts, 1,095 mood entries and three
medications with 3,285 intake events. Everything goes in through set-based
INSERT … SELECT generate_series; values and timestamps are functions of the
row index and `--anchor`, so a run is reproducible. The rollup tier is
folded with recomputeUserRollups, as the boot backfill does, and the tables
are analysed. About 80 s on the Mac mini.
scripts/bench/run.ts boots the production build once with every role (so
the queues exist), then measures against a web-only process under the
production heap ceiling: p50, p95 and max latency and SQL statements per
request (pg_stat_statements, read once nothing is in flight and the count
holds still) for /api/auth/me, the dashboard summary and snapshot, the pulse
series over 30, 365 and 3650 days, the day view, insights health status and
correlations, each cold (caches evicted by a one-row manual-sync ingest
before every request) and warm, plus a 5,000-row batch ingest as ten
requests of 500. It writes JSON and a markdown table.
docs/contributing/benchmarks.md describes the setup against a throwaway
Postgres in Docker with the production pg_stat_statements settings.
tests/quarantine/quarantine.json is the only way to take a flaky unit or
integration test out of a run. An entry names the file, the full test name,
the issue, the reason, the day it was added and the day it expires, at most
14 days later. Both setup files skip a listed test while its entry is live,
reported as skipped with its issue rather than retried into green. Past its
expiry date the test runs again, and quarantine-guard.test.ts fails until the
entry is removed; it also fails on an entry naming a file or test that does
not exist. The list starts empty.
A converted value that rounds to zero from below came out as -0, and
Intl.NumberFormat prints that as "-0": 0 °F typed and shown back read
"-0 °F", and a body-temperature deviation of a few hundredths below zero read
"-0.0 °F". The rounding now returns plain zero. Found by the new unit
round-trip properties, which check that every value a person can type in
their own unit is shown back exactly as typed.
fast-check properties with one shared configuration (tests/property/): a
fixed seed on every pull request, a fresh one and more cases at night through
FC_SEED and FC_NUM_RUNS, and a printed seed with the shrunk counterexample on
failure.
- Time zones: wall clocks agree with Intl in twenty awkward zones, a wall
clock maps back to an instant showing it, a skipped hour resolves forward
on the same day, every instant lies inside the window of the day it is
filed under, and day keys shift and count as inverses.
- Statistics: every median in the tree says the same number, medians and
spreads obey order, shift and scale, a least-squares fit recovers an exact
line and composes from stored sums exactly, and the segmented regression
recovers a noiseless interrupted line.
- Cadence and compliance: weekly and daily schedules expect exactly what a
day-by-day oracle expects, in any zone and on transition days, and the
rate stays within bounds and never falls when a missed dose is taken.
- Encryption: every codec opens what it sealed under the key it names,
rotation moves the key id and nothing else, and a tampered value never
opens to anything but the sealed bytes.
- Backups: randomly generated accounts come back from a disaster-recovery
restore with an identical canonical dump of every generated table.
fast-check 4.10.2 for property tests; @stryker-mutator/core and the Vitest
runner 10.0.0 for mutation testing of the core libraries. Exact versions,
each published more than two weeks ago; the audit reports nothing new.
Phase config, cadence, side effects, inventory, intake purge, bulk delete and
the import status run through the real handlers against the database. The
advisory-lock ordering the mocked tests read off call order is shown with a
real concurrent transaction holding the inventory lock, cache eviction through
the next list read with a control that the cached list would otherwise stay
stale, and soft deletes on the tombstone and sync counter they leave.
The channel settings routes, their test-message routes, the reminder
thresholds and the workout detail, delete and batch-scope tests run through
the real handlers against the database. Only the far ends are faked (the
relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what
those received. The private-origin source guard stays a unit test and now
points at the two files that carry its runtime proof.
The effective tag read, hide and show, custom tags and groups, and the tag
layout are tested through the real handlers on stored rows instead of a
mocked Prisma client: the migration-seeded catalogue served after a truncate,
owner scoping that leaves another account's tag byte-identical, the caps,
sealed labels, soft delete against purge, a deleted group re-homing its tags,
and the layout's optimistic token from 200 through 409 to a successful retry.
The Strava writer had eight mocked test files and no test against a
database. strava-sync.test.ts runs the real sync behind a fake Strava API at
the safeFetch boundary: field mapping, the after= watermark, idempotent
re-sync on the (user, source, external id) key, in-place overwrites that keep
what only the detail read carries, a cursor that never moves backwards or
past a row that failed to write, the paged backfill with its resume point
and requeue, token refresh with both rotated tokens stored sealed, and the
integration ledger after transient and permanent failures. The sync, the
disconnect and the credential routes are covered through the kit.
The mocked cases that asserted call counts and mock ordering are removed;
the pure retry-after parsing stays a unit test.
Fourteen route tests for the /api/auth/me preference family replaced Prisma
with a mock and asserted the calls it received. Their claims are now shown
through the real handler on the stored row and the response: validation
refusals with their codes, normalisation and defaults on read, optimistic
lock conflicts against a real second writer, rate limits driven to their
first refusal in the Postgres limiter, audit rows read back, cache entries
evicted, and the jobs a change queues observed on a real pg-boss queue.
Assertions that were only about the shape of a Prisma call are replaced by
the behaviour they stood for, and two that described a state only a mock
can reach (a missing column) by the stored value that state resolves to.
DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC
of the row id, and compared that handle with the caller's row id to decide
whether the caller signed out the session it was using. The two never match,
so every revoke was audited and annotated with self: false, including the
ones where a person ended their own session. The handle is compared with the
handle of the caller's session now.
The mocked route tests for the session list and revoke, the device list and
revoke, the security-activity feed and the FHIR surface move onto the kit.
Each claim is now shown on the stored rows and on the next request: a
revoked session, device login or token is refused when it is presented again,
another account's session or device answers 404 and stays signed in, the
current-session and current-device flags follow the credential that anchors
them, and no row id, full IP or push token reaches a response.
The FHIR read set, covered against a database only by the scope check until
now, is driven with seeded readings and medications: every resource carries
the seeded values, another account's rows never appear, a fhir:read token
reaches every data route and an unrelated narrow token is refused, the module
gate closes all eleven data routes, and $everything serves the same entries
as the document builder with references that resolve inside the response.
The module-gate inventory now names the new file as its behavioural proof.
One table holds an entry per write route, and every User column the backup
classifies as a setting appears in exactly one entry; a new setting column
fails the inventory case until it has one. For each entry the file sets a
non-default value through its write route, reads it back through its read
route and through /api/auth/me where that publishes it, and checks the stored
column moved off what a fresh account holds.
The backup leg runs through the routes too: the portable full backup a
person downloads, the operator's upload, and the restore into an account
recreated under the same id. Every setting must read back identically on the
restored account, through the same routes and the account payload, and every
stored column must come back with its value.
tests/kit/ holds typed fixture factories and callRoute(handler, options).
The factories write through the application's Prisma client, so a fixture has
the shape production stores and the compiler checks it; sessions and Bearer
tokens are minted by createSession and issueApiToken, so a fixture cannot
drift into a shape authentication no longer accepts.
callRoute builds a real NextRequest, fills the next/headers stand-in from the
auth fixture and runs the exported handler, apiHandler included. Every call is
also a contract check: the status must be declared for that path and method in
the OpenAPI registry, a JSON body must parse with the registered schema, and a
key the schema does not declare fails the call. The scope gate's generic 403,
documented on the shared 401, is the one answer accepted without its own
entry. kit-contract.test.ts proves each refusal can fire.
The token list and revoke move onto the kit. The mocked unit test asserted
the list's where clause and proved the API switch is not consulted by reading
api-handler.ts for a string; the kit test authenticates a Bearer token while
the switch is off, revokes it, and shows it refused on the next request.
Global setup migrates one template database and reads what a fresh install
holds: its schemas, its tables and the rows the migrations seed. Each Vitest
worker clones the template on its first file with CREATE DATABASE ... TEMPLATE
and keeps the clone for the rest of the run, so files run in parallel without
seeing each other's rows, advisory locks or queue schema. Schemas created at
runtime (pg-boss, migration probes) are dropped when the next file starts.
The truncate list is the catalogue now, every table in public except the
migration bookkeeping, instead of a hand-kept list of 42 of 166 tables. The
seeded catalogues are put back after each truncate in foreign-key order, so a
test starts from what a fresh install has. The settings singleton is the one
seeded table a test starts without, declared with its reason.
next/headers and the boot compatibility probe are mocked once in the setup
file. Two lock-wait probes now look at their own database only, since other
workers' backends share pg_stat_activity.
The gate summed every emitted chunk once and watched five pages. The sum
counts a module once per chunk group it lands in, so it rose whenever a
page got lighter by moving code into on-demand chunks, and it needed a
raise in most releases while no reader downloaded more; and a heavy import
into a page outside the five went unseen.
scripts/check-bundle-budget.mjs now measures:
- the first load of every page: rootMainFiles plus the page's
client-reference chunks, each file counted once by content hash;
- p50, p95 and max of those first loads across pages;
- the distinct client code: every chunk evaluated in a node:vm sandbox
with a stub TURBOPACK array, each module factory counted once by its
module id (not its text: each chunk is minified on its own, so copies of
one module differ in mangled names), catalogs excluded, gzipped once.
The summed aggregate is still printed but no longer gated. The catalog,
per-catalog and one-Recharts checks are unchanged. `--write-baselines`
rewrites the per-page baselines, `--dir` measures another build directory,
`--json` writes the report.
bundle-budget.json is re-based from the v1.44.0 build (local arm64): 110
pages, first load p50 336, p95 437, max 451 KB gz; distinct client code
1904 KB gz in 1224 modules; summed aggregate 3875 KB for comparison. Every
page has a baseline with the same 20 KB allowance; caps are p95 447, max
461 and distinct code 1930 KB. A page without a baseline fails until one
is written in the same change.
Proof: importing zod into the weight insights page measured
/insights/weight at 427.8 KB gz, 88.8 KB over its 339 KB baseline, and the
gate failed; the old gate passed the same build, because the page was not
watched and the summed aggregate did not move (3875 KB). The import was
removed again.
The runtime import graph (static value imports and re-exports, side-effect
imports, dynamic import() with a literal; `import type` erased) is built
with the TypeScript compiler's module resolution under the `@/*` mapping,
over every production module. No new dependency.
- Cycles: 13, counted one per back edge of a sorted depth-first walk (the
number `madge --circular` reports), in 7 strongly connected components:
the audit/geo/notifications/managed-profiles cluster (10 modules), the
compliance facade cluster (9), command palette ↔ store ↔ shortcuts,
modules ↔ cycle gate, boss-instance ↔ boss-ready, mood aggregates ↔
patterns, features ↔ signals-of-day. A new component or a component that
draws in another module fails whatever the count says; a lower count or a
shrunk component fails until written back. The target is zero.
- Re-export-only modules (every statement an `export … from`): 16 listed;
a new one fails.
Proof: two planted modules importing each other failed with
"lib/zz-probe/a.ts <-> lib/zz-probe/b.ts", a planted `export * from "./a"`
module failed the re-export ratchet, and a baseline raised to 14 cycles
failed with the write-back message until RATCHET_WRITE=1 restored it.
Add a ratchet mechanism (helpers/ratchet.ts): per-key counts against a JSON
baseline under src/__tests__/baselines/. A new key or a higher count fails;
a lower count fails until it is written back with
`RATCHET_WRITE=1 pnpm vitest run <guard>`, which only ever lowers numbers.
Raising one is a hand edit in a reviewed change. The baselines are
machine-written, so prettier leaves them alone.
helpers/source-metrics.ts measures the production tree through the
TypeScript compiler: the file list (dot directories included, tests and
generated code out), line counts, every function node with a stable
"outer > inner" name and its length, and the runtime import graph.
Guards and their baselines, measured on this tree:
- source-size-ratchet: 129 files over 800 lines and 415 functions over 200
lines (catalog and data files exempt, each named with its reason; slack
25 and 10 lines before a shrink must be written back).
- test-realism-ratchet: 321 route tests mocking @/lib/db wholesale, 138
stubbing @/lib/api-handler, 6014 `as never` casts in 570 test files, and
400 test files that read a source file by path (reads through readSource
are not counted).
- version-stamp-ratchet: 10634 comment lines in 2985 files that open with a
release version; none may be added.
Proof each can fail: a planted 953-line file with a 253-line function failed
both size ratchets, and one line appended to app/page-client.tsx failed the
file ratchet (2326 → 2327); a planted route test with vi.mock("@/lib/db")
and one `as never` failed both of those ratchets; a planted comment
"// v1.45.0 — probe stamp" failed the stamp ratchet. Raising one as-never
baseline entry by 5 failed with the write-back message, and RATCHET_WRITE=1
restored the file byte for byte. All probes were removed again.
A test that reads a source file by path and asserts an absence
(`expect(source).not.toMatch(…)`) stays green after a split: the code it
watched moved next door and the old path no longer holds it. A missing file
throws; a file that kept its name but lost its contents does not.
- `readSource(path, anchors)` in helpers/source-files.ts reads one file and
requires every anchor (a string or pattern the watched code carries) to
match at least once; it throws on a missing file, a lost anchor, or a read
without an anchor.
- `readSourceFolder(dir, { floor })` is the folder-scan form for a subject
that was split into several files.
- The twelve reads that were asserted only negatively now go through
`readSource` with an anchor: the batch route, the integration-sync
registrar, the dashboard client, the mood insights client, the sleep
overview and sleep page, the sidebar, the baseline form, the account
section (two guards), api-handler's requireAuth and the Google Health sync.
- The two schema parsers the wipe and enum guards share now refuse a parse
of the real schema that finds fewer than 50 models (and 10 enums), so a
moved file or a drifted block regex cannot leave those guards comparing
an allowlist against nothing. Synthetic schemas a test writes stay exempt.
Proof: renaming `setSaving` in the baseline form made the converted
confirm-failure guard throw "the file no longer contains setSaving"; the
helper's own test plants a missing file, a lost string anchor, a lost regex
anchor, an empty anchor list and an under-floor folder, each of which throws.
Add the in-repo rule `healthlog/import-boundaries`:
- a file under src/lib may not import src/components or src/app (alias,
relative, re-export and dynamic forms, type-only included);
- a "use client" file may not make a value import of the database client,
the crypto module, the Prisma client entry, the job queue, pg, pg-boss or
next/headers. Type-only imports and the Prisma enums entry stay legal.
The four lib files that still import upward are listed in the rule's allow
map with a reason each; a listed file that stops importing upward is
reported, so the map can only shrink.
Five of the nine upward imports are gone by moving pure modules to their
layer, without behaviour change:
- supply-runway estimate → src/lib/medications/supply-runway.ts
(read by the low-stock job and the medication list read);
- weekday BYDAY tokens → src/lib/medications/scheduling/weekday-tokens.ts
(read by the medication extraction prompt);
- settings section slugs → src/lib/settings/section-slugs.ts
(read by the record-settings classification);
- the command palette index → src/components/command-palette/ (it is a
client index built from component registries).
Proof the rule can fail: an import of @/components/ui/card appended to
src/lib/medications/supply-runway.ts reported libUpward, and a planted
"use client" file importing @/lib/db reported clientServerOnly; both were
removed again.
description:Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
description:Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
title:"[Translation] "
labels:["i18n","translation-feedback"]
body:
- type:markdown
attributes:
value:|
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name:ESLint
run:pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name:Prettier format check
run:pnpm format:check
- name:TypeScript strict check
run:pnpm typecheck
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name:OpenAPI spec drift check
run:|
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# The unit suite in three slices of about equal duration. `--shard` goes
# through the duration sequencer in `vitest.config.mts`, which deals files
# out by their measured time (`tests/durations/unit.json`) instead of by
# count. Each slice uploads its JSON report, which is what
# `scripts/test-durations.mjs` regenerates the timing file from.
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
# That used to be one job running everything in turn; it is now this one,
# which answers for the static checks and every unit slice, so a half-green
# set cannot satisfy the gate.
quality:
name:Lint, Typecheck & Test
runs-on:ubuntu-24.04
needs:[static, unit]
if:always() && github.event_name != 'schedule'
timeout-minutes:5
steps:
- name:The static checks and every unit slice finished green
run:|
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -134,16 +134,45 @@ To suggest a fix or improvement:
3. Edit `messages/<locale>.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR.
4. Open a PR titled `i18n(<locale>): <area>`. Describe what was unclear, awkward, or wrong in the previous wording.
### Translating in the browser
Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/<locale>.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English.
1. Open the link and sign in with GitHub.
2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork.
3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`.
Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless.
What a reviewer checks on a translation pull request:
- Only `messages/*.json` changed, and only the strings the description names.
- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise.
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
- No questionnaire text. The bundles hold the app's own copy around the questionnaires (titles, notes, buttons), never an item, an answer option or a stem; see the next section.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Questionnaire wording
The PHQ-9, GAD-7, WHO-5 and SCI are not translated through the bundles, and Fink never shows them. Their text lives in code, one file per instrument and language under `src/lib/mental-health/wording/<instrument>/<locale>.ts`, because changing a word in a validated questionnaire changes what it measures. Each file declares what it is:
-`validated: true` with a `source`: a published validated translation, reproduced word for word (stem, items, answer options and, for the PHQ-9, the follow-up question, in the form of address the source uses). The source names who made it, the document it was read from with its link, and the terms that allow reproducing it in software. The file's header comment names anything that differs from the printed form, such as a corrected misprint.
-`validated: false`: no validated version exists that may be reproduced, so the file carries a translation written for HealthLog, faithful to the English and on the same scale. The app shows a note beside it that the result is orientation, offers the English original in one tap, and records on the result that it was answered in an unvalidated translation. The header comment names any validated version that was found and why it is not used (paywalled, terms that allow private study only).
To propose a validated version for a language that has none, or a better source for one that does, open an issue or a pull request with the publication, a link to the PDF it can be read from, and its terms of use. A pull request replaces the language's file: the text copied exactly from the PDF, `validated: true`, and the `source` filled in. The tests in `src/lib/mental-health/__tests__/` check that every instrument has text in every language, that every validated entry names its source, and that each answer is bound to the score the instrument gives it; the spot checks in `instrument-wording.test.ts` are the place to pin a line that is easy to transcribe wrongly. A correction to HealthLog's own translation of an unvalidated entry is welcome too; it stays `validated: false`.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
2. Copy `messages/en.json` to `messages/<locale>.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget.
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`).
5.The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR.
6. Run `pnpm test -- i18n-integrity` locally before submitting.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR.
5.Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
@@ -156,7 +185,7 @@ If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
### Long-term
When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
@@ -50,13 +50,13 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. It is on by default, and nothing leaves the host until someone sets that location.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -85,11 +85,11 @@ Verify the running version:
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
```
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
## Self-hosting
@@ -105,17 +105,18 @@ The App Store listing carries a small price, which exists to cover Apple's yearl
## Tech stack
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
"$comment":"Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
@@ -630,9 +630,11 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. A live check on 2026-10-24 found the account's own list (`GET /backend-api/codex/models?client_version=…`) offering `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna` and the `gpt-5.6` line, all with `input_modalities: ["text", "image"]`. From a HealthLog container with its stored sign-in, `gpt-6.1-sol`, `gpt-6-sol` and `gpt-6-luna` each answered text and read a lab-report image correctly. `gpt-6.1-sol` refuses `reasoning.effort: "none"` and names `low` as its lowest effort, which the client learns and steps up to on the same slug; the other two accept `none`
- `gpt-5.5` — the default from 2026-06 until 2026-10-24, kept as the last chain rung while plans differ
Retired for ChatGPT sign-in (OpenAI's Codex models page): `gpt-5.2` and `gpt-5.3-codex` on 2026-05-26, `gpt-5.4` and `gpt-5.4-mini` on 2026-08-31, `gpt-5.5` on 2026-10-14 at 19:00 UTC. The backend answers each of them with 400 `model_not_found`, "The '<slug>' model is not supported when using Codex with a ChatGPT account.", which the client reads as a reason to try the next slug.
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -642,19 +644,25 @@ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-c
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
```ts
const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.5", // current default across paid tiers as of 2026-06-02
"gpt-5.3-codex", // late rung — still accepted intermittently
"gpt-5.2", // legacy floor
"gpt-5.6-terra", // verified against the backend 2026-10-24
"gpt-6-sol", // terra's upgrade in the upstream catalog
"gpt-6.1-sol", // the catalog default, on every plan
"gpt-6-luna", // lighter gpt-6 sibling
"gpt-5.6-luna", // lighter sibling on terra's line
"gpt-5.6-sol", // heavier sibling on terra's line
"gpt-5.5", // previous default, kept as the last rung while plans differ
];
```
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
Every default rung must be a model family the reasoning, sampling and vision gates recognise (`src/lib/ai/openai-model-family.ts`); `codex-slug-fallback.test.ts` holds the chain to that.
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
Two tools measure the server's read and write paths. Use them before and after any change to a hot path, and put both numbers in the PR.
- **`scripts/bench/run.ts`** measures latency (p50, p95, max) and SQL statements per request against a seeded account of about 1.26 million measurement rows, through the production build. It is a local tool, not a CI gate: latency depends on the machine.
- **`tests/integration/query-budget-hot-routes.test.ts`** holds a statement budget per hot route on a small fixture. Statement counts are exact on a fixed fixture, so this one is a gate: a change that adds a query per row, or a second read of something already loaded, fails it.
## The large-account benchmark
### 1. A throwaway Postgres with pg_stat_statements
Never point the seed or the benchmark at a database that holds real data. They write under a synthetic user, and the seed's `--reset` deletes that user with everything it owns.
```sh
docker run -d --name hl-bench-db --shm-size=256m \
Any throwaway key values work, as long as the seed, the benchmark and the server see the same ones.
The seed writes ten years for one user: Apple Health dense rows (heart rate about every ten minutes, HRV, SpO2, respiratory rate, gait, audio), Withings blood pressure, pulse and scale, a weekly manual weight, three years of Oura resting heart rate and RMSSD, 90 days of Nightscout CGM, `stats:` and per-sample cumulative rows, nightly sleep stages, about 3,000 workouts, three years of mood and of three medications with their intake events. It then folds the rollup tier the way the boot backfill does and runs `ANALYZE`. It takes about 80 seconds on the Mac mini.
The seed is deterministic for a given `--anchor`: values and timestamps are functions of the row index and that day. Without `--anchor` it anchors on today, which is what you want when the benchmark should see recent data.
The script boots the build once with every role, so the worker creates its queues as it does in production, drops whatever that boot scheduled, and then runs the measurement against a web-only process (`HEALTHLOG_PROCESS_TYPE=web`) under the production heap ceiling (`--max-old-space-size=792`, the value Node derives in the 1.5 GB production container). Without the worker, its polling does not land in every statement count. It prints the server's idle statement rate so the remaining background is visible.
For each route it sends three warm-up requests, then twenty measured ones in each of two modes, one request at a time:
- **cold**: before every request a one-row ingest marked as a manual sync evicts the user's caches, so the request rebuilds what it serves. This is a reader's first look after a sync.
- **warm**: back to back, what a cache hit costs.
The batch ingest posts 5,000 fresh heart-rate rows as ten requests of 500, the route's per-request limit, and removes them again at the end.
Statements are the change in `sum(calls)` of `pg_stat_statements` over the database, read before and after each request, once no statement is in flight and the counter holds still. Fire-and-forget writes a request starts are therefore counted with it.
Options: `--iterations`, `--warmup`, `--user`, `--heap-mb`, `--port`, and `--base-url` to measure a server you started yourself.
The output is `<label>.json` (every number, plus the commit, Node and Postgres versions and the row count) and `<label>.md` (the table). Commit the pair when a change is meant to move a number, so the PR shows before and after.
### Reading the numbers
Run on a quiet machine; a parallel build or test run moves latency by tens of percent and leaves the statement counts alone. Compare statement counts across machines, latency only across runs on the same machine. A local number is only valid under the production heap ceiling, which the script sets.
## Boot and idle-memory profile
`scripts/bench/boot-profile.ts` profiles a production start the way production runs it: `next start` on the build, every role (web and worker), the production heap ceiling (`--max-old-space-size=792`, the heap Node derives in the 1.5 GB container), against the seeded benchmark database.
- the time to the first answer from `/api/health` and to the worker's `startup` line;
- every `event_loop_stall` the process logs, with the uptime, startup phase, boot step and work in flight it names;
- every `runtime.heap.sampled` line, and every full collection from `--trace-gc` (used heap before, live heap after);
- a CPU profile of the start: the main thread's busy stretches over 50 ms without an idle sample, each with its self time per source module, resolved through the chunk source maps (`profile-analyze.ts`);
- one heap snapshot after the idle wait, taken with `--heapsnapshot-signal` (after a full collection, so it is the live heap), summarised by node type, constructor, largest strings and the largest retained subtrees with a retaining path, and the number of parsed locale catalogs.
The worker runs for the whole idle wait, and its boot passes fold and consolidate the seeded account (old dense readings into their `stats:` rows), roughly halving its row count. Reseed before the next request benchmark, or its numbers describe a different account.
The raw `.cpuprofile` and `.heapsnapshot` go to a temporary directory and are deleted after the summary unless `--keep` is given. Only run it against the benchmark database: a snapshot holds whatever the process decrypted. `--no-snapshot` and `--no-cpu-prof` skip either part. The profiler itself costs memory, so read RSS from this run as an upper bound.
### What the profile found (v1.44 runtime, October 2026)
On the seeded account, idle, with the worker running and no requests (`results/v1.45.0-before-boot.md`):
- **The boot stall is module evaluation.** The longest busy stretch of the main thread was 753 ms, 0.7 s into the start: the instrumentation hook importing the worker, whose five registrars pulled practically all of `src/lib` into one synchronous evaluation (`register-maintenance.ts` alone 280 ms inclusive), followed by the first `/api/health` request compiling its route. The event-loop monitor started after the key and time-zone checks, and a 250 ms tick measures a block by how late the next tick fires, so it reported less than the block or nothing. The beta's 718 ms and 671 ms stalls are this block on a slower machine.
- **The live heap is about 165 MB, not 400.** Full collections brought the heap back to 162 to 172 MB every 15 to 20 s while idle; the used heap between them reached 190 MB. What a heap line shows above the live heap is garbage awaiting the next full collection, plus, on a server with users, the server caches (13 caches of up to 2,000 entries with stale windows of up to an hour). The beta's 400 MB plateau is therefore not a baseline the process cannot shed; it is what the heap grows to under a large limit before a collection, plus caches.
- **What the live heap holds.** Of 172 MB: the query compiler about 50 MB (the engine object 34 MB, its WebAssembly base64 source twice at 4.4 MB, its chunk source twice at 5.6 MB); the locale catalogs about 33 MB (32 parsed catalog objects, eight locales in each of four server bundles, 14 MB, plus the eight-locale chunk source twice at 9.7 MB); module source strings over 50 KB about 44 MB in total (the catalog chunks included); compiled code 19 MB.
### What changed (v1.45.0)
- The event-loop monitor starts first in `register()`, and a stall line names the boot step, startup phase and work in flight (`src/lib/boot/boot-steps.ts`, one `boot.steps` line per boot with each step's duration).
- The worker's registrars are imported one at a time after `boss.start()`, with a turn of the event loop between them.
- The server reads a locale catalog from `messages/<locale>.json` on first use and keeps one parsed copy per process (`src/lib/i18n/server-catalogs.ts`); the seven matchers that index every locale build their index on first use.
Result on the same machine (`results/v1.45.0-boot.md`): the worker graph loads in stretches of at most 246 ms instead of one of 753 ms (the longest application stretch left is the query compiler's start, 336 ms, unchanged); the live heap after five idle minutes went from 162 to 172 MB to 142 to 153 MB; two parsed catalogs (1.6 MB) instead of 32 (14.1 MB), and no catalog chunk sources.
## Statement budgets in the integration suite
`tests/integration/query-budget-hot-routes.test.ts` seeds a small account (four months of the main types with overlapping sources, sleep, glucose, a medication, workouts and mood), and counts the statements of one request per hot route on a cache miss: `/api/auth/me`, the dashboard summary and snapshot, the pulse series over 30, 365 and 3650 days, the day view, insights health status, correlations, and a 500-row batch ingest.
The counter (`tests/integration/query-counter.ts`) wraps `pg`'s `Client.prototype.query` on the copy of `pg` the Prisma adapter loads, and settles background tasks before it closes the window.
Each budget is the measured count. When a change lowers one, lower the budget in the same change. When a change has to raise one, raise it with the reason in the PR. A failure prints every statement the request sent, grouped by text, so an added loop shows up as one statement with a count next to it.
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
| A journey through the real UI | `e2e/` | `pnpm e2e` |
The rule behind the table: put a behavioural test at the level where the
risky collaborator is real. For a route that collaborator is Postgres and the
API kit (`apiHandler`, `requireAuth`, the envelope), so a route test runs
against both. A unit test that replaces Prisma with a mock and then asserts on
the calls it received checks the query text, not the behaviour; it passes when
the query is wrong and fails when it is rewritten correctly. Do not write new
ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
`@/lib/db` wholesale or stub `apiHandler`, and `as never` casts in tests, and
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## Critical journeys
A few e2e journeys carry `@critical` in their test titles: signing in with a
password and a passkey and signing out, a reading reaching the dashboard, a
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
German and Brazilian Portuguese, a setting that persists, a backup coming
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
They run against the real server and a seeded Postgres. A spec file that
carries the tag may not intercept anything under `/api`, directly or through
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
refuses it and keeps a floor on the number of tagged tests. Stubbing an
unreachable third-party host that is not the journey's subject is allowed,
with the reason next to the call.
A journey that writes gets an account nobody else writes. The accounts in
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
and per Chromium project, so a journey runs at both widths without the two
projects meeting. Reset what a previous run left with `resetCriticalAccount`
in a `beforeEach`, and read results through the app's own routes rather than
through the database.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
`factories.ts` writes typed fixtures through the application's Prisma client
and returns the stored rows: `makeUser`, `makeAdmin`, `makeSession`,
@@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm"
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
@@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
@@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
<descid="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 16 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<descid="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 18 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
| `source`| no | string | Free-text origin label, not stored. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes`| no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `valueMin` | no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above `valueMax`. |
| `valueMax` | no | number | Upper bound of a range reading. |
| `sleepStage` | no | enum | A sleep row's stage: `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`. |
| `rhythmClassification` | no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
| `deviceType` | no | string | The writer of the reading (watch, phone), 1–32 chars. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| `structuredTags` | no | array | Ticked tags by key; replace the entry's tag links. |
| `context` | no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week |
| Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it |
| Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted |
| Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it |
| Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it |
## Wire format (binary)
The weekly, pre-wipe and off-host copies carry the whole record, document
files and ECG traces included, with the database's ciphertext inside them as
stored. An uploaded copy holds whatever its file held. The personal
export is portable: readable values, and documents as metadata only. It is
meant for the person to keep and read. A restore refuses it when the file
lists any document, because it carries no document content to put back.
```
magic = "HLBK" (4 bytes, ASCII)
version = 0x04 (1 byte)
keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key))
iv = 12 random bytes (AES-GCM nonce)
ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY,
associated data = "healthlog/offhost-backup/v4|<object key>")
authTag = 16 bytes (AES-GCM tag, trailing)
```
Two kinds of data are not in any account copy: workout GPS routes and sample
series. Credentials, devices, share links and account grants are left out on
purpose (see "What a restore does not touch").
Version `0x04` (from v1.39.3) adds two things. The key id says which off-host
key an object needs, so a reader holding a retired key as well finds the right
one. The associated data is the object's own key in the bucket,
`YYYY-MM-DD/user-<id>.json.enc`: an object copied to another account's name or
another date does not decrypt, where before it opened as if it belonged there.
The restore script passes the key it downloaded, so nothing changes for an
operator.
The JSON import in Settings → Export & import is not a restore. It adds
measurements and mood entries, up to 16 MB and 10,000 entries per list.
Four versions exist in the wild and all four restore. `0x01` encrypted the
JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the
ciphertext, which is what made them impossible to write a piece at a time —
GCM only produces the tag once the last block is in, so a leading tag means the
whole object has to exist before its first byte can be sent. `0x03` moves the tag to the end and changes nothing else: it still
covers every ciphertext byte, and the reader still verifies it before returning
a single byte of plaintext. Objects already in your bucket stay readable, and
the restore script needs no flag to tell them apart.
## The deletion grace window
## Required env vars
`ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/<host of APP_URL>/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
A dump is `pg_dump --format=custom` of the whole database: every account,
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
queue and the instance settings. It is two files:
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
app version and build, the newest applied migration, the Postgres version,
the archive's size and SHA-256, the row count of every table, and the
fingerprints (never the values) of the keys the database needs.
A dump counts as good only when both files exist under their final names. The
archive is written as `.partial`, read back with `pg_restore --list`, and
renamed after its manifest; a run that fails leaves nothing that looks good.
The admin backups page shows the newest good dump, where it is, and the
newest failed run. The start log says on every boot where dumps go, or that
none can be kept.
Local dumps are not encrypted. They sit beside the database volume, in the
same trust zone: whoever can read one volume can read the other. The columns
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
Keep a copy of the dumps off the host; the off-host bucket does that for you,
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
per-account copies use.
A dump holds every account that existed when it was taken, including one
deleted since. Deleted accounts leave the dumps as they age out: after
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
bucket. A pre-migration dump goes once it is older than every nightly dump
kept, except the one taken before the latest update: that one is the way back
from it and stays until the next update writes a newer one. On an install
that rarely updates, delete it by hand once you no longer want the way back,
or state that exception with the retention period. Give that rule a prefix that covers `database/` too; the admin
backups page reads the bucket's rules and says when none does, and the
recovery readiness card calls it attention and tells the admins once. This is the
retention period to state where you tell people that deleting their account
deletes their data.
A dump is kept on the server only when `DB_BACKUP_DIR` survives the container
being replaced: a mounted volume or bind mount, or any writable directory when
HealthLog does not run in a container. The image creates the default
directory, so an install that runs it without the `backups` volume can write
there, but the directory sits in the container's own layer and the next update
throws it away. HealthLog reads the container's mount table to tell the two
apart and treats such a directory, or one on a tmpfs, like one it cannot
write: the nightly dump is uploaded off-host and nothing is kept on the
server, and no dump is taken before migrations; with no bucket either, no
dump is taken at all. The start log, the admin backups page and the recovery
readiness card say so.
The per-account copies (the weekly in-database copy, the nightly per-account
objects) roll one account back on the same server. They cannot rebuild a
server; see [`backup-restore.md`](backup-restore.md).
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
The restore compares the keys in its environment with the fingerprints in the
manifest before it writes anything, and names each key that is missing or
different.
## Restore onto a new server
1. Install Docker and take the `docker-compose.yml` of the version that wrote
the dump or a newer one. The manifest's `appVersion` names it; to run that
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
2. Write `.env` with the keys from your records (see above) and a new
`POSTGRES_PASSWORD`.
3. Do not run `docker compose up` yet. The app would create an empty schema,
and the restore only writes into a database with no tables. Start only the
database:
```bash
docker compose up -d db
```
4. Restore. When the host is lost, its local dumps went with it, and the
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
`.env` as on the old server, then
```bash
docker compose run --rm db-restore list --from-bucket
docker compose run --rm db-restore latest --from-bucket
```
`latest --from-bucket` downloads the newest dump of this instance whose
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
and restores. Name a dump instead of `latest` to take an older one.
Each instance uploads under its own folder, `database/<host of APP_URL>/`,
so set `APP_URL` as on the old server. A bucket shared with another
instance holds its dumps too: `latest` refuses rather than take one of
those, and `list --from-bucket` marks them. To restore one on purpose
(after moving to a new domain, the old one), add
`--instance=<old host>`, or name the dump by its full key,
`database/<host>/healthlog-….dump.enc`.
The opened copy is a whole database, decrypted, in a scratch directory
beside the backups. The restore removes it when it ends, on Ctrl-C and
`docker compose stop` too. One left by a restore that was killed outright
is removed when the next restore starts, and by the nightly retention.
With the files in hand instead (copied off the host, or downloaded from
the bucket with your own tools: both `<name>.dump.enc` and
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
them in a directory such as `./restore`, make them readable for the
container (`chmod a+r restore/*`) and name the dump:
```bash
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
/restore/healthlog-20261024T233000Z-daily.dump
```
Either way it checks the archive against the manifest's SHA-256, the keys
against the manifest's fingerprints, and that the database is empty. Then
it restores in one transaction, gathers planner statistics and counts
every table against the manifest. A count that differs empties the
database again and fails. Wait for the line that starts with `Restored`.
5. Start the stack and check it:
```bash
docker compose up -d
curl -s http://localhost:3000/api/health
```
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
`--ignore-key-mismatch` restores although the active encryption key differs.
Use it only when that key is lost for good: everything encrypted stays
unreadable.
## Going back to before a migration
The entrypoint dumps the database before it applies migrations. To return to
that state (the previous image, the data as it was before the update):
```bash
docker compose stop app
# Keep the current state too, in case it is wanted later.
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Up to two pre-migration dumps are kept besides, none older than the oldest nightly dump except the newest. |
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: 2 database migrations pending. Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); a migration is not undone by going back to the previous image.
HealthLog: taking a database dump before 2 pending migration(s) into /var/lib/healthlog/backups...
"description":"Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
"description":"Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.",
"note":"Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
"categories":["Tools","Other"],
"platform":"linux",
@@ -33,7 +33,7 @@
{
"name":"HEALTHLOG_PLATFORM",
"label":"Platform hint",
"description":"Opens the admin's encryption key backup step on the Portainer instructions.",
"description":"Opens the admin's recovery kit on the Portainer instructions.",
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
| No proxy, or `tailscale serve` only | leave it unset |
At boot the app logs one `[proxy-trust]` line naming the rule in force:
which connections it reads forwarding headers from and how many hops deep.
A value that is not a CIDR is logged as an error, and until it is fixed no
connection may report a client address.
**A proxy that connects from a public address.** Without
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
it resolves to the proxy's own address, so all your users share one
rate-limit bucket and one person mistyping a password can hold sign-in back
for everyone. Releases before v1.46 believed any peer, so this shows up after
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
on a private network with HealthLog) and restart.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
the `CF-Connecting-IP` header, before any of the checks above: from every
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
Cloudflare sets that header on every request through its edge, but any
client can send it too. So the flag is safe only when nothing but
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
port, or a firewall that admits only
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
better still). If the origin also answers on its own public address, a
caller who finds it chooses the address every per-IP rate limit, the audit
log and the sign-in locations record, and changes it on every request.
Without Cloudflare in front, leave the flag unset.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
`X-Real-IP`. With `0` neither header is believed, because without a proxy
in front both come from the caller. With `2` or more, the hop count
in front both come from the caller; the address the connection came from
is used instead. With `2` or more, the hop count
describes an `X-Forwarded-For` chain, so have your proxies send one.
If requests arrive with a forwarding header the setting does not let
@@ -242,6 +327,15 @@ The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
env var down to the actual chain length and restart.
If every request logs the same client address, the proxy's own, the proxy
is not one HealthLog believes: the `[getClientIp]` warning about a
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
<ConfigName="WebUI Port"Target="3000"Default="3000"Mode="tcp"Description="HTTP port HealthLog listens on."Type="Port"Display="always"Required="true"Mask="false">3000</Config>
<ConfigName="DATABASE_URL"Target="DATABASE_URL"Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public"Mode=""Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name."Type="Variable"Display="always"Required="true"Mask="true"/>
<ConfigName="DATABASE_URL"Target="DATABASE_URL"Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public"Mode=""Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name."Type="Variable"Display="always"Required="true"Mask="true"/>
<ConfigName="ENCRYPTION_KEY"Target="ENCRYPTION_KEY"Default=""Mode=""Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable."Type="Variable"Display="always"Required="true"Mask="true"/>
<ConfigName="API_TOKEN_HMAC_KEY"Target="API_TOKEN_HMAC_KEY"Default=""Mode=""Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)."Type="Variable"Display="always"Required="true"Mask="true"/>
<ConfigName="SESSION_COOKIE_SECURE"Target="SESSION_COOKIE_SECURE"Default="false"Mode=""Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy."Type="Variable"Display="always"Required="true"Mask="false">false</Config>
<ConfigName="HEALTHLOG_PLATFORM"Target="HEALTHLOG_PLATFORM"Default="unraid"Mode=""Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions."Type="Variable"Display="advanced"Required="false"Mask="false">unraid</Config>
<ConfigName="HEALTHLOG_PLATFORM"Target="HEALTHLOG_PLATFORM"Default="unraid"Mode=""Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions."Type="Variable"Display="advanced"Required="false"Mask="false">unraid</Config>
<ConfigName="ENCRYPTION_KEY_CHECK"Target="ENCRYPTION_KEY_CHECK"Default="enforce"Mode=""Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check."Type="Variable"Display="advanced"Required="false"Mask="false">enforce</Config>
<ConfigName="NEXT_PUBLIC_APP_URL"Target="NEXT_PUBLIC_APP_URL"Default="http://tower:3000"Mode=""Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy."Type="Variable"Display="always"Required="true"Mask="false"/>
@@ -63,4 +63,6 @@
<ConfigName="ENCRYPTION_KEYS"Target="ENCRYPTION_KEYS"Default=""Mode=""Description="Optional. Key-rotation map, e.g. {"v1":"<hex>","v2":"<hex>"}. Leave empty unless rotating; use ENCRYPTION_KEY instead."Type="Variable"Display="advanced"Required="false"Mask="true"/>
<ConfigName="ENCRYPTION_ACTIVE_KEY_ID"Target="ENCRYPTION_ACTIVE_KEY_ID"Default=""Mode=""Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY."Type="Variable"Display="advanced"Required="false"Mask="false"/>
<ConfigName="Database dumps"Target="/var/lib/healthlog/backups"Default="/mnt/user/appdata/healthlog/backups"Mode="rw"Description="Where HealthLog keeps its nightly database dump and the one it takes before each update that brings migrations. Without this path the dumps would sit inside the container, which an update replaces, so none is kept. Must be writable by uid 1001 (chown 1001:1001 the folder once)."Type="Path"Display="always"Required="true"Mask="false">/mnt/user/appdata/healthlog/backups</Config>
"Keep src/lib below components and app, and server-only modules out of client files.",
},
schema:[
{
type:"object",
properties:{
allow:{
type:"object",
additionalProperties:{type:"string",minLength:1},
},
},
additionalProperties:false,
},
],
messages:{
libUpward:
"src/lib must not import {{target}}. Move the shared piece into src/lib (or the importer into the component folder); an exception goes into the rule's `allow` map in eslint.config.mjs with its reason.",
staleAllow:
"This file is in the import-boundaries `allow` map but no longer imports from src/components or src/app. Remove the entry so the list keeps shrinking.",
clientServerOnly:
'A "use client" file must not import {{spec}}: it is server-only and would ship to the browser. Import a type with `import type`, or move the call behind an API route or a server component.',
"failed":"Das Etikett konnte nicht gelesen werden. Versuch ein schärferes Foto."
}
}
},
"aiRuns":{
"readerBusy":"Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
}
},
"doctorReport":{
@@ -435,7 +438,8 @@
"autocorrelation":"starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein",
"window":"ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen",
"windowLine":"Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen."
}
},
"screenerUnvalidated":"{label}: {count} von {total} Ergebnissen wurden in einer nicht wissenschaftlich validierten Übersetzung beantwortet; sie dienen der Orientierung."
},
"errorBoundary":{
"title":"Da ist etwas schiefgelaufen",
@@ -628,7 +632,9 @@
"title":"Einladung ungültig",
"description":"Dieser Einladungslink ist unvollständig oder falsch kopiert. Bitte die Person, die dich eingeladen hat, um einen neuen Link.",
"toLogin":"Zur Anmeldung"
}
},
"deletionPending":"Dieses Konto wird am {date} gelöscht und ist bis dahin gesperrt. Ein Admin dieses Servers kann die Löschung vor diesem Datum rückgängig machen.",
"deletionPendingNoDate":"Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist."
"body":"Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.",
"action":"Zum Schritt"
},
"quickAddSymptom":"Symptom erfassen",
"todayCardVisible":"Obere Karte anzeigen"
"todayCardVisible":"Obere Karte anzeigen",
"recoveryKitBanner":{
"title":"Wiederherstellungs-Kit sichern",
"body":"Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
"action":"Kit öffnen"
},
"greetingOnly":"{greeting}.",
"welcomeNew":"Willkommen.",
"welcomeNewWithName":"Willkommen, {name}."
},
"measurements":{
"title":"Messungen",
@@ -3019,6 +3028,7 @@
"refusalInjection":"In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.",
"errorBudget":"Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.",
"errorProvider":"Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"errorReasoningOnly":"Das Modell hat nur nachgedacht und keine Antwort geschrieben. Versuche es noch einmal oder wähle in den KI-Einstellungen ein anderes Modell.",
"errorDemo":"In der Demo antwortet der Coach nicht.",
"errorNoProvider":"Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
"errorNoProviderAction":"KI-Anbieter verbinden",
@@ -3231,11 +3241,11 @@
"last30days":"{days} Tage",
"last90days":"{days} Tage",
"lastYear":"{months} Monate",
"allTime":"Alles"
"allTime":"{months} Monate im Detail, Labor länger"
},
"detailDays":"Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.",
"detailMonths":"Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.",
"detailAll":"Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.",
"detailAll":"Ohne Begrenzung liest der Coach {dailyMonths} Monate tagesgenau und ältere Werte als Monatswerte bis {years} Jahre zurück. Von Laborwerten liest er den jüngsten Wert der zuletzt gemessenen Marker und, wenn du nach einem Marker fragst, dessen {labRecent} vorherige Werte; ältere Werte sind pro Jahr zusammengefasst, bis {labYears} Jahre zurück, noch frühere Jahre werden nur gezählt.",
"note":"Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen."
},
"showConversations":"Unterhaltungen anzeigen",
@@ -3599,7 +3609,7 @@
"headphoneAudioBody":"Die Kopfhörerlautstärke erfasst in Dezibel, wie laut du über Ohrhörer oder Kopfhörer hörst. Die Lautstärke niedrig zu halten und Pausen einzulegen schützt dein Gehör langfristig.",
"audioEventsBody":"Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightBody":"Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung (CDC 2024), und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseBody":"Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"bloodGlucoseBody":"Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein. Als allgemeine Orientierung für Erwachsene ohne Diabetes gilt ein Nüchternwert unter {prediabetesFrom} {unit} als normal, {prediabetesFrom}–{prediabetesTo} als Prädiabetes, und ab {diabetesFrom} liegt er im Diabetesbereich (ADA 2024). Ein einzelner hoher Wert ist keine Diagnose; eine Ärztin oder ein Arzt bestätigt sie mit einer Wiederholungsmessung, und wer Diabetes behandelt, arbeitet mit anderen, ärztlich festgelegten Zielwerten.",
"skinTemperatureBody":"Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.",
"cardioFitnessBody":"VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.",
"cardioRecoveryBody":"Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt, ein Zeichen guter Fitness.",
"passphraseHelp":"Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning":"Es gibt keine Wiederherstellung. Geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"detail":"Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten; das Manifest der Datei nennt, was drinsteht.",
"detail":"Eine portable Kopie deiner Akte zum Aufbewahren und Lesen: Werte als einfaches JSON, Dokumente nur mit ihren Metadaten. Sie ist nicht die wöchentliche Kontokopie, mit der dieser Server ein Konto zurücksetzt, und sie kann kein Konto auf einem anderen Server neu anlegen. Einige Datenarten fehlen noch; das Manifest der Datei nennt, was enthalten ist.",
"contents":"Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei."
}
},
@@ -5777,7 +5787,7 @@
"cycleDaysFailed":"{count} Zyklustage konnten nicht geschrieben werden: {reason}"
},
"json":{
"title":"JSON-Datei",
"title":"Messwerte und Stimmung (JSON)",
"description":"JSON einfügen oder eine .json-Datei mit Messwerten und Stimmungseinträgen hochladen.",
"pasteLabel":"JSON einfügen",
"fileInputLabel":"Eine JSON-Datei zum Import auswählen",
@@ -5793,8 +5803,11 @@
"readFailed":"Diese Datei konnte nicht gelesen werden.",
"resultSummary":"{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.",
"resultSuccess":"{measurements} Messwerte und {moods} Stimmungseinträge importiert",
"droppedLinks":"Ohne diese Tags importiert, weil dieses Konto keine mit dem Namen hat: {keys}",
"keptLabels":"Dieses Konto hat Tags oder Gruppen mit diesen Schlüsseln schon unter eigenem Namen, die wurden behalten: {keys}",
"resultNothing":"Nichts importiert. Alle {skipped} Einträge wurden übersprungen.",
"resultEmpty":"Diese Datei enthielt weder Messwerte noch Stimmungseinträge."
"resultEmpty":"Diese Datei enthielt weder Messwerte noch Stimmungseinträge.",
"limitsHint":"Bis {mb} MB und bis zu {rows} Einträge je Liste. Das ergänzt deine Akte um Einträge; ein vollständiges Backup lässt sich so nicht wiederherstellen."
},
"csv":{
"title":"CSV-Datei",
@@ -5940,7 +5953,7 @@
}
}
},
"subtitle":"Exportiere deine Daten oder importiere aus einer Sicherung."
"subtitle":"Exportiere deine Daten oder importiere Messwerte und Einträge aus anderen Apps und Dateien."
"factorDescription":"Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
"verifyAndContinue":"Bestätigen und fortfahren"
"verifyAndContinue":"Bestätigen und fortfahren",
"oidcHint":"Du meldest dich bei deinem Identitätsanbieter noch einmal an und kommst direkt auf diese Seite zurück. Dann wiederholst du, was du gerade tun wolltest.",
"continueToProvider":"Weiter zur Anmeldung"
},
"passkeyName":"Name",
"noPasskeys":"Keine Passkeys registriert.",
@@ -6854,7 +6870,14 @@
"detail":"Das Token wird nur einmal angezeigt.",
"scopeNote":"Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.",
"createFailed":"Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
"namePlaceholder":"Name, z. B. Home Assistant"
"namePlaceholder":"Name, z. B. Home Assistant",
"appleHealth":{
"label":"Messwerte in Apple Health schreiben",
"createHint":"Die HealthLog-App auf deinem iPhone trägt die Werte dieses Tokens dann in Apple Health ein. Du kannst das später in der Token-Liste unten ändern.",
"toggleAria":"Werte von {name} in Apple Health schreiben",
"updateFailed":"Die Apple-Health-Einstellung konnte nicht geändert werden. Bitte versuche es erneut.",
"unattributed":"Werte, die vor dieser Einstellung gesendet wurden, lassen sich keinem Token zuordnen und bleiben deshalb außerhalb von Apple Health."
}
},
"workoutsToken":{
"title":"Training-Tokens",
@@ -6911,10 +6934,12 @@
"deleteAccountCardDescription":"Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions.",
"deleteAccountConfirmDescription":"Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet.",
"deleteAccountConfirmDescription":"Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"deleteAccountSuccess":"Konto wurde gelöscht. Du wirst abgemeldet.",
"deleteAccountFailed":"Konto konnte nicht gelöscht werden",
"unavailable":"Aufbewahrungsdetails sind derzeit nicht verfügbar."
"unavailable":"Aufbewahrungsdetails sind derzeit nicht verfügbar.",
"accountDeletion":"Löschen deines Kontos, eines verwalteten Profils oder aller Daten: abgeschlossen nach {days} Tagen",
"accountDeletionImmediate":"Löschen deines Kontos, eines verwalteten Profils oder aller Daten: sofort abgeschlossen"
},
"delete":{
"title":"Löschen oder zurücksetzen",
"description":"Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
"backupLag":"Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist.",
"openAdvanced":"Löschen oder zurücksetzen"
"openAdvanced":"Löschen oder zurücksetzen",
"immediateNotice":"Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"graceNotice":"Wenn du dein Konto löschst, wird es sofort gesperrt und nach {days} Tagen endgültig gelöscht, zusammen mit jeder Kopie, die dieser Server davon aufbewahrt, auch verschlüsselten Kopien außerhalb des Servers. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Beim Löschen aller Daten gilt dasselbe: Eine Kopie der Akte bleibt {days} Tage erhalten, sodass ein Admin sie wiederherstellen kann. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist."
},
"posture":{
"title":"Datenschutz-Einstellung",
@@ -7431,6 +7459,7 @@
"lastReceived":"Zuletzt empfangen {relative}",
"lastNewValue":"Letzter neuer Wert {relative}",
"lastBackground":"Zuletzt im Hintergrund {relative}",
"lastDuplicate":"Zuletzt ohne neue Werte erneut gesendet {relative}",
"never":"Noch nicht empfangen",
"lastBackfill":"Verlauf nachgeladen {relative}",
"lastEnteredHere":"Zuletzt hier eingetragen {relative}"
@@ -7494,7 +7523,9 @@
"description":"Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
"descriptionChange":"Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
"signInAgain":"Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
"failed":"Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
"failed":"Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal.",
"oidcDone":"Bestätigt. In den nächsten fünf Minuten kannst du weitermachen, ohne erneut gefragt zu werden.",
"oidcFailed":"Dein Identitätsanbieter konnte nicht bestätigen, dass du es bist. Versuche es noch einmal."
},
"passwordTitle":"Passwort",
"tokenNameLabel":"Token-Name",
@@ -7632,7 +7663,13 @@
"off":"Aus",
"change":"Unter Module ändern"
},
"timezoneUseDevice":"Zeitzone dieses Geräts übernehmen ({zone})"
"timezoneUseDevice":"Zeitzone dieses Geräts übernehmen ({zone})",
"dangerZoneConfirmDescriptionGrace":"Dieser Vorgang löscht alles in deiner Akte: Messwerte, Medikamente, Stimmungseinträge, Laborwerte, Zykluseinträge, Dokumente, KI-Gespräche und jede Integrationsverbindung, dazu alles daraus Abgeleitete und alles, was du früher gelöscht hast. Freigabe-Links, die du an eine Praxis gegeben hast, funktionieren sofort nicht mehr. Dein Konto bleibt bestehen, ebenso deine Anmeldedaten und Anzeigeeinstellungen wie Sprache, Zeitzone und Einheiten. Eine Kopie deiner Akte wird {days} Tage aufbewahrt, sodass ein Admin dieses Servers sie bis dahin wiederherstellen kann. Danach ist die Löschung endgültig.",
"deleteAccountCardDetailGrace":"Dein Konto wird sofort gesperrt und nach {days} Tagen endgültig gelöscht. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen.",
"deleteAccountConfirmDescriptionGrace":"Du wirst abgemeldet und dein Konto wird sofort gesperrt. Nach {days} Tagen wird es endgültig gelöscht, mit allen Gesundheitsdaten, Passkeys, Sessions und Audit-Einträgen. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"deleteAccountScheduled":"Dein Konto ist gesperrt und wird am {date} endgültig gelöscht. Du wirst abgemeldet.",
"dangerZoneSuccessGrace":"Deine Gesundheitsakte wurde gelöscht. Ein Admin dieses Servers kann sie bis zum {date} wiederherstellen.",
"dangerZoneRestoreLimits":"Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden."
},
"admin":{
"title":"Administration",
@@ -8068,7 +8105,24 @@
"cannotLogoutSelf":"Eigene Sitzungen können nicht beendet werden",
"emptyTitle":"Keine Nutzer in dieser Ansicht",
"emptyDescription":"Es gibt keine Nutzer, die zum aktuellen Filter passen.",
"emptyResetFilter":"Alle Nutzer anzeigen"
"emptyResetFilter":"Alle Nutzer anzeigen",
"deletionPending":"Löschung am {date}",
"deletionPendingNoDate":"Löschung vorgemerkt",
"undoDeletion":"Löschung rückgängig machen",
"undoDeletionTitle":"Löschung von {name} rückgängig machen?",
"undoDeletionBody":"{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionBodyGuardian":"Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionDone":"Löschung von {name} rückgängig gemacht",
"undoDeletionFailed":"Löschung konnte nicht rückgängig gemacht werden",
"undoDeletionBodyAdmin":"Ein Admin hat das Konto {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"deleteUser":"Konto löschen",
"deleteUserTitle":"Das Konto {name} löschen?",
"deleteUserBodyGrace":"{name} wird sofort überall abgemeldet und kann sich nicht mehr anmelden. Das Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du die Löschung hier rückgängig machen.",
"deleteUserBodyNow":"{name} und alles im Konto wird sofort gelöscht. Dieser Server hat keine Frist eingestellt, die Löschung lässt sich also nicht rückgängig machen.",
"deleteUserConfirm":"Konto löschen",
"deleteUserScheduled":"Das Konto {name} ist gesperrt und wird am {date} gelöscht",
"deleteUserDone":"Das Konto {name} wurde gelöscht",
"deleteUserFailed":"Das Konto konnte nicht gelöscht werden"
},
"invites":{
"title":"Einladungen",
@@ -8130,8 +8184,8 @@
},
"backups":{
"title":"Backups",
"subtitle":"Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.",
"description":"Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob. Download entschlüsselt den Snapshot für den anfragenden Admin; der Chiffretext bleibt in der Datenbank.",
"subtitle":"Kontokopien in der Datenbank dieses Servers, um ein Konto hier zurückzusetzen.",
"description":"Jedes Konto hat eine wöchentliche Kopie, die jeden Sonntag ersetzt wird, dazu hier hochgeladene Kopien und die Kopie, die vor „Alle Daten löschen“ angelegt wird. Eine Kopie setzt ein bestehendes Konto auf diesem Server zurück. Einen verlorenen Server kann sie nicht wiederherstellen: Dafür braucht es einen Datenbank-Dump und die Verschlüsselungsschlüssel. Die Größen zeigen die verschlüsselte Kopie. Beim Herunterladen wird sie für den anfragenden Admin entschlüsselt; der Geheimtext bleibt in der Datenbank.",
"runNow":"Jetzt sichern",
"runEnqueued":"Backup-Job eingeplant",
"runFailed":"Backup konnte nicht eingeplant werden",
@@ -8141,12 +8195,11 @@
"emptyDescription":"Backups laufen automatisch jeden Sonntag um 03:00 Uhr, oder erstelle jetzt einen ersten Snapshot.",
"colUser":"Nutzer",
"colType":"Typ",
"typeWeeklyAuto":"Wöchentlich (auto)",
"typeWeeklyAuto":"Wöchentliche Kontokopie",
"typeManual":"Manuell",
"colSize":"Größe",
"colCreatedAt":"Letzter Snapshot",
"colActions":"Aktionen",
"retentionLabel":"Aufbewahrung: {days} Tage",
"scheduleStaleTitle":"Die wöchentliche Sicherung läuft nicht mehr",
"scheduleStaleDescription":"Die neueste geplante Kopie ist {days} Tage alt. Ein funktionierender wöchentlicher Lauf lässt keine älter als {threshold} Tage werden.",
"scheduleFailedTitle":"Der letzte geplante Sicherungslauf ist fehlgeschlagen",
@@ -8164,7 +8217,7 @@
"downloadFailed":"Download fehlgeschlagen",
"downloadStarted":"Download gestartet",
"uploadTitle":"Backup hochladen",
"uploadDescription":"Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.",
"uploadDescription":"Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
"uploadButton":"Datei hochladen",
"uploadHelp":"HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
"restoreMissingTitle":"Wiederherstellung abgelehnt: Die Datei ist unvollständig",
"restoreMissingDescription":"Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.",
"restoreMissingRemedy":"Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.",
"restoreMissingRemedy":"Nutze eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers, oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest als ausgelassen angibt, ist etwas anderes und wird normal wiederhergestellt.",
"restoreActive":"Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.",
"restoreJobQueuedTitle":"Wiederherstellung von {username} wartet auf den Start",
"restoreJobQueuedNote":"Sie beginnt, sobald der Hintergrunddienst sie übernimmt.",
"restoreJobWaitingBackupNote":"Wartet auf das Backup, das gerade auf dem Server läuft. Eine Wiederherstellung läuft nie neben einem Backup und startet etwa eine Minute nach dessen Ende.",
"restoreJobWaitingBusyNote":"Wartet, bis eine große Aufgabe auf dem Server fertig ist, etwa ein Import oder ein nächtlicher Durchlauf. Sie startet etwa eine Minute danach.",
"restoreJobQueuedSince":"In der Warteschlange seit {when}.",
"restoreJobRunningTitle":"{username} wird wiederhergestellt",
"restoreJobRunningNote":"Das Konto zeigt seine bisherigen Daten, bis die Wiederherstellung fertig ist. Du kannst die Seite verlassen; die Wiederherstellung läuft auf dem Server weiter.",
"restoreJobResumed":"Nach einem Neustart des Servers erneut gestartet.",
@@ -8274,7 +8330,7 @@
"restoreFailureOwnerMismatch":"Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.",
"restoreFailureOwnerMissing":"Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.",
"restoreFailureSectionMissing":"Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.",
"restoreFailureDocument":"Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.",
"restoreFailureDocument":"Die Datei enthält Dokumente ohne ihren Inhalt, wie ein portabler Export. Nutze stattdessen eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers.",
"restoreFailureTimeBudget":"Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.",
"restoreFailureTransaction":"Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.",
"restoreFailureCauseTimeout":"Die Wiederherstellung hat länger gedauert, als die Datenbank zulässt, deshalb wurde alles zurückgerollt. War der Datenbank-Host ausgelastet, starte sie erneut, wenn er ruhiger ist. Das Server-Log nennt den Schritt.",
@@ -8295,7 +8351,32 @@
"restoreSkippedMoodLabelCiphertext":"Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)",
"restoreSkippedVisitCiphertext":"Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)",
"restoreSkippedVaccinationCiphertext":"Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)",
"restoreSkippedEnvironmentLocationCiphertext":"Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)"
"restoreSkippedEnvironmentLocationCiphertext":"Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)",
"typePreWipe":"Vor dem Löschen aller Daten",
"retentionNote":"Die wöchentliche Kopie wird jeden Sonntag ersetzt. Eine Kopie, die vor „Alle Daten löschen“ angelegt wurde, wird nach {days} Tagen gelöscht. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
"retentionNoteNoGrace":"Die wöchentliche Kopie wird jeden Sonntag ersetzt. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
"whereBoth":"auf diesem Server und extern gespeichert",
"whereLocal":"auf diesem Server gespeichert",
"whereOffhost":"nur extern gespeichert",
"none":"Noch kein Datenbank-Dump. Der erste läuft heute Nacht.",
"failedTitle":"Der letzte Datenbank-Dump ist fehlgeschlagen",
"failedDescription":"{when}: {reason}",
"disabled":"Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.",
"noDirectoryOffhost":"Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.",
"noDirectory":"Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.",
"retentionLocal":"Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights} nächtlichen Dumps, und der Dump vor dem letzten Update bleibt bis zum nächsten Update.",
"retentionOffhost":"Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.",
"retentionOffhostMissing":"Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
"notPersistentOffhost":"{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher). Ein Dump dort wäre beim nächsten Update verloren. Dumps gehen deshalb nur extern: Hier wird keiner aufbewahrt, und vor einer Migration wird keiner erstellt. Binde das Backup-Volume ein.",
"notPersistent":"{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher), und es ist kein externer Bucket eingerichtet. Damit wird nirgends ein Datenbank-Dump aufbewahrt. Binde das Backup-Volume ein.",
"offhostFailedTitle":"Der neueste Datenbank-Dump ist nicht im externen Bucket angekommen",
"offhostLastUpload":"Der neueste Dump im Bucket stammt vom {when}.",
"offhostNeverUploaded":"Bisher ist kein Datenbank-Dump im Bucket angekommen."
},
"restorePreWipeLimits":"Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
},
"danger-zone":{
"title":"Gefahrenzone",
@@ -8361,7 +8442,10 @@
"columnsHide":"Tabelle ausblenden",
"columnsShowAll":"Alle {count} Spalten anzeigen",
"columnsShowWithRows":"Nur Spalten mit Zeilen",
"columnsNoneWithRows":"Noch keine verschlüsselte Spalte enthält Zeilen."
"columnsNoneWithRows":"Noch keine verschlüsselte Spalte enthält Zeilen.",
"backupsDumpLocalLine":"Schlüssel {key}: {count} Datenbank-Dumps auf diesem Server wurden unter ihm geschrieben; er wird gebraucht, bis die Aufbewahrung sie entfernt.",
"backupsDumpOffhostLine":"Schlüssel {key}: Der neueste Datenbank-Dump, der unter ihm geschrieben wurde, kam am {when} in den Bucket; dort gebraucht bis {until}",
"backupsDumpOffhostLineUnknown":"Schlüssel {key}: Der neueste Datenbank-Dump, der unter ihm geschrieben wurde, kam am {when} in den Bucket. Wie lange der Bucket database/ behält, ist unbekannt; behalte den Schlüssel, bis dieser Dump sicher weg ist."
},
"maintenance":{
"title":"Pflege der Messwert-Tabelle",
@@ -8491,47 +8575,6 @@
"title":"Dieser Teil der Seite konnte nicht angezeigt werden",
"description":"Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt."
"description":"Ohne eine Kopie dieses Schlüssels lassen sich deine verschlüsselten Daten und Sicherungen nie wieder lesen.",
"loading":"Status der Schlüsselsicherung wird geladen…",
"loadError":"Der Status der Schlüsselsicherung konnte nicht geladen werden.",
"badgeDue":"Offen",
"badgeDone":"Gesichert",
"keyId":"Aktive Schlüssel-ID",
"fingerprint":"Fingerabdruck",
"whatIsLost":"Bewahre eine Kopie des Schlüssels außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Wird der Server mit einem anderen Schlüssel neu installiert, nimmt HealthLog den Betrieb erst wieder auf, wenn der ursprüngliche Schlüssel zurück ist.",
"fingerprintHint":"Der Fingerabdruck sind die ersten 12 Zeichen des SHA-256-Hashes des Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, damit du ihn mit deiner Kopie vergleichen kannst.",
"checkLabel":"Meine Kopie prüfen",
"checkButton":"Prüfen",
"checkNote":"Der eingefügte Schlüssel wird mit dem aktiven Schlüssel verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
"checkInsecureNote":"Diese Seite läuft nicht über https://, also geht der eingefügte Schlüssel unverschlüsselt über das Netz, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
"checkMatches":"Deine Kopie passt zum aktiven Schlüssel.",
"checkNoMatch":"Deine Kopie passt nicht zum aktiven Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
"checkFailed":"Die Prüfung ist fehlgeschlagen.",
"confirmButton":"Ich habe diesen Schlüssel gesichert",
"confirmed":"Schlüsselsicherung bestätigt.",
"confirmFailed":"Die Bestätigung konnte nicht gespeichert werden.",
"stale":"Der Schlüssel des Servers hat sich geändert, seit diese Seite geladen wurde. Prüfe die neue Schlüssel-ID und den Fingerabdruck und bestätige dann erneut.",
"confirmedAt":"Bestätigt am {date}.",
"confirmedAtBy":"Bestätigt am {date} von {who}.",
"dueAgain":"Für Schlüssel {keyId} wurde eine Sicherung bestätigt, aber der Server nutzt jetzt einen anderen Schlüssel. Sichere den aktuellen.",
"tileLabel":"Sicherung des Verschlüsselungsschlüssels",
"tileDue":"Nicht bestätigt",
"tileDone":"Bestätigt",
"platform":{
"composeLabel":"Compose",
"composeWhere":"Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.",
"truenasLabel":"TrueNAS",
"truenasWhere":"Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.",
"coolifyLabel":"Coolify",
"coolifyWhere":"Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.",
"unraidLabel":"Unraid",
"unraidWhere":"Bearbeite im Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
"portainerLabel":"Portainer",
"portainerWhere":"Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY aus seinen Umgebungsvariablen."
}
},
"keyMismatch":{
"title":"HealthLog kann seine Daten nicht öffnen",
"body":"Der Verschlüsselungsschlüssel, mit dem dieser Server gestartet wurde, ist nicht der, mit dem seine Datenbank geschrieben wurde. Zum Schutz deiner Daten liefert HealthLog nichts aus, bis das behoben ist.",
@@ -8562,6 +8605,185 @@
"defaultAboveCeiling":"Die Standardstufe darf nicht über der höchsten Stufe liegen.",
"gapLabel":"Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)",
"gapHint":"Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h."
},
"recoveryKit":{
"title":"Wiederherstellungs-Kit",
"description":"Jeder Schlüssel, den eine Wiederherstellung dieses Servers braucht, mit Kennung und Fingerabdruck.",
"intro":"Bewahre von jedem Schlüssel unten eine Kopie außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Ohne sie lassen sich weder ein Datenbank-Dump noch eine Sicherungskopie wieder lesen, und HealthLog startet nicht, wenn ein benötigter Schlüssel fehlt.",
"loading":"Wiederherstellungs-Kit wird geladen…",
"loadError":"Das Wiederherstellungs-Kit konnte nicht geladen werden.",
"badgeDue":"Offen",
"badgeDone":"Gesichert",
"keyId":"Kennung",
"fingerprint":"Fingerabdruck",
"noFingerprint":"Nicht auf diesem Server",
"kind":{
"application":"Anwendungsschlüssel {id}",
"backup":"Schlüssel für externe Sicherungen",
"backupPrevious":"Früherer Schlüssel für externe Sicherungen",
"token":"Token-Schlüssel"
},
"need":{
"active":"Verschlüsselt neue Daten.",
"configured":"Liegt auf diesem Server.",
"storedCopies":"Wird von {count} gespeicherten Kopien gebraucht.",
"offhostUntil":"Wird von externen Kopien bis {date} gebraucht.",
"offhostUnknown":"Wird von externen Kopien gebraucht, solange der Bucket sie aufbewahrt.",
"backupActive":"Versiegelt jede neue externe Kopie.",
"backupPrevious":"Öffnet externe Kopien, die vor dem letzten Schlüsselwechsel geschrieben wurden.",
"token":"Signiert Sitzungen, Tokens und Freigabelinks. Ohne ihn werden alle abgemeldet, Daten gehen aber nicht verloren.",
"localDumps":"Wird von {count} Datenbank-Dumps auf diesem Server gebraucht.",
"offhostDumpsUntil":"Wird von externen Datenbank-Dumps bis {date} gebraucht.",
"offhostDumpsUnknown":"Wird von externen Datenbank-Dumps gebraucht, solange der Bucket sie aufbewahrt."
},
"source":"Gesetzt in {variable}.",
"status":{
"confirmed":"Gesichert",
"new":"Noch nicht gesichert",
"notConfigured":"Nicht auf diesem Server",
"unreadable":"Kein gültiger Schlüssel"
},
"notConfiguredHint":"Eine Sicherung braucht diesen Schlüssel noch, aber dieser Server hat ihn nicht. Nur eine Kopie, die du anderswo aufbewahrst, kann diese Sicherungen lesen. Bestätige das Kit, sobald du diese Kopie hast.",
"unreadableHint":"Die Variable ist gesetzt, enthält aber keinen gültigen Schlüssel. Behebe das, bevor du dich auf die externen Kopien verlässt.",
"fingerprintHint":"Der Fingerabdruck besteht aus den ersten 12 Zeichen des SHA-256-Hashs eines Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, sodass du ihn mit deiner Kopie vergleichen kannst.",
"whereTitle":"Wo die Schlüssel gesetzt sind",
"platform":{
"composeLabel":"Compose",
"composeWhere":"Die Schlüssel stehen in der .env-Datei neben docker-compose.yml: ENCRYPTION_KEY oder ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY und, mit externen Kopien, BACKUP_ENCRYPTION_KEY.",
"truenasLabel":"TrueNAS",
"truenasWhere":"Öffne Apps, wähle HealthLog und dann Edit. Die Schlüssel stehen unter HealthLog Configuration; bei einer per YAML installierten App sind es ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY in den Umgebungsvariablen.",
"coolifyLabel":"Coolify",
"coolifyWhere":"Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Environment Variables.",
"unraidLabel":"Unraid",
"unraidWhere":"Bearbeite auf dem Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
"portainerLabel":"Portainer",
"portainerWhere":"Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Umgebungsvariablen."
},
"restoreTitle":"Diesen Server wiederherstellen",
"restoreBody":"Installiere HealthLog mit denselben Schlüsseln, spiele den neuesten Datenbank-Dump in eine leere Datenbank ein und starte dann die App. Die Anleitung führt durch jeden Schritt.",
"restoreLink":"Anleitung zur Notfallwiederherstellung",
"checkLabel":"Meine Kopie prüfen",
"checkKeyLabel":"Zu prüfender Schlüssel",
"checkValueLabel":"Deine Kopie des Schlüssels",
"checkButton":"Prüfen",
"checkNote":"Der eingefügte Schlüssel wird mit dem auf diesem Server verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
"checkInsecureNote":"Diese Seite wird nicht über https:// ausgeliefert. Der eingefügte Schlüssel geht deshalb unverschlüsselt über das Netzwerk, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
"checkMatches":"Deine Kopie passt zu diesem Schlüssel.",
"checkNoMatch":"Deine Kopie passt nicht zu diesem Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
"checkFailed":"Die Prüfung ist fehlgeschlagen.",
"confirmButton":"Ich habe von jedem Schlüssel eine Kopie",
"confirmed":"Wiederherstellungs-Kit bestätigt.",
"confirmFailed":"Die Bestätigung konnte nicht gespeichert werden.",
"stale":"Das Kit hat sich geändert, seit die Seite geladen wurde. Prüfe die Schlüssel, die es jetzt aufführt, und bestätige erneut.",
"confirmedAt":"Bestätigt am {date}.",
"confirmedAtBy":"Bestätigt am {date} von {who}.",
"dueAgain":"Das Kit wurde am {date} bestätigt. Es führt jetzt auch {keys} auf, was diese Bestätigung nicht abgedeckt hat. Sichere von jedem eine Kopie und bestätige erneut."
},
"recoveryReadiness":{
"title":"Wiederherstellbarkeit",
"description":"Ob sich dieser Server nach einem Ausfall wiederherstellen ließe, beurteilt anhand jeder Sicherung, die er anlegt.",
"loading":"Sicherungen werden geprüft…",
"loadError":"Die Wiederherstellbarkeit konnte nicht geladen werden.",
"state":{
"ok":"Bereit",
"attention":"Braucht Aufmerksamkeit",
"failing":"Gefährdet"
},
"signal":{
"database_dump":"Datenbank-Dump",
"weekly_copy":"Wöchentliche Kontokopien",
"offhost_copies":"Externe Kopien",
"restore_drill":"Wiederherstellungsprobe",
"offhost_setup":"Externe Sicherung",
"recovery_kit":"Wiederherstellungs-Kit",
"dump_retention":"Dumps im Bucket"
},
"at":{
"database_dump":"Neuester Dump: {date}",
"weekly_copy":"Älteste Kontokopie: {date}",
"offhost_copies":"Älteste externe Kopie: {date}",
"restore_drill":"Letzte Probe: {date}",
"recovery_kit":"Bestätigt: {date}"
},
"dueBy":"Fällig bis {date}",
"fix":{
"database_dump":"Sicherungen öffnen",
"weekly_copy":"Sicherungen öffnen",
"offhost_copies":"Sicherungen öffnen",
"restore_drill":"Sicherungen öffnen",
"offhost_setup":"Sicherungen öffnen",
"recovery_kit":"Kit öffnen",
"dump_retention":"Sicherungen öffnen"
},
"reason":{
"database_dump":{
"not_available":"Diese Version legt keinen geplanten Datenbank-Dump an.",
"disabled":"Der geplante Datenbank-Dump ist ausgeschaltet, daher kann keine Kopie diesen Server wieder aufbauen. Zum Wiedereinschalten DB_BACKUP_ENABLED entfernen oder auf true setzen und HealthLog neu starten.",
"unreadable":"Der Zustand des Datenbank-Dumps konnte nicht gelesen werden.",
"never_succeeded":"Noch kein Datenbank-Dump ist gelungen, und der letzte Versuch ist fehlgeschlagen.",
"never_ran":"In den {hours} Stunden, seit Dumps eingeschaltet sind, wurde kein Datenbank-Dump geschrieben.",
"first_pending":"Der erste Datenbank-Dump ist noch nicht gelaufen.",
"stale":"Der neueste Datenbank-Dump ist {hours} Stunden alt.",
"last_failed":"Der letzte Datenbank-Dump ist fehlgeschlagen. Der davor ist noch aktuell.",
"fresh":"Der neueste Datenbank-Dump ist aktuell.",
"nowhere_to_write":"Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden.",
"not_persistent":"Das Dump-Verzeichnis {directory} liegt nicht auf dauerhaftem Speicher, und es ist kein externer Bucket eingerichtet. Jeder Dump wäre beim nächsten Update verloren. Binde das Backup-Volume ein.",
"fresh_offhost_only":"Der neueste Datenbank-Dump ist aktuell und liegt im externen Bucket. Auf diesem Server wird keiner aufbewahrt, weil {directory} nicht auf dauerhaftem Speicher liegt, und vor einer Migration wird keiner erstellt.",
"offhost_failed":"Der neueste Datenbank-Dump liegt auf diesem Server, aber das Hochladen in den externen Bucket ist fehlgeschlagen.",
"offhost_stale":"Seit {hours} Stunden ist kein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt immer wieder fehl. Geht dieser Server verloren, gibt es keinen aktuellen Dump, um ihn wiederherzustellen.",
"offhost_never":"Noch nie ist ein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt fehl. Geht dieser Server verloren, gibt es keinen Dump, um ihn wiederherzustellen."
},
"weekly_copy":{
"no_accounts":"Es gibt noch keine Konten zum Kopieren.",
"stale":"Die älteste wöchentliche Kopie ist {days} Tage alt. Konten im Rückstand: {accounts}.",
"never":"Ein Konto hat seit {days} Tagen keine wöchentliche Kopie. Konten im Rückstand: {accounts}.",
"first_pending":"Die erste wöchentliche Kopie entsteht am frühen Sonntagmorgen.",
"fresh":"Jedes Konto hat eine Kopie aus der letzten Woche."
},
"offhost_copies":{
"not_configured":"Es ist kein externer Bucket eingerichtet.",
"stale":"Externe Kopien liegen mehr als zwei Nächte zurück. Konten im Rückstand: {accounts}.",
"first_pending":"Der nächtliche Lauf für externe Kopien hat noch kein Konto erreicht.",
"fresh":"Jedes Konto hat eine aktuelle Kopie im externen Bucket."
},
"restore_drill":{
"not_configured":"Die Probe liest den externen Bucket, und der ist nicht eingerichtet.",
"not_run_yet":"Die monatliche Wiederherstellungsprobe ist noch nicht gelaufen.",
"failed":"Die letzte Wiederherstellungsprobe ist fehlgeschlagen: {failed} von {checked} Kopien ließen sich nicht zurücklesen.",
"failed_run":"Die letzte Wiederherstellungsprobe konnte nicht zu Ende laufen.",
"passed":"Die letzte Wiederherstellungsprobe hat {checked} Kopien zurückgelesen.",
"nothing_checked":"Die letzte Wiederherstellungsprobe hat ihr Zeitlimit erreicht, bevor sie eine einzige Kopie zurückgelesen hat, und hat damit nichts belegt."
},
"offhost_setup":{
"configured":"Kopien verlassen diesen Server jede Nacht.",
"no_admin":"Es gibt noch kein Admin-Konto.",
"not_configured":"Es ist kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server.",
"not_configured_grace":"Es ist noch kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server."
},
"recovery_kit":{
"confirmed":"Von jedem Schlüssel, den eine Wiederherstellung braucht, gibt es eine Kopie.",
"out_of_date":"Das Wiederherstellungs-Kit führt jetzt auch {keys} auf, was die letzte Bestätigung nicht abgedeckt hat. Nichts ist kaputt: Sichere eine Kopie und bestätige das Kit erneut.",
"no_admin":"Es gibt noch kein Admin-Konto.",
"not_confirmed":"Niemand hat eine Kopie der Schlüssel bestätigt, die eine Wiederherstellung braucht: {keys}.",
"not_confirmed_grace":"Sichere eine Kopie der Schlüssel, die eine Wiederherstellung braucht: {keys}."
},
"dump_retention":{
"not_configured":"Es ist kein externer Bucket eingerichtet.",
"no_offhost_dumps":"Es geht kein Datenbank-Dump in den Bucket.",
"missing":"Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
"configured":"Der Bucket entfernt Dumps nach {days} Tagen.",
"unknown":"Der Bucket gibt nicht an, ob eine Regel alte Dumps entfernt. Prüfe, ob eine Lebenszyklus-Regel database/ erfasst."
}
},
"recovered":{
"database_dump":"Der Datenbank-Dump ist wieder aktuell.",
"weekly_copy":"Jedes Konto hat wieder eine aktuelle wöchentliche Kopie.",
"offhost_copies":"Jedes Konto hat wieder eine aktuelle externe Kopie.",
"restore_drill":"Die Wiederherstellungsprobe ist wieder bestanden.",
"offhost_setup":"Externe Kopien sind jetzt eingerichtet.",
"recovery_kit":"Das Wiederherstellungs-Kit ist wieder bestätigt.",
"dump_retention":"Eine Lebenszyklus-Regel im Bucket erfasst jetzt die Datenbank-Dumps."
}
}
},
"achievements":{
@@ -8875,7 +9097,10 @@
"offlineGeoUnavailableTitle":"Offline-Geodatenbanken nicht geladen",
"offlineGeoUnavailableBody":"HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}",
"tlsPinChangedBody":"Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md."
"tlsPinChangedBody":"Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md.",
"accountErasureRefusedTitle":"Konto konnte nicht gelöscht werden: {username}",
"accountErasureRefusedBody":"Die Löschfrist von {username} ist abgelaufen, aber das Konto ist die letzte aktive betreuende Person eines verwalteten Profils und wurde deshalb nicht gelöscht. Es bleibt gesperrt. Füge dem Profil eine weitere betreuende Person hinzu, lösche das Profil oder mache die Löschung unter Administration, Nutzer rückgängig.",
"accountErasureFailedBody":"Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration."
},
"user":{
"telegramTestBody":"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
@@ -9027,6 +9252,21 @@
"checkupReminders":{
"title":"Vorsorge-Erinnerungen",
"description":"Schalte einzelne Vorsorge-Erinnerungen ein oder aus."
},
"eventBackupHealth":"Zustand der Sicherungen",
"eventBackupHealthDesc":"Wenn eine Sicherung ausbleibt, zurückfällt oder wieder läuft. Nur für Admins.",
"functionalTitle":"Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?",
"functional":{
"0":"Überhaupt nicht erschwert",
"1":"Etwas erschwert",
"2":"Sehr erschwert",
"3":"Extrem erschwert"
},
"functionalOptionalHint":"Optional: Du kannst auch ohne Antwort auf diese Frage absenden.",
"result":{
"title":"Dein Ergebnis",
"totalLabel":"Gesamtwert",
"bandLabel":"Bereich für dieses Screening",
"takeAnother":"Weiteren Check-in machen"
"takeAnother":"Weiteren Check-in machen",
"scoreOf":"von {max}",
"firstCheckIn":"Das ist dein erstes Ergebnis mit diesem Fragebogen. Ab dem nächsten siehst du hier die Veränderung.",
"nextStep":"Du kannst den Check-in in zwei Wochen oder später wiederholen, um die Veränderung zu sehen. Wenn dich etwas beunruhigt, sprich jederzeit mit einer Fachperson."
},
"band":{
"PHQ9":{
@@ -11003,45 +11235,6 @@
"ifDanger":"Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.",
"resourcesTitle":"Menschen, die helfen können"
},
"items":{
"phq9":{
"1":"Wenig Interesse oder Freude an Ihren Tätigkeiten",
"2":"Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit",
"3":"Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf",
"4":"Müdigkeit oder Gefühl, keine Energie zu haben",
"5":"Verminderter Appetit oder übermäßiges Bedürfnis zu essen",
"6":"Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben",
"7":"Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitunglesen oder Fernsehen",
"8":"Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten einen stärkeren Bewegungsdrang als sonst?",
"9":"Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten"
},
"gad7":{
"1":"Nervosität, Ängstlichkeit oder Anspannung",
"2":"Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren",
"7":"Gefühl der Angst, als würde etwas Schlimmes passieren"
},
"who5":{
"1":"… war ich froh und guter Laune",
"2":"… habe ich mich ruhig und entspannt gefühlt",
"3":"… habe ich mich energisch und aktiv gefühlt",
"4":"… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt",
"5":"… war mein Alltag voller Dinge, die mich interessieren"
},
"sci":{
"1":"… how long does it take you to fall asleep?",
"2":"… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3":"… how many nights a week do you have a problem with your sleep?",
"4":"… how would you rate your sleep quality?",
"5":"… affected your mood, energy, or relationships?",
"6":"… affected your concentration, productivity, or ability to stay awake?",
"7":"… troubled you in general?",
"8":"… how long have you had a problem with your sleep?"
}
},
"crisisResource":{
"telefonSeelsorge":{
"name":"TelefonSeelsorge (rund um die Uhr, kostenlos, vertraulich)"
@@ -11069,6 +11262,24 @@
},
"crisisTextLine":{
"name":"Crisis Text Line"
},
"cvv":{
"name":"CVV, Centro de Valorização da Vida (Brasilien, rund um die Uhr, kostenlos, vertraulich)"
},
"samu":{
"name":"SAMU, medizinischer Notruf (Brasilien)"
},
"sns24":{
"name":"SNS 24, psychologische Beratung (Portugal, rund um die Uhr, Option 4 wählen)"
},
"sosVozAmiga":{
"name":"SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
},
"zelfmoordpreventie113":{
"name":"113 Zelfmoordpreventie (Niederlande, rund um die Uhr, kostenlos, anonym, auch per Chat)"
},
"zelfmoordlijn1813":{
"name":"Zelfmoordlijn 1813 (Belgien, rund um die Uhr, kostenlos, anonym)"
}
},
"instrumentDescription":{
@@ -11077,7 +11288,6 @@
"who5":"Wohlbefindens-Check mit 5 Fragen. Beziehe dich auf die letzten zwei Wochen; höhere Werte bedeuten besseres Wohlbefinden.",
"sci":"Schlaf-Check mit 8 Fragen. Die meisten Fragen beziehen sich auf den letzten Monat; höhere Werte bedeuten besseren Schlaf."
},
"progress":"{current} / {total}",
"next":"Weiter",
"lastResult":"Letzter Test",
"lastScore":"Letztes Ergebnis",
@@ -11089,62 +11299,6 @@
"who5":"Ein Wohlbefindens-Wert von 50 oder darunter kann einen genaueren Blick wert sein. Wenn du magst, schaut der PHQ-9-Check-in auf dieser Seite genauer auf die Stimmung. Das ist ein behutsamer Hinweis, keine Diagnose.",
"sci":"In der Studie zu diesem Fragebogen waren Werte von 16 oder darunter mit wahrscheinlicher Insomnie verbunden. Wenn schlechter Schlaf dich weiter belastet, kann ein Gespräch mit einer Fachperson helfen. Das ist keine Diagnose."
},
"validatedInEnglishNote":"Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
"who5Options":{
"0":"Zu keinem Zeitpunkt",
"1":"Ab und zu",
"2":"Etwas weniger als die Hälfte der Zeit",
"3":"Etwas mehr als die Hälfte der Zeit",
"4":"Meistens",
"5":"Die ganze Zeit"
},
"sciOptions":{
"duration":{
"0":"≥ 61 min",
"1":"46–60 min",
"2":"31–45 min",
"3":"16–30 min",
"4":"0–15 min"
},
"nights":{
"0":"5–7",
"1":"4",
"2":"3",
"3":"2",
"4":"0–1"
},
"quality":{
"0":"Very poor",
"1":"Poor",
"2":"Average",
"3":"Good",
"4":"Very good"
},
"impact":{
"0":"Very much",
"1":"Much",
"2":"Somewhat",
"3":"A little",
"4":"Not at all"
},
"problemDuration":{
"0":"> 1 year",
"1":"7–12 months",
"2":"3–6 months",
"3":"1–2 months",
"4":"I don't have a problem / < 1 month"
}
},
"stems":{
"who5":{
"period":"In den letzten zwei Wochen …"
},
"sci":{
"night":"Thinking about a typical night in the last month …",
"impact":"Thinking about the past month, to what extent has poor sleep …",
"finally":"Finally …"
}
},
"openDetail":"Details anzeigen",
"historyLoadError":"Dein Testverlauf konnte nicht geladen werden",
"historyLoadErrorHint":"Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.",
@@ -11157,6 +11311,50 @@
"worsened":"Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.",
"within_threshold":"Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen."
}
},
"wizard":{
"progressLabel":"Frage {current} von {total}",
"followUpLabel":"Zusatzfrage (freiwillig)",
"keyboardHint":"Mit den Zahlentasten 1 bis {count} wählst du eine Antwort.",
"allAnswered":"Alle {total} Fragen beantwortet"
},
"review":{
"title":"Antworten prüfen",
"description":"Gespeichert wird erst beim Absenden, bis dahin lässt sich jede Antwort noch ändern.",
"change":"Ändern",
"changeLabel":"Antwort auf Frage {number} ändern",
"notAnswered":"Nicht beantwortet",
"open":"Antworten prüfen"
},
"bandMeaning":{
"PHQ9":{
"minimal":"Deine Antworten weisen für die letzten zwei Wochen auf keine oder kaum depressive Beschwerden hin.",
"mild":"Deine Antworten weisen für die letzten zwei Wochen auf leichte depressive Beschwerden hin.",
"moderate":"Deine Antworten weisen für die letzten zwei Wochen auf mittelgradige depressive Beschwerden hin.",
"modSevere":"Deine Antworten weisen für die letzten zwei Wochen auf mittelschwere depressive Beschwerden hin.",
"severe":"Deine Antworten weisen für die letzten zwei Wochen auf schwere depressive Beschwerden hin."
},
"GAD7":{
"minimal":"Deine Antworten weisen für die letzten zwei Wochen auf keine oder kaum Angstbeschwerden hin.",
"mild":"Deine Antworten weisen für die letzten zwei Wochen auf leichte Angstbeschwerden hin.",
"moderate":"Deine Antworten weisen für die letzten zwei Wochen auf mittelgradige Angstbeschwerden hin.",
"severe":"Deine Antworten weisen für die letzten zwei Wochen auf schwere Angstbeschwerden hin."
},
"WHO5":{
"low":"Dein Wohlbefinden lag in den letzten zwei Wochen unter dem Bereich, den der WHO-5 als gut wertet.",
"good":"Dein Wohlbefinden lag in den letzten zwei Wochen in dem Bereich, den der WHO-5 als gut wertet."
},
"SCI":{
"belowThreshold":"Dein Schlaf lag im letzten Monat in dem Bereich, in dem die Studie hinter diesem Fragebogen eine Insomnie für wahrscheinlich hielt.",
"aboveThreshold":"Dein Schlaf lag im letzten Monat außerhalb des Bereichs, den diese Studie mit Insomnie verbindet."
}
},
"wording":{
"unvalidatedNote":"Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.",
"showOriginal":"Englischen Originaltext zeigen",
"showTranslation":"Übersetzung zeigen",
"resultUnvalidated":"Beantwortet in einer Übersetzung, die nicht wissenschaftlich validiert ist; nimm das Ergebnis als Orientierung.",
"historyUnvalidated":"Beantwortet in einer nicht wissenschaftlich validierten Übersetzung"
}
},
"records":{
@@ -11684,7 +11882,8 @@
"toast":{
"duplicate":"Bereits gespeichert. Das vorhandene Dokument ist hervorgehoben.",
"deleted":"Dokument gelöscht",
"restoreFailed":"Das Dokument konnte nicht wiederhergestellt werden."
"restoreFailed":"Das Dokument konnte nicht wiederhergestellt werden.",
"deletedHint":"Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht."
},
"timeline":{
"loadingMore":"Mehr wird geladen…",
@@ -11729,7 +11928,8 @@
"raster-failed":"Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.",
"provider-error":"Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.",
"pdf-needs-anthropic":"Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.",
"empty-transcription":"Der KI-Anbieter hat für dieses Dokument keinen Text geliefert."
"empty-transcription":"Der KI-Anbieter hat für dieses Dokument keinen Text geliefert.",
"resource-limit":"Dieses PDF ist zu groß oder zu komplex, um es hier zu lesen. Die Datei selbst ist in Ordnung und lässt sich weiterhin öffnen."
}
},
"loadError":"Dieses Dokument konnte nicht geladen werden.",
@@ -11801,7 +12001,7 @@
"dismiss":"Verwerfen",
"empty":"Aus diesem Dokument konnte nichts gelesen werden.",
"unavailableBody":"Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
"unavailableLocalOcr":"Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
"unavailableLocalOcr":"Schalte „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein, um Bilder auf diesem Gerät zu lesen.",
"unavailableAction":"KI-Einstellungen öffnen",
"errorGeneric":"Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
"errorOcr":"Aus diesem Bild konnte kein Text gelesen werden.",
@@ -11813,11 +12013,13 @@
"errorPdfNeedsVision":"Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
"errorFileType":"Dieser Dateityp kann nicht gelesen werden.",
"errorNotIndexed":"Lies das Dokument zuerst aus, dann extrahiere.",
"errorLocalOcrDisabled":"Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
"errorOutputLimit":"Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
"errorLocalOcrDisabled":"Schalte zuerst „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein.",
"errorProvider":"Dafür ist kein KI-Anbieter eingerichtet.",
"errorTextImageOnly":"Lokale Texterkennung liest nur Bilder, nicht diese Datei.",
"errorConsent":"Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.",
"errorUnavailable":"Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar."
"errorUnavailable":"Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar.",
"extractHint":"Nutzt den bereits ausgelesenen Text dieses Dokuments. Ausgelesene Werte warten auf deine Prüfung; nichts wird automatisch gespeichert.",
"extractEmpty":"Aus diesem Dokument konnten keine Werte ausgelesen werden.",
"extractIncomplete":"Ein Teil des Dokuments konnte nicht vollständig gelesen werden, deshalb können Werte fehlen. Vergleiche die Liste mit dem Dokument.",
"failUnitRequired":"Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
"failUnitMismatch":"Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
"failGeneric":"Dieser Wert konnte nicht gespeichert werden.",
@@ -11894,7 +12097,6 @@
},
"chat":{
"title":"Über dieses Dokument chatten",
"openAria":"Coach über dieses Dokument fragen",
"openButton":"Coach fragen"
},
"readingConsent":{
@@ -11921,6 +12123,7 @@
"loadMore":"Mehr laden",
"stateImported":"In HealthLog",
"stateDeleted":"In HealthLog gelöscht",
"stateInTrash":"Unter Zuletzt gelöscht",
"selectedCount":"{count} von höchstens {max} ausgewählt",
"importAction":"{count} importieren",
"importing":"Import läuft, ein Dokument nach dem anderen …",
@@ -11934,7 +12137,9 @@
"importedLinked":"Importiert und verknüpft",
"duplicate":"Schon in HealthLog",
"duplicateLinked":"Schon in HealthLog, jetzt verknüpft",
"purgeConfirmBody":"Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
}
},
"selfDescription":{
@@ -12664,7 +12893,8 @@
"entryHint":"Wenn {name} die Akte selbst führen möchte.",
"methodSso":"{name} meldet sich beim Einlösen des Links über Single Sign-on an.",
"methodBoth":"{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden."
}
},
"deleteBodyGrace":"Alles, was für {name} erfasst wurde, verschwindet mit dem Profil: Messwerte, Medikamente, Dokumente und Notizen. Das Profil wird sofort gesperrt und nach {days} Tagen endgültig gelöscht. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen."
},
"toast":{
"savedTo":"In der Akte von {name} gespeichert"
@@ -12719,6 +12949,7 @@
"practitionerContactRestore":"{name} hat einen Adressbucheintrag wiederhergestellt",
"illnessDayLogUpsert":"{name} hat einen Krankheitstag erfasst",
"medicationUpdate":"{name} hat ein Medikament geändert",
"medicationCourseEnd":"{name} hat eine Medikamenteneinnahme beendet",
"medicationDelete":"{name} hat ein Medikament gelöscht",
"medicationGlp1Update":"{name} hat eine GLP-1-Therapie geändert",
"medicationScheduleCreate":"{name} hat einen Einnahmeplan angelegt",
@@ -12766,6 +12997,9 @@
"rejectedDoseUpdate":"{name} wollte eine Einnahme ändern und wurde abgewiesen",
"rejectedDoseImport":"{name} wollte Einnahmen importieren und wurde abgewiesen",
"rejectedTrackedValue":"{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen",
"rejectedTrackedValueTypeCreate":"{name} wollte eine eigene Messgröße anlegen und wurde abgewiesen",
"rejectedTrackedValueTypeUpdate":"{name} wollte eine eigene Messgröße ändern und wurde abgewiesen",
"rejectedMedicationLayout":"{name} wollte die Medikamentenliste neu anordnen und wurde abgewiesen",
"deniedDoseImport":"Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden",
"anamnesisFactCreate":"{name} hat einen Fakt zur Vorgeschichte eingetragen",
"customMetricCreate":"{name} hat eine eigene Messgröße angelegt",
@@ -13075,7 +13309,8 @@
"backgroundScan":"Wird im Hintergrund gelesen. Mit einem langsamen Modell kann das ein paar Minuten dauern.",
"waitingForWorker":"Warte auf den Hintergrunddienst. Ändert sich das nicht, läuft der Worker auf dem Server vielleicht nicht.",
"errorWorkerUnavailable":"Der Hintergrunddienst ist gerade nicht erreichbar. Versuch es gleich noch einmal oder bitte die Person, die den Server betreibt, ihn zu prüfen.",
"errorTimedOut":"Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen."
"errorTimedOut":"Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen.",
"errorReaderBusy":"Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
},
"symptoms":{
"add":"Neues Symptom",
Loaded 100 of 3265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.