ADR 0007 — The call handoff
Status: accepted, 2026-08-30. Partly built — see the 2026-09-10 addendum. The handoff object exists and is wired; the capability it was written for — dispatching a call with a mandate — does not.
Context
Everything a call needs is currently a hardcoded literal. Scenario is six
frozen instances in poc/scenarios.py; a call is assembled at
ori_bot.py:366 from scenario.target_rate and scenario.vendor_last_rate;
the company name is a module constant in five places. There is no endpoint that
starts a call from a payload.
Ori is meant to be dispatched: told at call time who she is, what she may pay, what this vendor charged last time, what the market is doing, and how much authority she has. None of that is expressible today.
The architecture, however, already has the right seam. Scenario.load_brief()
is a method, not a field, so money fields cannot reach a prompt by accident.
The handoff extends that object rather than replacing the design.
Decisions
1. Authority is per call, not per deployment
The handoff carries a mandate. Two modes:
collect— get a price, defer. Today's only behaviour, hardcoded inpersona.SYSTEMas "NEVER book, confirm or agree".lock— take a clear verbal agreement, which a person confirms afterwards inside a timeout. The agent never becomes the last checkpoint.
⛔ lock is NOT "the agent commits and we are bound". A wrong figure would then
book a truck with nobody in the loop, which is the failure this whole codebase
is built to prevent. The confirmation step is not ceremony — it is the thing
that keeps a mishearing recoverable.
_disposition() gains outcomes for this; today deferred_to_manager is the
only success it can record.
2. She speaks as the client, and discloses as the client
When dispatched on behalf of a customer she gives the customer's name, and the AI-disclosure line names the customer too.
⚠ Disclosure naming the wrong principal on a recorded call is a legal exposure, not a wording preference. It requires written authorisation from the customer before the first live call — that is a gate on shipping, not a to-do.
⛔ Parameterise persona.SYSTEM; do not rewrite it. Its exact wording is
measurement-backed — the scoped gender rule scored 0/280 language errors
against 4/280 for the unscoped version, and both shorter variants produced
fatal failures. Identity is a substitution into that text.
3. Competing prices are an input, never a sentence
Other brokers' and vendors' quotes on the same load reach the ladder. They are never spoken.
⭐ This is what keeps the change cheap. A competitor's figure that could be
spoken would be a new class of speakable number, needing SPEAKABLE_BY_STANCE,
FigureGate and stance work — and every speakable figure this project has
added has leaked at least once. As an input it needs none of that.
The lever survives without the number: pressure is applied as "humein isse behtar rate mil raha hai", which names nothing.
4. Mid-call lookups get ~1–2s behind a filler line
She may say "ek minute ji" and look something up. The filler already exists —
FigureGate falls back to that exact line.
⛔ Lookups are NOT LLM tool calls. Function-calling hands number selection back
to the model, which is the one thing the architecture exists to prevent. The
deterministic layer calls the resolver; the result enters the prompt as brief
text and enters FigureGate.facts so its figures are permitted to be spoken.
The parser still owns the number.
Consequences
Scenariobecomes a handoff object and moves intoapp/domain/calling/. New fields: principal identity, mandate,cap_pct, competing quotes,RateBand.load_brief()stays a method.build_ladder(band=...)must actually be passed. It is currently supplied at zero of eight call sites, so the implausible-contract guard (ceiling.py:210) cannot fire in any live path, andneeds_humancannot be raised from the band branch. That guard catches a fat-fingered contract rate against ~₹80 Cr of annual spend.cap_pctbecomes per-customer policy instead of a fixed 5%.dialogue.classify()is keyword-based across 20 intents. Every lookup trigger is another keyword list, and the last six added that way shipped the substring bug that hung up on a spice load. The dialogue layer needs rework before lookups land, not after.- Telephony is unbuilt; the transport is
smallwebrtc. Pipecat 1.7.0 ships Twilio, Exotel and Plivo serializers and all three are installed, so this is a transport swap rather than a rebuild.
Addendum, 2026-08-30 — the sanity threshold was reversed
MIN_BAND_N = 20 gated the guard that challenges a contract contradicting its
lane. The real rate_contract table holds 39 rows across 21 lane+vehicle
groups and the largest holds 3, so the bar was unreachable and the guard
protected nothing.
⭐ Reversed to MIN_BAND_N_SANITY = 1, from a leave-one-out sweep over all 39
real contracts: zero of the 32 testable rows are challenged, and the
ten-times typo is still caught. The arithmetic is why — firing needs
contract * 1.05 > 3 * p90, so against a single peer the contract must exceed
~2.9x it, and two genuine contracts that far apart on one lane are worth a
person looking at anyway. The guard only asks; it never refuses or prices.
⚠ The two uses of band had their evidence bars the wrong way round. The
no-contract path SPENDS money — the band becomes the anchor and the ceiling —
and required only n > 0. The sanity guard, which merely raises a hand,
required 20. The pricing bar is still n > 0 and is a separate open item.
⚠ On the board path the band comes from booking, not rate_contract: the
contract is min(rate_contract.rate) over the same lane and vehicle, so a band
from that table would hand the guard the row it is checking. That also means
min() already masks a high typo whenever the lane has other contracts — the
guard's real value is this handoff, where the contract rate arrives from
outside the database entirely.
Revisit when
A customer asks Ori to commit on the call without a human confirmation, or a lane appears where refusing to name a competitor's figure demonstrably loses loads. Both change decision 1 or 3, and both should be argued from measured loss, not from a call that felt winnable.
Addendum, 2026-09-10 — what shipped, and two claims above that are now false
⛔ Both corrections below were found by reading the code against this document. The document was wrong, not the code.
The two stale claims
"the band at zero of eight call sites." Closed. Handoff.ladder() passes
band, cap_pct and competing through, and 13 call sites now pass a
band. This was the consequence the ADR cared most about and it is done.
"Reversed to MIN_BAND_N_SANITY = 1." No longer true. It went 1 → 5 on
2026-09-08 in d487b3c, with a better argument than the original:
n=1,2,3 is exactly the regime where a single row IS p90. One dropped digit on one booking row — ₹26,200 keyed as ₹2,620 — disabled every contract on that lane. Measured: 39 of 39.
⚠ And the reasoning in the addendum above — "the guard only asks; it never
refuses or prices" — was the load-bearing argument for 1, and it was false
when written. stance.py closes the call on contract_suspect
(close_defer, no autonomous deal recorded). Only a bare needs_human merely
flags and carries on. The distinction is real and worth holding:
| flag | effect |
|---|---|
contract_suspect |
closes the call. The concession derives from the same bad contract, so countering would speak a figure off a rate nobody authorised |
needs_human alone |
sets needs_authorisation, call continues. Closing on this made the no-contract path unable to transact at all |
What is built
app/domain/calling/handoff.py, 188 lines, 26 tests. poc/scenarios.py
declares class Scenario(Handoff), so the six frozen instances are handoff
objects. load_brief() stayed a method. Fields for principal identity,
mandate, cap_pct, band and competing_quotes all exist and validate.
What is not
- ⛔ No endpoint starts a call from a payload. Every API route is a
GET. The object is dispatchable; nothing dispatches it. This is the gap. - ⛔
lockhas no behaviour.may_commit()returnsTruefor it and nothing in the live path reads it;persona.py:131still hardcodes "NEVER book or agree on the call."_disposition()has twelve outcomes anddeferred_to_manageris still the only success. - ⛔ No confirm-inside-a-timeout flow — the step that keeps a mishearing recoverable.
- ⛔ Mid-call lookups. Still gated on the dialogue rework, per decision 4.
- ⛔ Telephony. Twilio, Exotel and Plivo serializers are installed; a transport swap, not a rebuild.
- ⛔ Written customer authorisation for disclosure naming the principal. A gate on shipping, not code.
Decisions 1 and 4 are therefore unexercised. Decisions 2 and 3 are built as
data — identity substitutes into persona.SYSTEM, competing quotes reach the
ladder and are never spoken — but neither has run on a live call.