Story-3183

Which matter does counsel mean?

Counsel says "the Addictive Learning one" or "the HDPE thing". resolve_matter_document finds a document inside a matter; until now nothing found the matter. This is the executor behind Vani's search_matters tool — the twin of pramaan-agents PR #405, which declared the tool and resolved it to UNAVAILABLE because there was nothing behind it.

Not a new pattern

Same rail, same broker execution, same RLS posture as the document resolver — one level up. A deferred tool call: Vani's call ends her turn, the broker runs the search under counsel's own access, and she reads the ranked result on the next turn. Spike-3126 settled this on 2026-07-30 after the founder corrected a pre-computed-candidate-list proposal: we do not know what to search for until Vani has read the message. Pre-computing means guessing; her calling it means the query is counsel's actual words.

The index was finally worth querying

business.matters.search_vector is a generated column over title · case_ref · core_dispute_text · outcome_sought_text · matter_frame_text, with pg_trgm GIN indexes alongside for fuzzy tolerance. Two gaps closed the day before this landed:

GapWhat it did
#881 (Story-3169) Party names became searchable — the client and the opposing side. They are rows in matter_participants, so the generated vector can never reach them; the search uses correlated EXISTS.
#882 (Bug-3170) matter_frame_text is actually written now. It had been None from creation forever while carrying two indexes. Before that fix, "the HDPE thing" would have matched nothing and the feature would have looked fine.

So the ranking is done in the query. A read that pulled an unordered list and left matching to something downstream would throw away the only reason those indexes exist.

How it ranks

rank = (party or client NAME hit ? 1.0 : 0.0)
     + ts_rank(search_vector, websearch_to_tsquery('simple', q))

ORDER BY rank DESC, opened_at DESC, matter_number DESC
LIMIT 10

One expression, two tiers, both index-backed. A name hit outranks every body-text hit, because a name identifies a matter while dispute text only describes its topic — and ts_rank alone scores a party-name match 0 and would sort it last, which is backwards.

The score that comes back is the score the rows were ordered by. That matters: the brain's near-tie rule (D17) says Vani asks on a near-tie and never picks, because a wrong pick answers the next turn from another client's matter — confident, correct-looking, privileged, and indistinguishable from a right pick in the transcript. She can only see a near-tie if the numbers she reads are the real ones.

Three rules that did not bend

1. The LIST is the security boundary, not the parsing

firm_id and membership come from the verified envelope. The brain does stamp a firm_id into the intent from its own envelope — and the executor still never reads it. The firm this search runs in is the session's, set by the broker from the caller's own membership, and record-level RLS (firm_isolation_policy / business.can_read_matter) decides which of that firm's matters this member sees. Two sources for one tenant boundary is how a tenant boundary breaks.

Every id in the payload was produced by that query. Nothing here parses an id out of model text, so there is no id to validate against the set — which is the stronger form of the same rule.

2. A ranked miss is still a miss

Zero results does not mean "no such matter". It means this query did not match. An unqualified empty would have Vani tell counsel their matter does not exist when it does.

The outcome names which answer this is:

outcomemeaning
candidatesEvery matching matter is listed. Nothing was cut.
ranked_top_nMore matched than the cap; this is the top 10 by rank, and totalMatches says how many there really were. A cap is an answer, not completeness.
no_matchThis query matched nothing this member can read. Not proof of non-existence.

The payload stays a real object in every case — the brain's MiraToolResult guard rejects a bare {} precisely so this distinction survives the wire.

3. Titles-only is enforced IN THE QUERY

The statement names its identity columns one by one and never selects the Matter entity, so core_dispute_text, outcome_sought_text and matter_frame_text are never fetched at all. They are searchable and unreadable: in the WHERE clause and in search_vector, absent from the SELECT list. Document contents are not merely unselected — documents and context are not in the FROM clause. They live in context.chunks and stay there.

A strip-after-fetch version returns the same JSON, passes the same integration assertions, and leaks the day someone maps one more column into the DTO. So the unit test compiles the statement and pins its select list against an allowlist — a new content column fails the build rather than the review.

What comes back

{
  "query": "high density polyethylene",
  "outcome": "ranked_top_n",
  "totalMatches": 12,
  "candidates": [
    {
      "id": "…", "code": "M-001",
      "title": "Arbitration",
      "parties": "Sugar Pipes Pvt Ltd v. Prithvi Banerjee",
      "rank": 1.0607, "lastTouched": "2026-07-31T03:59:47Z"
    }
  ]
}

Six keys per candidate, matching the brain's whitelisted MatterSearchCandidateView field for field. Anything else the executor grew would be dropped at the brain boundary anyway, so nothing else is sent.

Two deliberate choices

How this was proved

A green isolation test that has never been red proves nothing. The cross-firm assertion was run against a planted negative first — the explicit firm predicate monkeypatched out of the WHERE clause and the read moved onto the superuser session where RLS does not apply:

PLANTED RUN returned ids: ['e0fbef09-…', 'fa4c31bd-…']
E   AssertionError: assert ['e0fbef09-…', 'fa4c31bd-…'] == ['fa4c31bd-…']
E     At index 0 diff: 'e0fbef09-…' != 'fa4c31bd-…'
E     Left contains one more item
1 failed

The other firm's matter leaked in and the assertion caught it. Both boundaries restored, the same test passes — and it carries a positive control in the same session (the caller's own matter, matching the identical phrase), so the absence is a denial rather than a query that happened to match nothing.

Landing order

This merges and deploys first. pramaan-agents #405 then flips search_matters out of its UNAVAILABLE list. The other order ships an advertised tool with nothing behind it, which is the failure this repo has now shipped five instances of in a week.