Story-3169 · functions lane

Find a matter by the name of a party in it

Counsel identifies a matter by who is in it — “the one where the freight company is fighting that consignee.” Until now that phrase resolved to nothing. The matter search vector covered title, case reference, dispute text, outcome sought and frame text; no party-bearing table appeared in any search vector or trigram index. The client was picked from a dropdown at creation and every participant was recorded, and none of it was findable.

Free-text matter search now reaches the names of the parties: the owning client and every recorded participant. Misspellings are tolerated.

What counsel can now type

GET /app/matters?q=Rustwater Freight Pvt Ltd   → the matter, matched on "client"
GET /app/matters?q=Prithvi Banerjee            → the matter, matched on "party"
GET /app/matters?q=Prithvi Banerji             → the same matter (misspelt)

match_reason stays honest: a query that lands on a participant reports party, one that lands on the owning client reports client, and only a hit in the matter’s own text still reports text. The label is produced by the same SQL arms as the filter, never invented downstream.

The decision: no denormalised copy

business.matters.search_vector is a generated column, which is exactly why it has never drifted — every one of its sources is on the matters row. Party names are not: they are rows in business.matter_participants and business.clients, and a generated column cannot reach another table.

The two candidate designs were a denormalised party_text column folded into the generated vector, or a searchable projection queried alongside it. This story takes the second shape in its thinnest form: matter search queries the party rows live, through correlated, firm-scoped EXISTS arms, and stores no second copy of any name.

The reason is the failure this feature would otherwise ship with. A denormalised column has to be chased by every rename and every deletion; the day one write path forgets, search starts answering with a name that no longer exists on the matter, silently and with no error anywhere. Reading the live row makes that state unrepresentable rather than merely tested for. The concrete costs are paid honestly and are small: the party arm is a second query path (it already existed — the party_name and involves filters used it), and the rank merge below is the ranking decision the projection shape owes.

The rank merge

ts_rank scores only the matter’s own text, so a match found purely by party name scores zero and would sort below every weak text hit — backwards, because a party name identifies a matter while dispute text only describes its topic. Search therefore orders by one boolean tier — name match first — then the existing ts_rank, then the existing recency tiebreak. Listings without a query term keep their ordering byte-for-byte.

Fuzziness, and what was deliberately left alone

Each name column is matched two ways: the escaped leading-wildcard ILIKE, so a fragment of a name always matches, and pg_trgm’s % similarity operator, so a misspelling still finds the matter. Both are served by the gin_trgm_ops indexes added in migration 0234 — matter_participants.name had no index at all for the pre-existing party filter, and the client name columns carried only plain btrees.

The 'simple' text-search configuration is untouched. It is deliberate: an English stemmer mangles Hindi and Indian names, and pg_trgm is what buys fuzziness here. No matter is reindexed and no table is rewritten by this change.

Confidentiality

Party names are client-confidential. Both arms carry an explicit firm_id predicate in addition to RLS, so the tenancy floor holds even in a session where RLS contributes nothing. The scope is labels only — participant and client names. Document contents are not searched here and remain the Context Engine’s job.

Proof

ScenarioAsserts
MD-20The matter is found by its client’s name and by its opposing party’s name, neither of which is in its title.
MD-21A misspelt party name still finds it — the test first asserts the misspelling is not a substring of the real name, so only similarity can match it.
MD-22Renaming the party makes the old name return nothing and the new name return the matter; removing the party makes the new name return nothing.
MD-23Another firm’s party name never surfaces in free-text search, including with RLS disabled in the query.

MD-20, MD-21 and MD-22 were observed red against the pre-change service. MD-23 was observed red against a planted removal of the firm predicate. Every name in the suite is an invented pseudonym; no live-matter person appears in this repository.