← Choice Health remote-control-design-v0.md raw .md

RIFF — Remote Control Design, v0 (control events + surface schema)

Status: design handoff for the implementing Claude — open decisions marked OD-Rn Date: 2026-07-01 Depends on: riff-artifact-trace-schema-v0.md (artifact §3, trace §6, I1–I6), riff-console-map-design-v0.md (<KeypadChips>), riff-companion-channel-design-v0.md (link/token/stream, taps, receipt) Reframe adopted: the companion page is not a Space Channel mirror — it is the second screen of a modality-agnostic call. A tap is a third input to the same FSM that voice and DTMF drive. This doc specifies the two missing pieces: the input mirror of the §6 trace contract (control events) and a compiler-emitted surface schema so every flow gets a remote control for free.


0. New invariants

1. The input contract — control_event

The symmetric twin of the trace: trace events flow engine → subscribers; control events flow subscribers → engine, over the same per-call WS, gated by the same token.

1.1 Wire envelope

{"v":"0.1","call_id":"c_9f21","role":"caller","channel":"companion",
 "client_seq":3,"decision_epoch":14,"type":"tap",
 "payload":{"node":"main_menu","token":"launches"}}

1.2 Event types (define all now; implement tap + submit first)

type payload consumed by
tap node, token choose decision points; do outcome selection
submit node, fields: {slot: value} collect forms — atomic slot fill
text node, slot, value typed free-text into collect (OD-R3, deferred = OD-C5)
nav target: menu|back reloop affordances (compiles to a tap on the reloop token)

There is no client→engine capability event: the one capability frame is server→client, challenge_issued {code, expires} (§3.1). The screen never claims anything — DTMF entry of the displayed code is the proof.

1.3 Normalization and arbitration — locked_choice unchanged

All modalities collapse to one internal event before any decision logic:

InputEvent {source: dtmf|voice|tap|submit, node, decision_epoch, resolved: token|fields, confidence}

The race, resolved: caller begins saying "one," taps "3" mid-utterance. The tap enqueues first (instant), wins, locks epoch 14, the FSM advances (a tap during audio is a barge_in via: web_tap). The utterance finishes decoding stamped with epoch 14, arrives at epoch 16, dropped stale, traced. Deterministic, no special case.

1.4 Trace deltas (amends §6 + companion §7)

2. The surface schema — compiler-emitted

Every compiled node gains a surface block: the declarative projection of that state onto a second screen. riffc derives it; source YAML may override presentation (labels, grouping, order); L0 validates overrides against the grammar. Because the surface ships inside the artifact, the companion page is a generic renderer — one page, every flow, every tenant (theme = tenant config, the OD-M2 white-label hook).

2.1 Derivation rules

node type surface kind derived from
choose menu one button per grammar token, DTMF order, labels from token/aliases (override for display copy); reloop tokens render as nav
say media text_ref as readable script, progress bar from duration_ms
collect form one field per slot — requires typed slots (§2.2); enum slots → chips, tel/date/text → native widgets; submit button emits submit
do progresschoice hold text while in-flight; enum outcome frames as buttons (a tap here is a tap on the frame's confirm choose)
end receipt template + fields_from: [slots, tool_results] — this is the companion doc's §6 receipt, now just another surface kind
(reserved) handoff continue-on-web (§4, out of v0)

2.2 Required schema delta: extend slot_types (not a parallel system)

Slot typing already exists (riff/slot_types.py, Flow.slot_types) as extraction semantics: {phone, time, name, email, address, date, quantity, text}. Schema 0.2 extends this facility rather than adding a second representation:

slot_types:
  job_type:        {type: enum, options: [water_heater, drain, leak, other], label: "What's the problem?"}
  urgency:         {type: enum, options: [emergency, this_week, flexible]}
  callback_number: {type: phone, label: "Callback number"}
  notes:           {type: text, optional: true}

Migration surface: only flows using bare required_slots without a slot_types entry. Enum aliases feed the collect extractor as a constrained target set — the same determinism move as constrained ASR, applied to slot filling.

Implementation note (this slice): the unified type: field is real — since semantic names (enum, phone, …) never collide with JSON types (string, integer, …), one type: field carries both; {semantic_type: …} long-form still works. enum requires a non-empty options list (validated at load). riffc L0-10 makes an untyped collect slot a hard error. Migration checklist: docs/flow-eval/typed-slots-migration.md.

2.3 Example — main_menu compiled surface

main_menu:
  type: choose
  surface:
    kind: menu
    title: "Space Channel"
    controls:
      - {token: launches, label: "Launch status", accepts: [tap]}
      - {token: news,     label: "Space news",    accepts: [tap]}
      # …
      - {token: goodbye,  label: "End call",      accepts: [tap], style: quiet}

3. Write capability — tiered, channel-bound

The unguessable link is a bearer read capability. Writing is tiered inside the token/session:

tier grants granted by
read the filtered trace stream opening the link
tap tap/nav/submit on non-committing states caller-role link + session pinning (possession of the SMS ≈ possession of the phone) — adequate for the booking threat model
commit inputs on committing states (book, cancel, future payment) channel-binding challenge (§3.1): the server shows a random code on the requesting session's screen; the caller enters it as DTMF. Knowledge of the code proves screen possession; the DTMF proves leg presence; together they prove the caller controls that browser session.

3.1 Channel-bind protocol (resolves OD-R6)

A fixed "press 7" fails even with single-pending semantics — the confused-caller attack: an attacker holding a leaked read-link taps commit, the prompt plays on the victim's call audio ("press 7 to confirm"), and a compliant caller presses 7 having tapped nothing, granting commit to the attacker. The prompt itself is the social-engineering vector. The binding must require something the phone-holder can only know by seeing the requesting session's screen — the device-pairing pattern:

  1. A commit-tier input arrives from a session lacking commit. The engine holds the input, generates a challenge {code: 4 random digits, nonce, session_id, decision_epoch, ttl: 30s}, and enforces at most one pending challenge per call (a concurrent attempt from any other session → input_dropped {reason: challenge_pending}).
  2. The code is sent only to the requesting WS session (challenge_issued {code, expires} — server→client; the code never appears in the trace, which records capability_challenge_issued {session_ref} only). The FSM plays the compiled prompt: "to confirm, enter the code shown on your screen."
  3. While the challenge is pending, the engine intercepts DTMF ahead of locked_choice (the interrupted decision epoch is parked, not advanced). Correct code → capability_granted {tier: commit, session_ref, via: channel_bind}, and the held input auto-applies iff its decision_epoch is unchanged. Two wrong entries or TTL expiry → challenge void, input_dropped {reason: capability}, DTMF interception ends, the parked decision point resumes on the voice path (I7).
  4. Scope note: the challenge gates screen-originated commits only. A spoken confirmation is inherently leg-bound and needs no challenge — I7 guarantees that voice path exists.

The confused-caller attack now fails safe: the victim hears "enter the code shown on your screen," and their screen (if any) shows no code. This also deletes challenge_ack — the screen never claims anything; DTMF entry of the correct code is the proof.

4. Handoff patterns — the terminal surface generalized

The receipt was the first instance; the general pattern is the screen outliving or augmenting the voice turn, in both directions:

5. L0 additions (compile-time)

  1. Every surface control token exists in its node's grammar; every form field maps to a declared, typed slot; every receipt template field is resolvable from slots/tool_results.
  2. I7 (resolves OD-R7): every collect state bearing a form surface must have (a) an entry voice prompt AND (b) a reask on every non-optional slot in that surface. Collects without a form surface are untouched — their voice path is their only path, covered by existing validation. This keeps the 0.2 migration surface identical to the slot-typing checklist (same flows), not doubled.
  3. Every commit-tier state is a choose (something lockable) with a compiled challenge prompt asset ("press 7…") present.

6. What already exists vs. what's new

exists (design extensions against these) new
locked_choice deterministic router InputEvent normalization in front of it (I8)
§6 trace contract + per-call capability-scoped WS control_event inbound on the same socket; input_dropped, capability_granted events
riffc node classifier surface emission + typed slots (schema 0.2) + L0 rules 10–12
companion page (follow-along live) generic surface renderer replacing bespoke rendering
artifact model, receipt pipeline capability tiers + channel-bind challenge

7. Build order

Implementation status (2026-07-02):

  1. Schema 0.2: typed slots + surface blocks + trace deltas; riffc emits surfaces for both reference flows; L0 rules 10–12. (Pure compiler work, no runtime risk.)
  2. InputEvent normalization: refactor DTMF + voice to produce InputEvents feeding unchanged locked_choice; property-test in the L1 sim that behavior is byte-identical to today (this is the risky refactor — do it before any web input exists so regressions are attributable).
  3. tap end-to-end: control_event over the existing WS, validity ladder, input_dropped tracing; the race test (§1.3) as an L1 fault-injection case.
  4. Generic surface renderer on the companion page, replacing bespoke Space Channel rendering; submit forms against austin_plumbing.
  5. Capability tiers + channel-bind (may ship dormant per OD-R1).

Acceptance:

8. Open decisions

id decision default in v0
OD-R1 Commit-tier usage in v1 booking flows Mechanism in schema; flows mark nothing commit initially — pinned tap suffices for the plumber threat model. Flip when cancel/payment lands.
OD-R2 Continue-on-web handoff surface.kind: handoff reserved; unbuilt.
OD-R3 Typed free-text (text events) into collect Deferred (was OD-C5) — a fourth modality racing voice inside a turn has real design weight; submit (atomic form) covers the near-term need without per-keystroke arbitration.
OD-R4 Voice+form mixed fill granularity v0: submit is atomic and wins/loses whole; per-slot merge of voice-filled and form-filled fields is deferred until a real flow needs it.
OD-R5 Surface overrides scope Presentation only (labels, order, grouping, style). Overrides can never add a control without a grammar token — that would violate I7/I8 by the back door.

9. Codex review — resolved (2026-07-02)

Codex reviewed this doc + the first implementation slice; §1.3 arbitration is sound (TOCTOU-safe: one FSM queue, decision locks before the next epoch). Both design findings are now resolved by Fable 5:

Cross-cutting directive — one slot-enumeration contract. Codex's GoalSegment finding (L0-10 + surface both read target_slots off StateDef, but GOAP target slots live on GoalSegment) is fixed one level up: extract a single canonical iter_slots(flow) that enumerates every slot-declaration site, and have riffc, surface emission, AND the migration enumerator all consume it. Two consumers independently walking slot sources is how this bug happened; three is how it recurs at a new declaration site. Same "one contract" move as playback and trace. Owned by the surface session (it owns riffc/surface/types).