Atlas filters the matters board by client — and only sees its own firm
RFD-AGT-002 D-11 names the first proving slice: a Surface Operator that
actually operates a live page, not a chatbot beside it. Forums leads
(the clean Transport-B proof). The matters filter is the deliberately
messy half — built on the legacy gateway.list(), with no
useAppListMatters hook yet — so we prove the URL-param transport
and the firm-isolation guarantee on the surface where the wiring is hardest.
This Story executes the client filter that the Story-1709 Matters
Page Card already declared.
🛰️ Satellite — why "filter by client" is the boundary that fakes itself
The stakes are legal, not cosmetic. Counsel asking Atlas "show me Meridian's matters" must get exactly their firm's Meridian matters — never a neighbouring firm's, never a stale fixture's. A filter that looks right against mock rows is the most convincing lie in this codebase: the mock always carries the happy data, so the screen nobody ever runs is the real one — the empty result, the foreign client id, the firm boundary.
The naive fix — "the model knows the rows, let it pick the matching ones" —
detonates. The model's idea of the rows is orientation, not law. The
only authority is the backend: the bearer the gateway attaches scopes every
read to the caller's firm (Matter.firm_id == firm_id + RLS in
pramaan-functions). So the filter must ride that authorized path, never an
agent-computed row set. Role caps the verb; RLS caps the rows.
✈️ Airplane — the shape of the change
Think of the matters board as a window with shutters. The
glass is the authorized read — the gateway only ever shows the caller firm's
matters. The shutters are the URL params (status, type,
q, and now client): they narrow what you see
through the glass, but they can never let you see through someone else's
window. Atlas opens and closes shutters; it never replaces the glass.
Atlas drives the filter by navigating to /matters?client=<id>
— Transport-A, a URL-param change that triggers a fresh authorized RSC render.
It resolves the client name → id through the Page Card's second authorized hop
(resolve_client, needs clients:read) before it ever
offers the filter (RFD D-8). The deep link is distinct from the legacy
?clientId= "add a matter for this client" create link — one
filters, the other creates.
| Param | Intent | Owner |
|---|---|---|
?clientId=<id> | Pre-open the Add-Matter sheet for a client (legacy create deep link) | Clients page button (unchanged) |
?client=<id> | NEW — narrow the list to one client | Atlas (this Story) |
🔬 Microscopic — every detail, titled by its WHY
WHY the filter lives in the gateway, not the model
The legacy /app/matters endpoint accepts
q/status/type/opened/page — there is no
client_id query parameter (verified against the canonical
MatterListQuery DTO in pramaan-functions). So we cannot push the
filter to the backend. We also cannot let the browser/agent assemble the rows
(RFD D-3: setQueryData with agent-computed rows is banned). The
honest middle is to narrow the authorized result set server-side, in
the gateway:
// src/lib/matters/gateway.ts
async list(query) {
if (query.clientId) return listScopedToClient(query, query.clientId);
return fetchMatterPage(query);
}
listScopedToClient gathers the firm's authorized pages
(page_size=200, q/status/type still applied
server-side, no client_id sent upstream), narrows
to the requested client, then re-paginates the matched set so
page/total stay honest. Every fetch carries the firm
bearer, so the gathered set is firm A's rows and nothing else.
WHY firm A can never see firm B (the acceptance that matters most)
The filter is a narrowing inside the firm's window, never a key to
another. fetchMatterPage only ever returns the caller firm's rows;
a clientId that belongs to another firm simply matches none and
yields an empty page. Two unit tests lock this:
- test narrows a mixed firm page to one client and re-paginates;
- test a foreign client id returns
{ items: [], total: 0 }— firm isolation holds at the filter.
firm_id gate. This Story's job is to never undermine it:
the filter runs after the authorized read, never around it.
WHY a distinct ?client= param
?clientId= already means "open the create sheet for this client"
(the red Add matter button on a client's detail page). Reusing it would
pop the create sheet every time Atlas filtered — two intents fighting over one
key. A separate ?client= param keeps the legacy create deep link
inert and untouched while giving the filter its own clean handle. The param
name mirrors the Page Card column key (client), matching how
status and type already map.
WHY a removable chip, not a silent narrowing
A filter a human can't see or clear is a door welded shut. When
?client= is active the toolbar shows a removable
Client: <name> chip; clicking it drops the param and resets the
page. The display name is resolved server-side from the narrowed rows, falling
back to an authorized clients.get lookup (so the empty state still
names the client) and finally to the raw id — it never throws the list on a
foreign/archived id. The chip folds into hasActiveFilters so the
"no match" empty state reads correctly.
WHY the Page Card needs no structural change
Story-1709's Matters Page Card already declares client as a
filterable ref column with the resolve_client resolver
and Transport-A (url-params). This Story is the execution
of that declared capability, not a new declaration — the card header now points
at the executor. The card stays the hand-authored spike artifact; the real,
generated card (RFD D-2 gen:permissions + gen:catalog)
is a separate, later build.
One-sentence recall
Atlas narrows the matters board by navigating to
/matters?client=<id>; the gateway applies that filter
over the firm-authorized read, so the board shows exactly that client's
matters and firm A can never see firm B's rows.
Legacy debt called out for review
-
Built on the legacy
gateway.list()— there is nouseAppListMattersgenerated hook yet (AGENT4 / RFD D-11). The client filter is an in-process narrowing, costingceil(firmMatters / 200)authorized round-trips while active. Replace with a single backendclient_idfilter + the matters TanStack migration when those land. Nopnpm codegenwas run. -
The status segment counts remain firm-wide while a client filter is active
(they are a separate
countByStatusread). The narrowed list is client-correct; the segment badges are firm totals. Acceptable for the slice, noted here.
Acceptance criteria
| Criterion | Status | Evidence |
|---|---|---|
| Atlas filters matters by client via URL params → authorized RSC refetch | Done | ?client=<id> → page.tsx → mattersGateway.list({ clientId }); agentSurface advertises the capability |
| firm A cannot see firm B's rows | Done | Filter runs over the firm-authorized read; foreign-id test returns empty |
| PR notes the legacy-gateway dependency; walkthrough attached | Done | This document + PR body |
Files changed
10 file(s), +254 / -38 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +4 | -0 |
| messages/hi.json | Config | +4 | -0 |
| src/app/[locale]/(workspace)/matters/page.tsx | App route | +37 | -3 |
| src/components/matters/live-matters-list-page.tsx | Component | +46 | -9 |
| src/components/matters/matters-live-filters.ts | Component | +6 | -0 |
| src/lib/matters/gateway.ts | Library | +91 | -24 |
| src/lib/matters/types.ts | Library | +9 | -0 |
| src/lib/surface-registry/page-card/cards/matters.card.ts | Library | +4 | -0 |
| src/lib/surface-registry/route-registry.ts | Library | +2 | -2 |
| tests/unit/matters-contract.test.ts | Test | +51 | -0 |