ADR-0063: Restore the turnover voice call to capture-only (regression post-mortem + harden)

This is a regression post-mortem, not a redesign. The architecture below — capture-only live call → post-call Maestro orchestration → PM-confirm-gated dispatch — already existed and worked. It regressed over 2026-06-16..19 when action tools were wired onto the live call. The owner's words: "before a few days ago it was working great, we regressed." The only genuinely new element proposed here is the Haiku pre-check (Jose's Maestro, ADR-0061, accepted 2026-06-18).


1. TL;DR

  1. The live turnover call must do exactly one thing: capture notes. No charges, no dispatch, no actions of any kind until after the caller hangs up. Principle (owner): "least amount of tools possible for the live call; all heavy lifting is after the call with Maestro."
  2. The live agent regressed because action tools (dispatch_work_order, add_charge, apply_charge_decision, get_turnover_status, confirm_turnover, send_summary) were added to its in-call tool belt — so the LLM reaches for them mid-walk instead of capturing.
  3. The regression shipped green because the test harness mocked every tool and asserted nothing about which tools the agent chose to call. The real-call ElevenLabs E2E harness the owner asked for 10+ times — replay every real call through the live agent as if the PM were calling — was never built. That gap, not the individual bugs, is the headline.
  4. Fix = restore + harden (this ADR proposes; does not yet apply the live-agent change):
    • P0 — strip the live agent back to 6 capture-only tools, through the sync pipeline (the sync is additive-only and cannot remove tools today — that must be fixed in the same change), branch-tested per ADR-0048.
    • P1 — build the real-call capture-only gauntlet (all real calls → simulated callers → live agent → assert capture-only + clean hang-up) and make it the merge gate for any walk-agent change.
    • P2 — converge the post-call path onto the current Maestro (ADR-0061): hang-up → Haiku pre-check → reasoning → action queue → dispatch/charge, PM "yes" as the confirm gate.

2. What regressed, and when

The agent was born capture-only (PR #1466, 2026-05-26: "ambient-scribe / medical-scribe lineage — capture-only invariant, no $ amounts"). Action capability then crept onto the live voice surface:

Date PR Change Surface
2026-06-16 #2434 add_charge + apply_charge_decision added to catalog SMS only — voice explicitly deferred
2026-06-17 #2442 dispatch_work_order wired onto voice (comms gated off) voice
2026-06-18→19 #2436 add_charge / apply_charge_decision / get_turnover_status wired onto voice; merged 02:42 voice

By the incident, the live agent (agent_9601…) carried 12 tools — 6 capture/system + 6 action — verified live via the ElevenLabs API. #2434 had deliberately kept charges SMS-only; #2436 is the change that opened the voice surface. (The other two action/recap tools on the belt — send_summary, a V1 tool long since demoted to a no-op, and confirm_turnover, added to voice by #2203 — likewise don't belong on a capture-only call and are removed in P0.)

3. The live incident (verified)

Owner's call conv_1201kveyz963fcysbk7cfx22pf6t (2026-06-19, 124s). Dictation: "charge $150 cleaning; handyman fix a loose toilet; Miracle Method clean the tub, charge $250; handyman patch walls + repaint; that's it." The agent:

4. Root cause

Layer 1 — the live call became an actor. An LLM with a tool will use it. With dispatch/charge tools in the belt and no structural prevention, the model acts the instant the PM mentions work, and skips capture. ADR-0053 already says this is structural, not a prompt-tuning problem: "a removed tool can't fire — a prompt rule is only a hope."

Layer 2 (the headline) — the harness measured the layer that was already fine. Two harnesses existed; neither could catch this:

The owner asked 10+ times for exactly this gate — "use all the test calls and real ones from Camellia and run them through a simulator as if it was me calling… remove myself from the test loop." It was never built.

4.1 Proof — the capture-only gauntlet (run 2026-06-19)

We replayed all 41 harvested real calls back through the current live agent as simulated callers (tool returns mocked = zero prod writes; tool choices asserted). Result: 5 hard capture-only violations — on real PM dictation the live agent fired action tools mid-walk:

Call Forbidden tools fired live
conv_0801ktsk… dispatch_work_order ×4, add_charge ×5
conv_2901ktq2… add_charge ×2, dispatch_work_order
conv_3901ktqb… add_charge ×3
conv_5501ktsb… add_charge ×3, dispatch_work_order ×2
conv_9201ktqr… dispatch_work_order

Of the 41 calls: 16 PASS, 5 capture-only violations, 20 mock-fidelity FAILs. The 20 were artifacts — generic {ok:true} mock returns broke the agent's unit-resolution so the sim looped before capturing; not agent bugs. PR #2499's committed harness fixes this with faithful resolution mocks (realistic find_turnover/start_inspection returns) while still mocking the write tools and asserting tool choice — validated: the 3 clean calls PASS and all 5 violators FAIL. The lesson holds: a harness that asserts on what the agent chose to call catches this deterministically; the prior harness only checked that capture happened, never that actions didn't.

Why structural drift detection didn't catch it: the nightly el-config-drift-live check ran the morning of 2026-06-19 and turnover_intake PASSED — every catalog tool bound, schema/url/description matching live. The regression is invisible to drift detection because the action tools were legitimately added to the catalog (#2434/#2436); a catalog⇄live parity check has no opinion on whether a capture-only agent should carry action tools. Parity ≠ appropriateness. (The drift alert that did fire that night was unrelated — schema/description drift + missing SMS tools on the renewal / leasing / lease-billing / unknown-caller agents.)

5. Current (regressed) architecture

flowchart TD
    PM([PM places turnover walk call]) --> Triage[Clara — Triage]
    Triage -->|transfer_to_agent| Walk["Clara — Turnover Intake (agent_9601)\n12 TOOLS"]

    subgraph LIVE["LIVE CALL — acting mid-walk (REGRESSED)"]
      Walk --> SI[start_inspection]
      SI --> AC["add_charge ×3\n❌ INVALID_INPUT: amount is a string\n→ 0 charges posted"]
      SI --> DWO["dispatch_work_order ×3\n⚠ WOs created mid-dictation,\nnever confirmed by PM"]
      Walk -. "rarely / never" .-> AN[append_notes]
      Walk -. "never" .-> EC[end_call]
    end

    AC --> Hangup([PM hangs up — no recap, nothing captured])
    DWO --> Hangup
    EC -. skipped .-> Hangup

    Hangup -.->|BYPASSED| Post["Post-call recap → PM 'YES' → dispatch\n(the authorized path — never reached)"]

    classDef bad fill:#5a1f1f,stroke:#e05757,color:#fff;
    classDef warn fill:#5a4a1f,stroke:#e0b357,color:#fff;
    classDef skip fill:#333,stroke:#888,color:#aaa,stroke-dasharray:4 3;
    class AC,Hangup bad;
    class DWO warn;
    class AN,EC,Post skip;

6. Proposed (restored + hardened) architecture

flowchart TD
    PM([PM places turnover walk call]) --> Triage[Clara — Triage]
    Triage -->|transfer_to_agent| Walk["Clara — Turnover Intake\nCAPTURE-ONLY · 6 tools"]

    subgraph LIVE["LIVE CALL — capture notes ONLY · no actions until hang-up"]
      Walk --> FT[find_turnover]
      FT --> SI[start_inspection]
      SI --> AN["append_notes\n(findings + PM-stated amounts + vendor names\ncaptured AS NOTES, not actioned)"]
      AN --> RF[revise_finding]
      RF -->|PM says 'that's it'| EC[end_call]
    end

    EC --> Hangup([Caller hangs up])
    Hangup --> CE[call-ended webhook
packages transcript + notes] subgraph MAESTRO["AFTER THE CALL — Maestro does all heavy lifting"] CE --> PC{"Haiku pre-check (NEW)\nescalate? reason? hand-back?"} PC -->|escalate: gas/fire| ESC[Alert PM immediately] PC -->|reason| RB[Reasoning brain\nemits ordered action queue\nfrom a closed vocabulary] RB --> AQ["Action queue (PMS-agnostic)\ngather → judge → dispatch → persist"] AQ --> RECAP[Recap SMS to PM] end RECAP --> YES{PM replies 'YES'} YES -->|confirmed| DISPATCH["Dispatch WOs (in-house immediate,\nexternal flag-gated) + stage charges"] YES -->|edit| RB GATE["🔒 Merge gate: real-call capture-only gauntlet\nreplays every real call through the live BRANCH agent (ADR-0048),\nasserts capture-only + clean hang-up"] -.guards.-> Walk classDef good fill:#1f5a2f,stroke:#57e07a,color:#fff; classDef gate fill:#1f3a5a,stroke:#57a7e0,color:#fff; class AN,EC,DISPATCH good; class GATE gate;

Turnover becomes a sibling domain of the maintenance Maestro (ADR-0053: "renewals/tours get sibling Maestros later… never one god-orchestrator"), reusing the same primitives, PMS-agnostic at the action layer (PMS-specific tools resolve under the hood).

7. Decision (locked principles)

  1. The live call captures notes. Nothing else. No actions until after hang-up.
  2. Fewest possible tools on the live agent — capture-only set: find_turnover, start_inspection, append_notes, revise_finding, end_call, skip_turn. The 6 action tools removed: send_summary, confirm_turnover, get_turnover_status, add_charge, dispatch_work_order, apply_charge_decision.
  3. All heavy lifting is post-call, in Maestro — dispatch and charges happen only after the call, only after the PM's "yes."
  4. No walk-agent change merges without passing the real-call capture-only gauntlet.
  5. Enforcement is structural, not prompt discipline — the tool is removed from the belt; the prompt rule is a backstop, not the guarantee (ADR-0053).

8. Restore-and-harden plan

P0 — Strip the live agent to capture-only (through the pipeline; PROPOSED, held for owner approval).

P1 — Build the real-call capture-only gauntlet and make it the merge gate (this is the thing asked for 10+ times).

P2 — Converge the post-call path onto Maestro (ADR-0061).

9. Secondary bugs (documented; fixes proposed in their own PRs)

10. Lessons

  1. Test the layer that breaks. The regression is the live LLM's tool choice. Mocking tools and asserting on handlers measures the layer that was already fine. A harness that mocks tool returns is fine only if it asserts on tool choices.
  2. Never put autonomous-action tools on a live customer-facing agent without a gate that proves they don't fire when they shouldn't (CLAUDE.md: ungating autonomous action is high-risk).
  3. Green ≠ working. 13+ PRs merged green while the product got worse, because the gate was blind by construction.
  4. Structural drift checks can't catch behavioral regressions. The nightly catalog⇄live drift check passed for turnover the morning after the regression — the action tools were legitimately in the catalog, so parity held. Parity ≠ appropriateness; only a behavioral gate (assert tool choices on real calls) catches "a capture-only agent acquired action tools." The additive-only sync still bites the fix: removing the tools from the catalog won't unbind them live, and the parity check won't flag the leftover binding either (it iterates catalog entries) — so the strip needs an explicit removal/unbind path plus an extra-tools check.
  5. Keep the human's exact ask in view. "Real calls through a simulator as if I'm calling" was deferred for two days in favor of offline tests.

11. Consequences

12. References

Live agent agent_9601ksjwenzyecp9dpxxhad7a6xq; test bench property appfolio-45 / unit TEST-101 / turnover turnover_0a9b5eee…. Harness: evals/turnover-walk/**, scripts/simulate-walk-call.ts, scripts/voice-harness/drivers/l1-simulate.ts. Sync: scripts/sync-turnover-intake.ts. Incident: EL conv_1201kveyz963fcysbk7cfx22pf6t.