|
| 1 | +# ADR-0059: Voice control, WebRTC media, capability-gating, and replay protocol |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Proposed (Issue #496, Epic #491, 2026-06-24) |
| 6 | + |
| 7 | +## Version |
| 8 | + |
| 9 | +0.2.0 |
| 10 | + |
| 11 | +## Context |
| 12 | + |
| 13 | +[ADR-0058](ADR-0058-voice-digital-twin-capability-architecture.md) established the capability-gated, |
| 14 | +local-first, provider-neutral Voice Digital Twin architecture and deferred two transport questions to |
| 15 | +the protocol/transport child issues: whether to re-open a bidirectional WebSocket upgrade on the BFF |
| 16 | +(currently hard-rejected), and the precise wire contract for control, signaling, media, and replay |
| 17 | +(ADR-0058 D3, Consequences). Issue #496 answers the **protocol** half of that pair: it **defines** the |
| 18 | +versioned control/media protocol contract that Issue #497 then **implements** as transport. Per the |
| 19 | +[implementation sequencing](../voice/implementation-sequencing.md), #496 owns |
| 20 | +`packages/keiko-contracts/src/*` voice protocol types plus docs, and must remain additive — it does |
| 21 | +**not** add transport code, re-open the WebSocket upgrade, add runtime dependencies, or change any |
| 22 | +trust boundary. |
| 23 | + |
| 24 | +The protocol is defined against the seams ADR-0058 already mapped, all confirmed by a fresh read-only |
| 25 | +survey of the merged voice surface (#492–#495): |
| 26 | + |
| 27 | +- **Capability is already resolved, content-free, and serialisable.** `VoiceProfile` |
| 28 | + (`none | speech-to-text | speech-output | full-realtime`), `VoiceTransportPosture` |
| 29 | + (`websocketControl`, `webrtcMedia`), `VoiceProviderLocality`, `VoiceUnavailableReason`, and |
| 30 | + `VoiceCapabilityResolution` exist in |
| 31 | + [`gateway.ts`](../../packages/keiko-contracts/src/gateway.ts) (Issue #493). The protocol contract |
| 32 | + **imports and reuses** `VoiceProfile`, `VoiceProviderLocality`, and `VoiceUnavailableReason`, and is |
| 33 | + consistent with the `VoiceTransportPosture` / `VoiceCapabilityResolution` shapes; it redefines none |
| 34 | + of them. |
| 35 | +- **The control plane is realized on loopback HTTP + Server-Sent Events today.** The BFF binds |
| 36 | + `127.0.0.1` and **hard-rejects every WebSocket upgrade** with `HTTP/1.1 404` then `socket.destroy()` |
| 37 | + ([`server.ts`](../../packages/keiko-server/src/server.ts) lines 210–213). The STT-only dictation |
| 38 | + route `POST /api/voice/transcribe` ([`voice-handlers.ts`](../../packages/keiko-server/src/voice-handlers.ts), |
| 39 | + Issue #494) is the first realized control-plane exchange: a capability-gated request/response that |
| 40 | + rides the existing JSON + CSRF envelope, forwards audio once through `gatewayFetch`, persists no raw |
| 41 | + audio, and returns a deterministic `VOICE_UNAVAILABLE` when voice is absent. |
| 42 | +- **The media plane is greenfield browser-native WebRTC.** No `RTCPeerConnection` / `getUserMedia` / |
| 43 | + `MediaRecorder`-based realtime path exists yet; the dictation UX (#495) captures a clip with |
| 44 | + `MediaRecorder` and posts it. WebRTC is a browser platform capability requiring zero npm |
| 45 | + dependencies ([supply-chain-policy](../voice/supply-chain-policy.md)). |
| 46 | + |
| 47 | +The full normative specification lives in [`docs/voice/protocol.md`](../voice/protocol.md); the typed, |
| 48 | +machine-checkable contract lives in |
| 49 | +[`packages/keiko-contracts/src/voice-protocol.ts`](../../packages/keiko-contracts/src/voice-protocol.ts). |
| 50 | +This ADR records the load-bearing decisions. |
| 51 | + |
| 52 | +## Decision |
| 53 | + |
| 54 | +We adopt a **two-plane, capability-gated, versioned** voice protocol. It is content-free by |
| 55 | +construction (only enum literals, booleans, opaque identifiers, and opaque SDP/ICE/transcript |
| 56 | +strings) and adds no authority. |
| 57 | + |
| 58 | +### D1 — A dedicated, independently versioned protocol contract |
| 59 | + |
| 60 | +The protocol carries its own `VOICE_PROTOCOL_VERSION = "1"` string-literal constant, following the |
| 61 | +same evolution rule as `WORKFLOW_HANDOFF_SCHEMA_VERSION` / `CONNECTED_CONTEXT_SCHEMA_VERSION`: a |
| 62 | +breaking change introduces a **new literal member**, never a mutation of `"1"`. It is **independent of |
| 63 | +`CONVERSATION_CAPABILITY_CONTRACT_VERSION`** (the capability-registry contract, currently `3`) and |
| 64 | +**must not** bump it — the protocol and the capability registry evolve on separate axes. A peer |
| 65 | +accepts a message only when its declared version is one this build understands |
| 66 | +(`isVoiceProtocolVersionSupported`); v1 understands exactly `"1"`. |
| 67 | + |
| 68 | +### D2 — Two planes: WebSocket control is separated from WebRTC media (AC1) |
| 69 | + |
| 70 | +The protocol explicitly separates a **control / signaling plane** from a **media plane**: |
| 71 | + |
| 72 | +- The **control / signaling plane** carries every protocol message kind: session lifecycle, |
| 73 | + capability negotiation, provider selection, SDP/ICE signaling, cancellation, interruption |
| 74 | + (barge-in), transcript lifecycle, playback state, and policy decisions. |
| 75 | +- The **media plane** carries **only** real-time audio over native browser WebRTC (DTLS-SRTP), |
| 76 | + optionally with a low-latency `RTCDataChannel` that mirrors a subset of control events. |
| 77 | + |
| 78 | +**No control message kind carries raw audio.** Raw audio is modelled by a single |
| 79 | +`VOICE_MEDIA_PLANE` descriptor (`plane: "media"`, `transport: "webrtc"`, `redaction: "raw-media"`, |
| 80 | +`replay: "never-persisted"`) and never as a message in the control catalog. This separation is the |
| 81 | +typed, test-pinned expression of AC1: the `raw-media` redaction class is exclusive to the media |
| 82 | +plane, so a control message can never be raw audio. |
| 83 | + |
| 84 | +### D3 — Control-plane realization: loopback HTTP + SSE now; WebSocket upgrade is deferred to #497 |
| 85 | + |
| 86 | +"WebSocket is the authoritative control plane" describes a **role**, not a mandatory transport. The |
| 87 | +protocol's control transport is captured by `VoiceControlTransport` |
| 88 | +(`loopback-http-sse | loopback-websocket`), and the realization in effect for v1 is |
| 89 | +`VOICE_CONTROL_TRANSPORT_V1 = "loopback-http-sse"` — request/response over `POST /api/voice/*` plus |
| 90 | +server→client push over the existing `EventSource` channel. Re-opening a bidirectional WebSocket |
| 91 | +upgrade on the BFF (today hard-rejected) remains an **explicit, ADR-gated transport decision owned by |
| 92 | +Issue #497**, never an additive change smuggled in here. The protocol is defined so that a future |
| 93 | +switch to `loopback-websocket` is a transport realization detail, not a contract break. |
| 94 | + |
| 95 | +### D4 — Capability-gating and a deterministic fallback state table (AC2, AC3) |
| 96 | + |
| 97 | +The protocol is gated by the already-resolved `VoiceProfile`. `VOICE_PROFILE_ALLOWED_MESSAGE_KINDS` |
| 98 | +is the normative state table: |
| 99 | + |
| 100 | +- **`none` permits no control message at all** — the deterministic disabled behavior of AC2. With no |
| 101 | + voice capability, the protocol exposes nothing (no session can be created), mirroring the |
| 102 | + "no voice UI at all" posture of ADR-0058 D1. |
| 103 | +- **`speech-to-text` permits the controlled-dictation control subset only** — session lifecycle, |
| 104 | + capability negotiation, cancellation, the transcript lifecycle, policy, and error — and **excludes |
| 105 | + every WebRTC signaling, media-track, interruption, and playback kind**. STT-only dictation therefore |
| 106 | + never requires the full-realtime media path (AC3); its media transport is `gateway-batch` (audio via |
| 107 | + the existing `gatewayFetch` seam) with browser negotiation `disabled`. |
| 108 | +- **`speech-output`** adds playback and interruption; **`full-realtime`** permits every kind, uses the |
| 109 | + `webrtc` media transport, and is the **only** profile permitting SDP signaling. |
| 110 | + |
| 111 | +Degradation follows the ADR-0058 §5 ladder: an unavailable capability resolves down to a lower |
| 112 | +profile and ultimately to `none`; the protocol never silently streams raw audio over the control |
| 113 | +plane. |
| 114 | + |
| 115 | +### D5 — Replay, reconnect, and idempotency semantics (AC5) |
| 116 | + |
| 117 | +Every control message shares an envelope `{ protocolVersion, sessionId, seq, direction, kind }`. The |
| 118 | +per-direction monotonically increasing `seq` is the reconnect and idempotency anchor; a |
| 119 | +`session.create` additionally carries an `idempotencyKey` so a re-sent create after a reconnect |
| 120 | +resolves to the same session, never a second one. Each kind is classified by |
| 121 | +`VOICE_CONTROL_MESSAGE_REPLAY`: |
| 122 | + |
| 123 | +- **`replayable`** — durable control and **committed** transcript events; a reconnect re-delivers them |
| 124 | + up to the last acknowledged `seq`. This is the local system of record. |
| 125 | +- **`ephemeral`** — SDP, ICE candidates, and **partial** transcripts: valid only for the live |
| 126 | + negotiation, never replayed or persisted. |
| 127 | +- **`never-persisted`** — raw media frames (media plane only): excluded from replay and persistence by |
| 128 | + default. |
| 129 | + |
| 130 | +Thus **replay includes control and committed-transcript events but excludes raw audio by default** |
| 131 | +(AC5). No control kind is ever `never-persisted`; that class is exclusive to raw media. |
| 132 | + |
| 133 | +### D6 — Redaction semantics reuse the existing local-first stack |
| 134 | + |
| 135 | +`VOICE_CONTROL_MESSAGE_REDACTION` classifies each kind as `content-free`, `reviewable-text`, |
| 136 | +`secret-bearing`, or `raw-media`. Control kinds are only the first three; raw audio is the only |
| 137 | +`raw-media`. Before any message may enter a log or evidence manifest it passes the existing redaction |
| 138 | +and identifier-hashing seams exactly as recap / session-state records already do |
| 139 | +([privacy-contract §2/§3](../voice/privacy-contract.md)): `content-free` is safe verbatim, |
| 140 | +`reviewable-text` is redacted-by-construction then deep-redacted and identifier-hashed at persist, and |
| 141 | +`secret-bearing` (SDP/ICE/ephemeral credentials) and `raw-media` are never logged or persisted raw. |
| 142 | +The protocol invents no new crypto, storage, or redaction mechanism. |
| 143 | + |
| 144 | +### D7 — Browser↔provider negotiation options and the security surface (AC6) |
| 145 | + |
| 146 | +Real-time media negotiation is one of three modes (`VoiceNegotiationMode`): **`proxied-sdp`** |
| 147 | +(preferred — the Keiko host performs SDP negotiation, so the browser holds no token), |
| 148 | +**`direct-ephemeral`** (opt-in — the browser uses a short-lived ephemeral credential), or |
| 149 | +**`disabled`** (no browser-direct media). The protocol is content-free, so a security review can |
| 150 | +reason about **every external endpoint** (provider STT/TTS/realtime endpoints reached only through |
| 151 | +`gatewayFetch`; configurable, validated STUN/TURN hosts) and **every browser-exposed credential** (a |
| 152 | +short-lived ephemeral token under `direct-ephemeral`, or none under the preferred `proxied-sdp`) |
| 153 | +directly from the typed contract and [`protocol.md` §10](../voice/protocol.md) — satisfying AC6. SDP |
| 154 | +and ICE payloads are opaque `secret-bearing` strings the contract never parses, stores, or logs; ICE |
| 155 | +candidate privacy relies on browser mDNS `.local` obfuscation (privacy-contract §4). The signaling |
| 156 | +plane stays under Keiko's loopback origin so auth, rate limiting, audit logging, and host allowlisting |
| 157 | +are controlled locally. |
| 158 | + |
| 159 | +### D8 — No new runtime media packages (AC4) |
| 160 | + |
| 161 | +The protocol requires **only** the existing `ws` package and browser-native WebRTC APIs. It defines no |
| 162 | +message that needs `socket.io`, `simple-peer`, `peerjs`, `mediasoup`, `livekit`, a server-side WebRTC |
| 163 | +stack, or any other runtime media package. Media transport is modelled with native mechanisms only |
| 164 | +(`gateway-batch` over `gatewayFetch`, or `webrtc` over the browser platform). The |
| 165 | +[supply-chain policy](../voice/supply-chain-policy.md) and a live regression test (a denylist asserted |
| 166 | +against every workspace manifest) keep this enforced. |
| 167 | + |
| 168 | +## Consequences |
| 169 | + |
| 170 | +- Issue #497 has a stable, typed, versioned contract to implement transport against, with the |
| 171 | + capability-gating, replay, reconnect, idempotency, and redaction semantics fixed in advance. |
| 172 | +- The contract is additive and content-free: it adds no authority, no dependency, no transport, and no |
| 173 | + trust-boundary change, and it does not bump `CONVERSATION_CAPABILITY_CONTRACT_VERSION`. It does not |
| 174 | + reach the published root `@oscharko-dev/keiko` surface (it is consumed directly from |
| 175 | + `@oscharko-dev/keiko-contracts` by the transport packages), so `check:package-surface` is unchanged. |
| 176 | +- Re-opening the BFF WebSocket upgrade and any CSP / `Permissions-Policy` relaxation for browser-direct |
| 177 | + media remain **future, explicitly-gated** decisions for #497 (ADR-0058 D3/D6), not part of this |
| 178 | + issue. |
| 179 | +- This ADR is **Proposed**; it is design-and-contract only and ships no transport code. It is promoted |
| 180 | + as the transport child issue (#497) lands. |
| 181 | + |
| 182 | +## References |
| 183 | + |
| 184 | +- Epic [#491](https://github.com/oscharko-dev/Keiko/issues/491); Issue |
| 185 | + [#496](https://github.com/oscharko-dev/Keiko/issues/496). |
| 186 | +- [ADR-0058](ADR-0058-voice-digital-twin-capability-architecture.md) — voice architecture baseline |
| 187 | + (D3 transport, D6 security). |
| 188 | +- [`docs/voice/protocol.md`](../voice/protocol.md) — the normative protocol specification. |
| 189 | +- [`packages/keiko-contracts/src/voice-protocol.ts`](../../packages/keiko-contracts/src/voice-protocol.ts) |
| 190 | + — the typed contract. |
| 191 | +- [`docs/voice/privacy-contract.md`](../voice/privacy-contract.md), |
| 192 | + [`docs/voice/deployment-profile-matrix.md`](../voice/deployment-profile-matrix.md), |
| 193 | + [`docs/voice/supply-chain-policy.md`](../voice/supply-chain-policy.md), |
| 194 | + [`docs/voice/implementation-sequencing.md`](../voice/implementation-sequencing.md), |
| 195 | + [`docs/voice/architecture.md`](../voice/architecture.md). |
| 196 | +- [ADR-0038](ADR-0038-outbound-egress.md) (`gatewayFetch` egress), [ADR-0046](ADR-0046-local-credential-vault.md) |
| 197 | + (sealed credentials). |
0 commit comments