Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).
Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.
src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.
The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.
The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
- Load each settings section on its own: the section route imported all
twenty-one statically. A preload starts the chunk beside the record
gate's read, and the loading state paints the settings card shell.
/settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
run on each boot is plain TypeScript now, held to the published schema
by an equivalence test over good and broken blocks. The canonical unit
table moves to its own module, so the display transform no longer
pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
instead of the smooth scroll that lost the end, then follow late
layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
across on a phone and three or four on a desktop, with the month
headings kept.
- Add the admin card for the measurement table maintenance: a status
read on the maintenance route, one calm confirmation, and the run's
state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
watched at 403, aggregate cap 4335 with the reason written down.
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.
The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.
The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.
Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.
The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.
Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
MCP `search` and `fetch` link a metric as `/insights?metric=<id>` and a lab
value as `/labs?analyte=<name>`. Neither page read its query, so every link
opened the overview or the full list.
`/insights` now resolves the id server-side, in whichever spelling the
result carries (a Coach source key such as `hrv` or `bp`, a signal or
metric-status key such as `GRIP_STRENGTH`, a measurement type, or a
sub-page slug), and redirects to the metric's page, or to its value list
when no page focuses it. An id it cannot place renders the overview as
before.
`/labs` asks for the newest reading stored under the exact analyte name
and opens its marker's page; a name with no reading keeps the list and
drops the query.
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.
Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.
- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
admitted at MANAGE like the cycle symptom vocabulary) and owner-only
PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
medication filed under the category to OTHER in the same transaction
and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
the create and update bodies refuse a key that is not the caller's
(422 medications.category.unknown), and list, detail, the insights
medication tiles and the MCP medications resource carry categoryLabel.
A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
built-in rows with an inline add row; the list page gains a category
filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
restore writes them before the medications; wipe plan, encrypted
column registry, rotation script, sharing guards and OpenAPI follow.
- A new capture kind "symptom" (illness module, illness write grant) in
the bottom bar's picker and the dashboard Add menu. The sheet follows
the measurement and mood forms: the person's symptoms as chips with the
cycle sheet's dashed "new symptom" chip, the 0-10 slider with its
anchors, the time prefilled with now, an optional note, and a "during"
selector only while an illness episode is open.
- "Your symptoms" on the illness page: one line per symptom (how often in
30 days, how strong at worst, when last), the last 30 days grouped by
day, edit and delete per entry, rename, icon, hide and delete per
symptom. Renders nothing until a symptom exists.
- "Symptoms logged" on an episode's page: the entries filed against it or
logged while it ran, the filed ones marked. This is the reader of the
episode link.
- Query keys live under the illness root, so either side's writes
refresh both.
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.
Both now run as a background run (`DocumentAiRun`, migration 0366):
- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
answers 202 with a run id once every quick refusal has passed. Without
the header it answers exactly as before, which the iPhone app relies on.
A second request while a read of the same document is queued or running
attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
route answers with, or the failure code and status it would have given.
Scoped to the caller; another account's run is a 404.
The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.
Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.
The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.
The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.
HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
The streaming hook handles the step, result, followUps and clarification
frames and sends followUp and clarification on the tool route only. The
bubble gains slots for the step list, the tables and the method line; the
thread offers chips under the latest assistant turn only; the conversation
shows the clarification card above the composer and marks a typed answer
as answering it. The tables of a persisted message load lazily when it
scrolls into view, seeded from the stream for the turn that produced them.
The new components render nothing yet.
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.
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.
- migration 0354: two nullable columns on illness_episodes, reusing the
laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
keeps the stored value, so an edit from the shipped iPhone app, which
never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
conditions, folded after the decrypt and bounded per kind; with site
(and laterality) the visits and conditions there, one hop to their
documents, lab results and visits. Read in the profile section;
conditions only under an illness grant with the module on, and links
into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
one body-site field pair that suggests sites already used; a
condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
name the site on conditions; the Coach illness block carries it
sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.
- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
searched after the decrypt over site, side and reason, with body-site
choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
form offers the site for a procedure and keeps it in view on any
visit that carries one
- backup and restore carry both fields; the body site is registered as
ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
procedures when no window is named; the Coach visit block names a
procedure's site
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.
The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.
What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.
- One restore per account: a partial unique index admits one queued or
running job, and a second request gets 409 `backup.restore.active` naming
the running job. A per-account advisory lock inside the transaction holds
even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
limit would run past three quarters of it is refused before anything is
deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
queues a job whose worker stopped again from the start, at most twice,
then fails it as interrupted. The admission check releases an abandoned
job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
rest. It stays whatever the outcome, so a retry needs no new upload. The
job records a digest of the copy and refuses one that was replaced while
it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
after, and after a reload it finds the job again from the server. A poll
that fails on the network is never shown as a failed restore. A web
process separate from the worker evicts the account's caches when it
first reports the restore finished. The success line goes through the
outcome module, so an empty file is not drawn as a tick.
The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
The visit form's lab and episode pickers fetched with their own page
sizes under the same keys as the Labs list and the illness pages, which
fetch with others. Whichever read landed first served the other: the
picker could see 50 episodes instead of 100, or the Labs list a 200-row
page as the whole list. The picker reads now have keys of their own,
under the lab and illness roots so writes still evict them.
A dose or a visit can link documents from its own form, and a
document's sheet can link doses and visits; both ends write the same
table, and the sheet writes the whole set on every tap. Dose and visit
writes left the document reads alone, and the sheet seeded its pickers
from a detail cached for five minutes, so the first tap there after
linking from the dose's form wrote a set without the new link and
removed it.
Dose and visit writes now evict the document root, the sheet reads the
document again on every open, and its pickers wait for that read before
they offer a change.
Every AI surface reads its capability from the ai block on
/api/auth/me, but setting up or removing a provider, signing in to or out
of Codex, saving the fallback chain, the central-Codex opt-in, the
local-OCR switch, a minted consent receipt, the admin's server key and
central Codex, and a guardian's save inside a managed record evicted only
their own reads. The surfaces stayed on the old answer until a reload.
All of these writes, and the ones that already refreshed the payload
(consent, modules, Hide Coach, the operator's switches), now evict one
bundle, aiInputDependentKeys. A structural test holds every client write
to those endpoints to it.
Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.
Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
The visit and vaccination forms asked the document list for a page of
200 while the route accepts at most 100. The refusal read as an empty
list, so the picker said "No documents yet." to anyone with a full
vault, and the only way a scan reached a dose was the seven-day
suggestion offered at upload. The visit form's condition picker had the
same problem against the episode list.
The document pickers now walk the vault page by page at the route's own
limit, so any document can be linked from a vaccination or a visit, and
one document can sit on as many records as it covers. Documents dated
within seven days of the record stay on top under their own heading,
the rest follow by month and are searchable. A failed read now shows an
error with a retry instead of passing for an empty list.
Refs #1024, #1005
The Verlauf ledger dropped doses the full history showed. Three windows
ended too early or started too late:
- The server clamped the window floor to the medication's creation
instant and filtered recorded doses by the same floor, so a dose
logged for earlier the same day on a medication added afterwards
fell out of "Last 90 days". The creation floor now bounds minted
slots only; recorded doses use the requested window.
- A dose taken inside its window before the slot's own time is stored
on the slot anchor, which still lay past the window end. The read
horizon now reaches the latest recorded anchor within a day, without
minting later upcoming slots.
- The client pinned from/to at mount and reused them on every refetch,
so a dose added after the tab opened stayed hidden until a reload.
The window is now resolved when the request fires and only "from"
travels; the query key carries the window length instead of instants.
Refs #1028
The done screen moves focus to its heading like every other screen; the
confirm screen's preference list holds only dt/dd pairs and the settings
link stands beside it; Back from Q1 is not offered once the flow has
been entered, because the front door would resume past the welcome and
bounce straight back; the medication task records no target on the
ledger, as the contract says, and keeps the id on the screen; and the
two success affordances go through the outcome module rather than a
hand-rolled tick. The error-code catalogue lists the answers and restart
codes and no longer the retired step ones, and the session-surface guard
learns the one new deriving helper the onboarding pages read through.
The getting-started checklist reads the answers: a visit named within a
month adds a "prepare the visit" row that is done once one is on the
calendar, and the data-source row names the wearable that was chosen.
Its per-browser state moves to one module so the done screen, the skip
and Settings write the same keys.
Settings → Account gains the two ways back in: "Set up again" through
the restart route, which resets the ledger, keeps the answers as
prefill and touches no module; and "Show the checklist again", which
forgets what this browser hid.
A page whose module is off now answers with the shared notice and the
one action that helps, in one place for every module page: the shell
maps the route to the module through the navigation model's own gate,
so a page and its nav entry cannot disagree about which switch they
follow. And the tour's two dead stops get their anchors on the
integrations and health-record group headings.
The needs-based onboarding has no read of its own — its state rides
GET /api/auth/me — but every screen that persists an answer, the confirm
screen, the disclaimer acknowledgment and the Settings restart card are
mutations, and the queryKey-factory rule allows no bare key at a call
site. One module names them, and one read-back key for the reading the
first-result step logs, under the measurements prefix so a later write
evicts it.
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).
PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.
The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.
Refs #939
crypto.randomUUID() is [SecureContext] in the Web Cryptography IDL, so a
page served over plain HTTP never gets it. Measured in Chromium against
http://<lan-ip>: isSecureContext false, randomUUID and subtle undefined,
getRandomValues still a function.
The document vault minted its queue id and Idempotency-Key that way, inside
the file-input change handler. On a LAN self-host the call threw before a
queue row existed, so picking a file produced no upload, no error and no
trace — the picker simply looked inert, and the only way through was to
reach the app over HTTPS. The client transport has minted a default
Idempotency-Key the same way since v1.37.18, which put every apiPost and
apiPatch on the same footing: on such a host nothing wrote at all.
Add randomId(), which prefers the platform call and otherwise assembles a
v4 UUID from getRandomValues — the same CSPRNG, the same 122 bits, no
secure context required — and route every browser-side call site through
it. Nothing in an upload needs one: the bytes travel over multipart XHR and
every cryptographic operation happens on the server. Passkeys, web push and
home-screen install still need HTTPS; browsers withhold those outright.
A structural guard fails a direct crypto.randomUUID() anywhere outside the
two server-side files that may keep it, so the class cannot return one call
site at a time.
Also point the CSV and JSON import cards straight at the import guide. They
resolved through a redirect under the integrations tree, which is the wrong
place for a guide that is not an integration runbook. The suite that
covered those links pinned the old spelling rather than the guarantee; it
now asserts an absolute docs-site URL, no internal path, and new-tab
hygiene.
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.
This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.
Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.
The contract gains the endpoint and nothing else; the account payload
already carried the field.
The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
The restore dialog asked for its typed confirmation on nothing but a
filename and a date; the counts only surfaced in the audit row after the
rows were already replaced. A read-only summary endpoint now decrypts and
schema-validates the stored file through the same helpers the restore
itself runs and answers the summarizeBackup counts, and the dialog shows
them — per section, with an other-records line so a future summary key
can never silently vanish from the preview. A failed preview states its
own absence and never blocks the restore.
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.
Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
Refs #223. A due Vorsorge item could only be done, deleted or ignored;
Telegram's postpone moved the date with no trace. Both decisions are
first-class now, and both are honest:
- POST /{id}/skip (no body): the interval restarts from the skip instant
through the recurrence engine, lastSkippedAt is stamped, skipCount
increments, any snooze clears. lastSatisfiedAt is NEVER touched — done
and skipped stay distinguishable forever, enforced in the primitive and
pinned by tests.
- POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to
the notifyHour in the profile timezone and moves snoozedUntil and
nextDueAt to the same instant, so every due-state consumer follows
without knowing snooze exists. At least tomorrow, at most five years.
- GET /{id}/history: the new append-only completion ledger
(MeasurementReminderEvent), one row per satisfy from every engine path
and per skip, onTime derived at write time against the due instant that
was current when the event landed.
Two anchor fixes close the backdated-event gaps: the auto-resolve floor is
now the later of last satisfy and last skip (an old reading synced late no
longer resolves the cycle the user just skipped), and a backdated real
result re-anchors the next due at the later of its own instant and the
skip decision.
Screenings stay skippable and snoozable — the satisfy-side 409 guards
claimed fulfilment; a skip claims the opposite. Appointment reminders 404
on all three routes, in the lookup, like every by-id sibling. /checkups
gains the postpone sheet (quick chips, date field, the skip zone with its
own confirm) in both view branches, with muted meta lines and no card
colour change. The MCP preventive-care tool carries the resolved fields;
the OpenAPI contract publishes all three paths and the widened DTO.
Ten client mutation sites forgot a consumer: a coach-accepted checkup
stayed invisible for up to five minutes, a threshold or target edit left
the dashboard bands stale, a score-config save marked the daily reads
stale but never refetched them, a booked visit missed the Today rail, a
source-priority reorder kept every chart on the old canonical picks, an
adopted self-context note never reached the Settings read. Each site now
invalidates what its write changed and forces the unmounted snapshot +
digest reads to refetch through refetchInactiveDailyReads.
New illnessDependentKeys bundle carries the daily reads (Rest Mode
frames both), dailyDigest joins encounterDependentKeys, and the
chart-data prefix gains a factory accessor for whole-cache flushes.
The daily-reads guard learns the cycle / encounter / vaccination /
illness bundle families and a second matcher for direct snapshot/digest
invalidateQueries without a forced refetch, each with a frozen reasoned
allowlist. Both matchers were proven live by reverting a fix and
watching the guard go red (see the test doc comment).
GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH
validates a partial body with per-field length caps, builds the Prisma
data field-by-field from the parsed input (never a spread), narrows the
user from requireAuth rather than the body, encrypts the free text on
write, and audits the change. The GET decrypts fail-soft for the form
prefill. Registers the OpenAPI operations and schema. An integration
test reads the row back to prove the write lands.
- from the record: a document link picker on the capture form, gated on the
documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
within the shared ±7-day window; a single candidate pre-selects, two offer a
picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
ordinary VORSORGE reminder keyed on the dose's primary antigen; the
published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
reminder rather than minting a second (WR-9 seen red on the reminder count,
reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
- one sheet for create and edit, saving with a date and one identity arm
(catalogue pick or free text); nothing else can block the save
- catalogue picker searches localised names and generic synonyms over the
static antigen list, never overwriting the free-text arm
- optional dose/series, lot, site, practice combobox, visit suggestion and
note, all reusing the encounter-form primitives
- write hooks fan out through the dose dependent-keys and the reminder reads
- form/site/sheet copy in six locales
- read the immunization log through the query-key factory, unwrapping the
envelope; the series arrive resolved per component antigen from the DTO
- render one group per antigen, a combined dose appearing in each of its
component groups with that component's own position; free-text and dead-slug
rows fold under their verbatim name
- neutral cards, meta in muted, no due status on the page
- add the dose-list dependent-keys bundle, evicting the preventive-care root
alongside the dose list so a satisfied booster never lingers on checkups
- series/lot/error copy in six locales
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
The dead-code gate is right: a bundle with no caller is dead code. The
capture surface brings it back with the mutation that needs it, and the
key module says what that bundle will have to evict.
The bundle landed a release before the surfaces that read it and was removed
on trunk as dead. Both mutation hooks in this branch use it, and the merge took
trunk's deletion, so it comes back here together with the writes that need it.
The comment now names its consumers, so the next dead-code sweep can see them
without reading two files.
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve:
turn it on, read a disclaimer, get the chart. The chart stopped consulting
the flag several releases ago and has painted for every account since, so
the switch on Settings → Advanced changed no screen in either position. An
inert control that promises to hide a chart is worse than no control.
The curve is simply part of the medication page now. Gone with the switch:
the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status
DTO and its gate helper, the disclaimer-version stamp, the query key, and
the three user columns the acknowledgment wrote (migration 0321). The
Privacy page's second button pointed at the retired control and goes too.
Settings → Advanced is now what it reads as: reset the data, delete the
account.