docs/adr/0025-clankvox-placement-and-ipc.md

ADR 0025: ClankVox is an in-repo voice sidecar behind versioned bridge IPC

Status: superseded. ADR 0128 governs the current sole-owner process/role model; owned native media placement, licensing, and the live IPC client use ADR 0100. The proposal and diagram below remain historical.

Context

ClankVox is the Rust media sidecar carried selectively from the v1 Clankie repository. The v2 bridge needs Discord voice WebSocket/UDP, RTP/Opus pacing, transport encryption, DAVE, inbound speaker audio, and transport health without inheriting the v1 brain, its Go Live/video and user-token implementation, music/YouTube, or player-control surfaces. ADR 0024 keeps Go Live in v2 as a separately gated personal-lab capability; this ADR does not import that capability into the core voice boundary.

The source snapshot is Volpestyle/clankie@04734df9ec1ec4665a233c4c64f0a51a9d3b0b83, path clankvox/, Git tree 11f24ddcfc3ee62d45b83638e788877f39cd8fdc. VUH-805 records file-level provenance when it performs the selective import.

Decision

The design below is retained as the superseded proposal. It is not the live wire contract: the imported process uses the unversioned command/event contract defined together by apps/vox/src/ipc.rs and @clankie/vox-client under ADR 0100.

Placement and import boundary

The executable lives at apps/clankvox/ as an in-repository Cargo crate with a pnpm package facade. The crate and lockfile are reviewed and built with the rest of the repository; CI produces platform-named binaries from source. VUH-805 creates the directory, Cargo crate, package facade, provenance record, offline smoke fixtures, and build job atomically. VUH-804 deliberately does not leave a README-only workspace directory because arch:check requires every apps/* directory to be a complete package.

Vendoring remains gated on the owner's licensing disposition. The upstream crate declares AGPL-3.0-or-later, while this repository declares Apache-2.0. VUH-805 preserves the upstream license and provenance unless the owner records another valid disposition; no import silently relicenses the code.

The selective import retains voice RTP/Opus, PCM conversion and internal mixing, pacing, transport AEAD, DAVE audio, speaking/capture state, and transport statistics. The only governed outbound media input is generic audio. It does not retain v1 music/YouTube/player-control IPC, Go Live, video, user-token paths, v1 Realtime orchestration, or transcript-pane naming. User-session Go Live and transport-agnostic Discord presence were assigned to ADR 0024. The later receive/publish work shipped through ADR 0098 (user-session shares) and ADR 0100; it did not fold user credentials or video into this superseded schema-1 bot-only media plane.

One Discord media owner

The bridge invokes guild.voiceAdapterCreator(callbacks) directly. It uses the returned sendPayload to send Discord gateway OP4 voice-state updates for join and leave, collects VOICE_SERVER_UPDATE and the bot's VOICE_STATE_UPDATE, then sends one complete session_open to ClankVox. The bridge does not call joinVoiceChannel() for a ClankVox-backed session.

This distinction is structural. In the locally installed discord.js@14.26.4, Guild#voiceAdapterCreator registers the supplied callbacks and returns sendPayload backed by the guild shard. In @discordjs/voice@0.19.2, VoiceConnection supplies callbacks to an adapter, collects both gateway packets, and configureNetworking() constructs its own Networking instance. Using joinVoiceChannel() would therefore create a second voice WebSocket/UDP owner that competes with ClankVox.

The gateway packets are sufficient for the official-bot media handshake:

  • VOICE_SERVER_UPDATE supplies endpoint, token, and guild_id (serverId).
  • the bot's VOICE_STATE_UPDATE supplies session_id, user_id, and channel_id (daveChannelId);
  • the bridge already authenticates as the official Discord application bot and never accepts a normal-user token.

Local dependency source also supports the DAVE shape: @discordjs/voice@0.19.2 constructs its voice Networking from exactly those fields, advertises max_dave_protocol_version by default, and creates DAVESession(protocolVersion, userId, channelId). The v1 ClankVox snapshot constructs its DAVE manager from the same user_id and voice channel_id. This confirms credential-field sufficiency, not live interoperability. VUH-807 must prove dave_state=ready and audible outbound voice using bot credentials only before the path is treated as live-proven.

ADR 0025: ClankVox is an in-repo voice sidecar behind versioned bridge IPC

IPC transport

@clankie/vox-client and apps/vox/src/ipc.rs now own the live adapter contract under ADR 0100.

Node to Rust is newline-delimited UTF-8 JSON. Each complete line, including its newline, is capped at 8 MiB. The reader discards an oversized line through its newline, emits input_too_large, and continues; malformed UTF-8/JSON produces a typed error without terminating the process. Binary PCM accepted by the TypeScript API is encoded as base64 before it reaches this NDJSON wire.

Rust to Node uses a fixed five-byte frame header:

+---------+----------------+-----------------------+
| lane:u8 | length:u32 LE  | payload:length bytes  |
+---------+----------------+-----------------------+

length covers payload bytes only and is capped at 32 MiB before allocation. Unknown lanes, oversized lengths, malformed lane payloads, and incompatible schema versions fail closed; VUH-806 owns child termination and restart policy.

LaneValuePayloadDelivery semantics
control0versioned JSON lifecycle, speaking, end, and error eventsordered, must deliver
user_audio1binary header plus mono s16le PCMlossy under backpressure
log2versioned structured JSON log eventbest effort, secrets redacted
health3versioned JSON health and transport-stat snapshotslatest usable snapshot

Every JSON command and event carries integer schemaVersion: 1. Additive optional fields may retain version 1. A breaking semantic, field, lane, or binary-layout change increments the version. During migration, adapters may dual-read the current and immediately previous version, translate to the current in-memory type, and single-write the current version. Unknown versions never fall back to best-effort interpretation.

One ClankVox process owns at most one Discord voice session. Session-scoped output therefore does not repeat a session identifier; the bridge binds process identity to its session lifecycle.

Node-to-Rust commands

TypeFieldsMeaning
session_openendpoint, token, serverId, sessionId, userId, daveChannelId, sampleRateOpen the one official-bot voice media session. sampleRate is the model-facing PCM rate.
audioencoding="pcm_s16le_base64", pcmBase64, sampleRateQueue generic mono assistant PCM. The sidecar owns 48 kHz conversion, mixing, Opus, pacing, and encryption.
health_requestnoneRequest an immediate health snapshot without waiting for periodic transport stats.
session_closeoptional reasonStop media, close WS/UDP, zero session credentials, and settle the process session.

There is no v1 join, gateway-fragment, music, player, stream-watch, stream-publish, video, or user-token command in version 1.

Rust-to-Node events

Control JSON includes process_ready, session_state (including explicit daveState), speaking_start, speaking_end, user_audio_end, and typed error. Health JSON includes health_snapshot (including daveState) and transport_stats; audio-only stats retain cadence, IPC drops, inbound decrypt/loss/concealment, outbound RTP, and DAVE encryption failures. Log JSON contains level, target, message, and bounded structured fields; endpoint, token, session identifiers, raw private audio, and prompt content are never logged.

user_audio is the only binary event. Its schema-1 payload preserves the v1 18-byte little-endian header exactly:

OffsetWidthField
08Discord userId as unsigned u64 LE
82signalPeakAbs as u16 LE
104signalActiveSampleCount as u32 LE
144signalSampleCount as u32 LE
18remainingmono s16le PCM at the active session's model-facing sample rate

The binary payload version is selected by the process's schema-1 contract; it does not add a second version word to the v1 header. user_audio_end closes the current per-speaker PCM burst.

Options weighed

  • Git submodule — rejected because clean checkout, CI, provenance capture, atomic IPC changes, and offline verification would depend on a second repository state and submodule update flow.
  • Prebuilt released binary — rejected because it weakens source review and reproducibility, complicates platform/architecture coverage, and separates contract changes from their binary.
  • External sibling checkout — rejected because it makes local machine layout part of the product and cannot satisfy clean-checkout builds.
  • Library embedded in the Node process — rejected because Rust media failures, native linking, pacing, and backpressure need a process boundary and independent lifecycle.
  • joinVoiceChannel() with a custom adapter — rejected because @discordjs/voice creates its own Networking after the two gateway packets arrive.
  • Raw v1 IPC without a version — rejected because Rust and TypeScript could silently drift.
  • Carry v1 music/player commands — rejected because they contain brain/product policy and are not required for the governed outbound PCM boundary.

Consequences

  • ADR 0100 records and preserves the AGPL package license and exact source provenance for the imported native implementation.
  • ADR 0128 replaces the later @discordjs/voice path with one Vox child behind the direct gateway/client boundary for either media-enabled active body; a text-only official-bot process starts no media child.
  • The isolated, opt-in user-session transport and shipped Go Live receive/publish path are owned by ADR 0098 (user-session shares) and ADR 0100, preserving the single-owner invariant.