ADR-0006: Human-Fallback Handoff Model
Status: Accepted Date: 2026-04-25 Amended: 2026-06-14 — added the explicit-request handoff signal (D0), implementing the "explicit user request" trigger this ADR's Context already named but the shipped signal set (D1 low-confidence / D2 sentiment / D3 cost / D5 turn-count) omitted; also records the related first-contact AI self-disclosure (EU AI Act Article 50, already in References). See the Amendment section below.
Context
Ratiba's product thesis ("the AI agent IS the UI") is structurally different from the chatbot-with-escape-hatch pattern most competitors ship. The agent is the customer's primary interface, but when the agent cannot handle a conversation — low confidence, explicit user request, unrecoverable tool failure, sentiment degradation, policy tripwire — a human (admin / spa owner) must take over the same WhatsApp thread with full context, resolve the issue, and hand back to the agent cleanly.
A2 (docs/research/2026-04-25-human-in-the-loop-handoff.md) settled
the lifecycle architecture: LangGraph interrupt(payload) checkpoint
suspends the booking FSM into a HUMAN_DRIVING state, surfaces a
bilingual brief to the registered admin over the same WhatsApp DID,
forwards admin messages verbatim to the customer, then resumes via
Command(resume={...}) carrying optional slot updates as the resume
payload. Three FSM states (AGENT_DRIVING, SUSPENDED_FOR_HUMAN,
HUMAN_DRIVING, plus the one-shot RESUMED_BY_AGENT) capture the
driver-as-orthogonal-FSM-dimension pattern.
The 2026-04-25 research walkthrough locked four foundational
decisions in spec §12 (docs/superpowers/specs/2026-04-25-agentic-research-investment-design.md):
- Q2 — universalize EU AI Act Article 50 disclosure across all customers regardless of jurisdiction.
- Q3 — multi-admin paging is broadcast: all admins receive the page simultaneously; first to engage takes the conversation; others see "claimed by Wanjiku" indicator.
- Q4 — slot-update channel after handoff uses explicit slash-command
syntax (
/done service=X when=Y), not LLM extraction. - C3 — AI disclosure happens once at session greeting; no per-handoff badge flip; the agent/admin distinction is invisible to the customer.
ADR-0005 D5/D8 settled the boundary with AdminOrchestrator: when an
admin types in their WhatsApp DID, IdentityResolver checks for an
active customer-handoff for that admin → message routes to handoff
handler. Otherwise → AdminOrchestrator. This ADR builds the handoff
handler.
ADR-0003 settled the conversation_threads pointer table; ADR-0005
D5 added the actor_type discriminator. This ADR adds the
handoff_log table for admin↔customer message records during
HUMAN_DRIVING, plus three new public.tenants columns for
per-tenant handoff customization.
What this ADR settles is the operational specifics A2 punted to follow-up: page timeout cadence, voice-Phase-1 fallback, STK-in-flight interaction, per-tenant trigger threshold tuning, transcript fold language, audit log shape, page-delivery observability, and the authoritative briefing-card schema.
Decision
Eight specific decisions, organized as a coherent handoff model.
1. Architectural recap (inherited, locked here)
| Aspect | Where decided | Recap |
|---|---|---|
| Handoff lifecycle | A2 §1 | trigger → interrupt → brief → admin engages → admin signals done → resume + reorientation |
| FSM state additions | A2 §4 | driver is orthogonal FSM dimension. States: AGENT_DRIVING, SUSPENDED_FOR_HUMAN, HUMAN_DRIVING, RESUMED_BY_AGENT (one-shot) |
| Confidence triggers | A2 §2 | Eight named codes: LOW_CONF_INTENT, LOW_CONF_SLOT, EXPLICIT_REQUEST, TOOL_ERROR_UNRECOVERABLE, POLICY_TRIPWIRE, SENTIMENT_NEGATIVE, ADMIN_PULL, BUDGET_BREACH |
| Magic-phrase commands | A2 §7 | /take, /done [slot=value...], /end, /dismiss, plus Swahili equivalents |
| Multi-admin paging | spec §12 Q3 | Broadcast; first-to-engage takes; "claimed by X" indicator to others |
| Slot-update channel | spec §12 Q4 | Explicit slash-command syntax only; no LLM extraction over the human-driving log |
| AI disclosure scope | spec §12 Q2 + C3 | Universalized to all customers; once at session greeting; no per-handoff flip |
| Boundary with AdminOrchestrator | ADR-0005 D5/D8 | Active-handoff messages route here, not to AdminOrchestrator |
| Voice handoff Phase 2 | A2 §6 | Pattern 1 (LiveKit WarmTransferTask) primary, Pattern 2 (callback) fallback |
ADR-0006 builds on these without re-deriving them.
2. Page-then-no-engagement timeout — conservative cadence
Three timing parameters, all per-tenant configurable via new columns
on public.tenants:
| Parameter | Default | Column |
|---|---|---|
| Customer-facing notice ("calling the manager") | 120 seconds | handoff_admin_notice_seconds |
| Admin reminder ping cadence | 10 minutes | handoff_admin_reminder_seconds |
| Total escalation window | 60 minutes | handoff_escalation_seconds |
ALTER TABLE public.tenants
ADD COLUMN handoff_admin_notice_seconds INTEGER NOT NULL DEFAULT 120,
ADD COLUMN handoff_admin_reminder_seconds INTEGER NOT NULL DEFAULT 600,
ADD COLUMN handoff_escalation_seconds INTEGER NOT NULL DEFAULT 3600;
Lifecycle:
t=0s trigger fires; interrupt() called; brief sent to admin(s) via 360dialog
t=120s if no admin engaged: customer gets "naita meneja / calling the manager"
t=600s first admin reminder ping (sent only to admins who haven't engaged)
t=1200s second reminder (still only unengaged admins)
...
t=3600s total window elapses. Two paths:
- multi-admin tenant: fall back to second admin (escalation)
- single-admin tenant: convert to callback-tomorrow apology;
customer gets "Samahani, meneja atakupigia kesho asubuhi";
an admin task is created in the dashboard
Why conservative. Spa owners are running their actual business during business hours; massages are 60-90 minute appointments where phone-checking is socially awkward. A 5-minute reminder cadence (originally A2's draft) badgers admins; 10-minute respects their working context. The 60-minute total window is generous but bounded.
The 120-second customer notice is a slight relaxation from A2's 90s — it's the difference between "the bot is still working" perception and "something stalled" perception. In practice the difference is marginal; we keep room to tighten if pilot data shows customers abandon during the wait.
Eval-tunable. The first 100 production handoffs will reveal whether the defaults need adjustment; the per-tenant override mechanism absorbs any per-vertical patterns we observe.
3. Voice handoff Phase 1: Pattern 3 only (WhatsApp follow-up)
A2 §6 specified three voice-handoff patterns; this ADR locks Phase 1 scope explicitly.
Phase 1 = Pattern 3 (deferred WhatsApp follow-up) only. When a confidence trigger fires during a voice call:
1. Agent says (in detected language):
"Tutakuandikia WhatsApp mara tu meneja atakapokuwepo, sawa?"
"We'll WhatsApp you as soon as the manager is available, OK?"
2. Customer confirms (verbal "yes" / "ndiyo" / button if SIP supports DTMF).
3. Agent gracefully terminates the call.
4. The orchestrator pushes a WhatsApp message to the customer's
resolved phone number (already known from IdentityResolver).
5. From the inbound WhatsApp message onward, normal WhatsApp handoff
lifecycle (per §1 + §2 above) takes over — admin gets briefed,
admin handles, admin hands back, agent resumes.
No-WhatsApp edge case. If the customer's phone number is not WhatsApp-registered (rare in Kenya — ~90% smartphone WhatsApp penetration in 2026), the agent says:
"Samahani, meneja atakupigia simu kesho asubuhi."
"Sorry, the manager will call you back tomorrow morning."
…and creates a callback task in the admin dashboard's pending-actions queue. No Pattern 2 (callback orchestration) infrastructure is built in Phase 1.
Pattern 1 (LiveKit WarmTransferTask warm-bridge) and Pattern 2
(callback-customer) are explicitly Phase 2 scope. Both require
LiveKit-side SIP infrastructure that Phase 1 doesn't yet need; A2 §6
is the implementation reference when M7 (voice channel) lands.
The "no SMS fallback in Phase 1" decision (this ADR) means we explicitly defer Daraja's bulk SMS gateway integration as a handoff fallback. The cost is that the small segment of voice-only customers without WhatsApp gets a callback-tomorrow experience instead of an immediate SMS link. Mitigation: tenant onboarding doc mentions this gap; phase 2 adds SMS fallback if pilot data shows the no-WhatsApp segment is materially larger than projected.
4. STK-push-in-flight: callback always authoritative
The cross-cutting case A2 §10 #8 punted: a confidence trigger fires
while the customer is in AWAITING_PAYMENT (M-Pesa STK push
dispatched, agent waiting for callback). This ADR resolves it with a
clear precedence rule.
The M-Pesa callback (when it eventually arrives) is always authoritative. Handoff suspends the conversation thread immediately when the trigger fires; no attempt to cancel or pre-empt the in-flight STK push.
Lifecycle:
t=0s customer in AWAITING_PAYMENT (STK dispatched at t=-15s)
t=2s EXPLICIT_REQUEST trigger fires (customer says "I need a real person")
t=3s orchestrator calls interrupt(payload) on the LangGraph thread.
FSM state: SUSPENDED_FOR_HUMAN. Brief sent to admin(s).
t=12s M-Pesa callback arrives: ResultCode=0, receipt=ABC123XYZ
t=12s webhook handler resolves merchant_reference -> (tenant, thread_id)
per ADR-0007 / A4 §3. The handler:
a) updates payments row to status='completed' with receipt
b) appends a system message to the admin's brief:
"FYI: M-Pesa callback arrived during your handoff:
SUCCESS, receipt ABC123XYZ, amount KES 3500."
c) does NOT resume the LangGraph thread. The thread stays
in SUSPENDED_FOR_HUMAN. Admin sees the new system message
in their brief panel.
t=... admin engages, sees the augmented brief with payment outcome,
decides what to do (confirm booking, refund, etc.) and
handles via direct customer messaging. On hand-back, the
booking FSM resumes from a state that has the payment outcome
in its state dict.
System-message format appended to the brief panel:
[SYSTEM 12:14:32] M-Pesa callback received during your handoff:
Status: SUCCESS
Receipt: ABC123XYZ
Amount: KES 3,500
Customer: +254 7** *** 432
Reference: RTB-msmama-01HFM6Z9YQK4M2EXB7DH3RG0NV
The triple-layer idempotency from ADR-0007 / A4 §5 (Postgres unique constraint + Redis dedupe key + payment-row state check) catches any duplicate callbacks during a long handoff window.
Why authoritative-callback over preempt-and-cancel. Daraja's reversal API has its own constraints (not available for all payment states, async itself). Trying to cancel an in-flight STK adds error modes (what if reversal fails? what if the customer entered the PIN between trigger and reversal?). Letting the callback resolve naturally + augmenting the admin's context is operationally clean and preserves the customer's payment work — they don't have to re-enter the PIN once the admin resumes the booking.
5. Per-tenant trigger threshold tuning — JSONB column with per-vertical defaults
A2 §2 lists 8 trigger codes with default thresholds. This ADR makes those thresholds per-tenant configurable from day one, with sensible defaults seeded by tenant vertical at onboarding.
Schema:
ALTER TABLE public.tenants
ADD COLUMN handoff_thresholds JSONB NOT NULL DEFAULT '{}';
Default JSONB shape (per-vertical):
{
"low_conf_intent_threshold": 0.6,
"low_conf_intent_consecutive_turns": 3,
"low_conf_slot_threshold": 0.55,
"low_conf_slot_max_reprompts": 1,
"sentiment_negative_consecutive_turns": 2,
"policy_tripwire_refund_threshold_kes": 5000,
"budget_breach_max_turns": 30,
"budget_breach_max_tokens": 30000
}
Per-vertical seeding at onboarding (backend/app/tenancy/handoff_defaults.py):
| Vertical | Notable threshold differences from base |
|---|---|
spa / salon / barbershop | Defaults as listed above |
dental / physio / medical | low_conf_intent_threshold = 0.7 (higher recall on confusion); policy_tripwire_refund_threshold_kes = 1000 (lower refund threshold, more sensitive); sentiment_negative_consecutive_turns = 1 (escalate emotional issues faster) |
tutoring / legal | policy_tripwire_refund_threshold_kes = 10000 (higher tolerance — services are more bespoke); other defaults as base |
The defaults table lives in a Python module (per ADR-0005 D6 prompt storage convention — code, not config), reviewed via PR. Tenant admin can override individual thresholds via dashboard later.
Validation. A Pydantic model HandoffThresholds validates the
JSONB at every load. Schema violations (missing required keys, out-of-range
values) raise immediately at startup or on UPDATE — fail fast over
silent misconfiguration.
6. Transcript fold language — customer's words verbatim
A2 §3 already specified the briefing card summary in the admin's
preferred language (set on public.tenants.locale at onboarding).
This ADR locks the fresh decision about the verbatim transcript fold
below the summary card.
The transcript fold preserves the customer's actual messages in the language the customer wrote them. No translation by default.
On-demand translation button. The dashboard rendering of the brief
includes a "translate to {admin_locale}" button next to the transcript
fold. Tapping it triggers a single LLM call (using the
handoff_summarizer role per ADR-0005 D4 — Claude Haiku) that
translates the visible transcript turns. Result is cached per (thread_id, target_lang)
in Redis with a 1-hour TTL (avoids repeated calls if admin re-opens
the brief).
Why verbatim by default:
- Complaint texture is load-bearing. "this service was bad" hits differently from "huduma haikuwa nzuri sana" hits differently from a strongly-worded version. Paraphrasing flattens the complaint; admin handles it less effectively.
- Identifying information survives verbatim better. Customer names, references to specific staff, mentions of dates / times all transmit cleanly without translation distortion.
- Code-switching is a real Kenyan WhatsApp pattern. Customers mix English and Swahili in single messages; the admin spotting that pattern matters for context. Translation flattens the code-switch into one language.
- Cost discipline. Translation is one LLM call per request; on-demand respects cost-discipline (only translate when actually needed). The summary card already gives admin enough to act in their language for ~80% of pages.
Cost note. Translation cost (~$0.001 per request via Haiku) is absorbed by Ratiba's per-tenant operations budget, not metered to the customer conversation. Same accounting bucket as the brief-summary LLM call (per A2 §3).
7. handoff_log table per tenant — symmetric storage with 90-day retention
A2 §9 sketched a handoff_log table for admin↔customer message
records during HUMAN_DRIVING. This ADR specifies the schema,
retention, and compliance posture.
Schema (per-tenant, lives in tenant schema):
CREATE TABLE handoff_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id VARCHAR(100) NOT NULL,
actor VARCHAR(20) NOT NULL CHECK (actor IN ('customer', 'admin')),
actor_phone VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
language VARCHAR(10),
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
message_metadata JSONB DEFAULT '{}'
);
CREATE INDEX idx_handoff_log_thread ON handoff_log (thread_id, sent_at);
CREATE TABLE handoff_log_archive (LIKE handoff_log INCLUDING ALL);
Symmetric retention. Same 90-day live + cold archive pattern as
the LangGraph checkpoints tables (per ADR-0003 D2). Daily mover
job moves rows older than 90 days from handoff_log to
handoff_log_archive in a per-tenant transaction. Compliance /
dispute queries UNION ALL live + archive when needed.
Admin and customer content treated identically:
- Same redaction in structured logs (per Phase B §5.4 —
redacted_payloadrules apply equally to admin and customer messages: phone numbers masked to last-4, email patterns masked, money fields kept verbatim, free-text passed through the redactor). - Same archive policy (90 days live, archive thereafter).
- Same audit-log event types (
admin.handoff.interrupt,admin.handoff.resume,admin.message.relayedper Phase B §5.2).
Compliance flag for the future privacy ADR. Admin-typed content may contain PII the admin disclosed (e.g., "Mary, your appointment was moved because John needs the slot earlier" — both Mary and John are customers; admin typed about both). This is plausibly covered by existing service-delivery data-processing under Kenya DPA, but the specific Article and consent-grounds analysis belongs to a future privacy ADR (TBD; see References). ADR-0006 ships the schema and defers the policy framing.
8. Read-receipt observability via 360dialog webhooks
360dialog provides webhook events for message delivery and read status
(messages[].statuses[].status of delivered / read / failed). This
ADR captures these events for Phase 1 observability without acting on
them in the escalation logic.
Storage. 360dialog webhook events with status updates are stored
in handoff_log.message_metadata JSONB on the corresponding sent
message:
{
"wa_message_id": "wamid.HBgM...",
"delivery": {
"delivered_at": "2026-04-25T13:42:18Z",
"read_at": "2026-04-25T13:42:31Z",
"failed_at": null,
"failure_reason": null
}
}
Escalation logic stays time-based per D2. No read-receipt-driven fallback in Phase 1. The 10-min reminder ping cadence + 60-min total window from D2 governs admin engagement timing regardless of whether the page was actually read.
Why capture but not act. The data is genuinely valuable for Phase 2 calibration: "what % of pages get read within X minutes? what % never get read at all?" answers questions we'll need before deciding whether read-receipt-driven escalation is worth the complexity (additional code paths, race conditions, divergence from the time-based logic). Capturing now is cheap (one webhook event handler, one JSONB update); acting on it later is a Phase 2 amendment to this ADR.
Onboarding guidance. Tenant onboarding doc instructs admins to ensure WhatsApp notifications are enabled for the tenant DID. Low-tech mitigation that handles the most common case (admin disabled notifications by accident).
9. Briefing card schema — authoritative spec
ADR-0005 D8 deferred the briefing card schema entirely to ADR-0006. This ADR locks the JSON shape inline; rendering details (how AnswerShaper turns the JSON into the WhatsApp message structure) are cited to A2 §3.
Briefing card JSON schema:
{
"trigger": "LOW_CONF_INTENT",
"language": "sw",
"customer_phone_masked": "+254 7** *** 432",
"summary_one_line": "Mteja anataka kufutwa appointment ya kesho — anasema huduma haikuwa nzuri.",
"what_collected": {
"service": "Massage 90 min",
"appointment_date": "2026-04-26 14:00",
"staff": "Grace"
},
"why_paged": "Mteja amejaribu kueleza tatizo mara tatu, sintobaini.",
"suggested_next_action": "Confirm refund policy + offer reschedule.",
"agent_draft_to_send": "Samahani kwa usumbufu — naweza kukuhamisha kwa muda mwingine?"
}
Field semantics:
| Field | Type | Source |
|---|---|---|
trigger | enum (8 values per A2 §2) | The confidence-trigger code that fired |
language | enum en / sw | The admin's preferred language (per public.tenants.locale) |
customer_phone_masked | string | Last-4 digits visible; full number elsewhere in audit log |
summary_one_line | string | LLM-generated, in admin's language; preserves customer's verbatim quote if load-bearing for understanding the issue |
what_collected | object | FSM-state slot dictionary at suspension time; structured |
why_paged | string | LLM-generated, in admin's language |
suggested_next_action | string | LLM-generated, in admin's language |
agent_draft_to_send | string | The reply the agent had prepared but didn't send (customer language); admin can quick-reply with /send to dispatch as-is |
Generation. One LLM call per page using the handoff_summarizer
role (Claude Haiku per ADR-0005 D4). Fixed prompt template at
backend/app/orchestrator/prompts/handoff_summarizer/system.md. Cost
~$0.001 per page; absorbed by per-tenant operations budget (not
metered to customer conversation). Strict-mode structured outputs
enforce the schema.
Rendering. The AnswerShaper renders the JSON into a WhatsApp
message with structured layout (header / one-line summary / collected
slots / why-paged / suggested action / agent draft / quick-reply
buttons for /send /take /done /dismiss / "show full
transcript" expandable). English + Swahili rendered examples per A2
§3.
Schema changes go through ADR amendment. Adding fields, changing types, or removing fields requires an ADR-0006 amendment (not a silent research-doc revision in A2). The schema is the contract between the brief generator and the AnswerShaper.
Consequences
Positive.
- Per-tenant tunability from day 1 (D5) means every threshold is eval-driven, not deploy-driven. A tenant whose customers are chatty/Swahili-heavy can loosen thresholds; a clinic can tighten them — both via dashboard, no code changes.
- Cost-bounded handoff. Briefing-card LLM call (Haiku) and
on-demand translation (Haiku) both stay on the cheap model;
per-page cost is
<$0.005 across both. Aligns with cost-discipline principle. - Audit trail for admin actions via
handoff_log(D7). Every admin↔customer message duringHUMAN_DRIVINGis queryable post-hoc — critical for dispute resolution, eval-suite scenario harvesting, and operational debugging. - Read-receipt observability (D8) gives Phase 2 the calibration data it needs to decide whether read-receipt-driven escalation is worth building. Cheap to capture now; valuable later.
- STK callback safety net (D4) preserves payment fidelity even when handoff fires mid-payment. Customer doesn't lose their PIN work; admin sees the outcome in real-time as the brief panel updates.
- Conservative timing (D2) respects admins running their actual business — 10-minute reminder cadence over 5-minute is humane for SMB context.
- Phase 1 voice handoff is minimal but functional (D3). Pattern 3 leverages existing WhatsApp infrastructure; Pattern 1 + Pattern 2 complexity stays in Phase 2.
Negative.
handoff_logis net-new schema. Alembic migration adds the table + archive table to every tenant schema (per ADR-0002 D3 per-tenant invocation). Onboarding becomes a slightly heavier sequence; bulk migration script handles existing tenants.- Per-vertical defaults table is a new artifact contributors
need to understand. Documented in
docs/methodology/agentic-development.mdunder tenant-onboarding. The table lives in code (Python module); adding new verticals is a PR. - No SMS fallback in Phase 1 excludes the small no-WhatsApp customer segment from voice handoff. Mitigated by callback-tomorrow path; Phase 2 can add SMS via Daraja's bulk SMS gateway if pilot data justifies.
- Per-tenant
handoff_thresholds JSONBadds misconfiguration surface. A malformed threshold could trigger handoff on every turn or never trigger at all. Mitigated by Pydantic validation at onboarding + every load + onUPDATE; failures raise immediately rather than silently ship bad behaviour. - Read-receipts captured but unused for escalation in Phase 1. Admins with notifications disabled get the same time-based degradation as admins who are simply busy. Onboarding guidance tells them to enable notifications for the tenant DID; the gap isn't load-bearing for the happy path.
Neutral.
- 120s/10-min/60-min timing is starting, eval-tunable per pilot. First 100 production handoffs reveal the actual distribution.
- STK-in-flight handling is "callback wins" — preserves payment work but creates a "FYI: payment landed during your handoff" surprise that admins need to be trained to handle. Mitigation: the admin onboarding doc covers this scenario explicitly.
- The
handoff_summarizerrole is on Claude Haiku (per ADR-0005 D4 default). If pilot Swahili-quality data shows GPT-4.1 mini is adequate for this specific role, the per-tenant override mechanism from ADR-0005 D4 makes the swap a YAML change. - Briefing card schema is locked in this ADR, not in A2 §3. Schema changes go through ADR amendment process.
Alternatives Considered
| Alternative | Rejected because |
|---|---|
| More aggressive D2 timing (60s notice / 3-min reminders / 15-min escalation). | Pesters admins running their actual business; 5-min reminder cadence felt like badgering for the SMB-spa context. The conservative cadence accepts longer customer wait in exchange for better admin experience; eval-tunable per tenant if pilot data shows otherwise. |
| More conservative D2 timing (180s notice / 15-min reminders / 90-min escalation). | Customer wait at 180s starts to feel like the bot died. Conservative-but-bounded (120s/10-min/60-min) is the sweet spot. |
| Voice Pattern 2 in Phase 1 (callback-customer-back orchestration). | Requires LiveKit-side outbound SIP infrastructure and admin-dashboard "trigger callback" plumbing — non-trivial Phase 1 surface for marginal coverage gain. Defer to Phase 2 with the warm-bridge primary. |
| Voice Pattern 3 + SMS fallback for no-WhatsApp customers. | Adds Daraja SMS API integration + opt-in handling + template rules — Phase 1 surface bloat for a small inclusion gain (~10% no-WhatsApp segment). Callback-tomorrow path covers the gap; Phase 2 adds SMS if pilot data shows the segment is materially larger. |
| No voice handoff at all in Phase 1. | Loses the customer who hit the trigger; they may not call back if we don't promise to follow up. Pattern 3 is cheap to ship; the cost is justified. |
| STK preempt-and-cancel (try to reverse the in-flight STK push when handoff fires). | Daraja's reversal API has its own constraints and async failure modes; trying to cancel mid-PIN-entry has race conditions. Letting the callback resolve naturally + augmenting admin's brief is operationally cleaner and preserves customer's payment work. |
| STK block-the-trigger (queue confidence triggers until payment FSM reaches terminal state). | If the trigger reason IS the payment ("the M-Pesa prompt isn't working"), forcing the customer to wait for the payment to complete before paging admin is exactly wrong. Authoritative-callback (D4) handles both cases correctly. |
| Defaults-only thresholds (no per-tenant config in Phase 1). | Every tuning ask becomes a code change; eval-driven dev gets blocked at deploy cycle. Per-tenant configurable from day 1 is the lower-friction path. |
| Per-vertical-only thresholds (no per-tenant override). | Bridges defaults-only and per-tenant, but loses the customization that pilot tenants will demand. Per-vertical defaults seed the per-tenant JSONB — best of both. |
| Translated transcript fold by default. | Loses customer's actual words; flattens complaint texture, identifying information, code-switching patterns. Verbatim with on-demand translation is the right default. |
| Transcript fold with translation toggle (always-visible UI control). | More dashboard UI surface in Phase 1; on-demand button is enough until pilot data shows admins are repeatedly translating. |
| Asymmetric retention (admin content INDEFINITE for corporate audit, customer content 90 days). | Two retention rules to maintain; the corporate-audit framing is real but belongs to a separate audit-log table for high-stakes admin actions (delete tenant, etc.), not ordinary handoff conversation messages. Symmetric is operationally simpler. |
| No admin-content storage (rely on Langfuse traces). | Langfuse only captures LLM interactions; admin↔customer messages bypass the LLM entirely. Without handoff_log, "what did the admin tell the customer?" is unanswerable post-hoc — critical context lost. |
| Read-receipt-driven escalation (2 min unread → fall back). | Adds a divergent escalation path on top of the time-based one in D2; race conditions (admin reads at 9:59 but doesn't reply, do we still fall back at 10:00?); more code paths and eval scenarios for marginal gain. Phase 2 can layer this on with calibration data. |
| Ignore read-receipts entirely. | Loses the calibration signal Phase 2 needs to decide whether to act on read-receipts. Capture-but-don't-act is the right Phase 1 posture. |
| Briefing card cite-only (don't re-specify schema in ADR-0006). | Two ADRs both pointing at A2 §3 for the central artifact creates a "the ADR cites the research deliverable for the spec" shape — research is exploratory, ADRs are authoritative. Re-specifying the schema in ADR-0006 means schema changes go through ADR amendment process (explicit governance). |
Amendment (2026-06-14): Explicit-request handoff signal (D0) + AI self-disclosure
Deciders: Adrian (founder), Claude Opus 4.8 (implementation agent).
Context for the amendment
This ADR's own Context lists the handoff triggers as "low confidence,
explicit user request, unrecoverable tool failure, sentiment degradation,
policy tripwire." But the signal set that actually shipped
(app/orchestrator/handoff.py::evaluate_handoff) implemented only D1 (low
confidence streak), D2 (negative-sentiment streak), D3 (cost ceiling), D4
(token cost), and D5 (turn count). The explicit user request named in the
Context had no corresponding runtime signal. Live testing surfaced the gap: a
customer typing "I need to speak to someone" / "tell your customer support to
contact me" was classified other and answered with a generic deflection,
never escalating. That is the canonical reason a customer reaches for a human;
it should be the strongest, most immediate trigger, not a missing one.
Separately, the first agent message disclosed the persona name ("I'm Savannah from Aria Aura Spa") without disclosing that the responder is an AI — the transparency obligation already referenced below (EU AI Act Article 50).
Decision
-
New
HandoffSignal.EXPLICIT_REQUEST(D0) — evaluated first inevaluate_handoff, ahead of D1. It fires on a single turn (no streak): the customer asked once; making them repeat themselves three times to clear the D1 threshold is the wrong behaviour. Detection is a deterministic action-phrase match in the dispatcher (_is_human_handoff_request): "speak to / talk to a person/human/agent/staff/the team", "customer support/service", Swahili "ongea na mtu"; it setsBookingState.human_handoff_requested, which D0 reads. Checked on every turn regardless of FSM state (a mid-booking request escalates too). The existing briefing-card / page-admin / take-over machinery (D2 timing, D9 schema) is reused unchanged — D0 only adds a trigger, not a new lifecycle. -
First-contact AI self-disclosure — the
answer_shaperpersona (ADR-0010) now MUST disclose "I'm<name>, the AI assistant for<business>" when it introduces itself or when asked whether it is a human / real person / bot, and must never imply it is human. The booking GREET greeting discloses likewise. A question ("are you a real person?") routes to disclosure, NOT to D0 — only action-phrases ("speak to a real person") escalate.
Decision context
- Latency: none added — D0 is an in-process boolean check + a substring scan; no LLM call. (The disclosure rides the existing answer_shaper call.)
- Dependency surface: none — no new packages; one new
BookingStatefield- one enum value.
- Debuggability:
handoff.triggered signal=explicit_requestwith the detail "Customer explicitly asked to speak to a person." names the cause directly in the structured log. - Reversibility: trivial — the D0 branch + the keyword set are a few lines; removing them restores D1-D5-only behaviour.
- Blast radius: additive. D0 sits before D1; D1-D5 are unchanged. The keyword set is action-phrase-scoped so it does not steal disclosure questions or ordinary chat (covered by tests).
- Alternative considered: classify the request via the LLM intent
classifier (a new
humanintent) instead of keywords — rejected for v1: the keyword set is deterministic, zero-latency, and an explicit human-request phrase is unambiguous; the LLM classifier already falls back to keywords anyway. Revisit if phrasing variety in pilot data warrants it.
Consequences of the amendment
- Positive: the canonical "get me a human" path now works on the first ask; AI transparency is satisfied on first contact.
- Negative: the action-phrase keyword set is English/Swahili-scoped and substring-based — a creatively-phrased request ("can a teammate ring me?") won't match D0 and falls through to the D1 streak. Acceptable for v1; the LLM-classifier alternative above is the escalation path.
- Neutral: D0 reuses the D2-onward handoff lifecycle, so no change to the admin paging / briefing / take-over surfaces.
References
docs/prd/ratiba-prd.md— §1.4 conversational thesis, §5 conversation design principles (line 891 minimal handoff sketch this ADR expands)docs/adr/ADR-0001-tech-stack.md(amended 2026-04-25) — LangGraph- TenantScopedSaver model
docs/adr/ADR-0002-multi-tenant-isolation.md— schema-per-tenant table additions land via per-tenant Alembic invocation (D3)docs/adr/ADR-0003-fsm-persistence.md—conversation_threadspointer table; 90-day retention pattern (D2) inherited byhandoff_log_archivedocs/adr/ADR-0005-orchestration-model.md— D4 LLM router + role assignments (handoff_summarizer= Claude Haiku); D5 AdminOrchestrator boundary; D6safety_classenum on tools (also drives this ADR's irreversibility-confirmation pattern); D7 classifier failure budgetdocs/adr/ADR-0007-payments-orchestration.md(pending) — STK-in-flight callback handler that this ADR's D4 builds on; triple-layer idempotency for callback retriesdocs/research/2026-04-25-langgraph-postgressaver-spike.md— TenantScopedSaver wrapper (Option A) used for handoff thread checkpointingdocs/research/2026-04-25-orchestration-patterns.md— A1 §3 booking FSM (handoff state additions integrate cleanly); §4 intent classification (triggers 1-2 fire from this layer)docs/research/2026-04-25-human-in-the-loop-handoff.md— A2 (heavy use throughout; this ADR locks A2's open questions)docs/research/2026-04-25-eval-frameworks.md— A3 §8 Langfuse instrumentation (handoff eventsadmin.handoff.interrupt/admin.handoff.resume/admin.message.relayedper Phase B §5.2); read-receipt events captured heredocs/methodology/agentic-development.md— Phase B §5 auto-debug logging schema (handoff_log.message_metadataextends the per-event-type metadata pattern); §6 delegate-vs-human-review boundaries (handoff path is human-review-only)docs/superpowers/specs/2026-04-25-agentic-research-investment-design.md§12 — Q2 (universalize EU disclosure), Q3 (broadcast paging), Q4 (explicit/donesyntax), C3 (no per-handoff disclosure flip)~/.claude/projects/-Users-soft4u-Development-ratiba/memory/project_cost_discipline.md— per-conversation cost framing (informs Q5 cost-discipline; this ADR keeps brief-summary + translation on Haiku)- LangGraph
interrupt()+Command(resume=...)API reference - 360dialog webhook events documentation (delivery + read receipt status fields)
- EU AI Act Article 50 transparency obligations (universalized per Q2)
- Kenya Data Protection Act 2019 — flagged for future privacy ADR
coverage of
handoff_logadmin-typed content