# ADR-036: Channel 0 Is Pre-Negotiated `alknet/call` ## Status Accepted (amended 2026-07-18 by ADR-035 — channel 0's `stream_types` field is removed; the channels layer has no `stream_type` concept; the call protocol's `EventEnvelope` framing is the channels payload, carried transparently — see "Amendment (ADR-035, 2026-07-18)" below; further amended 2026-08-12 — channel 0 runs in single-stream call mode — see "Amendment (single-stream call mode, 2026-08-12)" below) ## Amendment (ADR-035, 2026-07-18) Channel 0's `stream_types` field (the `[0, 1]` active set) is **removed**. The channels layer has no `stream_type` concept (ADR-035) — it carries the call protocol's `EventEnvelope` framing (ADR-014) transparently in the 8-byte header's payload. The call protocol's bidirectionality (client writes requests, server writes responses) is a call-protocol concern, not a channels-layer concern; the channels layer routes by `channel_id` only and yields a `BiStream` to the `CallAdapter`. The `CallAdapter`'s `accept_bi()` returns one `BiStream` (per ADR-009); the call protocol reads/writes `EventEnvelope` frames on it, exactly as on a top-level `alknet/call` connection. The body below describes the **original** (with `stream_types`) shape; the amendment above is the operative decision. See ADR-035 for the resolution rationale and the cross-ADR impacts. ## Amendment (single-stream call mode, 2026-08-12) Channel 0 runs in **single-stream call mode**: all `EventEnvelope` frames (requests, responses, published chunks, aborts) are multiplexed on channel 0's one `BiStream`. The call protocol's stream-per-request model (`CallConnection` opens a fresh `open_bi` per call; `Dispatcher::run_loop` accepts fresh streams in a loop) does not apply to channel 0 — channel 0's `ChannelBidiStreamSource` is yield-once (ADR-038), so `open_bi` returns `StreamClosed` and `accept_bi` yields the one `BiStream` once. Without single-stream mode, every `call_open_op` on a `ChannelClient` fails with `StreamClosed` on the connect side, and channel 0's `Connection` is a black hole on the accept side (the dispatch loop has no stream to accept). ### What this means concretely 1. **`CallConnection` gains a single-stream mode.** `CallConnection::new_single_stream(connection, shared_writer)` constructs a `CallConnection` that holds a `SharedFrameWriter` (a `tokio::sync::Mutex>`) instead of opening a fresh `open_bi` per call. `call_with_payload`, `subscribe_with_payload`, `publish_with_payload`, and `abort` branch on `is_single_stream()`: in single-stream mode they write frames through the shared writer; in stream-per-request mode they open a fresh stream as before. The shared writer's mutex serializes frames so concurrent calls do not interleave. 2. **`Dispatcher` gains `run_loop_single_stream`.** The accept-side dispatch loop reads `EventEnvelope` frames off channel 0's read half (a `FrameFramedReader` backed by the reassembled `MpscRecvStream`), dispatches `call.requested` events, and writes responses through the same `SharedFrameWriter`. In-flight Pub (Sink) operations are tracked by `request_id` in a local `HashMap`: `call.published` / `call.completed` / `call.aborted` frames arriving after the `call.requested` are routed to the matching sink's `chunk_tx` while new `call.requested` frames for other requests continue to be dispatched. Query/Mutation and Sub responses are written through the shared writer immediately after dispatch. 3. **`ChannelClient::from_connection` drives the client-side read pump.** The client spawns a read pump (`read_single_stream_until_closed`) that reads `EventEnvelope` frames off channel 0's reassembled read half (fed by the demux) and routes them into the `PendingRequestMap` via `dispatch_envelope`, resolving pending calls. This is the single-stream analogue of `read_stream_until_closed` in stream-per-request mode. 4. **`ChannelsAdapter::handle`'s `InstallChannelZero` hook now receives channel 0's `Connection`.** The adapter constructs channel 0's `Connection` (from the reassembled read half + the mux write half) and passes it to the hook; the hook runs `Dispatcher::run_loop_single_stream` on it. This closes C-01's accept-side black hole: channel 0's `Connection` is actually driven by a call dispatch loop. 5. **Top-level `alknet/call` connections are unchanged.** `CallConnection::new` and `Dispatcher::run_loop` keep the stream-per-request model. Single-stream mode is only for channel 0 (and any future single-stream substrate that multiplexes call frames on one `BiStream`). ### Door type **Two-way (implementation detail).** Single-stream call mode is an implementation strategy for channel 0's yield-once `BiStream`, not a wire-format change. The wire format (length-prefixed `EventEnvelope` frames carried in channel 0's 8-byte chunk header payloads) is unchanged. A future QUIC-native channels substrate (ADR-039) that yields multiple bidi streams could use stream-per-request mode on channel 0 instead; the `CallConnection` and `Dispatcher` branching supports both. The `SharedFrameWriter` / `run_loop_single_stream` types are pub(crate) — not part of the public API surface. ### References - C-01 in `docs/reviews/001-pub-and-channels-integration-review.md` (the channel-0-is-dead-in-both-directions finding this amendment resolves) - ADR-038: `ChannelBidiStreamSource` (yield-once `accept_bi` — the constraint that forces single-stream mode) - ADR-035: channels pure channel multiplexing (no `stream_type` — the amendment this builds on) ## Context A channels connection carries N logical channels. One of them must carry the call protocol — the JSON-RPC layer that orchestrates channel lifecycle (`channel/open`, `channel/close`, `channel/control`, `channel/resources`). The question is how channel 0 relates to the call protocol: is it a special "control plane" with its own framing, or is it just `alknet/call` pre- negotiated? The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md` §DP-2) recommends channel 0 is `alknet/call` pre-negotiated — no special framing, no separate control-plane wire format. The call protocol runs on channel 0 exactly as it runs on a top-level `alknet/call` QUIC connection. This matters because the alternative (a special control plane) would mean the channels layer has its own JSON protocol for channel lifecycle, parallel to and duplicating the call protocol's `OperationRegistry`, `AccessControl`, `OperationContext`, and `forwarded_for` machinery. That duplication is the "re-implement every protocol's framing per transport" problem the hub motivation (§Hub Motivation) identifies as the thing channels exists to collapse. ## Decision **Channel 0 is `alknet/call`, pre-negotiated.** Both sides of a channels connection know that `channel_id = 0` is routed to the `CallAdapter` without an explicit `channel/open` exchange. The `CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly and dispatches operations exactly as it does on a top-level `alknet/call` connection. ### What this means concretely 1. **Channel 0 uses the same 9-byte chunk format as every other channel** (ADR-034). Its chunks have `channel_id = 0` in the header. No special first-byte trick, no separate framing. 2. **The `CallAdapter` is unchanged.** It receives a `Connection`, calls `accept_bi()`, gets one bidi stream (the channel-0 reassembled stream), and runs its dispatch loop. `EventEnvelope` frames ride on `stream_type = 0` of channel 0. The `CallAdapter` does not know it is inside a channels connection. 3. **Channel lifecycle operations are call operations.** `channel/open`, `channel/close`, `channel/control`, `channel/resources` are registered on the call protocol's `OperationRegistry` at assembly time (ADR-037). They are dispatched through the existing `OperationContext` (identity, scopes, capabilities, ownership, `forwarded_for`), gated by the existing `AccessControl::check`. No new auth machinery, no new framing, no protocol version bump. 4. **Channel 0 is allocated at `ChannelsAdapter::handle` entry.** The `ChannelsAdapter` constructs channel 0's reassembly buffers, wraps them as a `Connection` (via `Connection::from_source` with a `ChannelBidiStreamSource` — ADR-008/074), and hands that `Connection` to the `CallAdapter` — exactly as if `alknet/call` had been the top-level ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as every other ALPN. ### Channel 0's stream_type usage | stream_type | direction | purpose | |-------------|-----------|---------| | 0 | write (client→server) | `EventEnvelope` frames from the client (call.requested, call.aborted) | | 1 | read (server→client) | `EventEnvelope` frames from the server (call.responded, call.completed, call.error) | Channel 0 uses stream_types [0, 1] — the call protocol is bidirectional via two unidirectional halves, the same way every channel type works (ADR-034 §stream_type decomposition). The call protocol's `(SendStream, RecvStream)` pair maps directly: `SendStream` backed by stream_type 0, `RecvStream` backed by stream_type 1. Both sides write to their write half and read from their read half — no shared stream_type both sides write to. The call protocol is JSON-only and single-stream by design (ADR-014). stream_types 2-255 on channel 0 are reserved for future call-protocol sub-streams. ## Consequences **Positive:** - No control-plane duplication. The channels layer reuses the call protocol's `OperationRegistry`, `AccessControl`, `OperationContext`, `forwarded_for`, and `StreamingHandler` (ADR-021) machinery verbatim. Channel lifecycle is just another class of call operations. - The `CallAdapter` is transport-agnostic by construction — it works identically whether the `Connection` is a top-level QUIC stream or a channels-reassembled channel-0 stream. This is the "streams are streams" insight made concrete. - `channel/resources/subscribe` (ADR-037) is a `Subscription` operation on channel 0, using the already-implemented `StreamingHandler` / `invoke_streaming` path (ADR-021). The resource registry is a live view, not a polled snapshot. - Auth is inherited: `channel/open` goes through `AccessControl::check` exactly like any other call operation. The channels layer does not re- implement auth. **Negative:** - Channel 0 is a single point of orchestration. If channel 0's `CallAdapter` hangs, no new channels can be opened. This is the same property as the call protocol today (one dispatch loop per connection) and is not a new vulnerability. - The call protocol's JSON-only nature means channel lifecycle operations are JSON. For high-frequency control (e.g., per-keystroke resize), this is more overhead than a binary control frame. The division (ADR-037 §DP-4) handles this: `stream_type 3` on the data channel for data-ordered control, call operations for lifecycle and infrequent control. ## Door type **One-way.** Channel 0's role as `alknet/call` pre-negotiated is a wire- format and protocol-structure commitment. Changing it after deployments exist (e.g., to a special control plane) requires a version migration and re-architecting the channel lifecycle operations. The reservation of `stream_type` 1-255 on channel 0 is a two-way-door detail (they're currently unused; assigning them is additive). ## References - ADR-034: channels wire format (the 8-byte chunk header channel 0 uses, as amended by ADR-035) - ADR-035: channels pure channel multiplexing (amends this ADR — channel 0's `stream_types` field removed; the call protocol's framing is the channels payload, carried transparently) - ADR-037: channel lifecycle operations (registered on channel 0's `OperationRegistry`) - ADR-014: irpc never integrated — hand-rolled EventEnvelope framing (the call protocol channel 0 carries) - ADR-021: StreamingHandler for subscriptions (the machinery `channel/resources/subscribe` uses) - ADR-008: BidiStreamSource trait (the `Connection` extension point) - `docs/research/alknet-channels/phase-0-findings.md` §DP-2, §Channel 0