Delegated write access, and the front door a delegate lands on - #712
Merged
Conversation
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.
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.
…hing 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.
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.
… 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.
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.
…aring-write-integration
…tion # Conflicts: # src/__tests__/delegable-surface-guard.test.ts
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.
…rk/sharing-write-integration
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.
… 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.
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.
`.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.
…ult 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three lines of work that all touch the shared record, assembled once so the gate runs against what would actually ship.
Withhold the controls a shared record refuses. A delegate opened a page inside someone else's record, saw the add button, filled the form, and was refused by the server. Nothing leaked, because the refusal was right. It still taught them the product was broken. Every mutation affordance now asks what the person at the keyboard may do, and a control that is not theirs is absent rather than greyed out.
POST /api/allergiesandPOST /api/family-historyleft the delegable set. The argument for admitting them was never wrong; what they lacked was a caller, since the only surface that posts to either lives under/settingsand a switch closes/settings. Their integration proof is inverted rather than deleted, and it now also asserts the leg nothing else covered: that the owner's own unswitched write still lands.The front door. Ten routes refused on
/, which is the first page a delegate sees. Eight are now record reads and two are actor surfaces, each argued at its admission site, with the per-module-scope re-examination named where it applies. A second instance of the delegate-data-under-the-owner's-banner defect turned up on the way: the dashboard's client-side band fallback computed blood-pressure targets, the weight range and every shaded chart zone from the caller's own date of birth, sex and height. With the snapshot refusing, that fallback was permanent, so a delegate's reference ranges shaded the owner's charts for the whole visit.Recorded side effects survive a restore. A delegate can record a side effect against a drug, and that exact contribution was the one a restore lost. The rows now ride inside their medication in the backup payload and come back through the real restore route, with the encrypted note handled the way
Measurementalready handles its own.Plus, on the trunk under this branch: the dose-history purge tombstones instead of hard-deleting, the manual water quick-add and the lab-result un-delete each write an audit row, and the refused-delegation audit comment no longer claims the row is written for the owner when it is filed under the caller.
Every new assertion in all three lines was watched fail before it was believed, with the deliberate break confirmed present in the file first.