← Choice Health remote-control-control-layer-status.md raw .md

Remote-Control Control Layer — Status (for Fable 5 review)

Date: 2026-07-02 Scope: the input/decision/capability layer of the remote-control build (design: docs/remote-control-design-v0.md §1.3, §3, §3.1, §7). Status: three modules shipped to main, additive (not yet wired to the live transport). Every acceptance criterion below is proven by a named test.


1. The three modules, in plain language

The remote control lets a caller drive the same voice FSM three ways — by voice, by keypad (DTMF), and by tapping the companion screen. For that to be safe, three things must be true: all three inputs must go through one decision-maker, only one input can win a given decision, and screen inputs that change something real must prove the tapper is the caller. Three small modules provide exactly that.

riff/input_event.py — one shape for every input (invariant I8)

Voice, DTMF, and tap each become a single InputEvent {source, node, decision_epoch, resolved, confidence}. resolve_input_event then routes it: voice and DTMF delegate to the existing locked_choice resolver unchanged; a tap carries an already-chosen token, so it skips the grammar and matches the choice directly (tagged via: web_tap). This is purely additive — the same router everything already used, with one entry point in front of it.

riff/input_arbiter.py — exactly one winner per decision (§1.3)

An InputArbiter tracks the open decision epoch (a monotonic trace seq). The first valid event for the open epoch wins and locks it. After that: a second event for the same epoch is dropped locked; an event stamped with a past epoch (a slow voice decode that lost the race) is dropped stale; a no-match input is dropped invalid without locking (so a garbage input can never freeze the menu). Every drop emits an input_dropped trace frame.

riff/capability.py — screen writes must prove the tapper is the caller (§3.1)

Reading and tapping non-committing menus is open to whoever holds the texted link. Committing actions (book, cancel, pay) require the device-pairing challenge: the server shows a random code on the requesting screen only; the caller types it as DTMF on the live phone leg. Knowing the code proves screen possession; the DTMF proves phone-leg presence; together they prove the caller controls that browser session. The code never enters the trace.


2. How they compose

    voice  ─┐
    DTMF   ─┼─►  InputEvent  ─►  InputArbiter.submit(event, spec)
    tap    ─┘   (one shape)      │
                                 ├─ first valid for open epoch ─► ACCEPTED (locks)
                                 ├─ same epoch, already won     ─► dropped: locked
                                 ├─ past/foreign epoch          ─► dropped: stale
                                 └─ not a valid choice          ─► dropped: invalid

    commit-tier tap ─► CallCapability.open_challenge(session)
                          └─ code to that screen only ─► caller DTMF on leg
                                                          └─ grant COMMIT to that session

The arbiter is the single serialization point (I8): because every modality funnels through submit, "caller says 1 while tapping 3" has a total order — whichever reaches the arbiter first wins, the other is dropped. No new concurrency is introduced; a lock makes the check→resolve→lock one critical section as defense-in-depth.


3. Acceptance criteria proven

Criterion What it guarantees Proven by
A2 (race) speak one option while tapping another → exactly one route; the loser is dropped test_input_arbiter.py::test_exactly_one_route_regardless_of_order, ::test_concurrent_submits_accept_exactly_one, ::test_a2_race_tap_wins_voice_dropped_stale
Byte-identical routing the InputEvent path routes voice/DTMF identically to the pre-existing evaluator test_input_event.py (1450 corpus cases, decision-trace parity)
A4 (device-pairing) a commit needs the on-screen code entered as DTMF; only the requesting session is granted; a second/attacker session cannot be test_capability.py::test_a4_dtmf_grants_only_the_requesting_session, ::test_a4_second_session_rejected_while_pending, ::test_a4_attacker_B_cannot_be_granted_by_the_callers_dtmf
Fail-safe edges no-match never locks; monotonic epoch never unlocks a decided one; concurrent calls still pick one winner; challenge voids on wrong/expired; code never traced the adversarial + hardening tests across all three files

Totals: InputEvent 1468, Arbiter 12, Capability 13 tests — all green.


4. What is NOT yet wired (honest gaps)

These modules are additive — nothing in the live call path calls them yet. The remaining work:

  1. Transport swap: make the phone transport produce InputEvents and feed InputArbiter instead of calling locked_choice directly. Deliberately last and done under a real call, because it is the one live-path change; the contracts above were proven first precisely so this swap is low-risk.
  2. tap/submit/challenge over the companion WS: inbound control_event frames from the companion page into the arbiter + capability.
  3. Generic surface renderer (A1): one companion page rendering any flow from its artifact — waits on the surface-emission slice (in progress separately).

5. How this was built (process note)

Each module went through the same loop: codex implements or reviews → adversarial testing → codex review finds the soft spot in a "proven" claim → run it to the exact failing condition → fix + prove. Three review-and-harden cycles ran: InputEvent (the byte-identical debug hid non-deterministic latency), the arbiter (a staged A2 test, a backward-epoch unlock, an unlocked critical section), and capability (an unlocked one-pending check). All findings were fixed with targeted tests.