Story-3181 Walkthrough

Outcome: counsel says "the Addictive Learning one" or "the HDPE thing" and Vani can now be taught how to resolve it. search_matters — the matter-level twin of resolve_matter_document, one level up on the same deferred rail — is complete on the brain side: tool definition, model-facing description, query-only arg schema, envelope firm stamping, the whitelisted result view, and the two reading rules that make a wrong answer a confidentiality incident rather than a UX slip.

Read this before assuming it works. The tool is declared UNAVAILABLE, not live. The broker executor was never filed — that gap was caught during this story and is now pramaan-functions Story-3183; the declaration flips there, exactly the ruling the three record tools got (PR #390) and the citation tools got before pramaan-functions #870 merged. A tool declared live with nothing behind it is the "advertised capability, nothing happens" failure, and this PR does not ship a sixth instance of it. Counsel who asks Vani to find a matter today gets the honest answer: this capability is not built.
counsel: "the matter where Addictive Learning is fighting Ramanuj Mukherjee"
Vani   : search_matters("Addictive Learning Ramanuj Mukherjee")   ← query is the ONLY model key
brain  : drops everything else the model emitted (firm_id, matter_id, limit)
         stamps firm_id from the VERIFIED invoke envelope (runtime.py ingress seam)
broker : (Story-3183) websearch_to_tsquery + pg_trgm over the firm's matters, RLS-bound, ranked
Vani   : exactly one high-confidence candidate → propose it by name
         anything else → ASK, showing what she found — she never picks on a near-tie

Why a tool, not a prompt payload

Spike-3126 settled the design after the founder corrected the first proposal. The earlier design pre-computed an RLS-bound candidate list of matters into the prompt tail every turn and asked the model to match counsel's words against it by eye. Two fatal costs: it pays tokens on every turn to serve the minority of turns where anyone switches matters, and it uses full-text search to build a list, then throws away the ranking the GIN and trigram indexes exist to produce. The founder's deciding argument: we do not know what to search for until Vani has read the message — pre-computing means guessing; her calling the tool means the query is counsel's actual words.

The near-tie rule — the highest-stakes line

Two matters for the same client with similar names is the ordinary case. On a near-tie Vani asks which one counsel means; she never picks. The reason is not UX: a wrong pick means the next turn answers from another client's matter — confident, correct-looking, privileged, and indistinguishable from a right pick in the transcript. An ask costs one turn; a wrong pick costs a confidentiality incident. The rule is welded into both places the model reads — the tool description and the MATTER SEARCH prompt section — and recorded as design decision D18 on Vani's lens.

A ranked miss is still a miss

Zero results does not mean "no such matter", and a result list that excludes counsel's matter does not mean it is not there — Vani is reading a ranked subset, not the firm. "None of these" is always an offered outcome. This is the same obligation that made [] unacceptable for the citation tools; the shape changed, the rule did not. The brain-side guard backs it: a bare {} payload is rejected at the MiraToolResult boundary, while an outcome with an empty candidates list is a real ranked answer and renders honestly.

Scope comes from the envelope, never the model

query is the only model-facing key. firm_id is pinned from the verified invoke envelope at the same ingress seam that pins matter_id (runtime._envelope_firm_id → MiraConverseInput.firm_id → the intent stamp in deferred_tool_intents). A model-supplied firm_id/matter_id/limit is dropped in code, never validated and rejected. There is no limit at all: ranking depth is the broker's, and a model that can widen its own result set turns a top-10 into a firm-wide dump. With no verified firm scope the call fails closed — no intent, typed error turn, never an unscoped search.

When she reaches for it

Only when the referenced matter is (a) not the current one and (b) not identifiable from what she already holds. A mention is not a switch — counsel says "like we did in the Sugar Pipes one" constantly without wanting to move. Reaching on every mention is the noisy failure; missing an explicit switch is the annoying one. The prompt says to prefer the annoying failure.

Cached-prefix cost — re-measured, not estimated

The exact OpenAI-wire tool catalog is counted with o200k_base, resolver-only baseline to full catalog. The stub carries the live tool's real query schema and the near-tie / ranked-miss reading rules, so the number moved:

MeasurementBeforeAfter
Full catalog over the resolver-only baseline 2,145 tokens / 9,903 bytes2,412 tokens / 11,153 bytes
search_matters alone—269 tokens / 1,251 bytes
Assembled instructions + user prompt (control bytes) 18,959 bytes20,990 bytes (sha256-pinned)

All three are asserted in tests and fail on drift. The prompt version is bumped to mira.converse.v15: a prompt edit is a cache event fleet-wide.

Executable proof

RequirementProof
A model-supplied scope value is ignored in favour of the envelope test_model_scope_values_are_dropped_in_favour_of_the_envelope, test_envelope_firm_id_beats_a_mismatched_nested_input
No verified firm scope → fail closed; blank query → never reaches the broker test_a_search_without_verified_firm_scope_fails_closed, test_an_unrunnable_search_never_reaches_the_broker
A near-tie produces an ASK, never a pick test_a_near_tie_result_produces_an_ask_never_a_pick (wiring: both candidates reach the model through the whitelist, the phase offers no tools, the ask is the turn body) + test_the_prompt_teaches_the_near_tie_and_ranked_miss_rules (the contract text the live seat reads)
An empty result never denies existence test_an_empty_result_never_denies_existence, test_an_empty_candidates_list_is_real_data_but_a_bare_empty_payload_is_not
Results render identity labels only, through the whitelisted view test_a_search_result_renders_only_identity_labels_through_the_whitelist
Declared UNAVAILABLE until Story-3183; disclosure rule applies test_the_stub_returns_unavailable_and_the_disclosure_rule_applies, test_converse_registers_the_exact_specced_tools_and_measured_schema_delta
Re-measured prefix cost is asserted test_converse_registers_the_exact_specced_tools_and_measured_schema_delta (2,412 / 11,153), test_omitted_channel_prompt_has_v15_control_bytes (20,990, sha256)

Verification

uv run ruff check .              → All checks passed
uv run pytest -q                 → 457 passed  (repo governance)
uv run --package mira pytest -q  → 378 passed  (the CI-scoped agent step)
python -m evals.run adversarial  → pass 14 · fail 0  (offline, blocking in CI)
python -m evals.run context      → pass 5 · fail 0   (offline, blocking in CI)