← Choice Health remote-control-transport-swap-handoff.md raw .md

Remote-Control Transport Swap — Live-Call Handoff

Date: 2026-07-02 For: the operator-present "let's do the transport swap together" session Design: docs/remote-control-design-v0.md §1.3, §7 Step 2 Prereqs done: the control-layer foundation (input_event, input_arbiter, capability, control_event) is on main and tested; the §1.1 wire gate was hardened (envelope validation + per-client dedupe) in commit 42dd6850.


1. What already landed (safe, autonomous, SHADOW only)

The risky refactor half of the transport swap is done and cannot change a live call, because it is gated OFF and only ever observes. Concretely, in riff/live/session.py:

  1. A live decision-epoch counter. _sync_decision_epoch() advances a per-session monotonic integer (_current_decision_epoch) once on entry to a new locked_choice (choose) state — never on a no-match/retry re-ask. This is the epoch the arbiter arbitrates on (I8). Until now the live path had no decision-epoch at all (the §6 TraceEmitter seq has no live producer), so this fills the real gap that blocked the swap.
  2. Input stamping. TurnAccumulator.input_decision_epoch is stamped beside the proven seq-398 user_text_state guard at two points: voice first transcript and DTMF commit. (Direct-dial DTMF is left legacy this slice — it has no active locked_choice epoch.)
  3. A shadow-mode arbiter comparison. At the single committing decision point (inside _maybe_handle_locked_choice_turn_complete, after the choice-STT assist swap so it sees the final choice), _shadow_arbiter_compare() submits the input to the InputArbiter and records agreement vs the legacy locked_choice resolver into last_arbiter_shadow + a diag line. It never changes the route.

The safety contract, locked in by tests (tests/test_live_decision_epoch_shadow.py):

Flag Behavior
RIFF_INPUT_ARBITER unset (production default) arbiter never consulted; last_arbiter_shadow is None; routing byte-identical
RIFF_INPUT_ARBITER=1 arbiter agrees with the legacy resolver on the real flow; routing/FSM outcome still identical — arbiter only observes

Everything above is dormant in production. The 1492-test control-layer suite, the live-session direct-dial suite, and 6 new epoch/shadow tests are green. (The 4 pre-existing test_live_session.py failures are flow-schema drift on property_trouble_ticket — confirmed identical on clean HEAD, not from this work.)


2. What we do together on the live call (in order)

Step A — watch the shadow, believe nothing yet

Bring up a real OTA call with RIFF_INPUT_ARBITER=1 and drive a menu by voice and by DTMF. Watch the diag stream for:

decision_epoch OPEN: epoch=N state='...' reason='...'
shadow_arbiter: reason=accepted accepted=True arbiter_choice='X' legacy_choice='X' agree=True ...

Success = agree=True on every real menu turn. Divergences to expect and interpret (not necessarily bugs):

Step B — stamp voice from audio start (REQUIRED before A2)

Today voice is stamped at first transcript, which is later than utterance start. That is fine for shadow agreement but cannot prove the A2 tap-vs-speaking race: a mid-utterance tap could legitimately beat a not-yet-transcribed utterance and we'd be timing the wrong instant. Before claiming A2, stamp the voice epoch from the earliest non-suppressed caller-audio frame in the browser transport (around riff/live/transport.py:1003).

Step C — flip the arbiter authoritative (the actual swap)

Only after A and B: make submit's decision override the legacy path at the commit point (replace the shadow-only call with an authoritative one), still behind RIFF_INPUT_ARBITER. Prove A2 live: speak "one", tap "3" mid-utterance → exactly one route, via: web_tap, the voice traced input_dropped {stale}.

Step D — tap/submit over the companion WS

Wire inbound control_event frames (already parsed + validated) from the companion page into the arbiter + capability. This is where the hardened §1.1 gate and per-client dedupe start earning their keep.

Step E — retire the seq-398 guard (last)

The user_text_state != current_state guard stays authoritative until the epoch path is proven live. Only then does the arbiter's stale drop subsume it.


3. Hard rules (from the codex design consult, kept as guardrails)


4. One-line status

The transport swap's refactor is done and dormant; the live call is now a watch-the-shadow → stamp-from-audio → flip-authoritative → prove-A2 sequence, none of which can regress production until we deliberately flip it together.