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.
Two draws of one analyte a year apart and two reports on the same day, against
Postgres: each report lists its own reading id, and fetching it returns that
reading with that report in its links, not the latest one.
A link to a lab result named its analyte, and fetch lab:<analyte> answers
with the latest reading of that marker. Following a link from a report that
holds an earlier draw therefore landed on a later reading, whose links named
a different report and visit.
fetch labresult:<id> now returns that one reading (value or text result,
unit, reference range, status, date) with its own links, behind the labs
module and narrowed to the caller's live rows. Every link to a lab result
uses this id, each reading in get_labs carries it, and fetch lab:<analyte>
names the reading it quotes in metadata.readingId. The analyte view and
search are unchanged.
Refs #1323
The labs block and a single-reading read now build a reading from the same
select and the same shaping, so one reading fetched by its id renders exactly
like the latest reading of a marker.
A marker minted from a text result ("SEE NOTE") has an empty unit, and a
marker with a reading has its unit locked, so the first number to arrive,
a unitless ratio included, was refused against a unit nothing had chosen.
While every reading is text the unit has nothing to contradict, so the
first number sets it. A marker that already holds a number keeps its unit.
Refs #1313
postgresql-client-18 from the PGDG repository brought Perl and Debian's
client wrapper scripts into the runner for two binaries the app calls by
name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now
hands over pg_dump, pg_restore and the libpq they link; the Debian
libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the
upgrade and the image scan see them. The runner registers libpq with the
loader and fails the build when either binary has an unresolved library;
the major 18 version checks stay.
The client minor follows the bundled database's: a guard holds the two
pins together, and the Dockerfile's Dependabot watch now ignores a
PostgreSQL major the same way the compose watch does.
Both covered copies inside the npm CLI of the Node base image. The
runner removes npm, so neither CVE matches anything in the image; a scan
of the built image with an empty ignore file reports neither.
The image runs the glibc canvas build now, which needs no ARMv8.2
features, so the Pi 4 gets thumbnails and scanned-PDF rendering back
without a code change: the gate only ever closed for musl on arm64. The
gate's comment, the env example and the getting-started prerequisites
say so, and keep NATIVE_CANVAS as the override. The x64 AVX2 gate stays:
the glibc x64 build rendered a PDF page under an emulated core without
AVX2, but it still carries AVX2 code and that one run does not cover
every path.
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.
The probe now needs nothing but the node binary the image already runs,
which frees the base image from carrying wget. The image HEALTHCHECK and
the compose healthcheck use the same one-liner: a non-2xx answer, a
refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout.
The boot-storm guard reads the abort from the fetch and refuses a probe
that names wget or curl.
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.
Postgres checks the database's CREATE privilege before it checks
whether the schema exists, so CREATE SCHEMA IF NOT EXISTS fails for a
role that may not create schemas even when the schema is there. The
worker now asks first and passes createSchema: false when it exists.
DROP INDEX belongs to the table's owner; the MAINTAIN privilege covers
VACUUM and REINDEX but not the drops of retired indexes and invalid
rebuild copies. A run that does not own measurements now skips those
drops and names what is left, for the boot to drop as the owner. The
retired-index list moves to a plain module the boot script can read.
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
between() iterates from DTSTART, so every compliance read walked a daily
schedule from the day the medication was created: about 4 ms per rate,
growing with the medication's age, and enough under a loaded test run to
push a compliance property past its timeout. DAILY and WEEKLY rules repeat
with a fixed period, so the start moves forward by whole periods to just
before the window. A property test holds the occurrences in the window
equal to the walk from the real anchor.
The desktop table and the mobile cards carried the same status badge
twice. Pulling it into one component makes room for the injection-site
chip without growing the list function past its size baseline, and
the baseline tightens to the new, lower count.
An injected dose already stores where it went, but the per-medication
history only showed when it was taken and where the entry came from.
The list now renders the site as a chip next to the status, on the
desktop table and the mobile cards, so the rotation can be read back
from the history. Rows without a site are unchanged.
knip 6.44 follows the members a dynamic import reads, and the Insights
page reads two of the derived barrel's: the barrel now exports those two,
whose building blocks are imported from their own modules. BAND_VAR had
no reader, and the drawer re-exported useResettableValue for a test that
only imports nextResettableValue. The size and version-stamp baselines
take the lower numbers.
The briefing's lab input passed the stored "1" to the model, the unit
mismatch refusal read "is in 1", and the trend card left a gap where a
language puts the unit before a particle. get_labs says that a value
without a unit has an empty unit, and a lab link id is scrubbed like its
label. The review row is back at its baseline length.
The no-unit state was read from the value, so the first keystroke of
10^9/L or 1/min ticked the box and locked the field. Only the box makes
the choice now; a typed 1 stays text.
Helvetica's WinAnsi encoding turned every glyph outside Latin-1 into an
empty box: a Polish report lost its diacritics, a Korean report was
unreadable, and free text in any other script broke in every locale.
The renderer now registers the embedded ReportSans family on each
document, names it in every setFont, and makes it the autoTable default.
The Hangul variant is read only when the report is Korean or its data
contains Hangul. Font files are cached per process; streams are
compressed so the subset fonts add about 30 KB to a report.
The arrows, superscripts and comparison signs now print as themselves,
so the WinAnsi sanitiser and its ASCII fallbacks are gone. Tests render
Polish, Russian and Korean strings and arrows and read them back out of
the text layer.
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.
A unit of "1" is the explicit no-unit choice a person makes. When a
document read returns it on its own, the value is staged for review
instead of passing straight through, so a unit the read dropped never
lands as a dimensionless value.
The unit on lab results, biomarkers, the scanned-report commit and the
document fact edit now states that "1" means the value has no unit, that
it matches only itself, and that an empty unit stays refused. The lab CSV
import description names the 1 in the unit column.
Refs #1313
Every place a lab unit is printed beside a value now goes through one
helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the
list, history and detail, the chart and its tooltips, the reference bar
and range notes, the trend card, the timeline, the day view, the
clinician page, the doctor-report PDF and the changes card. Where the
unit stands alone (a marker's subtitle, a picker row, the merge preview)
it reads "No unit".
The Coach's lab block, the get_labs history and the visit-prep summary
hand the model an empty unit for such a value, so it never quotes the
"1" either.
Refs #1313
A value stored with unit "1" carries system and code on its quantity
even when the analyte map knows no coding for it: the unity is UCUM by
definition.
Refs #1313
A ratio (cholesterol/HDL, albumin/globulin) or an index such as INR has
no unit, and every lab write path refuses a numeric value without one.
That refusal stays: an empty unit usually means the extraction dropped
it. Instead the value is stored as the UCUM unity "1", chosen on purpose.
The unit field when a marker is defined, on a scanned report's row and
in the document review edit now carries a "No unit" checkbox that sets
"1". The CSV import takes 1 in the unit column, as the template and the
import sheet say; an empty cell is still refused, and other spellings
such as "ratio" or "-" stay the literal unit they always were.
"1" compares as a unit of its own: it matches only itself, so it is
refused under a marker kept in mg/dL and the other way round.
Refs #1313
Source system and id without the address, the Documents switch, the empty
answer, the cap, document links to labs and medications, row ids, and that
another account's document never appears, not even through a link row that
points across accounts.
get_labs readings (the latest, earlier values in full, trajectory readings)
carry the fetch ids of what they link to, with linksTruncated; each linked
record is described once per answer in linkedRecords, because one report
usually holds a whole panel and would otherwise repeat on every reading.
fetch lab: and fetch med: list their links like the other record kinds, and
get_medication_schedule rows carry their id and links inline. get_visits and
get_illness_recovery rows carry visit: and condition: ids, so a row's links
are one fetch away. The labs and illness blocks gain an opt-in to carry row
ids; the Coach never asks for them.
The links fetch lists beside a visit, condition, document or vaccination move
out of record-search into record-links, so the lab and medication reads can
list theirs through the same builder. A linked document imported from
Paperless-ngx or Papra now names its source system and its id there, never
the instance address. fetch document: also lists the lab results read from
it and the medications filed against it, each behind its own module.