Commit Graph
37 Commits
Author SHA1 Message Date
Marc-André Bombeck a294d7f684 fix(sharing): invitations need a fresh proof and end with sign out everywhere
Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.

Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
2026-09-26 22:34:21 +02:00
Marc-André Bombeck dd5dd1e45e docs(nav): correct two comments about utilities and manage invites
The More hub carries feature destinations only; Settings and
Notifications sit in the top-bar menu. The browser-only refusal for a
manage invitation comes from the transport check, which runs before and
independently of the second-factor step-up.
2026-09-13 14:12:54 +02:00
Marc-André Bombeck be7fbe37d5 docs(access): the grant card says the invitee needs an account
A sharing invitation carries no link and nothing is e-mailed — it is addressed
to an account that already exists on this instance, and the person accepts it
from inside their own session. The card's input hint already named the account
requirement and left the rest unsaid, so an inviter looking for a link to send
had nothing to find and no reason to stop looking. The card now names where the
invitation turns up, in all seven locales.
2026-09-11 09:16:24 +02:00
Marc-André Bombeck 58bc91a0a3 Add the setup flow's confirm screen
What the answers switch on is read off the registry's derivation and
never computed here, so the list the person confirms is the list the
completion route writes. Units, language and time zone are shown once
beside it, changeable in Settings, never asked again.

The profile half keeps the baseline form and its per-field refusals:
the fields are seeded from the account so a value it holds is confirmed
rather than typed again, and a refused field still stops the flow. For
"someone I look after" the managed-profile form takes the profile's
place, because that record is created here, after the questions; an
account without a second factor can finish without it and create it
later under Settings.

Confirming posts the completion, passes the first-result step outright
when there is no task to offer, and goes where the step machine says.
2026-09-10 15:19:48 +02:00
Marc-André Bombeck 0c46df1413 fix(api): put a ceiling on editing a managed record's identity
`PATCH /api/managed-profiles/{id}` had none, while creating a record and
inviting a Guardian have both been capped at ten an hour since they shipped —
one family answering the same abuse three ways. It matters more here than the
count suggests: `updateManagedProfile` opens a transaction and takes an
advisory lock on the id it was HANDED before it decides the caller is not a
Guardian of it, so an unthrottled caller can name any id and make the database
hold a lock about it.

The refusal now lands before the id is read, on the same bucket convention and
with the same ceiling as creation. The GET beside it stays unlimited, matching
the guardian roster read next to it: both are cheap identity reads a Guardian
may already make. The 429 is documented on the contract and the edit form's
message mapping grew the arm creation already had.
2026-09-10 03:24:50 +02:00
Marc-André Bombeck c673255355 fix(settings): stop a timezone edit writing the actor's language into a record
A managed record with no recorded language opens the edit form's language
control on the actor's, which is the same starting point the creation form
uses. The diff then compared that control against the record's NULL column, so
every save of such a record carried `locale` — a guardian correcting only the
timezone silently recorded their own language as the record's, and the audit
row said the language changed.

The comparison is against the value the control opened on, frozen at mount.
Nothing is sent unless somebody moves it.
2026-09-10 01:59:08 +02:00
Marc-André Bombeck 038fc72be2 fix(settings): keep a managed record's edit form across a refetch
The fields component was keyed on the query's `dataUpdatedAt` under a comment
saying the key stopped a refetch landing mid-edit from replacing what somebody
is typing. It did the reverse: the timestamp moves every time the query
resolves, so invalidating the `managed-profiles` prefix from anywhere — the
guardian list on another row is enough — remounted the fields and threw the
half-typed name away.

Keyed on the record's id instead, which is stable for as long as the form is
about the same record.
2026-09-10 01:57:42 +02:00
Marc-André Bombeck f521b03c35 feat: let a guardian edit a managed record, and record its sex
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).

PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.

The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.

Refs #939
2026-09-10 00:01:50 +02:00
Marc-André Bombeck b5c392636f feat(sharing): publish writable and manageable domains on the account payload
Every accountAccess entry on GET /api/auth/me carries writableDomains
and manageableDomains: the sections the grant opens, intersected with
the sections whose routes accept a delegated write at that level. A
READ grant gets two empty lists, a WRITE grant gets writable sections
only, a MANAGE grant gets both, and the vault appears in neither.

The client parser checks the structural invariants (no repeats, within
the grant's sections, manageable within writable, nothing below the
level) and requires the two published views of the active record to
agree on the lists. Regenerate the OpenAPI document and extend the
payload consumer guard with the active-record readers.
2026-09-09 19:42:26 +02:00
aucun6352 f7cc47850a fix(i18n): make the Korean locale actually work end to end
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.

Three failures were not missing keys but wrong assumptions:

- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
  accents become plain letters, but Hangul decomposes into jamo, which
  are not in that range and survived — so folded Korean text was a jamo
  sequence no precomposed table entry could ever match. It recomposes to
  NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
  name to the empty string, so the derived index held none of them and a
  Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
  tilde a Korean lab actually prints (`3.5~5.0`).

The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.

The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
2026-08-26 20:00:44 +09:00
Marc-André Bombeck f6a4acf779 test(sharing): follow the invite form's new scope default and one-line copy
The access options render as a row with an info affordance rather than a
wrapping label, the scope question preselects nothing, and the write and
manage detail moved behind the (i). Point the consent-copy and form-reset
guards at the shipped bundle for the detail text and at the unset scope
default.
2026-08-08 21:20:57 +02:00
Marc-André Bombeck ba4ec67812 fix(sharing): shorten the scope option copy to one line each
The write and manage levels each carried a paragraph of capability text
under their label, stacking into walls of text on the invite form. Cut
each to a single consent sentence inline and move the full enumeration
behind the info affordance at the option-heading height — what exactly
write may add, and the fence manage does not reach. The consent line
stays inline where it has to be read; the detail is one tap away for
anyone who wants the whole list. Six locales.
2026-08-08 21:09:52 +02:00
Marc-André Bombeck 97e75fd098 fix(sharing): stop preselecting a scope when inviting someone
The scope question defaulted to whole record, which reads as the form
nudging the choice. Preselect neither option: an untouched fieldset now
blocks the form with a quiet hint instead of falling through to a
whole-record grant. The wire contract is unchanged — scope-absent still
means the entire record on the server, so the unset state deliberately
does not omit scope, it blocks the client until the owner picks. The
empty-selection and manage floors stay fail-closed as before.
2026-08-08 21:05:10 +02:00
Marc-André Bombeck e33700c7a9 feat(sharing): name a record's owner in full to whoever they share with
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.

- the account-access resolver publishes the owner's `fullName` alongside the
  greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
  on the argument so the grant-view and actor shapes that never carried it
  keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
  compares it, so the two published views of the active record cannot name
  the same owner differently
- the OpenAPI contract regenerates with the additive field

This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
2026-08-08 16:07:25 +02:00
Marc-André Bombeck ba0cd63cf5 refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.

Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.

Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.

On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.

The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
2026-08-07 20:53:05 +02:00
Marc-André Bombeck b2e1aa0cde refactor(settings): one header, one action row, one integration card
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.

Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.

Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.

Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.

Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
2026-08-07 19:39:11 +02:00
Marc-André Bombeck 5445bfc1f1 refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.

Move the decisions into the primitives so a call site cannot re-open them:

- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
  header-to-body step is the container's, not a margin somebody remembers.
  A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
  and it mis-aligned action rows against their own forms wherever two
  neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
  licence call sites used to stack explainer paragraphs into a muted
  `text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
  the card. `align="start"` spells the one exception, a card whose only
  actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
  a group of cards, which is what Settings needs; Settings had been using a
  card header with no card, which reads as a card that failed to paint.

Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.

Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
2026-08-07 19:05:15 +02:00
Marc-André Bombeck 8793bbf229 fix: close four small gaps around the managed-profile surface
- bound profile creation at ten an hour per account, the ceiling its
  guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
  unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
2026-08-07 03:56:39 +02:00
Marc-André Bombeck 29940afb57 fix: name the step-up gate on the two acts that carry one
- deleting a profile and ending a guardian's access resolve fresh MFA,
  and both sit behind a confirmation, so the request that reaches the
  server is minutes old and routinely outside the five-minute window
- an account with no second factor is told where to set one up instead
  of being asked to confirm one it does not have
- the arm is scoped to those two acts: a 401 on accept, revoke or
  renounce is a lapsed session and must not read as a factor prompt
2026-08-07 03:55:25 +02:00
Marc-André Bombeck 217b0f41d8 fix: say what is wrong with a field where somebody will read it
- a refused field gets its own announced node in content contrast; the
  standing hint stays muted beside it, rather than being overwritten
- the timezone refusal is the only explanation for a submit button that
  will not press, so it leaves the picker's muted hint slot
2026-08-07 03:55:15 +02:00
Marc-André Bombeck 6bfcccd755 feat: manage the guardians of a managed profile
- list who looks after a profile, invite another guardian and end an
  existing access from the profile's own row
- state the last-guardian floor from the real roster before the act, and
  say plainly that an invitation counts only once it is accepted
- give each of the invitation refusals its own sentence, branching on
  status for the two the route sends without an error code
- cover every call the card can make against the real routes
2026-08-07 02:22:30 +02:00
Marc-André Bombeck b3548f105e feat: delete a managed profile from its guardian's panel
- confirm before deleting, naming what goes and that it cannot be undone
- state how many people lose the record, taken from the guardian roster
  rather than assumed to be one
- say that deleting is the way out when you are the last guardian, since
  this route sends no last-guardian refusal
- announce a refused deletion on the profile's own row, retryable
2026-08-07 02:07:49 +02:00
Marc-André Bombeck 110511ff31 feat: create managed profiles from shared access
- add a managed-profile card to Settings -> Shared access, between the
  invitation card and the standing-state cards
- collect exactly the four fields the create route accepts, with the date
  of birth genuinely optional and never derived from a year
- refresh the account payload on success, so the new record reaches the
  switcher and the banner without a reload
- assert the section is reachable for an owner and refused for a Guardian
  inside a profile and for an adult manager
2026-08-07 01:53:26 +02:00
Marc-André Bombeck c7e847a766 fix: bound the activity feed to the window it promises
The revoke dialog tells an owner that "who entered what" stays answerable for
N days, and the activity list prints the same number under its heading. The
query behind that list had no date filter: on an instance whose retention was
shortened it showed rows the sentence said were already gone, and the number
was a caption over an unbounded read rather than its limit. It is the filter
now, resolved once so the bound and the sentence cannot drift apart.

The list also stopped at a hundred rows and said nothing about it, which is the
more dangerous of the two silences — an owner who scrolls to the bottom reads
the oldest visible line as the beginning, and a busy household reaches the cap
in a fraction of the window. The response says which of the two limits it hit,
and the view states it only when it did: a caveat that is always on is one
people learn to skip.

The self-hosting page said an empty list means nobody opened the record. It
means nobody did so within the window. Corrected, with the row cap named
alongside it.
2026-08-07 00:48:58 +02:00
Marc-André Bombeck 71ad8d66b6 fix: preserve sharing failure states
Accepting, revoking and handing back a grant had no failure rendering at all.
The request came back refused, or never left the machine, and the panel did
nothing: the confirm dialog had already closed, the list did not change, and
the only signal was the absence of one. The two refusals that most need a
reader were the two that arrived most quietly — an invitation that had lapsed
before it was opened, and the last guardian being told they may not hand back
a profile that would then have nobody looking after it.

Each refusal now renders on the row it belongs to, announced, in content
contrast, beside a control that runs the same act again. The sentences are
split by what the person does next rather than by status code, so lapsed and
withdrawn read differently and the guardian refusal names the way out. A
request that never reached the server says so instead of reporting a refusal
nobody issued.

The failed state is read off the mutation rather than copied into local state
from onError: the copy is what goes stale, and the mutation already knows
which row it was for. The invitation form keeps every field it had, and only
a successful send clears them.
2026-08-06 23:39:39 +02:00
Marc-André Bombeck c92a80ee4c feat: describe managed sharing activity
Every action a manage-level route files now renders as its own sentence in
the owner's activity view, in all six locales. A write delegate can only add,
so the generic "made a change" was at least the right shape of fact; a manager
changes and removes entries the owner made themselves, and the generic line
withholds the only part that matters — whether a reading was corrected or
deleted, whether a dose history was purged.

The inventory reads the routes and calls the map rather than matching `case`
in its source, so an action that falls through to the fallback fails, and it
follows each resolved key into all six bundles. Every sentence interpolates
the actor's name and nothing else: the audit table is not encrypted and a
value in a verb line would be a health figure in a second store.

The revoke dialog's retention disclosure no longer depends on which of two
requests answers first. `retentionDays` rides the grant list, so a client
holding a row to revoke holds the window it must state, and the branch that
omitted the sentence is gone.
2026-08-06 23:30:07 +02:00
Marc-André Bombeck 1d575cf1ae fix(e2e): target the visible date control, not its hidden mirror
data-slot rides {...rest} onto DateField's hidden ISO input, so the
invitation spec asserted visibility on an element that can never be
visible and typed into nothing. Use the primitive's data-testid, which
lands on the overlay a person edits, and assert the mirror took the
value.
2026-08-06 20:58:30 +02:00
Marc-André Bombeck 8669df4cda feat: add scoped record invitations 2026-08-05 23:58:30 +02:00
Marc-André Bombeck ab14fdb6f3 Let an invitation name its sections and offer management
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.

The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.

Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.

The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.

The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.

The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.

Six locales.
2026-08-05 23:37:00 +02:00
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
Marc-André Bombeck 69343c8820 Stop a delegate deferring somebody else's medication reminders
Found by an adversarial pass over the release candidate before it was tagged,
and it is the reason the tag waited.

`POST /api/medications/intake` is admitted for delegates because a caregiver
standing next to the patient has to be able to say the tablet went down. It is
an update, though, and the same body can carry two other things. Snoozing
writes `snoozedUntil` on the OWNER's medication row, and the reminder cron
skips that medication for as long as the stamp is in the future. The field
takes any future instant. So somebody with write access could stop another
person's medication reminders for years, and three things made it quiet: the
owner is deliberately not notified of a delegated snooze, the activity feed
renders the whole family as "marked a dose", and the only accurate trace was a
line in the admin audit log.

The second was flipping a dose the owner had already recorded — taken to
skipped and back, which rewrites their compliance history and refunds
inventory. The consent screen promises that changing what is already there
stays with the owner.

Both refuse for a delegate now and stay open to the owner, who is the only
person either was ever for. A refused attempt is filed under the owner and gets
its own sentence in the activity panel rather than borrowing the one for
marking a dose, because folding them together is what made the gap dangerous.

Two more from the same pass:

The consent notice named allergies among the things a delegate may add. That
left the admitted set before this release shipped, and it said so in six
languages on the one screen where somebody decides who may write into their
health record. It also omitted half of what IS admitted. The copy now matches
the frozen list, and the test asserts the withdrawal rather than only the
presence of the rest.

The banner told a WRITE delegate nothing at all — the mode line rendered only
in the read case, guarded by a comment stating that write grants did not exist
yet. This is the release that made them exist. It matters that the banner say
it, because the rule that a refused control is absent rather than disabled
rests on the banner naming the mode globally.

And a third instance of the substitution this release already fixed twice: the
BMI chart divided the owner's weights by the height of whoever was looking.
Inside a shared record it draws nothing now rather than a number belonging to
two different people.
2026-08-04 12:30:01 +02:00
Marc-André Bombeck 251b534660 Merge remote-tracking branch 'origin/main' into work/dw-e 2026-08-03 12:25:34 +02:00
Marc-André Bombeck 748def03bd Say what somebody did, not that they did something
The activity view told an owner that a delegate acted on their record. With
delegated writing it can say which act: added a reading, recorded a lab result,
marked a dose. The mapping already existed as a pure module with its own test
and a fallback for anything unmapped; this is the wire it was missing.
2026-08-03 12:24:05 +02:00
Marc-André Bombeck d82e29737b Say what the level means on both screens that decide it
The invitation form gains the choice, and the option labels say what actually
ships rather than the word "write": a delegate can add readings, results,
observations, a medication and a marked dose, and can edit or delete nothing,
including what they entered a moment ago. That gap between what "write access"
sounds like and what it does is where a support case lives, so it is on the
card rather than in a reply.

The delegate reads the same thing before they accept. A pending write
invitation states its level and one sentence about what accepting means, on the
screen where they accept it, because acceptance is where the two consents meet
and it happens once.

Ending an access says what stays: the entries remain and the owner can remove
any of them, and the activity view keeps saying who entered what for as long as
the instance's audit window reaches. That number comes from the server, so an
instance running ninety days says ninety, and a browser that has not been told
says nothing rather than a made-up year.

The browser journey now invites at the wider level and reads the posted body
for it. That is the only check in the tree that can: the component convention
is server-rendered only, so no test under src/ runs the form's submit path, and
every one of them stays green with the control wired to a constant.
2026-08-03 10:12:22 +02:00
Marc-André Bombeck 6780e54036 Let somebody actually set the date a shared access lapses on
The invite route has accepted expiresAt, stored it and annotated on it since
the grant model shipped. The card sent a hardcoded null, so the column existed,
the validator accepted it, the wide event reported on it, and no person could
set one. The release note said an invitation could carry a lapse date.

The form now offers the date, floored at tomorrow because a grant that lapses
today confers nothing. A chosen day maps to the END of that day in the
browser's own zone, so "until the 30th" means the 30th rather than the
midnight that starts it, which is the reading anybody picking a date has in
mind. Empty stays the default and still means no lapse date.

Both ends are pinned rather than one: the control is asserted in the rendered
form, and the mapping is exported so its contract holds without a click, which
is this repository's convention for component tests.
2026-08-02 20:56:31 +02:00
Marc-André Bombeck 04d30c2570 test(sharing): break every control, then keep the ones that noticed
Each behaviour here was verified by removing the code that provides it and
confirming the test went red, then putting it back. Two of them did not, and
both were rewritten rather than kept:

The restore-side persister check asserted that the delegate's own dashboard
could not be read back inside a shared record. Deleting the guard left it
green, because the record-scoped hash already makes a snapshot written in one
record unreadable in another. It now counts what landed in the cache, so the
guard it names is the thing it measures — and the weaker line is kept beside
it with a note saying why.

The switcher's account rows were asserted from server-rendered markup. Radix
portals do not render server-side at all, so an earlier draft was measuring
the renderer. Those rows moved to the browser suite; what stays here is the
gate, which fails sharply — with the `canSwitch` bail removed the two cases do
not return different markup, they throw.

Two more shapes worth naming. The cache dimension is exercised through a real
QueryClient built from the app's own defaults, because "the hash string
changed" and "one record's data cannot be read while inside another" are
different claims. And the invalidation is a plain function over a client with
three live entries seeded, with a test that asserts they start valid — without
it, a client that failed to seed and one where everything was invalidated look
the same from every other assertion.
2026-08-02 14:39:55 +02:00
Marc-André Bombeck 5199de78af feat(sharing): the shared-access panel and the record-activity view
Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.

An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.

Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.

`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.

Six locales ship with it.
2026-08-02 14:26:39 +02:00