Commit Graph
5017 Commits
Author SHA1 Message Date
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
Marc-André Bombeck a0fc404fb8 Merge pull request #697 from MBombeck/dependabot/npm_and_yarn/tanstack/react-query-5.101.4
build(deps): bump @tanstack/react-query from 5.101.2 to 5.101.4
2026-08-03 18:13:01 +02:00
Marc-André Bombeck e621da670c Merge pull request #696 from MBombeck/dependabot/npm_and_yarn/react-hook-form-7.83.0
build(deps): bump react-hook-form from 7.81.0 to 7.83.0
2026-08-03 18:12:53 +02:00
Marc-André Bombeck 5da64628ec Merge pull request #695 from MBombeck/dependabot/npm_and_yarn/eslint-config-next-16.2.12
build(deps-dev): bump eslint-config-next from 16.2.6 to 16.2.12
2026-08-03 18:12:46 +02:00
Marc-André Bombeck 3a7fe5f1aa Merge pull request #694 from MBombeck/dependabot/npm_and_yarn/prisma/adapter-pg-7.9.1
build(deps): bump @prisma/adapter-pg from 7.8.0 to 7.9.1
2026-08-03 18:12:38 +02:00
Marc-André Bombeck 33e9cc2784 Merge pull request #693 from MBombeck/dependabot/github_actions/docker/login-action-4.6.0
build(deps): bump docker/login-action from 4.5.1 to 4.6.0
2026-08-03 18:12:30 +02:00
Marc-André Bombeck 1d28aeecf9 Merge pull request #710 from MBombeck/fix/delegated-fix1
Never seed one person's record into another person's page
2026-08-03 17:09:35 +02:00
Marc-André Bombeck 24c45ad9e9 Return the sharing journey to the page Accept lives on
The coach positive control navigates to the dashboard to prove the launcher
renders for a delegate in their own record. It left the browser there, and the
next step clicks Accept, which lives on the access page. The click waited out
its timeout against a page that does not carry the button.

An assertion inserted mid-journey has to restore the state it borrowed.
2026-08-03 15:56:28 +02:00
Marc-André Bombeck 154449d77a Stop a prefetching page from seeding the wrong person's record
A server-prefetching page dehydrates a health record into the HTML and
seeds it into the client's query cache under the key the client cell
reads. That copy is only right when the account the page read is the
account the client's route would answer with, and under an account
switch it never is: `getSession()` answers who is CALLING, deliberately
and permanently, so all five prefetching pages seeded the delegate's own
rows into a document opened on somebody else's record. The dashboard was
the worst of it — snapshot, digest and widget layout ride routes that
refuse the refetch, so the delegate's numbers stayed on the owner's
dashboard, while the chart row refetched to the owner and left the page
showing two people at once under a banner naming one.

Resolve the session through `getUnswitchedSession()` instead, which
answers null while the browser is acting on another record. The prefetch
steps aside and every cell fetches through its route, which is where the
grant check lives. Resolving the owner here instead was the other
candidate and is worse: the grant check has exactly one home, half of
what these pages prefetch rides routes that declared themselves not
delegable, and re-deriving it in an RSC would be a second program
deciding whose record this is.

Close the two shell-level doors beside it. The Coach launcher, its
drawer mount and the module tour hang off `<AuthShell>` rather than off a
route, so dropping Insights and Coach from the navigation under a switch
left them painting on every page in the record and on the unavailable
panel too — one tap from a drawer whose every action refuses, and a
`POST /api/insights/coach/seen` fired by opening it at all. All three
bind `useRecordCapabilities()`, the same resolved grant the banner and
the switcher read. Absent, not disabled.

The prefetch rule is structural: the guard walks the app tree, so a sixth
prefetching page is covered the moment it lands rather than the moment
somebody remembers. The pages stay on the session-surface allowlist with
their reasons rewritten, because a wrapper one directory deeper is the
easiest way for a surface to walk out of a guard.
2026-08-03 13:49:19 +02:00
dependabot[bot] 15bd7e660a build(deps): bump react-hook-form from 7.81.0 to 7.83.0
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.81.0 to 7.83.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.81.0...v7.83.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.83.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 11:03:27 +00:00
dependabot[bot] ad18303f7e build(deps-dev): bump eslint-config-next from 16.2.6 to 16.2.12
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.6 to 16.2.12.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.12/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 11:03:23 +00:00
dependabot[bot] 02ff6dff2b build(deps): bump @tanstack/react-query from 5.101.2 to 5.101.4
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.101.2 to 5.101.4.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.4/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 11:03:20 +00:00
dependabot[bot] 8779fb9cc9 build(deps): bump @prisma/adapter-pg from 7.8.0 to 7.9.1
Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.8.0 to 7.9.1.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.9.1/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 11:03:01 +00:00
dependabot[bot] 95293461f2 build(deps): bump docker/login-action from 4.5.1 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.1 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 11:02:10 +00:00
Marc-André Bombeck 326db73883 Merge pull request #709 from MBombeck/work/dw-e
Stop offering a delegate buttons the server will refuse
2026-08-03 12:59:04 +02:00
Marc-André Bombeck 134bf204dc Merge pull request #708 from MBombeck/work/dw-d
A delegate can add to the record they were given
2026-08-03 12:58:08 +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 59965e141e Drive a delegated write through the browser, once the level control lands
The component suite runs without a DOM renderer, so it proves what a page
paints and never what a button does. That is the right shape for this work,
because the property that matters most is a control being absent — but it
leaves the other half unproven: a form that renders is not a form that
submits, and a write that reaches somebody else's record is exactly the thing
worth watching happen.

The journey stands down while the invitation form has no level control, since
minting a WRITE grant behind the UI's back would prove a path no person can
walk. It names the control it is waiting for, so a run that skips while the
control exists reads as a bug here rather than an absence upstream.
2026-08-03 12:17:15 +02:00
Marc-André Bombeck d5a9360c52 Give the caregiver a front door and a receipt
Sharing lived in a settings panel and a menu three levels deep. That is where
a grant is managed and not where it is used: somebody who opens this app every
morning to check on a parent does not go through settings to do it, and the
design standards draw the same line, settings being account utilities rather
than feature destinations. So the dashboard carries one compact card listing
the records shared with this account, each row a one-tap switch through the
existing flow.

It is a doorway and not a preview. No numbers, no last reading. A slice of
somebody else's record on the delegate's own dashboard would sit outside the
one frame the design leans on, the banner naming the record you are inside.
An account nobody has shared with sees nothing at all.

The other half is the receipt: a reading, a lab result or a dose recorded
under a switch now says which record it landed in, because "Saved" alone is
the confirmation somebody acting for another person does not need. The undo
on the dose toast goes at the same time, since a delegate cannot remove what
they recorded and an undo they can see is one the server refuses.

Also here: the lines the owner's activity view owes each admitted verb, as a
translated mapping with a fallback, so a later release filing a verb it has
never heard of still renders a sentence rather than a blank row.
2026-08-03 12:12:08 +02:00
Marc-André Bombeck ab53d42874 Keep dose marking under a switch, and nothing else on medications
Marking a dose taken or skipped is the verb the delegation was written for:
somebody looking after a parent confirms the morning tablet. Adding a
medication and noting a side effect come with it. The card menu keeps only
what survives and disappears entirely when nothing does.

Four detail tabs leave the registry inside somebody else's record, because
their whole content is owner work: the schedule editor and its plan history,
the supply ledger, the external-token row, and the advanced surface holding
the wizard, the import and the destructive zone. A stale deep link into one
lands on the landing tab, the way an inapplicable tab already does.

The take-all-due sweep goes too. It rides the bulk intake route, which the
delegation does not name, and a control that half-works is worse than one
that is not offered.
2026-08-03 12:11:52 +02:00
Marc-André Bombeck 7817632385 Hide the controls a delegate cannot use, surface by surface
Every page a switch can reach now renders the controls the grant covers and
none of the others. Readings, lab results and illness entries keep their add
path at WRITE and lose edit, delete and selection at both levels. Mood,
screeners, cycle days, checkup reminders and the document vault lose every
mutation control at both levels, because no grant admits those verbs and the
routes behind them refuse a switched session outright.

Absent, not disabled, throughout. The banner already says whose record this
is, so no chip repeats it beside each missing button; the one exception is
the measurements empty state, where somebody who may add will look for the
edit control beside the row they just entered, and one muted line says where
the limit is.

Two dead ends went with them: the custom-metric section on the measurements
page linked every row into a route the shell turns a delegate away from, and
the wrench glyphs point at settings pages sharing does not cover.
2026-08-03 12:11:42 +02:00
Marc-André Bombeck bc43a9f062 Drop the shared delete, bulk and capture affordances under a switch
Three controls carry most of the app's mutation surface between them. The row
delete is spelled once for measurements, lab results, biomarkers, allergies,
family history, custom metrics and medication supply; the selection bar is
the only bulk delete; the capture picker is the fastest path to four entry
forms on a phone and bypasses every page header.

Deleting belongs to the owner at every grant level, so the first two are
absent inside somebody else's record rather than greyed out: a disabled bin
still claims the row is the delegate's to remove. The picker keeps the two
kinds a delegation admits and drops a mood entry and a glass of water, which
the server refuses under a switch; when nothing survives, the bar's plus
button goes with them instead of opening an empty sheet.
2026-08-03 12:11:29 +02:00
Marc-André Bombeck 785a1eacfc Freeze the delegable write set and drive it against Postgres
The surface guard grows a write leg. Membership is decided by the argument
a module passes to the record resolver, not by the identifier, because a
delegable read and a delegable write name the same symbol and mean opposite
things. The call matcher resolves aliased and namespace-qualified calls
back to the original name, and proves it can against synthetic modules.

Two assertions carry the decision not to add a provenance column to eleven
tables. Every module in the write list has to call auditLog, which is the
only writer that stamps the acting account, and none of them may reach the
audit table through the client instead. That decision rested on the trail
existing; it now rests on a test.

The prose on the read list said the write arms keep refusing under a
switch. Eleven of them no longer do, so those sentences are rewritten
rather than left to lie.

The integration suite drives the shipped POST exports through the real
handler, resolver, grant table and Prisma. Five legs per verb: the write
lands under the owner and nowhere else, a read grant is refused with
nothing written, a caller who was never granted is refused the same way,
the request after a revocation is refused with no re-login, and a caller
who never switched is indistinguishable from before. The grant is minted
through the invitation flow rather than planted, and the audit assertion
reads both ends of the row.
2026-08-03 12:11:28 +02:00
Marc-André Bombeck 3e7127dca5 Ask what the record on screen allows before rendering a control
Account sharing shipped read-only, and everything that consulted the grant
lived in the application chrome: the nav bars, the banner, the switcher. No
feature surface asked. A delegate opened somebody else's record, saw the add
button on every page, filled the form, and was refused by a server that was
right to refuse. Nothing leaked; it taught people the product was broken.

This is the answer every mutation affordance asks for. Two booleans, because
a grant at WRITE admits a short closed list of verbs and nothing else:
`canAdd` for an entry of an admitted kind, `canManage` for changing what is
already there or adding what the list does not name. `canWrite` arrives
resolved from the server and is bound, never recomputed; what this decides is
only which class of control a resolved level covers.

The absent case is the permissive one. Failing closed would blank every add
button in the app for every account on every cold load, to spare a delegate
one frame between the reload and the account query landing.
2026-08-03 12:11:18 +02:00
Marc-André Bombeck 9ac7d5af7c Let a delegate add a medication, record a side effect and mark a dose
Four medication verbs resolve the record: adding a medication with its
nested schedule, recording a side effect, and both forms of marking a dose.
Editing and deleting a medication stay refused under a switch, which is the
sharpest asymmetry in the feature — a delegate can create a medication and
can never correct the typo in its dose afterwards.

The side-effect rate bucket keys on the acting account rather than the
record. That was the condition on which the verb was admitted: on the
record key a delegate could exhaust the owner's allowance and lock them out
of their own log, and could collect a fresh one by switching to another
record. Everyone who has not switched lands in the bucket they always did.

Both intake routes tell the owner who marked their dose. Everything else
downstream stays addressed to the owner and is correct there: the
cross-device sync wakes their devices, the web-push clear closes their
pending reminder and recounts their badge, the compliance rollup recomputes
their day.

Two validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
2026-08-03 12:11:10 +02:00
Marc-André Bombeck 095aba095e Let a delegate add to the clinical record of a shared account
An allergy, a family-history entry, an illness episode and a custom-metric
value now resolve the record rather than the caller.

Two of them carry a consequence worth naming. The family-history row
describes the record's relatives, so it is the one admitted write where
third-party health information is present by design; it is stored as the
record's own, exactly as the read arm already serves it. The illness module
gate runs against the record before the write, so a delegate cannot open an
episode inside a record whose owner switched the module off, whatever they
set on their own account.

The custom-metric entry reaches its metric through the ownership hop, which
now resolves against the record: a delegate can only feed a definition the
owner made, and their own metric of the same name is unreachable from in
there. Its validation breadcrumb moves to auditLog().
2026-08-03 12:10:59 +02:00
Marc-André Bombeck c6be0778dc Let a delegate enter a lab result and the marker it needs
POST /api/labs and POST /api/biomarkers resolve the record. The free-text
lab path mints its biomarker under the record's catalogue rather than the
caller's — a result added to somebody's record whose marker landed on the
helper's account would leave the owner with a reading they cannot chart.

Adding a marker the record already tracks stays the ordinary 409 from the
same per-account uniqueness the owner would hit themselves. The PUT and
DELETE on the sibling routes are unchanged and still refuse under a switch.

Both validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
2026-08-03 12:10:49 +02:00
Marc-André Bombeck 17c4e76165 Let a delegate enter a reading into a shared record
Both POST arms of /api/measurements resolve the record rather than the
caller. Every `userId: user.id` in the handler already meant "the record",
so the bodies are untouched: the safety-floor check, the reminder
satisfaction, the rollup recompute and the cache eviction are statements
about the record and stay addressed to it.

The two validation breadcrumbs move from a bare client create to
auditLog(). That helper is the only writer that stamps the acting account,
and a delegate's malformed payload filed without it reads as the owner's
own typing.
2026-08-03 12:10:37 +02:00
Marc-André Bombeck 9e3cd7c430 Merge pull request #706 from MBombeck/work/dw-c
Tell the owner when somebody else marks a dose
2026-08-03 11:22:17 +02:00
Marc-André Bombeck d72451d59b Merge pull request #705 from MBombeck/work/dw-b
Offer read or read-and-add when you invite somebody
2026-08-03 11:21:26 +02:00
Marc-André Bombeck 53ef27e63d Merge pull request #707 from MBombeck/work/dw-a
Scope the idempotency replay cell to the record a request claims
2026-08-03 11:21:17 +02:00
Marc-André Bombeck c98a478003 Merge remote-tracking branch 'origin/main' into work/dw-a 2026-08-03 10:54:01 +02:00
Marc-André Bombeck f751510675 Merge remote-tracking branch 'origin/main' into work/dw-c 2026-08-03 10:39:47 +02:00
Marc-André Bombeck aadf751355 Merge remote-tracking branch 'origin/main' into work/dw-b 2026-08-03 10:39:45 +02:00