Roles are cluster-wide. Two HealthLog databases on one server shared
healthlog_app: each start set its own password, locking the other
instance out, and the role held grants on both databases. The role is
now healthlog_app_<database>_<hash>, marked with a comment naming its
database, its password is derived per database, and a role holding an
explicit CONNECT on another database is not used.
The role owned the pgboss schema, so it could plant a trigger, a rule or
a function body that a later owner session (a rollback, DB_APP_ROLE=off,
a fallback start) would run with the owner's rights. pg-boss changes its
schema only on install, on migration and for partitioned queues, which
the app does not use. The start now installs and migrates the schema as
the owner with pg-boss's own plans, and the worker runs pg-boss with
migrate: false when it does not own the schema. The role gets rows only,
read-only on pgboss.bam (SQL the migration runner executes) and on the
version column. Every start, on every path, takes back what a
HealthLog role owns there, drops triggers and rules, and after a
take-back restores the queue functions to pg-boss's own definitions and
retires the legacy role. Bloated queue indexes, which pg-boss only
reports when it does not own them, are rebuilt at start.
The Wolfi runner has no wget, but health checks outside this repository
call it: compose files copied from here up to v1.46.6 run
'wget --no-verbose --tries=1 --spider .../api/health', and the TrueNAS
catalog app renders 'wget --quiet --spider <url>'. Either replaces the
image's HEALTHCHECK, so such a container never turned healthy and a proxy
that routes on health dropped its traffic.
/usr/local/bin/wget now runs scripts/wget-shim.mjs: the URL, --spider,
-q, -nv, --tries, --timeout/-T, -O - and -O /dev/null, -S; other flags are
ignored. Exit 0 on 2xx/3xx, 1 on any other status, a refused connection
or the timeout. It follows no redirect and refuses any other output file
before sending a request, so it is no download tool. The image's own
HEALTHCHECK and the bundled compose file keep the Node probe.
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.
nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
Every stage now starts from node:22-trixie-slim, pinned by its multi-arch
index digest, so the native modules load their glibc builds. The arm64
musl Skia build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with
SIGILL on an ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the
glibc build runs there (#1311).
The runner keeps what it had: tzdata, TZ, the build-time OS upgrade, the
nextjs user (uid 1001, now in group nodejs gid 1001), the backup and
GeoLite2 directories and every build-time assertion. pg_dump and
pg_restore of major 18 come from the PGDG apt repository, whose signing
key is fetched by node and checked against a pinned SHA-256 before apt
trusts it; PATH names the major 18 directory so the binary does not
depend on Debian's version-picking wrapper. The canvas hoist takes the
-gnu binary package by the pinned version and still fails the build on a
mismatch. bookworm-slim was the other candidate; on the day of the
switch it carried a CRITICAL zlib finding that trixie-slim does not.
After the migrations the entrypoint ensures healthlog_app, a login role
with row access to the app's schema, MAINTAIN on measurements and
ownership of the pgboss schema, and starts the app with a DATABASE_URL
for it in place of the owner's. The role cannot alter or drop tables,
create roles, schemas or functions in the app's schema, read server
files or run programs, or write the migration history.
The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a
separate worker container derives the same one, and it is sent as a
SCRAM verifier. Every boot re-applies the grants in one transaction
under an advisory lock, and drops as the owner the indexes the
maintenance job may no longer drop. When the owner cannot create roles,
the server is older than Postgres 17, or the role cannot log in, the
boot says why and the app stays on the owner as before. DB_APP_ROLE=off
skips it.
- baselines: take main's entries, keep the two ru-driven line counts (coach-prose-grounding 1091, insight-generator 1115) and regenerate the function baseline.
- messages/ru.json: translate the five unit-less markers keys the release added (labs.unit.none/noneHint, labs.ocr.errorUnitMismatchNoUnit, labs.ocr.unitDiffersBadgeNoUnit, recordImport.unitlessHint) — parity is 11 159 keys again.
Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
"пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
people actually see on the box; the deferral words ("спросите врача",
"как назначено") stand those shapes down.
Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
(?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
edges around the written-date pattern.
Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.
Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
«Процент жировой массы», which is what the two metrics measure.
Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
dash is Russian punctuation, not a fragment join); the middle dot still
fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.
pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
The report's built-in Helvetica is WinAnsi-only. These fonts cover what
the report can carry: ReportSans (Noto Sans plus the arrows and
mathematical operators of Noto Sans Math; Latin incl. Extended-A/B,
Greek, Cyrillic) in regular, bold and italic, and ReportSansKR, the same
plus Hangul from Noto Sans KR, in regular and bold.
scripts/build-pdf-fonts.py rebuilds them from the upstream sources. All
are SIL OFL 1.1; the licence sits beside the files. The directory is
named in outputFileTracingIncludes because the renderer reads the files
by a path the tracer cannot follow.
Full Russian catalog (11,154 keys, 1:1 with en, identical nesting) and every
locale wiring point the app declares, so ru is a first-class locale instead
of an English fallback:
- messages/ru.json, project.inlang/settings.json, i18n boot script
- i18n config / load-locale, format-locale (ru-RU), date-fns ru
- labs: parse-reference-range RANGE_PROSE for Russian lab sheets
("3,5 - 5,0", "от 3,5 до 5,0", "до 5,6", "не более 5,6")
- AI: citation-coverage normative stems, coach system prompt and insights
reply footer (Russian prompts must override the hard-coded
"Respond in English"), population-norm patterns
- safety: outbound-screen Russian imperative/dose/unit/causality bank
- mental health: PHQ-9, GAD-7, WHO-5 and SCI wording
verify-message-catalogs.mjs passes for all 10 locales.
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.
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.
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.
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 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.
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 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/.
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 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 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 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.
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 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.
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.
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.
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.
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.
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.
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.