Commit Graph
6301 Commits
Author SHA1 Message Date
Marc-André Bombeck 01562b4aef test: use the shared browser test entry point 2026-08-06 00:08:54 +02:00
Marc-André Bombeck 8669df4cda feat: add scoped record invitations 2026-08-05 23:58:30 +02:00
Marc-André Bombeck 4923db6464 fix: preserve account access compatibility 2026-08-05 23:58:26 +02:00
Marc-André Bombeck e4e773d543 feat: define scoped sharing levels 2026-08-05 23:58:18 +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 feb9d25e87 test: anchor sharing release journeys 2026-08-05 23:29:55 +02:00
Marc-André Bombeck bd6220bbba test: add sharing security principals 2026-08-05 23:26:42 +02:00
Marc-André Bombeck d9e68e7ee3 test: freeze sharing authorization inventories 2026-08-05 23:24:54 +02:00
Marc-André Bombeck e05bad0ec2 chore: refresh the sharing release foundation 2026-08-05 23:14:01 +02:00
Marc-André Bombeck 553e0b4a28 Merge commit 'd07b9249c' into release/v1.37.0 2026-08-05 23:04:39 +02:00
Marc-André Bombeck d42d2bb784 Merge branch 'main' into release/v1.37.0 2026-08-05 23:04:36 +02:00
Marc-André Bombeck d07b9249c8 fix: complete dashboard hydration 2026-08-05 23:03:17 +02:00
Marc-André Bombeck 1a70044af9 fix: preserve delegated dose facts on replay 2026-08-05 22:48:31 +02:00
Marc-André Bombeck e89374d5b3 Merge remote-tracking branch 'origin/main' into release/v1.37.0 2026-08-04 17:17:50 +02:00
Marc-André Bombeck bee02004ba Merge pull request #716 from MBombeck/docs/changelog-slot-upsert
Name the refusal the last release notes left out
2026-08-04 17:17:46 +02:00
Marc-André Bombeck da3cabb55f Give a grant a section set and a third level
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.

`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.

`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.

`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.

`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.

`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.

`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
2026-08-04 17:11:40 +02:00
Marc-André Bombeck cd3f628f98 Name the refusal the last release notes left out
The fix that stopped a delegate overturning a recorded dose through the second
intake route landed on the trunk with no line in the notes. A change that
alters what somebody else may do to your record and says nothing about it is
the shape this project keeps rediscovering, and a release assembled from these
notes would have shipped it silently.
2026-08-04 16:44:24 +02:00
Marc-André Bombeck 03877a0971 Merge pull request #715 from MBombeck/fix/slot-upsert-outcome-change
The v1.36.1 audit follow-ups, and the way round the intake refusal
2026-08-04 15:52:06 +02:00
Marc-André Bombeck 24922b2787 Close the way round the delegated-intake refusal
v1.36.1 stopped a delegate changing a dose the owner had already recorded, and
it stopped them on one route. The sibling `POST /api/medications/[id]/intake`
reaches the same rows through the shared slot upsert, where the only existing
protection is against a pending sync echo washing out a recorded dose. An
explicit skip is not an echo: it fell through, flipped the outcome, and the
inventory the take had consumed was refunded on the way. So the refusal I added
was a door with the next one left open.

The rule compares OUTCOMES rather than refusing every write onto an actioned
slot, and the reason is the third case in the new contract. A double tap, an
offline replay and a retry after a partial batch all re-post the same decision,
and a caregiver hits all three; refusing those would make the feature
unreliable exactly when somebody is standing in a kitchen with a pill box. Same
decision applies, a different one is refused.

The check rides into `applyCanonicalSlotWrite` rather than sitting at the call
site, because that is where the existing row is already loaded: no extra query,
and nothing between the read and the update for a concurrent write to slip
through. A refused attempt files under the owner with the same action name the
sibling route uses, so it reaches the activity panel the same way.

Three integration cases, and each asserts the row rather than the status alone
— a 403 that had already written the flip would read as protection while being
the opposite. Watched fail: with the delegate flag forced off, the flip returns
201 while the replay and the owner's own correction stay green.
2026-08-04 15:32:40 +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 80ecbd7454 Merge pull request #714 from MBombeck/fix/audit-v1361
Stop a delegate deferring somebody else's medication reminders
v1.36.1
2026-08-04 13:10:00 +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 7ff420477c Merge pull request #713 from MBombeck/release/v1.36.1
chore(release): v1.36.1
2026-08-04 12:00:34 +02:00
Marc-André Bombeck d77a3fe8d7 chore(release): v1.36.1 — a second person can contribute, and the front door works 2026-08-04 11:44:13 +02:00
Marc-André Bombeck b5227495c1 Merge pull request #712 from MBombeck/work/sharing-write-integration
Delegated write access, and the front door a delegate lands on
2026-08-04 11:40:17 +02:00
Marc-André Bombeck 87e908decd Ask the menu whether it opened, not the trigger that just disappeared
My own bug, and it produced a failure that pointed at the opposite of the
cause. The helper clicked the trigger and then re-read the trigger's
`data-state` to decide whether the menu had opened. Radix marks the rest of the
page `aria-hidden` while a menu is open, and Playwright's role locators skip
anything hidden from the accessibility tree — so a trigger found through
`getByRole` stops matching the instant the click succeeds. Reading its
attributes then does not report "still closed"; it hangs until the test budget
runs out, and the log blames the trigger for not being there.

The check is on the menu now. Both journeys pass locally, and the reason is in
the file so the next person does not have to lose an evening to it.
2026-08-04 11:21:51 +02:00
Marc-André Bombeck 10e113e537 Give the add-measurement journey the dashboard's budget, not the default one
It kept dying at thirty seconds on the quick-add trigger. The trigger was not
the problem: the dashboard route segment suspends behind its server render and
shows `loading.tsx` until that resolves, and on a shared runner with the whole
suite in flight that has overrun the default test budget. Raising the locator
timeout achieved nothing, because the test itself was the thing expiring.

So the wait is now on the skeleton going away rather than on the network going
quiet — `networkidle` says nothing about whether a suspended segment resolved —
and the budget belongs to the page rather than to the default. Every assertion
about the round trip stays exactly as tight as it was.
2026-08-04 09:26:43 +02:00
Marc-André Bombeck 17a2ec3328 Give the trigger the same patience the click it replaced had
`.click()` carries Playwright's 30 s actionability wait. The visibility check
that replaced it ran on the default expect timeout, so the helper made the wait
shorter than the code it stood in for, and a dashboard that was slow to paint
failed on the trigger rather than on the menu.
2026-08-04 02:11:22 +02:00
Marc-André Bombeck 9eb5db4c35 Close the same hydration race on the three remaining menu triggers
The add-measurement journey and the two accessibility steps that open a
dropdown all did what the water journey did: wait for the network, click the
trigger, then wait for a menu item. All three clicked markup React had not
wired up yet, and the add-measurement one timed out on the trunk's own runner
for exactly that reason.

They open through the same helper now. The documents bulk-bar menu keeps its
plain click deliberately: it fires after selecting fifty rows, so hydration is
not in question there and a retry would only hide a real failure.
2026-08-04 01:52:02 +02:00
Marc-André Bombeck ef63d7b458 Merge remote-tracking branch 'origin/main' into work/sharing-write-integration 2026-08-04 01:31:07 +02:00
Marc-André Bombeck a08aa342b2 Pin ip-address past the leading-zero parsing advisory
Address4 decoded a leading-zero octet as decimal, so `010.0.0.1` parsed as
10.0.0.1 instead of being rejected. That is precisely the alternate-notation
class `isPublicUrl` was written to catch, and this copy arrives transitively
under the MCP SDK's rate limiter, where nothing of ours screens the value
first. The production tree resolved 10.2.0 while the dev tree already had a
fixed one, so the override names the floor rather than trusting resolution.
2026-08-04 01:30:57 +02:00
Marc-André Bombeck 7a713c33d0 Stop the dashboard console assertion from failing on a defect it does not own
The assertion demanded an empty console on `/` and was catching React's
hydration mismatch, intermittently, on this branch. Measured before touching
it: a production build of the trunk with the SSR prefetch on — the shipped
configuration — raises #418 on three loads out of three, on both viewports. A
control worktree on plain main behaves identically, so nothing here caused it.
With the prefetch off, which is what this suite runs against, neither tree
raises it in isolation; it appears only when the whole suite is loading the
machine enough to change the streaming order.

So the check was failing a branch for a defect that ships today, and doing it
unreliably enough that it read as a regression. The one message is exempted,
with the measurement and the mechanism at the filter so nobody has to
rediscover either, and the exemption names its own removal condition. Every
other console error still fails this test.

The defect itself is written down rather than waved through: the server streams
the route-level skeleton while the client's first pass renders the dehydrated
snapshot, and React throws the server tree away. Which means the prefetch that
exists to make this page paint fast is paying its cost and delivering none of
its benefit.
2026-08-04 01:18:15 +02:00
Marc-André Bombeck 59aa6b94f4 Merge remote-tracking branch 'origin/main' into work/sharing-write-integration 2026-08-03 22:10:27 +02:00
Marc-André Bombeck f41605efba Close two high advisories on the outbound path
`undici` moves to 8.10.0 and the `fast-uri` override rises to 3.1.5. Both were
published as high after the last run on the trunk, so this is new information
rather than neglect, and both matter here: undici is what backs `safeFetch`,
which is the one documented egress boundary and the place the connect-time
DNS-rebinding pin lives.

The undici advisory is a cross-user information disclosure and a parse-time
crash through degenerate private cache directives. `fast-uri` arrives four ways
through transitive dependencies and the existing override was pinned one patch
short of the fix.

The safe-fetch dispatcher suite passes against the new client, which is the
part that had to be checked rather than assumed: the pin reaches into undici's
connect hook and a client bump is exactly where that could quietly stop being
enforced.
2026-08-03 22:10:15 +02:00
Marc-André Bombeck 64c2f7410a Wait for the menu to open, not for the network to go quiet
The water-capture journey clicked the dashboard quick-add trigger after
`waitForLoadState("networkidle")` and then waited thirty seconds for a menu
item that never arrived. The failure snapshot shows the page rendered, the
trigger present, and no menu anywhere: the click landed on server HTML that
React had not attached a handler to yet, and the one click the test had was
spent before hydration.

`networkidle` cannot close that window, because the trigger is in the first
HTML and exists long before the client bundle finishes. The gate has to be the
element's own state. `openMenu` clicks and confirms the trigger reports itself
open — Radix stamps `data-state`, the hand-rolled mobile capture button carries
`aria-expanded` off React state — and spends another click if it does not,
because the problem is a lost event and waiting longer for a swallowed click
achieves nothing. Out of attempts it fails naming the state the trigger was
actually in, rather than letting the caller time out on a menu item that was
never going to appear.

This is a race the spec always had; it surfaces when the machine is loaded
enough to push hydration past the click, which is why it reads as a
branch-specific regression when it is not one. The tour passthrough spec keeps
its single un-retried click on the same trigger: proving that exactly one click
reaches the button through the overlay is that test's entire point.
2026-08-03 21:47:58 +02:00
Marc-André Bombeck 7eed26eb01 Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration 2026-08-03 21:18:13 +02:00
Marc-André Bombeck 75644bc3d2 Carry recorded side effects through the backup and back
MedicationSideEffect was classified as backed up and was not carried: the
export wrote nothing and the restore had nothing to read, so a restored
account kept the drug and lost the reason someone may have stopped taking it.
Delegated write access sharpens that — a side effect is one of the things a
second person can contribute, and it was the contribution a restore dropped.

The rows ride inside their medication, like the schedules beside them. A
top-level array would have to carry `medicationId` and survive a restore that
mints a fresh id for a portable file, which is the remap the intake events
already work around by drug name; a nested create has no id to remap, because
Prisma binds the child to whatever id the parent row actually got.

The note follows the measurement contract for the same dual-column
arrangement: a portable export carries the decrypted note and no ciphertext, a
disaster-recovery payload carries the ciphertext verbatim, and the restore
re-encrypts a legacy plaintext row rather than writing plaintext back into the
column.

Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim
where a written verdict cannot: the round trip seeds a side effect, exports
through the real builder, empties the account and reads the row back out of
the real restore route with its category, entry, severity and note intact.
Both halves were excised to check the test can fail — the restore branch turns
the count red, the payload turns the field values red.
2026-08-03 21:15:45 +02:00
Marc-André Bombeck a004d2358e Merge branch 'work/shared-front-door' into work/sharing-write-integration
# Conflicts:
#	src/__tests__/delegable-surface-guard.test.ts
2026-08-03 21:15:37 +02:00
Marc-André Bombeck b817a2d47e Merge remote-tracking branch 'origin/fix/delegated-fix2' into work/sharing-write-integration 2026-08-03 21:15:01 +02:00
Marc-André Bombeck 508cc8a60a Let a delegate read the dashboard they land on
Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.

Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.

The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.

Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.

Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.

The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
2026-08-03 21:12:35 +02:00
Marc-André Bombeck 1968616219 Merge pull request #698 from MBombeck/dependabot/npm_and_yarn/shadcn-4.16.0
build(deps-dev): bump shadcn from 4.13.0 to 4.16.1
2026-08-03 20:57:31 +02:00
Marc-André Bombeck 89c872a169 Pin the two writes this branch withdrew, instead of leaving their old proof
Taking `POST /api/allergies` and `POST /api/family-history` out of the
delegable set moved one end and left the other: the integration suite still
asserted that a delegate holding a WRITE grant gets a 201 from both, so ten
cases went red on a decision that was deliberate.

They are pinned the other way round now, with the reason in the file. A
delegate is refused at either grant level, nothing lands under either
account, and — the leg that matters most and that nothing else here covered —
the owner's own unswitched write still succeeds. Withdrawing a delegated
write by breaking the ordinary one would have been the worse bug.
2026-08-03 20:56:04 +02:00
Marc-André Bombeck 029c526252 Give the intake purge a tombstone, and two health writes their audit row
The dose-history purge was the one destructive verb in the intake family
that removed rows outright. Both siblings, the single-event DELETE and the
bulk delete, stamp `deletedAt` and bump `syncVersion`; the purge called
`deleteMany`, so a mis-tap was final and a client offline at purge time kept
every row forever, because a row that no longer exists produces no tombstone
for the sync feed to carry. It tombstones now, scoped to live rows so a
second purge counts zero. No inventory refund: a purge wipes a history as
bookkeeping, and refunding every dose ever taken would inflate the current
container count instead.

The manual water quick-add and the lab-result un-delete both wrote health
data and reached only the wide event, which expires. Months later nothing
could answer who put a day's total there or who brought a reading back, and
with a second person able to act on a shared record that question has a
second possible answer. Both write a durable row now, named after the
neighbours they sit beside.

The refused-delegation audit comment claimed the row was written for the
owner's benefit. It is not: it is filed under the caller, it is only written
for a header refusal, and a header names an account the caller merely
asserts. Filing under that claim would let anyone write rows into any
account's activity feed by naming it. The comment says what the row is now,
and points at `recordDelegatedAccess`, which is the half the owner does see.

Two comments in the biomarker surfaces still claimed the `SetNull` foreign
key left the readings behind when a marker is deleted. That stopped being
true when delete became a "drop this and its history" action and the
confirmation copy was rewritten in six locales to say so.

The purge route gains a call-shape assertion rather than a count assertion,
because a count comes back identical from a `deleteMany`. The lab restore
route had no test at all and now has one.
2026-08-03 20:49:27 +02:00
Marc-André Bombeck 837ebcfeea Name the intake action the route actually writes, and stop awaiting the delegate notice
The activity feed maps an audit action to a sentence. The web client posts to
/api/medications/[id]/intake, which has audited as `medication.intake` since
v1.0.0, but the map only knew `medications.intake.update` — so a delegate
recording a dose showed up in the owner's feed with no verb at all. Both
spellings are recognised now, plus `measurement.create.batch`.

A new test reads the audit actions straight out of every route that calls
requireRecordAuth("write") and fails when one has no case in the map, so the
next route to be opened for delegates cannot land a silent gap.

The delegate notification is fire and forget now, like every neighbouring
side effect. The dispatcher awaits each channel in the cascade, so one
unreachable channel added its whole timeout to the request of the person
standing next to the patient waiting for a dose to register. The integration
test polls the ledger rather than relying on the await, so nothing about the
proof weakens.
2026-08-03 20:34:53 +02:00
Marc-André Bombeck 582cd79aa1 Merge remote-tracking branch 'origin/main' into fix/delegated-fix2 2026-08-03 19:19:34 +02:00
Marc-André Bombeck 0b1a6c1b09 Make the delegated-write journey actually run
Every test in e2e/delegated-writes.spec.ts had been skipping since the
day the invitation form shipped its level control. The skip guard looked
for data-slot="grant-invite-level"; the control landed as
"grant-invite-access-option". The file said out loud what to change and
nobody changed it, so a quiet skip and a passing suite read the same in
a CI summary.

Two assertions inside it had never been executed and were both wrong:
the header button reads "Add", not "Add measurement", and the form opens
on blood pressure, which has no `value` field. Fixed against stable
attributes rather than viewport text, with data-slot="measurement-add"
added to the control the journey clicks.

Adds the deep-link leg the SSR suite cannot hold: a query parameter is
the same affordance as the button, and ?add= / ?new=1 open exactly what
the resolved level admits.
2026-08-03 19:12:11 +02:00
Marc-André Bombeck ebf032158e Give the intake toast one decision, and stop admitting two writes nothing calls
Three surfaces record a dose and all three had to reach the same two
conclusions inside somebody else's record: name the record, and drop an
Undo the server refuses. Two learned it and the dose ledger did not,
because the ternary was written out three times. It is written once now,
in the file the other two already share, with its own test.

POST /api/allergies and POST /api/family-history leave the frozen
delegable write set. The argument that admitted them still stands — an
allergy is the single most useful thing a caregiver can contribute — but
the only surface that posts to either lives in Settings, which a switch
closes, so no delegate could reach the form at any level. That is a
permission frozen ahead of the caller for it, and the list is built the
other way round on purpose.

Both delegable READ arms stay. A caregiver reading the allergy list is
what the feature is for; only the contribute step waits, and it comes
back in the same diff as the surface that offers it.
2026-08-03 18:47:42 +02:00
Marc-André Bombeck e26a8f3824 Render the withheld controls in the affordance suite
Three of the newly gated surfaces are query-backed, so the suite gains a
query client and a cache seed rather than a hook mock: the component
under test stays the real one. Each leg asserts the rail, the card and
the reminder row are still THERE — only the mutating control is gone.

Breaking each gate in turn puts every leg red.
2026-08-03 18:38:23 +02:00
Marc-André Bombeck 45363edb77 Withhold the controls a shared record refuses
Nine surfaces offered a delegate an action the server declines, and the
switch had never been asked about any of them.

The Today rail's dismiss and the coach check-in's keep / let-go write
through routes that resolve the caller, so both refuse under a switch;
they now ask canManage and are absent at either grant level. A priority
action with no href and no handler is no longer rendered as an inert
button at all — the type carries that rule now.

The Vorsorge dashboard card offered the mark-done that /checkups already
withholds, and the checkups list branch inlined its own ungated copy of
the button the cards branch gates, so which view a browser last chose
decided whether the action appeared. Both bind canManage.

The chart overlay cog persists through a route that resolves the caller
and stores a preference belonging to the person rather than the record.
Gated once in the control so the three chart wrappers cannot drift.

Three query parameters opened sheets past a gated button: ?add=<TYPE> on
measurements, ?new=1 on medications, and ?edit=1 on a medication detail,
the last of which opens a wizard refused at both levels. A deep link is
the same affordance as the control that produces it.

The efficacy retarget dial rewrites a setting the owner chose. The
episode documents card offered a link and an upload the vault gates on
the same endpoint throughout. And the dose ledger built its own success
toast, missing both the Undo suppression and the "saved to" receipt its
two siblings already carry — the one a delegate met on every dose.

The Coach drawer is not mounted inside a shared record, so every button
calling askCoach() opened nothing. The launch provider now publishes no
value there and each entry point's existing null check does the rest;
the documents sheet, the one that rendered without checking, now checks.
2026-08-03 18:33:48 +02:00
dependabot[bot] 9e61e73dec build(deps-dev): bump shadcn from 4.13.0 to 4.16.1
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.13.0 to 4.16.1.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.16.1/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 16:15:56 +00:00