PR: #418: feat(matters): client filter via ?client= URL param [Story-1712] by @ankitmaheshwari-com
View PR →
Story 1712 · Epic-1646 Agentic interaction · RFD-AGT-002 D-11 / D-2 · ADR-110

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.

ParamIntentOwner
?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 clientAtlas (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:

The isolation is not created by this Story — it is inherited from the backend 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

Acceptance criteria

CriterionStatusEvidence
Atlas filters matters by client via URL params → authorized RSC refetchDone?client=<id> → page.tsx → mattersGateway.list({ clientId }); agentSurface advertises the capability
firm A cannot see firm B's rowsDoneFilter runs over the firm-authorized read; foreign-id test returns empty
PR notes the legacy-gateway dependency; walkthrough attachedDoneThis document + PR body

Files changed

10 file(s), +254 / -38 lines

Path Role + -
messages/en.jsonConfig+4-0
messages/hi.jsonConfig+4-0
src/app/[locale]/(workspace)/matters/page.tsxApp route+37-3
src/components/matters/live-matters-list-page.tsxComponent+46-9
src/components/matters/matters-live-filters.tsComponent+6-0
src/lib/matters/gateway.tsLibrary+91-24
src/lib/matters/types.tsLibrary+9-0
src/lib/surface-registry/page-card/cards/matters.card.tsLibrary+4-0
src/lib/surface-registry/route-registry.tsLibrary+2-2
tests/unit/matters-contract.test.tsTest+51-0