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.
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
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.
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.
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.
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.
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.
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:
| Measurement | Before | After |
|---|---|---|
| Full catalog over the resolver-only baseline | 2,145 tokens / 9,903 bytes | 2,412 tokens / 11,153 bytes |
| search_matters alone | — | 269 tokens / 1,251 bytes |
| Assembled instructions + user prompt (control bytes) | 18,959 bytes | 20,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.
| Requirement | Proof |
|---|---|
| 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) |
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)