Files
HealthLog/src/lib/record-activity/activity-verb.ts
T
Marc-André Bombeck 25e86b36b3 Close the delegated-access gaps the release audit left open
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.

The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.

Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.

Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.

The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.

Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
2026-08-04 14:55:32 +02:00

89 lines
4.2 KiB
TypeScript

/** The `t` from `useTranslations()`, injected so this stays pure. */
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* What somebody did in your record, in a sentence.
*
* ## Why this exists
*
* The activity view renders every non-read row as one generic line: "somebody
* made a change in your record". That was honest while sharing was read-only
* and no delegate could make one. Now a delegate can enter a reading, a lab
* result, an illness entry, a biomarker, a side effect, a medication, and can
* mark a dose taken — and "made a change" for seven different things is a way
* of answering nothing. An owner reading this at breakfast wants the verb.
*
* The map keeps a line for allergies, family history and a tracked value even
* though a delegate can no longer add any of the three: rows written while
* they were admitted sit in the same table and still have to render as
* something, which is the same reason the fallback below exists.
*
* ## Why unknown actions fall back rather than fail
*
* The action string comes from the audit row, which outlives this map: a
* release that admits a twelfth verb writes rows this map has never heard of,
* and rows written by an older image sit in the same table. So an unmapped
* action renders the generic line it renders today. A blank row would be the
* one outcome worse than a vague one.
*
* Pure and translator-injected so the mapping can be pinned without rendering
* a card, and so the keys stay literal for the i18n call-site guard.
*/
export function recordActivityVerbLine(
t: Translator,
action: string,
name: string,
): string {
switch (action) {
case "measurement.create":
return t("recordSharing.activityVerb.measurementCreate", { name });
case "labResult.create":
return t("recordSharing.activityVerb.labResultCreate", { name });
case "allergy.create":
return t("recordSharing.activityVerb.allergyCreate", { name });
case "family-history.create":
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
case "illness.episode.create":
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "biomarker.create":
return t("recordSharing.activityVerb.biomarkerCreate", { name });
case "customMetricEntry.create":
return t("recordSharing.activityVerb.customMetricEntryCreate", { name });
case "medication.sideEffect.create":
return t("recordSharing.activityVerb.medicationSideEffectCreate", {
name,
});
case "medication.create":
return t("recordSharing.activityVerb.medicationCreate", { name });
// Both spellings, and the reason is worth keeping. The web client posts to
// `POST /api/medications/[id]/intake`, which has audited as
// `medication.intake` since v1.0.0; `medications.intake.update` is the
// canonical route's action and the one the plan's copy deck named. Mapping
// only the second meant the single most-used delegated act — a caregiver
// marking a dose — rendered as the generic "made a change" for the person
// whose tablets they are.
case "medication.intake":
case "medications.intake.update":
return t("recordSharing.activityVerb.medicationIntake", { name });
// The refused half of the same route, and it earns a line of its own for
// the reason the whole panel exists. Deferring a reminder and rewriting a
// dose the owner already recorded are the two things that request can
// express and delegation does not cover. Folding them into "marked a dose"
// is what made the gap dangerous in the first place: the owner would have
// seen the sentence for a contribution against an attempt to switch their
// reminders off. An attempt that was refused is still something the person
// whose record it is should be able to see.
case "medications.intake.update.refused":
return t("recordSharing.activityVerb.medicationIntakeRefused", { name });
// A batch post is still readings arriving; the owner does not care that the
// client sent them together.
case "measurement.create.batch":
return t("recordSharing.activityVerb.measurementCreate", { name });
default:
return t("recordSharing.activity.acted", { name });
}
}