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:
- A live decision-epoch counter.
_sync_decision_epoch()advances a per-session monotonic integer (_current_decision_epoch) once on entry to a newlocked_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 §6TraceEmitterseq has no live producer), so this fills the real gap that blocked the swap. - Input stamping.
TurnAccumulator.input_decision_epochis stamped beside the proven seq-398user_text_stateguard at two points: voice first transcript and DTMF commit. (Direct-dial DTMF is left legacy this slice — it has no activelocked_choiceepoch.) - 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 theInputArbiterand records agreement vs the legacylocked_choiceresolver intolast_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):
reason=lockedon a self-looping choose-state (a menu that stays put after a match): the epoch does not advance on a self-loop, so the arbiter sees the epoch as already decided. Decide together whether self-loops should open a fresh epoch (likely yes) — this is the main open epoch-semantics question.skipped: assist_swap: expected when choice-STT rescued Gemini→Whisper; the arbiter is deliberately not re-run on mismatched text.
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)
- Only committing decision points submit. The echo-filter predicate, the
early preview gate, and the whisper/choice-STT comparison must never
submit(they are speculative — a submit would wrongly lock the epoch). invalid≠ suppress. The arbiter returninginvalid(no lock) is the mirror of legacychoice is None; it must not suppress the existing no-match/retry prompt.- Never hard-assert divergences in production. Log them. Use a separate strict test flag if you want assertions.
- Handle "repeat options" before the arbiter — it is meta, not a choice.
- On assist swap, compare the final selected text, not the pre-swap text.
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.