Skip to content

Commit 2e692ac

Browse files
committed
Merge dev into editor foundation branch
2 parents 366aca6 + 317e999 commit 2e692ac

240 files changed

Lines changed: 44231 additions & 152 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
- feat/keiko-agent-native-editor-foundation-and-runtime
99
- feat/prompt-enhancer-1307
1010
- feat/keiko-establish-governed-end-to-end-git-delivery
11+
- feat/keiko-voice-digital-twin
1112
- "release/**"
1213
pull_request:
1314
branches:
@@ -16,6 +17,7 @@ on:
1617
- feat/keiko-agent-native-editor-foundation-and-runtime
1718
- feat/prompt-enhancer-1307
1819
- feat/keiko-establish-governed-end-to-end-git-delivery
20+
- feat/keiko-voice-digital-twin
1921
- "release/**"
2022
workflow_dispatch:
2123

@@ -30,7 +32,7 @@ jobs:
3032
- name: Confirm dev branch gate
3133
run: |
3234
case "${{ github.ref }}:${{ github.base_ref }}" in
33-
refs/heads/dev: | refs/heads/feat/keiko-editor: | refs/heads/feat/keiko-agent-native-editor-foundation-and-runtime: | refs/heads/feat/prompt-enhancer-1307: | refs/heads/feat/keiko-establish-governed-end-to-end-git-delivery: | refs/heads/release/*: | *:dev | *:feat/keiko-editor | *:feat/keiko-agent-native-editor-foundation-and-runtime | *:feat/prompt-enhancer-1307 | *:feat/keiko-establish-governed-end-to-end-git-delivery | *:release/*)
35+
refs/heads/dev: | refs/heads/feat/keiko-editor: | refs/heads/feat/keiko-agent-native-editor-foundation-and-runtime: | refs/heads/feat/prompt-enhancer-1307: | refs/heads/feat/keiko-establish-governed-end-to-end-git-delivery: | refs/heads/feat/keiko-voice-digital-twin: | refs/heads/release/*: | *:dev | *:feat/keiko-editor | *:feat/keiko-agent-native-editor-foundation-and-runtime | *:feat/prompt-enhancer-1307 | *:feat/keiko-establish-governed-end-to-end-git-delivery | *:feat/keiko-voice-digital-twin | *:release/*)
3436
echo "Protected or integration branch gate satisfied."
3537
;;
3638
*)

docs/adr/ADR-0058-voice-digital-twin-capability-architecture.md

Lines changed: 281 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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

Comments
 (0)