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.
e2e/
End-to-end browser tests. Playwright 1.60 drives a production build of the app against a real PostgreSQL, with @axe-core/playwright for accessibility assertions. This is the heaviest test tier; CI runs it on every PR, and locally it is worth running when a change touches a UI flow.
Running
pnpm e2e
The Playwright config (../playwright.config.ts) builds the app (next build) and brings up Postgres before the specs run; setup/global-setup.ts seeds the auth + fixture state. The suite is not part of the fast local gate (typecheck / lint / test) — it surfaces at PR CI, so expect responsive/wizard/nav reworks to flag here first.
Layout
setup/—test.ts(the suite'stest+expectentry point),global-setup.ts(auth + DB seeding),test-helpers.ts(shared login + navigation helpers).utils/— fixtures such asmock-dashboard-snapshot.ts.*.spec.ts— one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (medications-wizard-*), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (a11y.spec.ts), and thev1427-*regression set.
Conventions
Import test and expect from ./setup/test, never from @playwright/test — types (Page, Route, …) still come from the package. The entry point extends Playwright's test with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. src/__tests__/e2e-route-cleanup-guard.test.ts enforces the import boundary.
Assert against stable data-* attributes (data-slot, data-display-step, root display attrs), not viewport-dependent visible text. Responsive sm:hidden UI changes break getByText on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See ../CLAUDE.md.