Story-3162 Walkthrough

Outcome: counsel's most consequential question — is this citation real? — now reaches a source. Vani's six case-law tools stop answering UNAVAILABLE: this capability is not built and become live deferred declarations against the broker executor that merged tonight (pramaan-functions #870, Story-3133). Nine stubs remain.

counsel pastes the other side's authorities
  → Vani calls citation_audit(citations=[…])          (no matter reference in the params)
  → SDK returns DeferredToolRequests.calls; the turn ENDS
  → broker loads THIS matter's protected terms under counsel's RLS
  → broker scrubs the outbound query, calls Indian Kanoon from a non-VPC boundary
  → broker re-invokes Vani with a bounded typed result
  → Vani answers: a row per citation, each with what was found and what was not
Boundary. The brain never egresses. There is no requests, no httpx, and no Python function behind these tools — they are SDK ExternalToolset definitions, so the model's call is an intent and nothing else. Scrubbing and the gateway call are the broker's and were already merged. Ending the turn is also the performance answer (D9): a lookup takes 8–11 seconds, longer than a whole turn.

The trap in a tool name

One tool is named verify_citation, and that name is model-facing: it invites the seat to answer "I verified this." It cannot be renamed from this side — it is the broker's registry key — so three things push against it instead:

Recommendation for a later ticket: the honest name is lookup_citation. It needs the broker's registry key to move first, so it is deliberately not done here.

UNAVAILABLE, never empty

Three outcomes are three different facts and the code keeps them apart:

StatusWhat it meansWhat Vani may say
FOUND_IN_SOURCEthe corpus holds matching records report court, date and citation count off the bound record
NOT_FOUND_IN_CORPUSwe searched; nothing matched there "I could not locate this authority in Indian Kanoon" — never "fabricated"
UNAVAILABLEthe lookup could not run at all the returned reason, plainly — never "there is no problem"

An empty or malformed payload is rejected at the brain boundary (MiraToolResult), because [] reaching the prompt is what makes an agent say "there are no problems with their citations" about a check nobody ran. That is fabrication by omission in a legal record.

Streaming safety

A validator alone was not enough in Story-3101: rejected text had already streamed to counsel before it was rejected. So a case-law turn joins the record turn in withholding answer deltas until validation passes, then emits the validated body. It costs that one turn its token-by-token stream — the turn that already waited 8–11 seconds on the source. Provider thinking deltas and the inter-chunk dead detector are unchanged.

Why a case-law result reopens the case-law tools

proposition_match and citation_history take a record id that exists only inside a previous lookup's result, and the prompt forbids inventing one. A strictly single-leg rail would therefore declare two tools live that nothing could reach honestly. The follow-up leg offers only the six case-law tools — no resolver, no frame delegate, no stubs — and the broker's own _MAX_TOOL_STEPS bounds the chain.

Cached-prefix cost — re-measured, not estimated

The exact OpenAI-wire tool catalog is counted with o200k_base. Six zero-parameter stub schemas became six live tools carrying real parameter schemas and the output-vocabulary rules, so the number moved:

MeasurementBeforeAfter
Full catalog over the resolver-only baseline 1,281 tokens / 6,086 bytes2,145 tokens / 9,903 bytes
Assembled instructions + user prompt (control bytes) 14,980 bytes16,980 bytes

Both are asserted in tests and fail on drift. The prompt version is bumped to mira.converse.v13: a prompt edit is a cache event fleet-wide, which is exactly what a real parameter surface costs.

Executable proof

RequirementProof
The six are live deferred declarations, not stubs test_the_six_case_law_tools_are_live_deferred_declarations
Params match the broker's strict models exactly test_case_law_param_keys_match_the_broker_models_exactly
Each call becomes one broker intent, with no matter reference test_each_case_law_call_becomes_one_broker_intent
A stray key cannot fail a 20-citation audit at the boundary test_unknown_keys_are_dropped_before_they_can_fail_the_whole_call
An unrunnable call never buys a lookup test_an_unrunnable_case_law_call_never_reaches_the_broker
Real data reaches the prompt through a whitelist test_each_case_law_tool_exposes_real_data_through_its_whitelisted_view
A miss is explained; a failure is UNAVAILABLE; neither is empty test_a_case_law_miss_is_explained_and_a_failure_is_unavailable_never_empty, test_a_corpus_miss_is_not_the_unavailable_contract
No answer claims verification, and the rejected one never streams test_a_verification_claim_is_rejected_and_never_reaches_the_delta_stream
An UNAVAILABLE case-law result must be disclosed test_a_case_law_unavailable_result_must_be_disclosed_plainly
The follow-up leg offers only the six test_a_case_law_result_reopens_only_the_case_law_tools, test_a_follow_up_lookup_rides_the_same_deferred_rail
The other nine stubs still return UNAVAILABLE test_every_stub_returns_unavailable_and_vani_says_it_is_not_built
Story-3101 delta guard remains test_rejected_unavailable_finding_never_reaches_the_delta_stream
Re-measured prefix cost is asserted test_converse_registers_the_exact_specced_tools_and_measured_schema_delta

Verification

uv run ruff check .              → All checks passed
uv run pytest -q                 → 457 passed  (repo governance)
cd agents/mira && pytest tests   → 364 passed  (the CI-scoped agent step)