Story 1802: query_matters v2 — the real matter search
Why this exists (the catastrophe it prevents). At 5 matters the board is fine; at 200 it is a wall. Counsel asks Atlas “show me matters where Manish is involved” or “anything about the 2024 supply dispute?” — but the broker’s query_matters could only answer substring on the title or case-ref. Half the RFD-AGT-003 param surface was modeled nowhere, and the two that were modeled (opened_from/opened_to) were silently dropped by the handler. The render substrate (#1793–#1797) had cards to draw, but nothing honest to put in them. This Story makes the search actually find matters by what they are about and who is involved — the half of RFD-AGT-003 that makes the .feature acceptance testable.
Satellite view: typed params in, one RLS-scoped union query out
Atlas changes query inputs, never query outputs. The brain picks typed params (it holds no DB creds and writes no SQL — D3); the broker builds one indexed, RLS-scoped, LIMIT-bounded statement (ADR-130 O(1)). The shape is unchanged from v1 — it just grew real dimensions:
query_matters{ q?, involves?, party_name?, team_member?, client_name?/client_id?,
status?, type?, value_min?, value_max?, opened_from?, opened_to?,
include_archived=false, page, page_size }
The decisions that matter
1. Full param schema, plumbed DTO → handler → service
The gap was a coordinated 3-way edit, not a tweak. MatterListQuery grew party_name, team_member, involves, value_min, value_max, and include_archived; the agent-tool handler now forwards all of them plus the previously-dropped opened_from/opened_to; the service applies them. Because the brain→broker channel is dict[str, str] (panel V6), a default-off bool arrives as a fuzzy string. So include_archived and the value bounds carry lenient before-validators: "yes"/"1"/"include" → true, a non-numeric bound is dropped, and anything unrecognised falls back to the safe default — a fuzzy token never raises and kills the whole read.
2. FTS adds ranking; substring still carries the floor (D5 amended, V5)
A generated tsvector column + GIN index over title·case_ref·core_dispute_text·outcome_sought_text·matter_frame_text lets q find matters by what the dispute is about — not just the title. Two deliberate choices keep it safe:
websearch_to_tsquery, neverto_tsquery(V5): the websearch parser never raises on legal punctuation, and it is the only model-influenced SQL surface — so it stays a bound parameter, not model-authored SQL (D3 intact).- FTS does not replace substring. The escaped
ILIKEfallback still carriesM-005/number lookups and en/hi/Hinglish parity (substring is script-agnostic). The'simple'tsvector config (no stemming) keeps Devanagari/Hinglish tokens intact, so both arms find a Hindi term. Theqfilter isFTS OR substring.
3. Involved-anywhere union, as OR’d correlated EXISTS (D4)
involves matches a name appearing as a party, the owning client, a team member, or in the matter’s searchable text — party ∪ client ∪ team ∪ FTS, OR’d as correlated EXISTS on the single matter query. party_name and team_member are the narrow arms. Each resolver is correlated to the outer Matter only (the inner clients is aliased so it keeps its own FROM), so the whole search stays one bounded statement — no N+1.
4. The defense-in-depth floor: explicit firm predicate, not RLS alone (panel S1b)
The Q-004 resolver the union was modeled on — client_repository.find_all_active_by_name — leans 100% on RLS, with no firm predicate. The new resolvers do not copy that shape: _party_name_exists, _team_member_exists, and _client_name_exists each carry an EXPLICIT firm_id predicate (for the team arm, on matter_assignments, since identity.users has no firm column). The proof: a test searches involves=Manish as the firm’s counsel while another firm has a client, matter, and party all named Manish — and the cross-firm matter is absent even when RLS is disabled in the query (an admin/BYPASSRLS session). The explicit predicate, not RLS, did the scoping.
5. match_reason is broker-produced, never invented (D13)
A flat OR’d union returns the right rows but loses which arm matched. So the broker computes match_reason as a single CASE WHEN EXISTS(party/client/team/FTS) over the same arms the filters use — 'party' / 'client' / 'team' / 'text', first-match-wins, NULL on a plain list. It rides each row of result.matters[]. The brain and the frontend never invent it — a counsel-misleading reason is worse than none.
6. document_count via a grouped IN, not a per-row join (D7 amended)
The documents table is FK’d to the matter but was never aggregated. _document_counts mirrors _party_counts exactly: ONE grouped IN count over the page’s matters (live docs only), keyed by id → dict → zip in memory. The list path now issues a constant 5 queries — count, page, party-count, document-count, principal — independent of how many matters the page returns (the existing O(1)-query guard still passes). MatterListItem is an ApiResponseModel, so the OpenAPI snapshot is regenerated and committed.
7. Archived excluded by default (D6)
Archived is noise in normal discovery — a lifecycle state, not an authority boundary. With no explicit status filter, list_matters excludes status='archived' unless include_archived=true. An explicit status=archived still wins (the user asked for it). Value-range filters honour nullability: a NULL / non-monetary value falls out of a “worth at least X” range, which is the honest semantics.
Verification
The acceptance layer is pytest-bdd against the ported tests/features/matter_discovery.feature, run through the real MatterService.list_matters under a real firm RLS session against the functions test DB — system boundary, including the zero-rows first run, not a fixture spot-check:
| Scenario | Proves |
|---|---|
| MD-01 | involved-union returns both the party match and the team match, with correct match_reason |
| MD-02 | FTS finds a matter by dispute text that is not in its title (match_reason='text') |
| MD-03 / MD-04 | archived hidden by default; shown only on explicit include_archived |
| MD-06 | zero matches → empty list, total 0 — never a fabricated count |
| MD-08 (P0) | cross-firm isolation, and the cross-firm row is still absent with RLS disabled in the query (S1b floor) |
| MD-10 | bilingual parity — a Hindi term in the dispute text is found by the non-English search |
Plus unit coverage for the V6 lenient coercion and the value-range guard. The Alembic migration round-trips (up/down/up) and alembic check reports no drift for the generated column + GIN index. Ruff is clean; the existing matter, agent-tool, adversary-read, and O(1)-query-count suites stay green; the OpenAPI app-api.json snapshot is regenerated and committed (documentCount + matchReason on MatterListItem).
One sentence to remember: the brain picks typed params, the broker builds ONE RLS-scoped union query — FTS for ranking, substring for the bilingual/ref floor, OR’d EXISTS with an explicit firm predicate for who’s involved — and stamps each card with a broker-computed match_reason it never lets the brain invent.