Story 1805: D14 ask-on-ambiguity carrier — clarify, never auto-pick
Why this exists (the catastrophe it prevents). Counsel tells Atlas “show me matters for Manish.” The firm has two clients the name resolves to. The broker’s name→entity resolver had exactly three things it could say back: executed, proposal, denied. None of them is “which Manish?” So the only options were to auto-pick one — a silent wrong-matter, the precise failure D4 forbids in a legal product — or to hard-fail (422), which reads to the user as a refusal for a perfectly reasonable question. The clarification turn in the RFD sequence diagram was unserializable: there was no wire for it. This Story adds that wire.
NEEDS_CLARIFICATION → render “which one?” → re-issue with the chosen id) is the paired pramaan-agents ticket. The brain still emits typed params; the broker still owns the SQL; RLS is still row authority; one audit row per tool call is unchanged.Satellite view: a fourth outcome, and a resolver that refuses to guess
The executor already classified every server-plane intent into one authoritative outcome. We add a fourth — NEEDS_CLARIFICATION — carried alongside a typed, firm-scoped choices[] payload. The name→entity resolver, on matching more than one firm entity, stops auto-picking and signals this outcome instead.
ServerToolOutcome = executed | proposal | needs_clarification | denied
ServerToolExecuteResponse{ ..., choices?: ClarificationChoice[] }
ClarificationChoice{ entity_type, id, label } # firm-scoped, never auto-picked
The decisions that matter
1. A typed wire carrier, not a stringly reason
D14 offered two options: a real NEEDS_CLARIFICATION outcome + typed choices, or a denied+structured-reason convention. We took the typed carrier — the brain needs the candidate ids to re-issue the read, and packing those into a denial string would just be a worse DTO. ClarificationChoice is an ApiResponseModel on the snapshot-tracked execute boundary, so the OpenAPI app-api.json is regenerated and committed; the brain mirrors it as a typed shape, never a free-text parse.
2. The resolver signals, the executor serializes
The ambiguity is discovered deep in a READ handler (_resolve_client_name, where a client name resolves to a client_id). Rather than thread an out-of-band return type through every handler, the resolver raises a typed ToolClarificationNeededError carrying the choices; the executor catches it in the READ branch and turns it into the authoritative response. This is a valid outcome, not an error — so it returns normally and the call still commits its single audit row (stamped outcome=needs_clarification). Raising past the executor would have rolled the audit row back with the request.
3. Never auto-pick; the choices are the disambiguator
A single match resolves and proceeds exactly as before; zero matches still answers honestly (404 — an honest “none”, never a false 0 matters). Only >1 match changes: it returns one ClarificationChoice per candidate. Two clients can share a display name, so the label appends a distinguishing detail (email, else the firm-unique client number) — "Acme Holdings · #2" — so the brain can render a real choice, not two identical buttons.
4. The choices are firm-scoped — the P0 tenancy floor (Q-004, panel S1b)
A clarification payload that leaked another firm’s namesake would be a cross-firm oracle: “there’s a Manish over there” is itself confidential. The resolver runs under the caller’s RLS, so a cross-firm entity is already invisible — and Q-004 holds: the same NotFound whether the entity is elsewhere or nowhere. We also hardened the resolver the panel flagged: client_repository.find_all_active_by_name leaned 100% on RLS with no firm predicate. It now carries an EXPLICIT firm_id = app.current_firm_id_or_null() predicate (the S1b defense-in-depth floor the sibling union resolvers already honor), so the choices stay firm-scoped even if RLS is ever disabled in the query.
Verification
pytest-bdd against tests/features/matter_discovery.feature — the new MD-11 scenario runs through the real server-plane executor under a real firm RLS session against the functions test DB (system boundary, audit included), and the cross-firm floor through an RLS-disabled admin session:
| Check | Proves |
|---|---|
| MD-11 (clarify) | an ambiguous client name → NEEDS_CLARIFICATION + one firm-scoped choice per candidate; result is null (never auto-picked) |
| MD-11 (tenancy, RLS on) | a same-named client in another firm never appears in the choices |
| MD-11 (tenancy, RLS off, P0) | the cross-firm namesake stays absent even with RLS disabled — the explicit firm predicate did the scoping |
The integration suite (tests/integration/test_app_agent_tools.py) is updated through the real ASGI route: the former “ambiguous → 422” test now asserts the clarification carrier (200, needs_clarification, firm-scoped choices, cross-firm namesake absent) and exactly one audit row per call stamped outcome=needs_clarification. Single-match and zero-match (404) regressions stay green. Ruff is clean; the agent-tools unit suite passes; mypy reports no new errors in the changed modules; the OpenAPI app-api.json snapshot is regenerated and committed (ServerToolOutcome.needs_clarification, the ClarificationChoice schema, and the choices field).
One sentence to remember: when a name resolves to more than one firm entity, the broker returns NEEDS_CLARIFICATION with firm-scoped choices instead of silently picking the wrong matter — and an explicit firm predicate keeps a cross-firm namesake out of those choices even with RLS off.