Seven surfaces spelled `/checkups?visit=<id>` by hand, some encoding
the id and some not. They now share `visitPath()` from
`src/lib/encounters/visit-links.ts`, the place the next fix extends.
No behaviour change: a cuid encodes to itself.
A shared related-records component renders related[] as rows that open the
server-resolved path, with how a derived relation runs. The medication
overview lists them and, in the account's own record, files its condition,
documents and visits; the visit sheet files medications and doses. The
condition page, the document sheet and the dose sheet list what their own
blocks do not already show. A dose's visit named by the form's field counts
as the same relation as the link table.
The link service gains medication and lab result sources and the five new
tables, each readable from both ends. POST/DELETE /api/medications/{id}/links
and /api/labs/{id}/links replace their 501 stubs; the visit links route also
files medications and doses, the dose links route its visit. A scan commit
with a documentId files each written result against its report.
GET on a lab result, visit, document, condition, medication and dose now
carries related[]: stored links, the condition a result shares a visit with,
and the medication whose efficacy target is the result's marker, each with
its web path. A kind is listed only when its module is on and the caller's
grant covers its section. relation-matrix.test.ts holds every one of the
fifteen pairs to a table, a reader or a written exception.
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.
- migration 0354: two nullable columns on illness_episodes, reusing the
laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
keeps the stored value, so an edit from the shipped iPhone app, which
never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
conditions, folded after the decrypt and bounded per kind; with site
(and laterality) the visits and conditions there, one hop to their
documents, lab results and visits. Read in the profile section;
conditions only under an illness grant with the module on, and links
into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
one body-site field pair that suggests sites already used; a
condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
name the site on conditions; the Coach illness block carries it
sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated
Refs #1025
A document could show its visits read-only and its vaccinations not at
all, so a childhood record covering a dozen doses had to be attached
from a dozen dose forms. The document's detail sheet now lists the
visits and vaccinations it is filed against and, for someone who may
manage the vault, links and unlinks them in one place, with the records
dated near the document on top. Both ends write the same link tables
through the link service, so the dose's form and the document's sheet
cannot disagree about what is filed where.
The document detail response carries the linked doses, named by
catalogue slug or the person's own wording. It is null rather than
empty for a grant that opens the vault but not the health background,
so a shared page never claims to record no doses. The update route
takes a replace-set of vaccination ids, up to 100 so one record can
cover a whole primary schedule; ownership is re-checked, a foreign id
answers 404 and the change lands in the audit log beside the other link
fields. Linking from the dose suggestion now refreshes the sheet.
Refs #1024, #1005
Same shape as the derived and MCP barrels: the line in the barrel had no
importer because the consumer imports the concrete module. Affected:
cycle (15), coach tools (9), record-settings (8), links (4), data-list,
workout-detail and analytics/score (1 each).
analytics/score also carried CompositeValue on an explicit export line
above an export * of the same module, so the name stays exported either
way.
Definitions are untouched.
`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.
A structural guard asserts that no file outside src/lib/links touches
the five link tables the service owns, with the two backup restore
writers as the only, frozen exemption. The matcher is whitespace
tolerant and proven non-vacuous: it must match every owned table inside
the service, so it can never pass by matching nothing.
This makes provable the property the link module was built for, that the
later absorption of these narrow tables into one join model is a storage
change behind the module rather than a sweep across every call site.
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.
- one new source and one new pair; the module's invariants carry over
unchanged, so ownership, idempotency, the batch cap and the
soft-delete refusal need no new code
- each invariant gets its own case against the new table, because every
one of them is a where clause on a different table and a pair wired to
the wrong column would pass all the existing ones
- the ceiling is written down and asserted: the vaccination source owns
exactly one pair, and asking for a second is refused loudly
Every arm here is the same shape as the one that already exists for
conditions, because the vault's filing model is settled and a second shape
would be the thing to explain rather than the thing to use: encounterIds on
upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter
beside the episode pair in the bulk action, and an encounterId list filter.
Both directions ride ONE table. The visit's sheet writes encounter_document_links
from its end and the document reads it from the other, declared as a second
direction in the link service rather than a fourth table. A visit's kind
crosses the wire as the enum constant rather than a rendered name: unlike a
condition label, which is the person's own words, a kind is one of eight closed
values whose human name the reader's own bundle owns.
The suggestion is offered where the document is being looked at, resolved by
the shared rule rather than by this surface: one candidate pre-selects with an
undo, two offer a picker with nothing pre-selected, none offers nothing at all.
It resolves with no AI provider configured, and it renders nothing once the
document already names a visit.
The bulk verb has a caller — the vault's selection bar files a whole selection
against a visit, mirroring the condition verb and hiding itself on an account
with no visits. Nothing here is one-ended.
OpenAPI regenerated in the same commit.
Three narrow link tables landed with the visit record, which is a step
away from the one-shared-service direction the polymorphic-linking work
argues for. This bounds that step to a single module: every caller now
speaks one vocabulary — a source, a target kind, a list of target ids —
so replacing the storage underneath later is a change in one file rather
than a sweep across every route that files something.
The document-condition links move behind the same signature, so there
are two families and one call surface. Behaviour is unchanged and the
existing vault tests are the proof; where a test double had to follow
the new query shape, it says why in the diff.
The rules that used to live at each call site now live in the module and
are proven against real Postgres rather than a fake that ignores a
where clause: both ends re-read scoped to the owner before a row is
written, a repeated link is a success that changes nothing, a call is
capped at a hundred targets, and a tombstoned record is not linkable on
either end. Unlinking one deliberately still works after a delete, or a
filing nobody can clear would be left behind.
Reads return a resolved label and date rather than a bare id, so a
client rendering a list never re-derives what the server already knows.
The restore is the one documented exemption and writes the tables
directly: it rebuilds an account rather than filing something in one.