A snooze lives on the medication row, and the reminder tick applied it to
every slot of that medication. A Telegram "skip" of the morning dose
snoozes to the end of the day, so the evening dose of the same medication
was neither reminded nor marked missed by the reminder path. A snooze now
holds only slots whose reminder window had opened when it was set, dated by
the row's updatedAt that the snooze write stamps (the same reading the skip
and pin suppression already uses). A later edit to the row can only widen
the hold back to the old behaviour.
The doctor report kept adherence in a map keyed by medication name, so two
medications with the same name (an ended course and a new one, two
strengths) collapsed into one row and the second overwrote the first. The
map is keyed by medication id now and carries the name as the label; the
report's medication list carries the id. The PDF, the FHIR adherence
observations, the MCP visit summary, the clinician view and the GLP-1
section read it that way.
Local lab OCR reads a report photo on the device with tesseract.js, whose
defaults load the worker, the WebAssembly core and the language data
from cdn.jsdelivr.net. The production CSP allows workers and fetches only
from this origin, so the engine could not load; where it did load (dev),
the browser of anyone scanning a report contacted a third-party CDN.
The worker, the three LSTM core builds and the deu and eng best_int
language data (about 17 MB) are now copied from pinned packages into
public/tesseract/ at prebuild and predev, and createWorker is pointed at
them with the worker started from its URL rather than a blob. A test
holds the options against the staged file list.
Two requests left the server without anything being connected: the
HaveIBeenPwned range query on every password set (registration, change,
admin reset) and the daily GitHub releases request behind the About page
and the admin version tile. Neither could be turned off, which made
"nothing leaves" untrue for an instance that wanted it to be.
PASSWORD_BREACH_CHECK_DISABLED and UPDATE_CHECK_DISABLED (1, true, yes or
on) stop each request. Both stay on by default. The breach check keeps
its fail-open contract: switched off, a password is simply not looked up.
The update route answers status "unknown" with reason "disabled" and
the admin tile says the check is off. The password routes reach the
check only through the switch, and a test holds that import line.
Both are on the compose whitelist, in the env manifest under a new
"Outbound requests" group with IP_GEO_LOOKUP_DISABLED, and documented in
both env examples and the getting-started guide.
The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever
its INTERVAL, so a quarterly injection with two doses left showed about 60
days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a
yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra
BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused,
and a PRN schedule as if it were due every day. The rate now honours
INTERVAL for every frequency, counts the listed days and months, scales a
cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job
selects the schedule type and cycle lengths so it reads the same rate.
Whole-dose counts no longer lose one to binary floating point: 2.4 mL at
0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two
doses. The stock summary, both runway estimators, the list and detail
routes, the GLP-1 route, the Coach snapshot and the inventory readout now
floor through one helper that treats anything within 1e-9 of the next whole
number as that number.
The "Send raw data" switch promised anonymised single readings from the
last 30 days. It sends bucket averages from the rollup table: per day for
the last 90 days, per week for the year before, per month back to five
years, each with its date and reading count. The label, both
descriptions and the warning now say that, and the "off" text no longer
claims the summaries carry no individual value (they include the latest
one).
The data-posture line and the Coach's own description of the app said
all health data is encrypted at rest and nothing leaves the instance
beyond the AI provider. Free text, documents, questionnaire answers,
AI-written text and tokens are encrypted; measurement values, lab
values, medication names and mood scores are not. Both now say so, and
the Coach is told not to claim otherwise. Two narrower absolutes are
scoped to what they cover: the local-model onboarding title and the
off-host backup card.
All seven locales.
A reminder with FREQ=DAILY;BYHOUR=7,19 (the morning-and-evening blood
pressure course) was only ever sent in the morning. The tick opened its
send window at notifyHour alone, so the 19:00 slot stayed due until 07:00
the next day, and the claim was keyed per local day, so a second send that
day would have been refused anyway.
The window is now the hour of the current slot: the local hour of
nextDueAt when it is one of the rule's BYHOUR values, else notifyHour. The
early skip for a reminder already sent for its slot uses the same hour, so
it stays consistent with the hold-open rule and lastNotifiedAt. A
multi-hour reminder claims per slot hour; a single-hour reminder keeps the
per-day key.
The password-reset runbook tells an operator to run
`docker compose exec app node scripts/reset-password.mjs`, and the
intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts`
inside the container. The runner stage copied nothing from scripts/, so
both failed with "Cannot find module", the reset at the moment an admin
is locked out.
The runner stage now copies both scripts and the Argon2 parameter module
the reset imports, and proves at build time that each reaches its own
first check with every import resolved. A structural test holds every
in-container command in docs/ and the Dockerfile copies in step.
Key rotation is documented the way it works on the published image: the
Rotate now button under Admin > Encryption is the primary path. The
rotation script imports the application graph and runs from a source
checkout of the same release; the runbook, the admin hint in all seven
languages and the decrypt error now say so.
A medication's startsOn/endsOn are calendar dates stored as UTC midnight,
and the rrule library emits its day anchors the same way. The engine read
those midnights on the user's wall clock, which west of UTC is the evening
before. A FREQ=WEEKLY;BYDAY=MO plan therefore fired on Sundays in New York,
a daily course starting on the 14th produced a dose on the 13th, and an
evening dose on the last course day was cut off. One-shot doses, the first
dose of a rolling cadence and the legacy weekday walk (startsOn floor,
endsOn cap, every-N-weeks phase) carried the same shift.
The engine now keeps a calendar day as a calendar day end to end: startsOn
and rrule anchors are read from their UTC fields, instants (createdAt,
intake times) are converted to the local day they fall on, and only then is
the HH:mm materialised in the user's zone. Rolling cadences count calendar
days from the local day of the last intake, so a late-evening dose across a
DST change no longer slides a day. A nonexistent local time now always
moves forward, including in zones whose clocks skip midnight. RRULE anchors
with BYHOUR are collapsed per day so a twice-daily protocol cannot emit
duplicate slots.
CYCLIC on/off weeks are counted as seven-day blocks from the start day in
the local calendar. They used to snap to Sunday-rooted UTC weeks, so a
three-weeks-on course starting on a Wednesday lost its first days and the
off week began on the wrong day east and west of UTC.
Measurement reminders pass their anchor and course end as instants; the
adapter now converts them to the user's calendar day before handing them to
the engine.
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.
Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
Papra's date: filter matches the document date, so a row now shows that
date or none, and an undated document falls out of a date range in the
list as it does on the server. Import still files an undated document
under the day it was added.
Papra organization ids are checked against org_ plus 24 characters
before anything is sent. A 403 user.not_in_organization and a 400
server.invalid_request.params read as no such organization instead of a
refused key; the error body is read for its code and never passed on.
The connection test also reads the tags, so a key without tags:read
fails at save (permissionMissing) instead of on the first filter. Name
searches quote each word, so a leading dash is not a negation, and the
date range goes to Papra as date:>= / date:<= with the per-row check
kept as a net.
Keyed uploads and picker imports also check deleted, not yet purged
documents by content and answer deleted, remembering the new key on the
tombstone so the purge carries it into the ledger. A hand upload without
a key still stores a new copy. No content hash of a deleted document is
kept past the purge.
The settings card stays visible, read-only, while a connection is
stored but the operator list is gone or Documents is off, so the token
can still be removed; a new address asks for the token again. The
picker clears a selection when the search or a filter changes, says
when the 25-document limit is reached, steps back the rows a finished
run did not touch and offers Pick more, shows an empty state, and
announces each row's result to screen readers. Both system switches
are radio groups with one tab stop and arrow-key movement. Strings in
all seven languages.
- Source keys carry the normalised origin of the Paperless-ngx or Papra
they came from (source_instance on the document, the alias and the
purge ledger; unique NULLS NOT DISTINCT). A key stored without one
(v1.39.2 imports) matches any instance of its system, and a lookup
without one matches any stored instance. The upload route, the lookup
route and the import script accept or send it; backups carry it.
- Saving a connection reuses the stored token only for the same origin;
a new origin needs the token again (422 tokenRequired).
- Document ids are checked against what each system issues (Paperless
digits, Papra [A-Za-z0-9_-], never . or ..) before any URL is built;
the Papra organization id likewise.
- Paperless API 9 filters dates on created__gte / created__lte, in the
picker and the script. A search page the local re-check empties reads
on, up to five source pages. Tags are read past the first 250.
- The import's key pre-check uses the lookup allowance, a failed fetch
no longer charges the upload allowance, and one import per key runs at
a time under an advisory lock, so a double click downloads once.
- Reading and deleting one's own connection works with the documents
module off and the operator list unset.
97507-8 is the mean of interstitial glucose in mass/volume. A spot-reading
mean is neither, so it now goes out text-only in the account's unit. A
continuous-stream mean keeps the code and is sent in mg/dL, the unit it is
computed in, whatever unit the account displays.
A run of the HL7 validator against every code the export can emit found
terms that do not exist, are inactive, mean something else, or carry a
display the code system does not publish.
- Body water and bone mass use 101683-1 and 101685-6; the old codes are a
nasogastric fluid output and a calculated body-fat mass.
- Step length, walking speed, walking asymmetry and VO2 max ride their
HealthKit identifiers: LOINC has no fitting term, and two of the old
codes do not exist.
- Mood, estimated A1C, average cycle length and average period length are
text-only. The old codes did not exist or were questionnaire items.
- LDL, vitamin D and eGFR use the method-less and total terms (2089-1,
62292-8, 69405-9) a recorded value can honestly claim.
- Resting heart rate and pulse-oximetry SpO2 also carry the heart-rate and
oxygen-saturation codes the R4 vital-signs profiles require.
- Blood pressure, adherence, glucose-panel and injection-site codings carry
the published displays; ATC codings carry none, since the user's
medication name is not the ATC name. The name stays on the text.
- Mood is a survey finding, not a vital sign, so it no longer rides the
vital-signs panel.
Allergy used 106190000, inactive since 2013; it is now 473011001 Allergic
condition. Injury keeps 417163006 with the display SNOMED CT publishes,
Traumatic or non-traumatic injury; the old display was rejected.
A window without a vital sign emitted the panel with result: [], which R4
rejects. The DiagnosticReport now goes out only when it has a result, and
the vital-signs section drops an empty entry list rather than sending one.
The HL7 validator reports 419161000 and 419465000, the left and right
concepts of the R4 bodysite-laterality value set, as inactive. Use the
active qualifier values 7771000 Left and 24028007 Right, with 51440002
Right and left. The side word in bodySite.text stays English, the language
of every other generated text in the Bundle; the builder takes no locale.
Follow-ups to the clinical-record search, test first:
- Condition and vaccination notes are no longer searched or returned.
Both reads use an explicit column list without the note, as the Coach
illness snapshot and the doctor report already do. A structural guard
fails when any MCP read of either model names noteEncrypted or has no
explicit select.
- A document uploaded with AI reading deferred is not searched by its
text and returns no excerpt (metadata.reason "ai_read_deferred"); its
title, kind and links stay findable.
- Documents older than the newest 500 are found by title and file name
through a bounded SQL lookup on those plaintext columns.
- Query words shorter than four letters match whole words or word starts
only, so "arm" no longer finds "Darm".
- A deleted practitioner is no longer named or matched, in search, fetch,
get_visits and the appointments in get_preventive_care. The empty-query
listing shows visits that happened, not booked ones.
- get_labs, get_medication_schedule, get_medication_compliance and the
lab and medication resources (read, list, completion) answer
{ present: false, reason: "module_disabled" } when their module is off,
without reading. get_visits withholds condition labels when illness is
off and says so in conditionsReason.
- The consent screen and the tool descriptions name what a connection
can read, the indexed text of stored documents included.
A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
The ingest path takes the capability from its caller, so the upload and
the import route each read getAiCapability("documentAi") in their own
source, where the AI route inventory classifies them.
A person stores one connection per system (base address, Papra
organization id, API token encrypted at rest) and can search it by
document name with tag and date filters, then import picked documents
one per request through the same ingest path as an upload: source-key
answer before any download, content dedup, deleted stays deleted, quota,
provenance, and the person's own AI reading setting. An import can link
the document to the visit, condition or vaccination it was picked for.
The operator lists the instances in DOCUMENT_SOURCE_ORIGINS; unset, the
feature is off. Only listed origins are contacted, through safeFetch with
the origin pinned, redirects refused, timeouts and byte caps. The routes
are cookie-only and owner-only. The token is registered for rotation,
removed by Delete all data, and not carried in backups.
In its default mode ChatGPT can call only search and fetch, and until now
those two covered metric domains, medications and lab analytes. A question
about a knee operation or a discharge letter had nothing to land on.
search now also returns visit:<id> (procedures included), condition:<id>,
document:<id> and vaccination:<id>. Matching runs after the decrypt over
the account's own rows, bounded per kind, folded the way the body-sites
view folds sites, with German/English body-part and vault synonyms.
Documents match on title, file name, kind and whole words of the blind
content index; the body is never read for a search. Results rank by
matched query words, then score, kind, date and id, so the offset cursor
stays stable. Record results are capped at 50.
fetch returns each record's key fields and its one-hop links through the
link service, every link carrying an id fetch accepts. Free text is fenced;
short labels are scrubbed. A document returns metadata and at most a
1500-character excerpt of its indexed text, never the file or its stored
summary.
A switched-off module's records are neither read nor returned: illness,
inboundDocuments and vaccinations for the new kinds, and now medications,
labs and the owning module of a discovery signal for the older ones.
The upload route keeps authentication, its rate buckets, multipart parsing
and the response shape; the source-key answer, size and type checks,
pre-link narrowing, content-hash dedup with alias memory, the quota-gated
insert, the audit row and the background jobs move to
src/lib/documents/ingest.ts unchanged, so a second way in can store
through the same rules.
Condition.bodySite carries the free-text site as text. The side rides a
contained BodyStructure reached through the core bodySite extension, coded
with the SNOMED CT concepts of the R4 bodysite-laterality value set, and is
repeated in the text so a receiver that ignores extensions still reads it.
Renaming an overdue booster check-up on the web still moved it to next
year. The edit sheet sends the first due date as the browser's local
midnight on every save, a booster row stores the dose instant plus N
days, and the route read the different instant as a new first due date.
The route now compares the first due date by calendar day in the
profile timezone and drops a resent same-day value from the write. The
edit sheet sends only the fields that changed. Re-enabling a reminder
that stays due after it is sent keeps the slot it was on instead of
scheduling from now; a short-cycle reminder is still scheduled from
now. Moving the notify hour later on the day a reminder was already
sent carries the last-sent cursor along, so the slot does not go out a
second time at the new hour.
The start page read every reminder due before the end of the day, so a
daily weigh-in at 20:00 or a twice-daily blood-pressure course showed
"check-up due" from the morning on and took a pinned place on the rail.
`holdsOpenAfterReminder` is now the one definition of a reminder that
stays due after it is sent: an interval longer than seven days, a rule
whose next gap is longer than seven days, a rule with no occurrence
left, or no cadence; never an appointment. The tick uses it to decide
whether a delivered reminder rolls on, and the start page uses it to
show such a reminder for its whole due day and keep it under the cap.
A short-cycle reminder shows only once its time has come and is not
pinned.
The tick also skips a reminder already sent for its current slot
outside its notify hour before the auto-resolve read. The eventful
satisfy worker still resolves it the moment a reading or lab result
lands, and the poll still runs for it once a day, in the notify hour;
the weekly hold stays after that poll so the safety net is daily.
The first cut kept only free-text check-ups due after their reminder
and let every reminder with a measurement type roll on. A fortnightly
PHQ-9 or GAD-7 reminder is a measurement reminder, and each one still
jumped two weeks forward the morning it was sent without the
questionnaire being filled.
The rule is now the cycle length. A reminder whose next slot is more
than seven days away, or that has no cadence, stays due after its
reminder until it is satisfied (a matching reading, a lab result or
"done"), skipped or snoozed, and is sent again at most once a week. A
reminder on a weekly or shorter cycle rolls on as before. Appointments
stay one-shot. A reading that lands on an open measurement reminder
still sets lastSatisfiedAt and schedules the next cycle from itself.
The 0355 repair drops its "no measurement type" condition for the same
cycle rule, so the fortnightly questionnaires in the reported shape
are put back on the day they were reminded.
The start-page item choice stores the enabled kinds, so a list saved before a kind existed reads as that kind switched off. Say so next to the kind list, where the next kind will be added.
Every edit of a reminder recomputed its due date from now, so fixing
the label or the location of an open, overdue check-up moved it to its
next slot, the same "gone until next year" the reminder tick caused.
The due date is now recomputed only when the edit changes when the
reminder recurs: the interval, the rule, the first due date (compared
by value, since the edit forms resend every field), or switching a
disabled reminder back on. A notify-hour edit keeps the due day and
moves the hour on it, together with a snooze pinned to that slot.
The start page's short list left out things a reminder had just told
the person about. A check-up counted only once its due hour had
passed, a booked visit was read one at a time and dropped out at its
start time, which is when its reminder fires, and both sat below doses,
sync problems and the Coach check-in under a three-item cap, so a busy
morning pushed them off.
The check-up item now covers every check-up due today or overdue and
names up to three of them. Visits are read for the whole local day,
also once started, and for the next 48 hours: everything booked today
shares one item, the next day with a visit gets a second, and the
today / tomorrow / day-after wording follows the calendar rather than
an hour count. A due check-up and today's visits always keep a place
under the cap; the displayed order is unchanged. A Coach cadence label
is translated here as it is on the checkups page.
The digest's wire shape is unchanged: visits reach it only as
`upcoming_visit` items, which may now appear twice.
The reminder tick rolled every delivered reminder on to its next slot.
For a check-up (a reminder with no measurement type, made by the person
or suggested by the Coach) that filed an open task under next month or
next year the moment it was reminded, although nobody had done it. The
start page and the check-up card then showed a yearly check-up due
today as scheduled for next year, and a check-up with no cadence
dropped off every surface.
A check-up now keeps its due date after the reminder until it is
marked done, skipped or snoozed, and is reminded again at most once a
week while it stays open. A new `last_notified_at` column records the
last delivered reminder; a repeat is held while that send was for the
current slot and fewer than seven calendar days have passed, so a
snooze, a skip or a completion opens a fresh slot that fires on its own
day. The per-day claim stays the guard against two sends in one day.
Measurement reminders keep rolling on (the next reading satisfies them
and their next slot comes round soon), as do check-ups on a weekly or
shorter cadence, whose next slot is the next reminder anyway.
Appointment reminders stay one-shot.
Migration 0355 adds the column and puts back check-ups the old tick
rolled on: a live, enabled check-up whose most recent reminder claim is
followed within ten minutes by its last write, carrying a due date more
than a week past that send (or none, for a one-shot), not done, skipped
or snoozed since. Its due date returns to the instant of that send.
Anything else is left as it is. The same migration adds `upcoming_visit`
to start-page item choices stored before visits were one of them, which
otherwise read as visits switched off.
The due phrase on the check-up card and the checkups page counted a
check-up one day past its date as "Overdue by 1 days", and the same
counted form read wrongly in German. One day over now has its own
phrase in all seven languages. Both overdue phrases keep the warning
colour through a shared `isOverdueDue` helper, so a surface no longer
compares against a single key.
Key rotation re-sealed a single-value backup in place, which holds the
stored string, its bytes, the plaintext and the new ciphertext at once; a
copy can be over a hundred megabytes and the in-app rotation walked 25 of
them per page, enough to run a 1 GB container out of memory. Rotation now
converts each such copy into pieces under the active key instead, one row
at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte
at a time with its tag checked at the end, inside one transaction, so a
copy that fails the check is left untouched; the copy keeps its date. The
scan reads only the first characters of each value. Measured on a copy of
a 2.6 million-reading account (126 MB stored): converted in 25 s with the
heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out
of heap.
The weekly and manual backup leave an account alone while a restore of
its current copy is queued or running, and count it as skipped, not
failed. A reader whose copy is replaced mid-read answers 409
backup_changed, "replaced by a newer one while it was being read", rather
than the undecryptable error; a restore rolls back with the same code.
Pieces are sealed with a fixed associated-data label, so a value the same
key sealed for another purpose never opens as a piece; rotation re-seals
under the same label. A row holding both a single value and pieces, which
only an older release writing after a downgrade produces, is refused with
a message saying so, and the next weekly run replaces it. Rotation treats a
row deleted between its read and its write as gone rather than an error.
A stored backup was one value in data_backups.data. v1.39.1 wrote it a
piece at a time but still joined the pieces into that one value, and one
value has to pass through the app whole, so a copy was capped at a fifth of
the heap: 105 MB in the default 1 GB container. An account with 1.75
million readings is past that and could not be backed up at all (#1031).
A copy is now kept as ordered pieces of about a megabyte in the new
data_backup_chunks table (migration 0352). Each piece is sealed on its own
with AES-256-GCM, and its sealed header names the copy (a random stream id
kept on the row), its position and whether it is the last. A reader checks
every piece before it releases a byte, so a piece that is missing, moved,
altered, taken from another copy, or a copy cut short is refused with
backup.payload.undecryptable before a restore deletes anything, and the
second read during the restore checks each piece again.
The write is one transaction, so the previous copy stays whole and readable
until the new one commits. Pieces are written through the model API: a raw
query with a large parameter is kept alive by Prisma's query plan cache,
which left 160 MB of heap behind per hundred one-megabyte inserts.
Restore, preview, download, the restore job and the admin list read both
forms; copies written before this change keep their single value and still
restore. The heap-derived limit is gone. The one limit left is on storage,
BACKUP_MAX_STORED_MB (default 2048), and its message names the setting. Key
rotation covers the pieces, the wipe and account deletion take them with
their backup, and the backup plan lists the table as not carried.
Measured on an account of 2.6 million readings in a 1 GB container with web
and worker in one process: backup 87 s, 97 MiB in 96 pieces, preview 20 s,
download 25 s, restore 264 s with every reading back exactly, peak 567 MiB,
no restart.
A document uploaded with aiRead=defer is now indexed locally by any
automatic path, because the indexer reads the marker from the row rather
than from the job. Index all for search leaves such documents out and no
longer counts them. Only an explicit Read with AI or Generate summary ends
the hold.
Uploads that send a body keep their slot of the hourly limit, duplicates
included; handing it back let a token send the same bytes under id after
id for free. One document takes at most 20 further source ids, beyond
which the upload answers 409 documents.inbound.sourceAliasLimit. The
query-string key check is metered on the lookup allowance it shares with
GET /api/documents/inbound/source.
Refs #1038