ADR-0090 — Tool-Call Followthrough Architecture (the Say/Do Gap)

Context

The failure class. Clara sometimes verbally agrees to, or claims she performed, an action on a text turn (SMS/email) without emitting the matching tool call — she says the reschedule/booking/cancellation happened without doing it. The anchor incident: CAM-F-001, 2026-07-11 — a tenant's tour-reschedule request was verbally acknowledged by Clara with a since-stale time, but the corresponding reschedule_tour tool call never fired, so the tour record was never updated to match what the tenant was told.

This is a canonical class, not a one-off. Prior research (external + internal, synthesized before this ADR) puts hard numbers on it:

What's already shipped, tonight, ahead of this ADR. PR #3697–#3700 landed a deterministic-pipeline-first response to CAM-F-001's class: #3699 in particular persists a tenant's accepted offer terms at acceptance time (not re-derived from whatever Clara's final reply happens to claim) and generalizes the "did you actually discharge what you promised" guard. That guard is not theoretical — it fired live, unprompted, during this ADR's own replay experiment (§2): on a maintenance healthy-corpus case, the loop logged "REFUSED silence ... duty undischarged: You promised to text the tenant something but the matching send tool was not called in the same turn." That is the mechanism this ADR is deciding whether to widen, replace, or supplement.

The question this ADR answers: given the deterministic pipeline already shipped, do we ALSO need a model-loop-level backstop (forced tool_choice / reply-as-tool), and if so, which one, at what cost, and does it reach voice at all?

Experiment evidence (2026-07-13/14, fede/agentic-replay-harness)

Full methodology, raw per-case-run data (PII-bearing, never committed), and the harness code are on the branch. Summary here is caseId/score-only.

Per-failure-case scorecard (3 real failures × 4 archs × 2 models × k=3 = 72 runs, 0 harness errors):

Case Result Reading
CAM-F-001 (the anchor incident) 23/24 runs pass, all 4 archs, both models — current included Consistent with #3699 already covering this specific incident — NOT proof; this is one replay under a synthesized environment, not an A/B against pre-#3699 code. A small flake (1/3 fails) appeared only on sonnet-5 under forced-tool-choice and reply-as-tool — a new, minor instability from forcing tool_choice on that model, not something either mechanism was supposed to introduce.
CAM-F-002 (preference-correction / bilingual loop) Fails 24/24, every arch, every model The forced-tool-choice classifier fires ZERO tools on this case in every run — its gate is scoped to offer-acceptance / time-change shapes and has a real blind spot for "prospect corrects a stated preference." current/reply-as-tool/both DO call tools (update_prospect, get_available_units) but still fail — Clara's reply text itself doesn't satisfy the case's content bar. No architecture tested here fixes this case.
CAM-S-001 (lockout, verdict=SUSPECT not TRUE-FAILURE) Fails 24/24, but the escalation tool (forward_to_property_manager/notify_leasing_team) fires in every single run, every arch This is a policy/judgment failure in reply content, not a say-do gap. Clara consistently suggests trying another contact number — exactly what the case says she must not do (the tenant already exhausted the available paths). Forcing tool_choice mechanically cannot fix what she chooses to say.

Healthy-corpus regression (36/39 cases ran — 3 skipped, see limitations; 4 archs × sonnet-4-6 only × k=1 = 144 runs, 0 errors):

Arch Regressed (of 36) New vs. current
current (baseline, no changes) 17 (47%)
forced-tool-choice 18 (50%) +2
reply-as-tool 21 (58%) +10
both 21 (58%) +10

current itself "regresses" on 47% of healthy cases with zero prototype changes — this is a noisy baseline from replay-environment-fidelity gaps (synthesized property/unit data vs. the real historical environment), not genuine drift. Read the new-vs-current column only: forced-tool-choice adds 2 new regressions — at or near the noise floor. Reply-as-tool/both add 10 new regressions each — a real signal, not noise.

Latency: forced-tool-choice ≈ free (same or fewer API round trips than current on mutation turns — it forces choice on one call, not every call). Reply-as-tool costs +1.5 round trips and +25–35% wallclock on mutation turns (the mandatory separate send_reply call after every tool result) — the "+1 round trip" tax the mechanism was already expected to carry, now measured.

k/model caveats. Failures ran the full requested matrix (4 archs × 2 models × k=3). Healthy ran 4 archs × 1 model (sonnet-4-6) × k=1 — cut on wall-clock time, not dollars (total session spend ≈ $5.56; the $150 budget guard never triggered). There is no flake-consistency data on healthy and no sonnet-5 comparison on healthy — a real gap, not a finding.

Dev workspace API cap. Hit mid-session (400, "reached your specified workspace API usage limits... regain access 2026-08-01") after the real-corpus sweep finished, mid-synthetic-pack — a shared dev/subscription-key cap, not something this run alone exhausted (total spend well under any per-session limit). Production is verified unaffected — separate key, confirmed live with a 200 probe.

Voice

Mechanisms B (forced tool_choice) and C (reply-as-tool), and their combination D, structurally do not apply to voice. ElevenLabs' hosted runtime generates live voice turns itself; there is no in-process Claude tool-calling loop on PropFlow's side for these mechanisms to attach to (the same architectural fact ADR-0022 and the channel-drift audit both independently confirm — voice is a SessionChannelAdapter with lifecycle hooks, not a participant in conversation-manager.ts's loop).

What the voice-side audit found:

Recommended voice mechanism: post-call entity-diff reconciliation, extending the existing realness-ledger rather than inventing new voice infrastructure — widen TOOL_LIVE_EFFECT_ENTITY to cover leasing/tour tools, and after each call ends, diff what the transcript claims happened against the actual Tour/WorkOrder state, using the ledger's existing breadcrumb pattern as the template. This is the voice-side analogue of #3699's "persist on acceptance, verify against ground truth" shape (§Alternatives, option e), not a from-scratch design.

Channel-drift protections

Channel unification is real where it was deliberately built — the router/pipeline layer (ADR-0022) — and genuinely absent downstream of it, in the say-do-specific guard/audit layers ADR-0022 never addressed. The channel-drift audit confirmed 12 divergences (see the confirmed forks below); it also ranked concrete, cheap protections against the repo's existing *.drift.test.ts enforcement pattern:

Rank Protection Effort Catches
1 PR-template/reviewer-lens question: any PR touching the router, tour pipeline, confirmation handler, agent-loop guards, or stress scripts must answer "does this apply equally to SMS and email? Does it need a voice equivalent, or a one-line note why not?" Trivial The exact 07-08 gap: the reliability gauntlet landed in scripts/sms-stress only, no note on scripts/email-stress.
2 CI grep for hardcoded channel: 'sms' / trigger: 'voice_handler' literals passed into shared cross-channel writers instead of a variable derived from the real inbound channel Small D1 — email tour cancellations mislabeled voice_handler/sms in the durable audit history.
3 channel-parity.drift.test.ts — feed the identical fixture message (tour request/reply/reschedule/confirm/decline/cancel) through the SMS and email paths in-process, assert identical classification, tool call, and history-entry shape Medium D3 — two competing deterministic state machines produce different outcomes for the same "yes" on SMS vs. email. R6 — a decline can get silently escalated to a real cancel.
4 A written coverage-boundary doc: per shared mechanism (router, classifier, state machine, guards, audit/ledger), which channels it covers and why any exclusion is deliberate Small Would have made ADR-0054's "three unrelated identity mechanisms" and ADR-0057's "scoped to one voice sub-path only" facts discoverable in one place, instead of requiring a reactive audit.
5 Parametrize the classifier's own tests over both SMS and email inputs for the same fixture Small The "Unified Message Classifier" misnomer — the SMS fast path never recognizes a tour_reply shape that the email path does.
6 Paired promptfoo eval cases (SMS + email + voice-where-reachable) per new tour scenario, per CLAUDE.md's existing "classification bugs become eval cases" rule Small–Medium Turns #5's finding into a standing regression gate, not a one-time manual catch.
7 Unified audit events — a real row whenever the deterministic pipeline mutates a Tour, and widen the realness-ledger to leasing/tour + voice Medium–Large D1 + R4 + R5 at the root — the deepest, most expensive, most complete fix; ranked last because it's the largest lift.

Confirmed forks this ADR's recommendation must account for: D2 (email tenants have no automatic maintenance work-order filing path at all — Clara's own prompt unconditionally tells her she lacks a create tool, which is false for email); D3 (the older, un-hardened, phone-keyed confirm/cancel state machine can commit a stale/wrong time with live side effects — calendar sync + PM notify — on SMS before the #3699-hardened pipeline runs on the same turn); R2/R3 (all 5 text guards are voice-blind, and the hallucination guard's docstring falsely claims voice coverage); D1/R4/R5 (audit-trail gaps: mislabeled trigger/channel on email, the deterministic pipeline's own mutations never produce a Conversation tool_use row at all, and voice's ledger has no leasing keys); R6 (decline can silently escalate to cancel, channel-uniform).

Prior art — the say–do detector already substantially exists (reuse, don't rebuild)

Review feedback (Gera, 2026-07-14) surfaced that this ADR's first draft proposed detection mechanisms without citing the in-house infrastructure that already implements the same shape. Verified against the repo:

Principle adopted from this review: one detection brain, not three. The recommendations below are re-stated as extensions of the fake-detector family rather than new systems. The replay harness on fede/agentic-replay-harness remains what it was built as — a one-off experiment instrument for this ADR's evidence — not a production detection system; ongoing text regression coverage belongs in the existing eval/golden checkpoint machinery (mustNotHappen / tool-fired checkpoints), extended to leasing/tour.

Decision

Recommendation (proposal — Fede & Gera decide; revised after Gera's 2026-07-14 review):

  1. Deterministic-pipeline-first stays the primary layer (already shipped, #3697–#3700). This experiment's strongest single data point — CAM-F-001 passing 23/24 — is consistent with that bet paying off for the incident that motivated it.
  2. Detection before prevention: extend the fake-detector family to leasing/tour, on both text and voice.
    • Voice post-call reconciliation = Sherlock's claim→independent-verify core pointed at Tour/WorkOrder state after real calls, with the realness-ledger's breadcrumbs as one input (widen TOOL_LIVE_EFFECT_ENTITY to tour tools) — not a new detector. Same fail-closed classify, same shame-row output surface, new claim extractors + verifiers for tour entities, plus a production (non-eval) trigger at call end.
    • Text: extend assert-tool-followthrough beyond renewal_escalate to leasing/tour commitments, and add leasing/tour mustNotHappen/tool-fired golden checkpoints — the standing regression net for this class, replacing any notion of productionizing the branch harness.
  3. Defer the forced-tool_choice backstop (Prototype B) behind a tripwire. It measured nearly free (+2 regressions ≈ noise, no latency cost), but it is also this ADR's weakest-justified build: Open question #4 concedes no corpus case reproduces a gap the shipped pipeline still misses. With the detection net from #2 in place, the honest sequencing is: the first confirmed post-#3699 text say–do failure caught by the detector re-opens Prototype B, whose implementation stays ready on the experiment branch (classifier scope-widening past tour-offer-acceptance shapes remains the prerequisite if/when it ships).
  4. Ship channel-drift protections #1–#3 immediately (PR-template question, CI grep, channel-parity.drift.test.ts) — cheap, and each maps to a confirmed fork already sitting in production.
  5. Explicitly do NOT ship reply-as-tool (Prototype C) platform-wide. It is the vendor-blessed reference pattern for exactly this problem, but the data doesn't support it here: no unique fix over Option B on either case B already fails to fix (CAM-F-002, CAM-S-001), a real regression signal (+10 new vs. current, not noise-level like B's +2), and the highest measured cost (+1.5 round trips, +25–35% wallclock on mutation turns).

What would change this verdict:

Consequences

Commits us to:

Makes easier: future audits of "did channel X get this fix" have a written coverage-boundary doc and a standing parity test to point to, instead of a reactive dig.

Makes harder / explicitly NOT foreclosed: reply-as-tool is deferred on this evidence, not rejected forever — the "what would change the verdict" criteria above are the reopening condition, not a permanent no.

Explicitly out of scope for this ADR (see Open questions): CAM-F-002's actual fix (a conversational/language bug, not an architecture gap), CAM-S-001's actual fix (a prompt/policy/eval change, not a tool-forcing gap), and any reply-CONTENT quality gate — this ADR only covers whether a tool call fires, not whether what Clara says is correct once it does.

Alternatives considered

Open questions

  1. Dev workspace API cap — raise the existing cap vs. cut a dedicated key for the remaining consistency runs (k=3 / 2-models on the full healthy corpus, the synthetic pack). Production is confirmed unaffected (separate key, live 200 probe).
  2. CAM-F-002's actual fix path — presents as a language/conversational-looping bug in how Clara handles a preference correction, not a tool-forcing gap. Flagged as its own investigation (Gera's bug #1 territory), not resolved by this ADR.
  3. CAM-S-001's actual fix path — a prompt/policy/eval change (what Clara should say when escalation is right but no new contact path exists), not an architecture change. Not resolved by this ADR.
  4. Corpus curation gap — no case in today's corpus reproduces the exact incident class (a say-do gap the already-shipped deterministic pipeline itself still misses) under live replay; CAM-F-001 didn't reproduce post-#3699. Until a reproducing text case exists, the case for a text-side backstop beyond #3699 is plausible, not demonstrated.