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
A leaf on a share link can be absent from the payload for two reasons
that look identical downstream: the owner shared it and recorded
nothing, or they shared it and the domain is switched off on their
account. The aggregator ANDs the selection and the module map and
returns the same null either way, so the recipient could not tell an
empty section from one that never had a chance to carry anything.
`loadShareViewData` now resolves the whole module map — it needed the
`doctorReport` key from it anyway — and returns `unavailableLeaves`: the
leaves the link DOES carry whose owning module is off. Only selected
leaves appear, because a withheld leaf's module state is not the
recipient's business in any direction.
The map is deliberately NOT handed to the aggregator. The third argument
is the frozen selection and there is no fourth, which is what keeps this
surface from growing an options object that widens what it asks for; the
resolver memoises its reads per request, so the aggregator resolving its
own map again costs no round-trip.
The module-gate suite moves off `isModuleEnabled` with it, and gains
three cases for the new verdict, including the one that proves an
unshared leaf is never named.
The page at c/[token] resolved the owner's timezone; the PDF download beside
it resolved nothing and fell through to the display default. Same link, same
reading, two dates — and the one a practice files is the one that was wrong.
Resolved in the shared download gate rather than in the route, so the PDF and
the FHIR bundle cannot drift apart the way the page and the PDF did. The
owner's clock preference travels with it for the same reason.
The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin
fixture cannot tell a resolved timezone from a forgotten one.
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.
One suite per gate: the rich reads omit with an honest module_disabled
reason, the correlations reader omits and touches no channel, the
doctor-visit resource and prompt refuse without assembling the record,
and the clinician share collapses to its documents-only state. Each
asserts the operator availability path alongside the user toggle, and
that the gate short-circuits before the engine runs rather than building
the answer and withholding it.
The structural test pins that every metric the rich reads can resolve has
either an owning module or a written review decision, and is explicit in
its own comment about what it cannot prove — it does not show that any
read calls the gate. Writing it surfaced an unowned HRV_RMSSD, the
fallback type an RMSSD-only account resolves to, which would have served
recovery data the primary type refuses.
The MCP surface reached three data families that deliberately bypass the
Coach snapshot and so inherited none of its module narrowing: the
doctor-report aggregate behind healthlog://report/doctor-visit and the
doctor_visit_summary prompt, the rollup-backed rich reads (compare_metric,
get_metric_baseline, detect_changepoints), and the correlations reader.
A disabled module — or one an operator switched off server-wide — still
egressed to a third-party assistant.
Gate at the read rather than per call site. The module-to-data ownership
table moves out of the Coach snapshot builder into
lib/modules/measurement-scope so every read gates off one map; the rich
reads resolve metrics through a module-aware resolver; the correlations
reader gates on insights itself, which also closes the same gap on the
Coach chat tool and the metric-page read strip.
Per-domain reads omit with an honest module_disabled reason; the
whole-record aggregate refuses, mirroring /api/export/health-record.
collectDoctorReportData gains a fail-closed backstop on its own module
key. The clinician share link, found while tracing the aggregate's
callers, had the same gap and now collapses to its documents-only state.
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:
- Sleep rendered nothing in the doctor-report PDF: the sleep section is
default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
nothing (FHIR already emitted it). Added it to the allowlist with a
minutes-to-hours conversion; corrected the stale 'intentionally excluded'
comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
glucose module was on, with no report-level privacy control. The toggle
actually already existed in the UI (with i18n) but was dead because the prefs
fold dropped the key. Wired it end to end (glucose added to the prefs Zod
schema + defaults, default-on to preserve behaviour; gated the render loop +
the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
five sources.
Regression test per fix.
Dashboard layout changes take effect immediately: the save now evicts the
locale-keyed home-screen snapshot it previously missed, so a re-ordered or
toggled tile set no longer reverts to the prior selection for the SWR window.
A share link built with a narrowed report-section selection now serves exactly
that selection. The stored selection is the grouped export shape; the shared
view read it through the flat parser, which dropped every grouped toggle and
fell back to the full defaults — an over-share against the owner's choice. The
view now folds the grouped shape down faithfully. Documents-only shares were
never affected.
Hardening: the shared-document download path now sanitises the filename
identically to the owner download path, and thumbnail generation re-checks the
pixel ceiling after decode so a malformed image that slips past the header
sniff still cannot exhaust the worker.
A share link created to carry only documents now records an explicit,
frozen document_only flag, and the shared view gates on that flag directly
instead of inferring documents-only from "are all report sections off?".
The inference would have re-opened an existing documents-only link the day
a new report section was added — the link's frozen settings never mention
the new section, so it would default on and begin serving that data. The
flag closes that path; the derived check stays only as the fallback for
links created before the column.
Migration 0237 adds the column and freezes it on for every existing link
whose sections are entirely off (checked at any nesting depth), leaving
full-record shares untouched.
Sharing a single document from the vault minted a full-record share: the
create form always defaulted `resourceTypes` to ["Patient","Observation"]
and never signalled a documents-only intent, so the served /c/<token> view
aggregated the whole doctor report — every vital, lab, medication and
wellness figure — alongside the attached file. An empty section blob was
indistinguishable from "defaults", so the report always resolved to the
full record.
Introduce an explicit documents-only share:
- add a `documentOnly` flag to the create payload; when set the server
forces an empty report scope (all-off sections, no FHIR resource types,
FHIR API off) and requires at least one document, so a document link can
never widen into a record — whatever the body sends.
- gate the clinician-view aggregation on an enabled section: a share with
no section on never calls the report aggregator, so no health metric
leaves the database. The recipient sees only the attached document(s).
- the document detail Share action opens the create form in documents-only
mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the
history-range selector, keep the picker/label/expiry/passphrase, and
always carry the launched document into the created link.
- Settings sharing keeps the full record-share form unchanged.
Extract the create-payload builder as a pure function and cover the scope
contract, the empty-scope data load, and the served-view metric-free
guarantee with tests; refresh the share e2e and regenerate the OpenAPI
contract.
P3-1-T6. loadShareViewData now returns the link's document set as metadata only
(id, title, kind, documentDate, byteSize, servingClass) — scoped to the
membership rows, the owner, and live rows; the blob column is never selected
(the serve route is the only decrypt path). Register documentCount on the share
summary, documentIds on create, and the public /c/{token}/d/{id} serve route in
the OpenAPI contract; regenerate docs/api/openapi.yaml.
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded
documentIds set; validate each id as the caller's own live document and create
the membership rows in the same transaction (frozen write-once). Summary DTOs
gain documentCount (never ids, never bytes).
Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved
(never requireAuth), passphrase-gated via the token-scoped unlock cookie,
revocation/expiry re-checked before any decrypt, confined to the link's frozen
document set (foreign/deleted id → flat 404), fail-closed decrypt, identical
serving-class posture as the owner route (inline Class A / attachment Class B,
nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate
limit.
Carve the serve path out of the proxy's blanket DENY posture with the same
document CSP as the owner route (default-src 'none'; frame-ancestors 'self'),
anchored to exactly two dynamic segments so no lookalike over-matches.
P3-1-T3. Dependency-free metadata stripper for JPEG (drops APP1/APP13/COM),
PNG (drops eXIf/tEXt/zTXt/iTXt/tIME), and WebP (drops EXIF/XMP chunks and
clears the VP8X flag bits). No pixel decode, no native dependency; image data
copies through verbatim. Applied on egress only — the stored original is never
rewritten. Non-raster types (PDF/TIFF/HEIC/Office) and any unparseable input
pass through unchanged (fail-safe).
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
A protected share link no longer renders the record until the passphrase
is verified. The public page checks the gate state first (without bumping
access counters), and for a link with a passphrase hash it serves a gate
island unless a valid, token-scoped unlock cookie is present. A legacy
link with a null passphrase hash renders exactly as before.
The unlock route verifies the submitted passphrase with a constant-time
compare against the stored hash, is rate-limited by share-unlock token
and IP before any compare, and on success sets a short-lived (30 min)
httpOnly, SameSite=Strict, Secure cookie scoped to that one token's view
path. Every failure class answers one blunt 401; no-store and the flat
404 for unknown links are preserved. The gate island auto-submits the
#k= fragment (scrubbing it from history first) or prompts for input.
Every new share link now carries a high-entropy passphrase (80-bit,
Crockford-base32, grouped XXXX-XXXX-XXXX-XXXX). The create response
returns the raw passphrase, the share URL, and a QR deep link EXACTLY
ONCE alongside the token; the server stores only the passphrase's HMAC
hash (the same key as the token hash). The QR deep link carries the
passphrase in the URL fragment (#k=) so it never reaches a server log,
referrer, or access record.
Add the passphrase generator, normaliser, and constant-time verifier;
the short-lived token-scoped unlock-cookie mint/verify; a gate-state
resolver that reports live + passphrase status without bumping access
counters; and the unlock request schema.
Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the
$everything operation and honoured search params) to fhir/rest.ts; the
metadata CapabilityStatement and the share-link resource-type enum now
derive from it so the advertised set can never drift from what is routed.
Extend GET /api/meta/capabilities with the FHIR REST descriptor
(restBaseUrl, readScope, resourceTypes, operations, searchParams) and a
clinician-share descriptor (supported, maxDays, resourceTypes, scopeable
sections) — every list sourced from its canonical registry, guarded by
the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to
match.
Fill the clinician test-matrix gap: assert the clinician view never
requests a KVNR / identifier opt-in from the aggregator (default-OFF by
construction) and resolves a rolling window without reaching data older
than the frozen start.
A standalone read-only RSC that resolves the path token through the share-token
resolver and renders an owner-scoped clinical summary: provenance header,
clinical vitals and labs, medications with adherence, and a fenced muted card
for the descriptive wellness scores carrying the not-a-clinical-assessment,
not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI,
no session, no markdown — every value renders as escaped text. force-dynamic and
noindex; the page returns a flat 404 for any null resolve.
The route joins the proxy public-path allowlist and the AuthShell standalone
list so it reaches the page bare without a sign-in redirect. New clinicianView
strings land in all six locales.
resolveShareToken hashes a raw hls_ token, looks up the ClinicianShareLink,
enforces not-revoked and not-expired, and returns a ShareContext carrying only
the owner userId plus the frozen scope. It is not an auth primitive: it never
reads a session or sets a cookie, so a share token can authenticate only the
share surface. Unknown / revoked / expired / malformed tokens resolve to null
for a blunt 404. Access counters bump fire-and-forget on a hit.
A token presented on a normal authed route is rejected by construction —
requireAuth reads only the cookie session and Authorization: Bearer (against
ApiToken), never the share header, and an hls_ token has no ApiToken row. The
invariant test locks that boundary.