Story-3009 · pramaan-app · 27 Jul 2026
A locked door, not a broken page
An unstaffed firm_editor can see every matter in the register and every
entry in the settings audit — that visibility is correct and stays. What they may
not do is open a matter they are not on the team of. The backend already refuses
that open with an HTTP 403 (pramaan.matter.not_on_team, Story-2677). Before
this Story, that 403 fell through to the error boundary — a jarring, jargon-filled
page for a routine permission boundary. This Story catches that one signal and answers it
with a calm, plain-language modal that names whom to ask — this
matter’s lead counsel and the firm admins (Story-3011) — and walks counsel back
to the matters list.
The one idea
pramaan.matter.not_on_team. Every other failure keeps its existing handling.
Where the gate lives
Every matter work-surface (Brief, Record, Hearings, Filings…) is a thin page under one
fat layout, matters/[matterId]/layout.tsx. That layout performs the single entry
read via getMatterCached. It is therefore the one place the whole detail subtree
enters through — so it is the one place the gate belongs. Block it here and no child
page renders.
Before & after
Something went wrong
GatewayError: pramaan.matter.not_on_team: 403 — an internal
message counsel was never meant to read, with no way forward but the browser back
button.
You’re not on this matter’s team
You don’t have permission to open this matter. Ask this matter’s lead counsel (Priya Ramesh) or an admin (Ravi Kumar, Meera Shah) to grant you access.
What the register still shows
The block is on open, nothing else. The register keeps listing every matter and each row stays clickable; the settings audit is untouched. This Story adds no hiding anywhere — it only changes what happens the moment an ungranted open is refused.
What shipped
| Piece | What it does |
|---|---|
lib/matters/access-gate.ts |
The single discriminator: isMatterNotOnTeamError(err) is true only for a
GatewayError whose status is 403 and whose code is the exported
MATTER_NOT_ON_TEAM_ERROR_CODE. Routes on the code, never on status alone or a
client role guess.
|
matter-not-authorized-modal.tsx +
-view.tsx + -presentation.ts
|
Container / view / pure-helper split. The container reads the access contacts via the
generated useAppGetMatterAccessContacts hook and publishes the blocked
surface; the view renders the shared @/components/ui/alert-dialog from
resolved contacts (so Storybook and tests drive every state by prop); the
helper picks the body key + interpolation names. One action — “Back to
matters” via buildSurfacePath("matters"). No cancel, no dismiss.
|
access-contacts read: gateway + BFF proxy + hook
|
The generated useAppGetMatterAccessContacts hook (from the vendored
Story-3011 contract) routes through a new /api/internal/matters/[matterId]/access-contacts
proxy (+ toBrowserRoute mapping) to the server
mattersGateway.getAccessContacts (runtime-validated with
assertContract, HR-21). The blocked editor is authorized for this
firm-isolation read even though matter ENTRY was refused.
|
matters/[matterId]/layout.tsx |
Wraps the entry read in a try/catch: the not-on-team 403 server-prefetches
the access contacts into a dehydrated QueryClient and returns the modal inside
a HydrationBoundary (names present on first paint, no flash); a 404 still maps
to notFound(); every other error rethrows to the boundary.
|
| i18n — en + hi |
MatterDetail.notAuthorized gains three named-variant bodies
(bodyWithLeadAndAdmins / bodyWithLeadOnly /
bodyWithAdminsOnly, interpolating names) alongside the generic
body, plus the AgentSurfaces.matterNotAuthorized presence key
— all in both locales.
|
| Agent-surface consistency (Codex P1) |
usePublishOverlaySurface publishes an explicit kind: "modal"
blocked surface (agent null) while the modal is mounted, and
SurfacePublisher now yields to an active overlay — so the brain
no longer thinks the user is viewing matter content they are actually blocked from.
|
| Storybook + tests |
Story states for named (lead + admins), lead-only, admins-only, and generic fallback;
tests for the copy helper, the view’s named/fallback rendering + back navigation, the
access-gate discriminator, and the SurfacePublisher overlay-yield.
|
Naming whom to ask (Story-3011)
A blocked editor should not have to hunt for who can let them in. Once #3011 shipped the
access-contacts read — a deliberately minimal, firm-isolation-only payload of
the lead counsel + firm admins (name / email / userId,
no matter content) — the modal names them:
| Contacts returned | Body shown |
|---|---|
| lead + admins | “… Ask this matter’s lead counsel (Priya Ramesh) or an admin (Ravi Kumar, Meera Shah) to grant you access.” |
| lead only | “… Ask this matter’s lead counsel (Priya Ramesh) to grant you access.” |
| admins only (no-lead state) | “… Ask an admin (Ravi Kumar, Meera Shah) to grant you access.” |
| loading / errored / nobody | generic: “… Ask an admin or this matter’s lead counsel to grant you access.” |
The fallback is not an afterthought — it is the same generic body the base modal shipped, so a slow or failed contacts read degrades to a still-useful sentence, never a broken or empty modal. A contact with no display name falls back to its email, so the sentence is never blank.
The brain must not think you’re reading the file (Codex P1)
SurfacePublisher derives the active surface from the URL. But the URL still says
/matters/42/brief while the layout is showing the block — so, uncorrected, it
would advertise the matter.brief surface and Atlas would reason as if counsel were
reading the brief. The fix models the block as a first-class overlay: the modal
publishes a kind: "modal" matter.notAuthorized surface (agent
null), and the publisher yields to any active overlay rather than clobbering it.
When counsel clicks “Back to matters”, the overlay unmounts and the pathname change
re-runs the publisher, which resumes normally.
Why a modal, and why no dismiss
An AlertDialog — not a plain dialog — is the right primitive: it is a
decision the user must acknowledge, not a panel they can idly close to reveal content behind
it. There is nothing behind it. The matter shell never rendered, because the layout returned
the modal in its place. The single forward action is the honest one: go back to where every
matter you can open is listed.
Verification
pnpm lint — 0 errors.
pnpm typecheck — clean.
pnpm test — green, incl. the copy-helper, view (named + fallback + back
nav), access-gate, and SurfacePublisher overlay-yield tests.
pnpm check:routes / pnpm check:catalog — fresh (42 surfaces;
catalog current after the permission-map sync).
pnpm check:css-tokens — OK; the modal reuses only token-backed utilities
shared with the remove-member dialog, no color literals.
pnpm storybook:build / storybook:drift — green; the named,
lead-only, admins-only, and generic-fallback states render through the real
messages/en.json.
Codegen sync — committed separately as chore(api): sync generated
client: openapi/app-api.json + permission-map.json vendored
from pramaan-functions main (Story-3011, #835) and the kubb client regenerated
pinned to the mirror (LI-6). The generated useAppGetMatterAccessContacts hook is
what the modal consumes — no hand-rolled client.
Observability — the one new backend hop, the access-contacts
BFF proxy, is auto-instrumented at the shared gateway seam (pramaan-docs ADR-065/104); no new
manual span is warranted (pramaan-docs ADR-124). The entry read and its 403 already exist and
are logged there.
Files changed
35 file(s), +1703 / -1 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +9 | -0 |
| messages/hi.json | Config | +9 | -0 |
| openapi/app-api.json | Config | +164 | -0 |
| openapi/permission-map.json | Config | +9 | -0 |
| src/app/[locale]/(workspace)/matters/[matterId]/layout.tsx | App route | +35 | -1 |
| src/app/api/internal/matters/[matterId]/access-contacts/route.ts | App route | +45 | -0 |
| src/components/matter-detail/matter-not-authorized-modal-view.test.tsx | Component | +98 | -0 |
| src/components/matter-detail/matter-not-authorized-modal-view.tsx | Component | +82 | -0 |
| src/components/matter-detail/matter-not-authorized-modal.stories.tsx | Component | +85 | -0 |
| src/components/matter-detail/matter-not-authorized-modal.tsx | Component | +55 | -0 |
| src/components/matter-detail/matter-not-authorized-presentation.test.ts | Component | +70 | -0 |
| src/components/matter-detail/matter-not-authorized-presentation.ts | Component | +48 | -0 |
| src/gen/client/appGetMatterAccessContacts.ts | Generated contract | +68 | -0 |
| src/gen/client/index.ts | Generated contract | +1 | -0 |
| src/gen/hooks/index.ts | Generated contract | +8 | -0 |
| src/gen/hooks/useAppGetMatterAccessContacts.ts | Generated contract | +135 | -0 |
| src/gen/hooks/useAppGetMatterAccessContactsSuspense.ts | Generated contract | +138 | -0 |
| src/gen/index.ts | Generated contract | +36 | -0 |
| src/gen/schemas/matterAccessContact.json | Generated contract | +25 | -0 |
| src/gen/schemas/matterAccessContactsView.json | Generated contract | +70 | -0 |
| src/gen/types/AppGetMatterAccessContacts.ts | Generated contract | +70 | -0 |
| src/gen/types/MatterAccessContact.ts | Generated contract | +26 | -0 |
| src/gen/types/MatterAccessContactsView.ts | Generated contract | +23 | -0 |
| src/gen/types/index.ts | Generated contract | +14 | -0 |
| src/gen/zod/appGetMatterAccessContactsSchema.ts | Generated contract | +88 | -0 |
| src/gen/zod/index.ts | Generated contract | +13 | -0 |
| src/gen/zod/matterAccessContactSchema.ts | Generated contract | +25 | -0 |
| src/gen/zod/matterAccessContactsViewSchema.ts | Generated contract | +38 | -0 |
| src/lib/kubb-client.ts | Library | +10 | -0 |
| src/lib/matters/access-gate.test.ts | Library | +41 | -0 |
| src/lib/matters/access-gate.ts | Library | +27 | -0 |
| src/lib/matters/gateway.ts | Library | +21 | -0 |
| src/lib/matters/types.ts | Library | +4 | -0 |
| src/lib/surface-registry/use-surface.test.tsx | Library | +82 | -0 |
| src/lib/surface-registry/use-surface.ts | Library | +31 | -0 |