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.
A dose can now name a vaccine the catalogue does not list. The person
defines it once (a name, the diseases it protects against from the
catalogue's antigen list, the series length and the booster interval) and
logs doses against it; such a dose counts into each listed antigen's
series, settles a booster reminder keyed to one of them and offers a
booster, the way a catalogue combination does.
One resolver (resolve-vaccine-entry.ts) now answers for every consumer
that asked the catalogue directly: the series, the satisfy matcher, the
booster mint, the dose DTO, the doctor report, document links, the
upload suggestion, MCP search and the client's info sentences and
booster offer. The catalogue wins when a dose carries both.
The custom routes resolve the record through requireRecordAuth (list at
READ, create at WRITE with the actor-keyed write bucket, edit and remove
at MANAGE with the C4 overwrite audit) and join the sharing guard, the
MANAGE matrix and the activity feed. Removing a definition tombstones it
and lets go of its doses, each keeping a name. Definitions travel in the
backup and restore before the doses that name them; the dose form picks
them, adds one without leaving the form, and the page lists them for
editing. With the last contract stub implemented, the 501 helper and the
not_implemented code leave the tree.
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.
- Migration 0368 adds medication_courses and backfills one course per
medication window (an end-only window starts on the creation day, or
on its end day when that lies earlier); a medication without a window
stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
.../courses/{courseId} (owner-only); create seeds the first course
from a window; the medication PUT edits the latest course and refuses
a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
between them; every compliance read selects the courses. A rolling
cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
the detail Lifecycle group lists the courses with start, edit and
delete. The Coach compliance block, the MCP resource, the doctor
report window and table, the clinician view and FHIR (one
MedicationStatement per course with effectivePeriod and status) read
the courses.
- Backup carries courses per medication and the restore writes them and
re-projects; an older file derives the one course its window
describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
The full backup now reads the account's own medication categories, and
three route tests that drive the payload builder against a mocked client
had no delegate for it.
The full-backup route tests mock the symptom definition read the payload
now makes; the destructive-control registry names the symptom hooks (an
entry or a whole symptom, confirmed by an AlertDialog); the symptom quick
entry's save toast is pinned beside the measurement and mood forms it
mirrors.
Both backup purposes now carry an accountSettings section. The restore writes
it to the account being restored inside the same transaction, names every
refused value under the new accountSetting skip catalogue, and re-warms the
stored texts after the transaction when a unit preference changed, the way the
unit settings routes do. A file without the section leaves the account row as
it is, which is how every older backup restores. The instance settings
restore stays its own opt-in.
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.
The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.
Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:
- cycle calendar, insights, Coach snapshot and prediction refresh start
their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening
49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
The report's dense exception covered pulse and glucose. Heart-rate
variability and blood oxygen were read raw for any window, and a watch
or ring writing them every minute puts six figures of readings into a
long report that draws them as one sparkline.
Their report sections are a statistics row and a series, the same as
pulse, so they take the same rule: beyond 90 days per local day, and
inside 90 days per local day once the window holds more than 10 000 of
their readings. One grouped count decides each type on its own. Count,
minimum, maximum and the latest reading stay exact; the mean is the
readings' mean. Glucose keeps its own rule because its clinical panel
needs the per-reading counts.
The bucket query now takes a list of types instead of two flags.
GET /api/export with type=all (the default), POST /api/export/health-record
and GET /api/fhir/Patient/$everything hand over the whole record but took a
live session alone, so a stolen browser session could still walk off with
everything the full-backup gate was meant to protect. A browser session now
needs a sign-in or re-proof within five minutes, with the second factor on
an account that has one, and the doctor-report panel opens the re-proof
dialog and retries. When a manager builds the report for a record they
manage, their own session is the one asked.
On a token the shipped app keeps working: health-record and $everything
take the token as before, including a fhir:read token, and the recent-proof
guard names both. GET /api/export?type=all, which no shipped client calls
with a token, wants a step-up elevation there. Single-type exports are
unchanged.
The API reference now also describes the enrollment rule for accounts
with a second factor and the passkey refusal it adds.
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.
These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.
On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.
Key rotation keeps its stricter second-factor gate.
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.
The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.
A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
An account of 1.25 million measurements could not export its data or be
restored from any backup (#1031). Measured in a 1 GB container:
- The full export and the passphrase export built the whole payload and
its JSON string in memory; the app ran out of heap and restarted.
- The weekly backup's JSON is 662 MB, longer than any string V8 can hold,
so `unpackBackupBlob` threw on every read. Restore, download and the
restore preview all answered "could not be decrypted".
- Upload accepted 10 MB, a fraction of a large export.
Every reader and writer of a backup file now streams it:
- `scanBackupJson` reads a backup's bytes once, keeps every section but
the named bulk arrays, and hands their elements out one at a time.
`readStreamedBackup` uses it twice: a first pass that validates each
measurement against the element schema and keeps the rest of the file,
and a second pass that hands the measurements to the writer in batches.
Stored copies and off-host objects are authenticated whole on their
compressed ciphertext before any plaintext is read; only the
decompression is streamed.
- Restore writes the measurements with one `INSERT … FROM unnest` per
batch. Prisma caches a query plan per `createMany` row count, and one
1 000-row plan kept about 145 MB alive, which alone put the restore past
the heap limit. The transaction budget grows with the measurement count.
The awards and ECG sections are handed the ids they reference that were
written, not every written id.
- The export routes write the file into the response as it is produced.
The passphrase export spools its ciphertext to a temporary file because
the HLX1 format puts the tag first; the format is unchanged, so existing
archives open as before.
- Upload accepts the raw body, JSON or gzip, up to the 512 MB request
ceiling, checks it as it arrives and stores it in the same pass; the admin
page compresses the file in the browser before sending it. Multipart stays
for small files.
- Download streams the stored JSON; the preview counts measurements from
the stream. The monthly restore drill and `scripts/restore-backup.ts`
read the off-host object the same way; the script can write `.json.gz`.
On the seeded account in a 1 GB container: export 25 s at 389 MB peak,
passphrase export 25 s, both uploads 19 s, restore of the weekly backup
94 s at 554 MB peak, and export, upload and restore round-trip to exactly
the same rows. The encrypted-column guard now also recognises the raw SQL
statement that writes `DataBackup.data`.
Nothing recomputes an answer. The module map they produced survives on the
account row, but the map says what is switched on, not that the person told the
flow they take medication daily and have a visit next month — so a restore
without the row opens a blank questionnaire for somebody who already answered
it, and leaves the checklist ordering itself from nothing.
Both ends live in one file, the arrangement the visits and the score already
use, because a reader who greps only the restore route gets a false negative
when the route delegates.
`modulesDerivedAt` is the field that makes this more than history and it is
asserted rather than defaulted: it is the once-only latch on the module
derivation, so a restore that let it default to null would hand the next confirm
permission to re-apply the questionnaire over every module decision taken since,
silently. The round trip seeds it non-null and reads it back.
The file's schema validates the three answer objects loosely and the restore
narrows them through the same fail-soft readers the account payload uses. Being
stricter there would refuse a whole account's restore over one retired chip.
The wipe takes the row for the reason the reset already takes the wizard's own
columns: the record the answers were about is gone, so the answers describe
nothing.
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.
Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.
`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.
The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
The per-user date order reached the entry form and nothing else. A
reporter switched away from MM/DD/YYYY, the date picker followed, and
the dashboard chart axes and the measurements list kept the old order.
`makeFormatters` took `timeFormat` and `dateFormat` as optional
parameters defaulting to "AUTO", so six of the seven constructions in
the tree silently rendered the locale default and nothing anywhere
said the setting had been dropped. Drop both defaults. The compiler
then names every caller: the chart bucket labels, the legacy
`@/lib/format` helpers the measurements list renders through, the
medication compliance and efficacy charts, the doctor-report PDF and
the clinician share view.
Thread the preference:
- `makeBucketLabelFormatters` takes the order; the health and mood
charts read `useDateFormatPreference()`. The UTC pin decides which
day the label names, the preference decides how it is spelled, and
the two are separate questions.
- `format.ts` reads `readStoredDateFormat()` beside the timezone and
hour-cycle mirrors it already read.
- The PDF takes `dateFormat` as a required render option, alongside
`timeFormat`, which was optional for the same reason.
- The clinician share view spells the OWNER's dates the owner's way.
It also picked up the owner's hour cycle, which it had been ignoring
while the PDF route beside it already honoured it. Both resolve
through the new `resolveUserFormatPreferences`, the counterpart to
`resolveUserTimezone`.
Textual months stay on the UI locale. The preference pins numeric
field order by rendering through de-DE / en-US / en-CA, and a textual
month through those would change the month name's language.
Add `date-order-explicit-formatter-guard`: a raw numeric date renderer
must take its locale from the preference or carry a written reason,
and a formatter construction may not hard-code "AUTO" for the order.
Its allowlist holds thirteen calendar-day key builders, each with the
reason it has no order to get wrong. The sibling timezone guard passes
every one of them, because naming a calendar says nothing about
spelling a date.
Refs #922
The register said the originating device may still hold them. That holds
for about as long as the phone beside the watch does, and not at all once
a Withings connection is revoked: the signal id stops resolving with it.
So a self-hoster who exported and wiped had a file with no traces in it
and a restore that reported success.
Both ends live in src/lib/export/ecg-backup.ts, the arrangement the
reminders, visits and Coach sections already use.
Three things needed deciding.
The waveform follows the note contract: ciphertext verbatim in a
disaster-recovery payload, the micro-volt samples decrypted in a portable
one. There is no placeholder for a strip that will not decrypt the way
the Coach has one for a turn, because waveformEncrypted is a required
column. Such a recording is left out of a portable file with a warning on
the wide event, so the number of strips in the file is the number of
strips that can be shown.
measurementId is a REAL foreign key against the event row a strip was
filed with, unlike the Coach references next door. A value that will not
resolve does not dangle quietly here, it violates the constraint and
rolls the whole restore back over one cross-reference. A portable export
omits soft-deleted measurements, so that case is reachable rather than
theoretical. The reference is resolved against the measurements the
restore wrote, nulled when it cannot be, and reported as ecgReference.
The strips are wiped and rewritten by the section itself. The wipe at the
top of the restore never reached them: a recording hangs off the account,
not off a measurement, so deleting the measurements only nulled the
pointer and left the row behind.
The round trip asserts the row column by column rather than counting it.
A recording is three things at once and each is the whole value of the
row on its own: a strip with no rhythm verdict is a squiggle nobody has
read, a strip with no recordedAt belongs to no day, and a strip with no
samples is a claim that an ECG exists which nothing can draw.
Four models leave the coverage-pending register with both ends and a
round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and
EnvironmentTravelLocation. Two new section modules hold reader and restore
beside each other, the arrangement the visits, vaccinations, reminders and
Coach sections already use.
The register understated two of them. The bests are "recomputable in
principle", which is true and does not help: nothing recomputes them, and a
recomputation over a restored history would find different rows, because
sample series and GPS routes never travel, a portable file omits deleted
readings, and a record set in a reading that has since been corrected cannot
be found twice. The badge's unlock date is worse. The evaluator prefers a
persisted date over a derived one, and several of the dates it can still
derive come from counters no backup carries, so a dropped row does not relock
the badge, it re-earns it today.
PersonalRecord.sourceMeasurementId is the reference that needed care, and it
is the dangerous kind. The schema declares no relation for it; migration 0054
created the column as a real foreign key against measurements. Writing it
blind is not a dangling pointer, it is a constraint violation that rolls the
whole restore back and hands the operator an empty account. Measured, with the
resolution removed: "Foreign key constraint violated on the constraint:
personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So
the restore runs after the measurements, resolves the pointer against the rows
it actually wrote, nulls what it cannot resolve and reports it under a new
personalRecordReference skip kind.
An unlocked badge names a definition in the code catalogue, which drifts
between the release that writes a file and the one that reads it. The id is
carried and written back verbatim rather than judged, the same answer the
vaccination restore gives an antigen slug it cannot resolve.
The readings and the location periods travel as a pair because carrying the
readings alone is worse than losing them. The environment resolver reads the
periods live, so with them gone the nightly seven-day lookback re-resolves
every trip day to the home location and upserts over the row: an account would
come back with a fortnight abroad and then have it quietly rewritten as a
fortnight at home, days after a restore that reported success. Day keys stay
strings end to end, and fetchedAt is restored verbatim so a two-year-old
provisional reading does not claim it was confirmed today.
The round trip seeds bests that share a metric type and an instant and differ
only in the sport slot, two badges earned years apart (one of them under an id
this build no longer defines), and a trip day beside a home day, then reads
each back column by column. Counting alone passes every one of the failures
worth fearing here: slots dropped so a 5 km best is presented as a 10 km best,
unlock dates stamped at restore time, a trip day re-attributed to the home
city, a location period whose bounds no longer cover the reading it explains.
Each of those was applied on purpose and each failed on its own assertion.
Two schema comments claimed no read path selects a column that this backup now
selects; both say what reads it instead of staying wrong.
`DocumentConditionLink` and `ExtractedFact` were classified as backed up and
came back from no restore. The register said a restored vault is unsorted and
that the facts are re-derivable by re-running the extraction. The first is
right; the second understates it. What a re-run cannot produce is the REVIEW
DECISION on each fact, and every column holding one defaults to "nobody has
looked at this yet". `POST /api/documents/inbound/[id]/confirm` acts only on a
PENDING fact and commits it into the structured store, so a restore that let
those columns default would offer an already-committed reading for approval a
second time and write a second lab result behind it.
Both ends live in `src/lib/export/document-filing-backup.ts`, beside each other
like the Coach, reminder and vaccination sections. `status`, `needsReview`,
`confidence` and the commitment pair are carried and restored verbatim, and the
wire schema requires the first three rather than defaulting them.
Ordering: the section runs after the documents, the condition episodes, the lab
results and the medications. The first two are real foreign keys, unlike the
Coach's bare id columns, so an unresolvable value there does not cost one edge,
it aborts the transaction and the operator gets none of the account back. The
last two are what `committedRecordId` resolves against. The builder therefore
carries a filing or a fact only when both of its ends are carried, and the
restore still checks and drops what a hand-edited file leaves unresolvable.
`committedRecordId` is nulled together with its type, because a type with no id
claims a commitment the row cannot name.
The round trip seeds a second condition and files the page under it, so a
restore that kept the count and lost the pairing fails rather than handing back
a lab report filed under a head cold. A third case breaks both references in a
saved file to prove the drop path runs and still answers 200.
Three new skip catalogues arrive with it, and with them labels for the three
that had none: a dropped Coach attachment had been reaching the operator's
screen labelled "mood factor" since it was added. The label chain is exhaustive
now, so the next catalogue without one stops the build.
Two models come off the coverage register together, and neither is an
ordering or an id-remap problem. Both hang off the account and nothing
else. What kept them off was a question the code never wrote down, so
this change writes it down.
The screeners hold completed WHO-5, PHQ and GAD administrations, and for
PHQ-9 the encrypted blob contains item 9, the question about thoughts of
being better off dead. The application already draws a line around that
blob: it keeps a coarse item9Flagged boolean so the history view can
re-surface crisis resources without decrypting anything. The intent here
was to follow that line and carry the administration while leaving the
answers encrypted. The schema settles it more firmly. responsesEncrypted
is Bytes and NOT NULL, so there is no half-row to write and the choice
is the whole thing or none of it. A plaintext export in a downloads
folder is the wrong home for that answer, so the row rides in a
disaster-recovery file only.
The consent receipts are not sensitive in that way. Their problem is
that a consent is given to an operator. A receipt restored onto a
different instance asserts an agreement that operator never obtained,
with a signature timestamp to make it look settled. A disaster-recovery
file goes back to the same instance, so it travels there and not in a
portable one.
In both cases the portable file SAYS so in its own manifest rather than
reporting an account that has neither. The manifest is merged rather
than added beside the records one, because two keys of the same name
would shadow each other depending on spread order.
item9Flagged is restored from the file rather than recomputed.
Recomputing would mean decrypting every administration during a restore,
which is the handling this data is kept away from, and the stored flag
is what the history view has been reading all along.
Verified by breaking it: dropping revokedAt on the way back fails the
round trip with a withdrawn consent coming back as an active one.
An account that ever created its own mood category could not be restored
at all. Not partially, not with a gap: the restore violated
mood_tags_category_id_fkey and rolled the whole file back, so the
operator got a 500 naming a constraint and an empty account.
The coverage register described this as custom tags landing in a
grouping that no longer exists, which reads like a cosmetic loss.
Measured, it is the same shape the retired-catalogue-key work already
removed once: one row nobody thought about taking down everything else
with it.
MoodTag.categoryId is a real foreign key. Seeded categories belong to
the instance and are deliberately still not carried, but a category the
person made is theirs, cascades away with them, and has to travel. It
now does, and it is written before the tags that point at it.
The hidden set travels by key rather than id, because what people hide
is almost always a seeded tag whose id differs on every instance. A key
this instance no longer knows costs one hidden row and is reported: a
silent drop would un-hide a factor somebody had deliberately taken out
of their mood editor, and they would have no way to know why it came
back.
Found by experiment rather than by reading: seeding a user-owned
category into the round trip turned it red with the constraint name, and
the same test is green now.
The transcript came back in the previous change; what the Coach keeps
BETWEEN threads did not. A restored account had every word of every
conversation and a Coach that has to be told its own history again — the
register said as much for all three: "a restore makes the account
re-tell its history", "keeps the talk and loses the commitment", "a
restored account silently stops sending them".
Tombstones ride only in a disaster-recovery file. All three soft-delete,
and a deleted fact is hidden from injection while kept for audit — a
distinction only the owning instance can act on. A portable file
carrying them would hand a person rows their own app refuses to show
them, and a restore that resurrected one would put a fact back into the
Coach's mouth that the person had deliberately taken out of it.
Neither reference here is a foreign key. sourceConversationId, on all
three, and relatedPlanId on the reminder are bare id columns, so nothing
in the database refuses a value pointing at nothing. That makes them
more dangerous than a real key rather than less: a dangling reference
costs no error, it just quietly stops meaning anything while the row
goes on looking complete. The restore resolves both against what it
actually wrote, nulls what it cannot resolve, and reports the count.
Nulling rather than dropping is the point. A live reminder must not be
lost because the plan it mentions was tombstoned out of a portable file.
The reminder is still the person's and it still fires; what it loses is
a pointer that was already going nowhere.
Order follows from that: threads, then plans, then reminders. Running
the memory before the transcript would cut every fact loose from the
conversation it came out of and still report success.
Verified by breaking it: making the conversation lookup always return
null fails the round trip on the fact's own source field.
A restored account got a Coach that had never spoken to it. The
conversations, every turn in them, and the record of which vault
documents a thread was grounded in were all classified as backed up and
travelled with nothing. The register's own entry for the messages called
them the largest volume of free text an account owns after its
documents.
The three travel together because two of them mean nothing alone: a turn
without its thread has nowhere to hang, and an attachment names a
pairing rather than a thing.
documentScoped is the field that makes this more than lost history. It
is a permanent fence — set when a conversation is created through the
fenced endpoint or gets its first document, and lowered by no code path
anywhere, not by detaching every document and not by deleting one. A
restore that let it default to false would hand the tool loop back to
precisely the conversations the fence exists to keep away from it, and
nothing in the interface would look wrong afterwards. So it is carried
verbatim, restored verbatim, and asserted per thread rather than in
aggregate: a restore that wrote false everywhere would still return both
threads and every turn and satisfy every count.
Prose follows the contract the rest of the payload already uses. A
disaster-recovery file carries the stored ciphertext; a portable file
carries the text, because a portable export exists to be readable by the
person who owns it, exactly as it already decrypts medication notes,
mood notes and document summaries. A single row under a key the instance
no longer holds is named rather than thrown, so one unreadable turn
cannot cost someone the rest of the transcript.
Ids ride in both modes, as the reminders do, so an attachment resolves
and the facts, plans and reminders still owed have something real to
point at. The restore runs after the documents for the same reason, and
an attachment naming a document the file does not carry is dropped and
reported instead of failing the whole file over one link.
Verified by breaking it: forcing documentScoped to false on the way back
fails the round trip on that field by name.
Two findings from the deferred-audit list, both live since July.
A FHIR lab quantity stamped the UCUM system unconditionally while the
code was only added when the analyte map resolved a symbol for exactly
that unit. A receiver was told the unit came from UCUM and then handed
no UCUM code, which is a coding claim the bundle cannot back. System
and code now travel together, on the value and on both reference-range
bounds.
The doctor-report export accepted a selection that admits nothing. The
panel disables its own submit in that state, so only a hand-built
request reached it, and it rendered an empty artefact while spending a
rate-limit slot. This route has no documents-only mode that would make
an empty leaf list a legitimate scope, so it refuses with 422. The
share-link route, which does have that mode, is untouched.
The reminder cadences were the oldest entry on the coverage-pending
register: carried in name, restored never — an account came back silent
until every reminder was rebuilt by hand. Both models are two-ended now,
in the house shape (both ends in one file, reminders-backup.ts): every
column travels, nextDueAt verbatim rather than recomputed, the completion
ledger beside it, tombstones in disaster recovery only, and a portable
export omits a tombstoned reminder together with its ledger rows.
Encounter.reminderId and VaccinationRecord.reminderId stop being dropped
on restore: both remap against the restored reminders and fall to NULL
with a skip report only when the file genuinely lacks the row. The wipe
plan names the ledger child-before-parent; the classification guard,
completeness guards and the integration round trip all demand the new
coverage (proven failing with the section removed).
Refs #223
- freeze the booster-mint and upload-suggestion routes into the sharing
surface guard with their why sentences; delegable-write and mutation
inventories move by the two routes they add
- inventory the two new routes as module-exempt data routes (SURFACE-gated,
like the medications posture)
- give the booster-plan audit action its own owner-facing verb in six locales
- register the dose delete as a destructive control; route the vaccination
success toasts through the outcome module
- move the report leaf counts (structured 15→16, all 92→93) and the history
group count in the panel; regenerate the OpenAPI selection bound
- seed the immunization rows in the export-route mock so the aggregator no
longer throws for a fully-selected report
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 VISITS leaf ships in the release that made a visit possible, because a
selectable checkbox that produces nothing is worse than no checkbox. Leaf,
collector and PDF renderer land together, and the leaf carries no LEAF_MODULE
entry — like ALLERGIES beside it — because a visit is core and the whole report
is already gated by doctorReport. The absence is commented at the map so a
later reader does not read it as an oversight.
The rows print the date, the practice with its specialty, the kind, the reason,
the outcome and the conditions the visit was filed against. The two encrypted
columns decrypt at render, fail-soft, and never enter the stored selection. A
condition the person has since deleted is not named: the link row survives a
soft delete, and printing a label they withdrew would put it in front of a
doctor.
No FHIR resource type is added. That deferral is already written at the
Encounter model, and the bundle builder now says so at the point a reader would
look for the missing section: FHIR splits the future from the past across
Appointment and Encounter, which is a serialisation question this release does
not answer.
Two properties are free and worth stating: a stored selection never grows
(unknown ids drop) and a clinician share link's sections are frozen write-once,
so no existing report and no already-shared link gains a section.
The panel also offers a booked visit as the report's reason — one query, one
prefilled window end, one dismissible offer that names its own source and never
applies itself. A window control that moves on its own is what makes an export
panel untrustworthy.
- a new backup section reads the dose and its document link through
their own delegates and writes both ends in one file, the arrangement
the visit section already uses
- the practitioner and the visit remap to the rows the same restore put
back; the booster reminder never travels, so its reference resolves to
null and the drop is reported rather than swallowed
- the antigen slug restores verbatim: a slug the catalogue has since
dropped still says what a person was given
- the round trip seeds a dose with every optional column filled and
reads each one back, so a returning row count cannot stand in for a
returning record
A person's whole 'which doctor' history was one free-text column on a
reminder: they typed the practice name once and could reach nothing else
that happened there. This turns that string into a record.
Five tables in migration 0320, all additive: an encounter that happened
or is booked, the account's own address book of practitioners, and three
links from a visit to the documents, lab results and conditions it
produced. The encounter is slim on purpose — the diagnosis stays on the
linked condition, the address on the linked practitioner, and the reason
each field was kept or cut is written at the model. No module gate, and
the schema says why rather than leaving the absence to be read as an
oversight.
Every carrier lands with the tables rather than after them. All five are
classified as travelling both ways and are proven doing it: a seeded row
of each goes out through the real export builder, the account is deleted
so the schema's cascade decides what survives, and the rows are counted
back through the real restore route. Two references deliberately do not
survive and are named instead of dropped quietly — the reminder a visit
points at is not in the backup, and a link whose far side a portable
export does not carry cannot resolve. The wipe plan deletes the links
before the lab results and documents they hang off, or the audit row
would report zero for rows that existed. The three encrypted columns are
registered for key rotation, without which dropping a legacy key would
make those notes permanently unreadable.
Widening the reminder origin enum is a type error at the reminder DTO,
which is the right outcome: the mapper now refuses an appointment row
outright rather than relabelling it as a checkup.
MedicationSideEffect was classified as backed up and was not carried: the
export wrote nothing and the restore had nothing to read, so a restored
account kept the drug and lost the reason someone may have stopped taking it.
Delegated write access sharpens that — a side effect is one of the things a
second person can contribute, and it was the contribution a restore dropped.
The rows ride inside their medication, like the schedules beside them. A
top-level array would have to carry `medicationId` and survive a restore that
mints a fresh id for a portable file, which is the remap the intake events
already work around by drug name; a nested create has no id to remap, because
Prisma binds the child to whatever id the parent row actually got.
The note follows the measurement contract for the same dual-column
arrangement: a portable export carries the decrypted note and no ciphertext, a
disaster-recovery payload carries the ciphertext verbatim, and the restore
re-encrypts a legacy plaintext row rather than writing plaintext back into the
column.
Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim
where a written verdict cannot: the round trip seeds a side effect, exports
through the real builder, empties the account and reads the row back out of
the real restore route with its category, entry, severity and note intact.
Both halves were excised to check the test can fail — the restore branch turns
the count red, the payload turns the field values red.
One resolver, both transports, on every request. The cookie path reads
the carrier off the session row it has already loaded; the Bearer path
reads the `X-HealthLog-Account` selector header, because stamping switch
state onto a long-lived token would make the credential itself ambient
authority. Two carriers converge on one function for the same reason
`resolveBearerToken` lives in one file for two wires.
Three declared modes. `requireRecordAuth("read" | "write")` honours a
valid switch and substitutes the owner as the data scope while keeping
the delegate as the actor. `requireActorAuth()` always serves the
caller's own rows and keeps working under a switch, which is what the
switcher and the banner need.
Bare `requireAuth()` refuses to run under a switch at all, with a stable
`sharing.not_permitted` code. It does not fall back to the caller's own
account, and that arm is the point of the whole design: a delegate who
believes they are reading the owner's record and is handed their own is
the same data mixing pointed the other way. A route nobody has reviewed
is inaccessible while switched, never wrongly accessible.
The grant is loaded from Postgres on the request that uses it. Nothing
memoises the verdict, so a revocation lands on the delegate's next
request rather than their next login.
Refusals do not enumerate. The denial takes no constructor arguments and
carries a fixed message, and neither branch looks the named account up:
an account that does not exist and one that granted nothing produce the
same empty row from the same query, so the two answers are identical in
bytes and in timing rather than by careful wording. The reason reaches
the audit row and the wide event and nothing else.
`getSession()` still answers who is calling. That is what leaves
`requireAdmin`, `requireCookieAuth` and `requireFreshMfa` structurally
out of a switch's reach; the row's carrier rides out beside the session
id as a value to be checked, never as a decision already made.
The selector header is a Bearer carrier only. Sent over the cookie
transport, or to an actor surface, it is refused rather than ignored. A
client that names an account on a route which never reads one has a bug,
and a correct response to a wrong expectation hides that bug until it
arrives as a support case about data that looks wrong.
Five test files gain the session shape their fixtures always claimed to
have; they were building an auth context without one.
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
`IntradayCumulativeProfile` shipped classified as backed up and carried by
nothing. The drain folds the per-sample rows into twenty-four running totals
and deletes the originals in the same transaction, so past the grace window
the table is the only copy of a day's curve: a restore rebuilt the account
without it, reported success, and left the same-time baseline reading "still
learning" for a fortnight.
Builder and restore sit together in one file, the way the profile and cycle
pairs do, because the restore route delegates and a grep of the route alone
lies. The section spreads into the payload rather than being copied key by
key. Two curves for the same metric and day, or a curve that is not a whole
day, throw naming the day instead of writing a row every reader drops.
The pair is proven end to end rather than at each end: the round-trip suite
builds a real payload from a populated account, serialises it, parses it back
through the real parser, drives the real restore, and asserts on the rows
written. Remove the read and it goes red.
The classification guard proved every model has an opinion and would have
passed unchanged while a model classified as carried was read into the payload
and never written back — which is what happened to the nutrient day totals, the
custom mood tags and the custom cycle symptoms, three releases running.
Split BACKED_UP into the models with both ends and the models without, each of
the latter carrying what an account loses meanwhile, so the debt is a register
rather than a silence. The check reads declared writer and restore files rather
than grepping for a route, because the route delegates: a review that grepped
only the route concluded the cycle data was never restored and had to be
retracted. Declaring the file list caught its own first omission — measurements
are read through the pager, not directly.
Adds a round trip that builds a real payload, parses it through the wire schema
and drives the real restore, asserting on the rows written rather than on the
payload. Dropping the cycle section spread makes it fail the way production
did: the restore throws on a symptom key nothing wrote back.
Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.
Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.
Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
Four things an independent read found, three of them things this release said
about itself that were not true.
A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.
The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.
The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.
And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
The restore resolves a mood entry's rated factors by key and asks only for the
seeded catalogue. A tag the user created is not in it, so the lookup came up
short and the whole restore threw 'Unknown mood factor keys'. The file was
complete; it simply could not be read back.
That is the same cause as the cycle symptoms two commits ago, with the
opposite ending. There, an unresolvable key was filtered out and the loss was
silent. Here it stopped everything. One shape of missing definition, two
answers, neither of them the right one.
The account's own tag definitions travel now, get written before the factors
resolve, and the lookup accepts the seeded catalogue or the owner's own. The
seeded rows stay out of the file for the same reason as before: a restore must
not rewrite another instance's reference data.
Four more test mocks needed the new delegate, which is again the compiler and
the suite naming the call sites rather than me hunting for them.
restoreCycleData was loud about one missing reference and silent about
another, three lines apart. An unknown cycle reference threw; an unknown
symptom key was filtered out of the list, so the day-log came back with one of
its symptoms quietly gone and the restore reported success.
The cause was upstream: the seeded catalogue is reference data every instance
has, but a symptom the user created lives only in their account and was never
carried. The key had nothing to resolve against, and the filter turned that
into silence.
Both ends now. The account's own definitions travel with the backup, the
seeded rows deliberately do not — shipping those would let one instance's
restore rewrite another's reference data. And the resolve step throws on an
unresolvable key rather than filtering it, so the same function answers the
same question the same way twice.
Widening the builder's client type made four export tests fail on a missing
delegate, which is the sweep working: those are the call sites, named by the
compiler instead of found by reading.
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.
The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.
The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.
Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.
The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
Five layers, because the defect had five places to hide.
The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.
The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.
The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.
The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.
The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.
Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.
The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.
Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.
The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.
A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.
Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.
The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.
Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.
Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.
The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.
The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".
Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.
The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.
Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
Two layers, because the defect had two halves and each needs its own kind of
proof.
The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".
The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.
Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.
The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.
Three more controls were untruthful in their own way:
- The medications switch documented itself as excluding all medication data
and excluded the adherence figures only. The drug list and the per-dose
administration ledger rode along regardless. Both follow the switch now;
the rows are still read, because compliance and the GLP-1 block are computed
from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
unconditionally for the FHIR bundle, so a package could carry a compliant
PDF and a violating bundle in the same zip. The reads are gated, and a
deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
They rode the mental-health module alone, which has been default-on since
v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
links and the MCP doctor-visit resource on accounts that never chose to send
them. They get their own section key, defaulting off, with the module gate
kept as the second lock, and the stale privacy-by-default comment that
argued otherwise is corrected.
The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.
Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
The doctor-report contract has always described the practice / clinic name as
a stored preference, and `/api/auth/me` has returned
`User.lastReportPracticeName` since it shipped, but nothing ever wrote the
column. Every report asked for the clinic name again.
The export route now persists the sanitised name after the requested artefact
has actually been produced, so a generation that throws cannot overwrite the
remembered value with a half-typed one. Clearing the field persists null, which
keeps the cover line cleared next time. The export panel seeds its input from
the auth payload during render, gated per user id, so a late resolve never
clobbers a field the user is already typing into, and a finished export
invalidates the cached payload so a remount reads the new value.