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:
- Transport swap: make the phone transport produce
InputEvents and feedInputArbiterinstead of callinglocked_choicedirectly. 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. tap/submit/challenge over the companion WS: inboundcontrol_eventframes from the companion page into the arbiter + capability.- 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.