Commit Graph
13 Commits
Author SHA1 Message Date
Marc-André Bombeck fb344c4f78 Remove two dead alias constants
SENSITIVE_LEAVES and COACH_SUGGESTED_ACTION_TYPES each aliased a
constant defined elsewhere and were never imported. Neither is a
re-export the way the barrel lines were — the definition itself is the
dead thing, and both doc comments describe a consumer that does not
exist: the purity guard reads SENSITIVE_LEAF_IDS from ./catalogue
directly, and nothing mirrors the suggested-action allowlist.

The imports that fed them go with them.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 0e9c5f1a00 Drop unimported re-exports outside the barrels
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.

Definitions are untouched; imports that existed only to feed a removed
re-export go too.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 310a9a4690 feat(doctor-report): put emergency data on page one
Add an EMERGENCY leaf to the identity group of the report catalogue,
default-on, no module gate. When the leaf is admitted and the profile
holds emergency data, the aggregator collects it and the PDF renders an
emergency sheet as page one (banner, blood type, severe allergies, active
medications, chronic conditions, implants, advance directive and organ
donor, contacts, notes), then breaks to send the rest of the report to
page two. The section is withheld when the leaf is not admitted or no
data is present. Guards updated for the new leaf counts.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 2da0159577 feat(vaccinations): add the immunization history to the doctor report
- IMMUNIZATIONS report leaf in the history group, gated both ways: the
  vaccinations module and the leaf selection each exclude independently
  (WR-7 seen red on the selection gate, reverted by content hash)
- collector loads the whole live history (reference data, not windowed, like
  allergies) with server-resolved series positions; the PDF renders a compact
  per-dose table — date, vaccine, dose, lot — with no due-status or gap analysis
- added to the default template so a new report offers it; stored selections
  and frozen share links never widen
- leaf label and section copy in six locales
2026-08-08 21:21:12 +02:00
Marc-André Bombeck 4d2f706dad feat(doctor-report): a report can mention a visit, and know why it exists
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.
2026-08-08 14:38:26 +02:00
Marc-André Bombeck c9c824ca4d Publish share-selection group membership in capabilities
GET /api/meta/capabilities dropped group membership at the wire: it
shipped a flat group-id list and a flat leaf list with no boundary
between them, so a native client could not tell which leaves live
under which group, or which group is the fenced sensitive tier. That
made it unsafe for the client to offer a "select all" affordance for
any group, since it could not mirror the server's fence against
bundling more than one sensitive leaf behind a single control.

Add SHARE_GROUPS to the report-selection catalogue, derived from the
existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP
maps: each group now carries its member leaf ids, and the group whose
id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in
the capabilities response now ships this shape instead of bare ids;
share.leaves is unchanged. Update the OpenAPI schema and regenerate
docs/api/openapi.yaml.

Add an invariant test asserting every leaf in the flat leaves set
appears in exactly one group and no group carries a leaf outside that
set, plus a check that exactly one group is flagged sensitive.
2026-07-30 19:54:49 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck bc1633db40 fix(export): make the standard report a click, not a preselection
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck f12290532f fix(sharing): let the owner replace a link the upgrade closed, and count only what is shown
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.

Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 9e68ff525f feat(share): serve the record a link already implies, and say when there is none
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.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f42934487d test(export): prove the scope holds at the artefact, not just at the switch
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.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 582db8a538 feat(export): one grouped scope picker, on both surfaces that choose a scope
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.

The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.

First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.

Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.

The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.

The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.

The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 5e490f31ec feat(export): make the doctor report carry only what was chosen
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.
2026-07-26 00:45:08 +02:00