Channel 0 was dead in both directions (C-01): the call protocol's stream-per-request model (open_bi per call) is incompatible with channel 0's single yield-once BiStream — every call_open_op failed with StreamClosed on the connect side, and channel 0's Connection was a black hole on the accept side. The fix is single-stream call mode (ADR-036 amendment): all EventEnvelope frames are multiplexed on channel 0's one BiStream. Changes: - CallConnection gains single_stream_writer: Option<Arc<SharedFrameWriter>> and new_single_stream() constructor. 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 (mutex-serialized) instead of opening a fresh open_bi per call. - Dispatcher::run_loop_single_stream reads frames off channel 0's read half, dispatches call.requested, writes responses through the shared writer, and routes in-flight call.published/call.completed/ call.aborted to the matching Pub sink's chunk_tx by request_id. - ChannelsAdapter::handle's InstallChannelZero hook now receives channel 0's Connection (built by the adapter) and runs the single-stream dispatch loop on it — closing the accept-side black hole. Mux runner is spawned BEFORE install_channel_zero so mux.register(0) can complete. - ChannelClient::from_connection uses CallConnection::new_single_stream and spawns a read pump (read_single_stream_until_closed) that routes channel-0 response frames into the PendingRequestMap via dispatch_envelope — closing the connect-side StreamClosed path. - ADR-036 amendment records the single-stream-mode decision (two-way door: implementation detail, wire format unchanged). Acceptance gate (C-25 #1): three end-to-end tests wire ChannelClient ↔ ChannelsAdapter over a real tokio::io::duplex pair carrying the channels 8-byte chunk header wire format: - channel_0_end_to_end_call_round_trip: a Query op round-trip - channel_0_end_to_end_unknown_op_returns_not_found: NOT_FOUND - channel_0_end_to_end_publish_delivers_chunks: a Pub op with 3 chunks Verification: 437 tests pass (was 434; +3 e2e), clippy clean, fmt clean, doc warnings unchanged (4, pre-existing C-09).
12 KiB
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
-
CallConnectiongains a single-stream mode.CallConnection::new_single_stream(connection, shared_writer)constructs aCallConnectionthat holds aSharedFrameWriter(atokio::sync::Mutex<FrameFramedWriter<W>>) instead of opening a freshopen_biper call.call_with_payload,subscribe_with_payload,publish_with_payload, andabortbranch onis_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. -
Dispatchergainsrun_loop_single_stream. The accept-side dispatch loop readsEventEnvelopeframes off channel 0's read half (aFrameFramedReaderbacked by the reassembledMpscRecvStream), dispatchescall.requestedevents, and writes responses through the sameSharedFrameWriter. In-flight Pub (Sink) operations are tracked byrequest_idin a localHashMap<String, mpsc::Sender>:call.published/call.completed/call.abortedframes arriving after thecall.requestedare routed to the matching sink'schunk_txwhile newcall.requestedframes for other requests continue to be dispatched. Query/Mutation and Sub responses are written through the shared writer immediately after dispatch. -
ChannelClient::from_connectiondrives the client-side read pump. The client spawns a read pump (read_single_stream_until_closed) that readsEventEnvelopeframes off channel 0's reassembled read half (fed by the demux) and routes them into thePendingRequestMapviadispatch_envelope, resolving pending calls. This is the single-stream analogue ofread_stream_until_closedin stream-per-request mode. -
ChannelsAdapter::handle'sInstallChannelZerohook now receives channel 0'sConnection. The adapter constructs channel 0'sConnection(from the reassembled read half + the mux write half) and passes it to the hook; the hook runsDispatcher::run_loop_single_streamon it. This closes C-01's accept-side black hole: channel 0'sConnectionis actually driven by a call dispatch loop. -
Top-level
alknet/callconnections are unchanged.CallConnection::newandDispatcher::run_loopkeep the stream-per-request model. Single-stream mode is only for channel 0 (and any future single-stream substrate that multiplexes call frames on oneBiStream).
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-onceaccept_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
-
Channel 0 uses the same 9-byte chunk format as every other channel (ADR-034). Its chunks have
channel_id = 0in the header. No special first-byte trick, no separate framing. -
The
CallAdapteris unchanged. It receives aConnection, callsaccept_bi(), gets one bidi stream (the channel-0 reassembled stream), and runs its dispatch loop.EventEnvelopeframes ride onstream_type = 0of channel 0. TheCallAdapterdoes not know it is inside a channels connection. -
Channel lifecycle operations are call operations.
channel/open,channel/close,channel/control,channel/resourcesare registered on the call protocol'sOperationRegistryat assembly time (ADR-037). They are dispatched through the existingOperationContext(identity, scopes, capabilities, ownership,forwarded_for), gated by the existingAccessControl::check. No new auth machinery, no new framing, no protocol version bump. -
Channel 0 is allocated at
ChannelsAdapter::handleentry. TheChannelsAdapterconstructs channel 0's reassembly buffers, wraps them as aConnection(viaConnection::from_sourcewith aChannelBidiStreamSource— ADR-008/074), and hands thatConnectionto theCallAdapter— exactly as ifalknet/callhad been the top-level ALPN. TheCallAdapteris looked up in the sameHandlerRegistryas 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, andStreamingHandler(ADR-021) machinery verbatim. Channel lifecycle is just another class of call operations. - The
CallAdapteris transport-agnostic by construction — it works identically whether theConnectionis 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 aSubscriptionoperation on channel 0, using the already-implementedStreamingHandler/invoke_streamingpath (ADR-021). The resource registry is a live view, not a polled snapshot.- Auth is inherited:
channel/opengoes throughAccessControl::checkexactly 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
CallAdapterhangs, 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 3on 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_typesfield 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/subscribeuses) - ADR-008: BidiStreamSource trait (the
Connectionextension point) docs/research/alknet-channels/phase-0-findings.md§DP-2, §Channel 0