Three simplifications to the channels spec, all flowing from the review: 1. Substrate simplification (ADR-071 revised): the 9-byte chunk header is used in ALL substrates — in-line (TCP+TLS, WebTransport), native (QUIC bidi streams), and multi-connection. The ChannelsAdapter reads headers off every bidi stream it accepts; the transport's native multiplexing is a performance optimization (independent flow-control windows), not a protocol change. One wire format, one code path, one handler experience. The channel_id in the header is the correlation key across substrates. 2. Stream_type decomposition (ADR-071 revised): every stream_type is unidirectional. Bidirectionality is two stream_types (write + read), not one 'bidirectional' stream_type. Grouped in threes: 0/1/2 = data write/read/err, 3/4/5 = control write/read/err, % 3 formula. This resolves the TTY control channel's 'not actually bidirectional' flaw — control is now 3 (write, client→server) + 4 (read, server→client), each with its own reassembly buffer and EOF. Channel 0 uses [0,1] (call frames bidirectional via 0=in, 1=out). TTY uses [0,1,2,3,4]. ADR-072, 073, 074, 077 updated for the new stream_type assignments. 3. Sub-crate decomposition (ADR-081 new): channels-core (pure multiplexer — wire format, demux/mux, ChannelBidiStreamSource, ChannelManager; depends on alknet-core only, no call dependency, ALPN-blind) / channels-call (channel 0 pre-negotiation + lifecycle op registrations; depends on channels-core + alknet-call) / channels-hub (relay) / channels-worker (ChannelClient). Isolates the call-protocol coupling from the pure multiplexer so the dependency graph is honest. ADR-077 (TTY inside channels) updated: TTY now uses 5 sub-streams [0,1,2,3,4] with control properly bidirectional via 3/4; amends ADR-052's stream_type assignments for direct mode too (direct alknet/tty now uses 0-4, not 0-3). Spec docs updated: channels-wire, channels-connection, channels-adapter, channel-operations, overview, README.
10 KiB
ADR-081: alknet-channels Sub-Crate Decomposition
Status
Accepted
Context
The initial channels spec (ADR-071-080) framed alknet-channels as a
single crate depending on both alknet-core and alknet-call. The
dependency on alknet-call arises because channel lifecycle operations
(channel/open, channel/close, channel/control,
channel/resources/subscribe) register on the call protocol's
OperationRegistry, and channel 0 is pre-negotiated as alknet/call
(ADR-072).
This creates two issues:
-
The dependency graph conflates the pure multiplexer with the call- protocol coupling. The wire format, demux/mux, and
ChannelBidiStreamSourceare ALPN-blind and call-protocol-blind — they depend onalknet-coreonly. The channel 0 pre-negotiation and lifecycle op registrations are the call-protocol coupling. Baking both into one crate means any consumer that wants the multiplexer also pulls in the call-protocol coupling, even if they don't need channel 0 to bealknet/call. -
The "no special-casing for downstream crates" principle is violated. The user's constraint: "we don't want to be doing anything special for downstream crates unless it's needed/useful to the point of they all kind of benefit." The call-protocol coupling is a channels-crate concern (channels needs call for orchestration), not a downstream-crate concern. Separating them makes the dependency graph honest: the pure multiplexer has no opinion about channel 0; the call-protocol coupling is isolated where it belongs.
The substrate simplification (ADR-071 revised) strengthened this: the wire
format is the same across all substrates, and the ChannelsAdapter is
substrate-agnostic. The pure multiplexer (channels-core) is cleanly
separable from the call-protocol orchestration (channels-call).
Decision
Three crates
alknet-channels-core — the pure multiplexer (wire format, demux/mux,
│ ChannelBidiStreamSource, ChannelManager). Depends
│ on alknet-core only. ALPN-blind, call-protocol-blind,
│ transport-blind.
├── alknet-channels-call — channel 0 pre-negotiation + lifecycle op
│ │ registrations on the call protocol's
│ │ OperationRegistry. Depends on channels-core +
│ │ alknet-call.
│ ├── alknet-channels-hub — the relay (ADR-079). Depends on
│ │ channels-call. The hub-side role.
│ └── alknet-channels-worker — ChannelClient (ADR-080). Depends on
│ channels-call. The client/worker-side role.
alknet-channels-core
The pure multiplexer. Contains:
- The 9-byte chunk wire format (
parse_header/write_header— ADR-071) - The demux/mux (
Demux,MuxHandle/MuxRunner— ADR-075) ChannelBidiStreamSource(implementsBidiStreamSource— ADR-074)ChannelSubStreams/SubStreamHandle(the typed destructure accessor)ChannelManager(the shared state — channel_id → ChannelState, but without thecall_ops: Arc<OperationRegistry>field; the manager is ALPN-blind and call-protocol-blind)ChannelsAdapter(theProtocolHandleronalknet/channels— the read/demux loop, substrate-agnostic per ADR-071 revised)
Depends on alknet-core only. No alknet-call dependency. No opinion about
what channel 0 carries — that's the consumer's concern.
The ChannelsAdapter::handle in channels-core does NOT preinstall channel
0 as alknet/call. It runs the demux loop and routes chunks by
channel_id. Channel 0 is just another channel; what ALPN it carries is
determined by the consumer (the channels-call crate pre-negotiates it as
alknet/call; a hypothetical other consumer could pre-negotiate it
differently).
alknet-channels-call
The call-protocol coupling. Contains:
- Channel 0 pre-negotiation as
alknet/call(ADR-072) — thepreinstall_channel_0logic that constructs channel 0's reassembly buffers with stream_types [0, 1] and hands theConnectionto theCallAdapter. - The four lifecycle operations (ADR-073):
channel/open,channel/close,channel/control,channel/resources/subscribe— registered on the call protocol'sOperationRegistryat assembly time. ChannelOperations(the registration helper that closes over aChannelManagerclone).
Depends on channels-core + alknet-call. This is where the
call-protocol coupling lives, isolated from the pure multiplexer.
alknet-channels-hub and alknet-channels-worker
The two roles. These may be sub-crates or feature-gated modules within
channels-call; the exact packaging is a two-way-door implementation
detail. The contract is:
channels-hub(or the hub role): the relay (ADR-079). Translateschannel/openon channel 0, byte-forwards data channels withchannel_idrewrite. Depends onchannels-call(for the call-protocol translation) + the hub's ownCallAdapter/from_callmachinery.channels-worker(or the worker/client role):ChannelClient(ADR-080). Dials a transport, establishes the channels connection, runs the demux/mux, exposesopen_channel(alpn, params) -> Channel. Depends onchannels-call(for channel 0 pre-negotiation) +alknet-call'sCallClient-equivalent.
The naming (hub/worker vs server/client) is a two-way-door detail. The user noted "server/client" is muddy because a hub/worker can act as both depending on the use case (bidirectionality — ADR-073 §direction semantics). The roles are "the side that relays" (hub) and "the side that dials" (worker/client), but both can open channels in either direction.
What moves where
| Component | Original (ADR-071-080) | Now |
|---|---|---|
| 9-byte wire format | alknet-channels |
channels-core |
| Demux/Mux | alknet-channels |
channels-core |
ChannelBidiStreamSource |
alknet-channels |
channels-core |
ChannelSubStreams |
alknet-channels |
channels-core |
ChannelManager (without call_ops) |
alknet-channels |
channels-core |
ChannelsAdapter (demux loop only) |
alknet-channels |
channels-core |
| Channel 0 pre-negotiation | alknet-channels (ADR-072) |
channels-call |
channel/open/close/control/resources/subscribe ops |
alknet-channels (ADR-073) |
channels-call |
ChannelOperations registration helper |
alknet-channels |
channels-call |
| Hub relay (ADR-079) | alknet-channels (spec) / alknet-hub (impl) |
channels-hub (or alknet-hub — see below) |
ChannelClient (ADR-080) |
alknet-channels |
channels-worker |
Relationship to alknet-hub
The existing alknet-hub crate (docs/architecture/crates/hub/README.md)
is the hub pattern: peer lifecycle, aggregated env, service discovery. The
channels hub relay (ADR-079) is a channels-specific concern that the hub
crate consumes. The split: channels-hub (or the hub role within
channels-call) provides the relay logic; alknet-hub wires it into the
hub runtime alongside the call-protocol peer management. Whether the relay
lives in a channels-hub sub-crate or directly in alknet-hub is a
packaging decision — the contract (ADR-079) is the same either way. The
user's preference for decomposing channels into core/hub/worker suggests a
channels-hub sub-crate that alknet-hub depends on, but this is not
one-way and can be revisited during implementation.
Consequences
Positive:
- The dependency graph is honest:
channels-coreis the pure multiplexer with no call dependency; the call-protocol coupling is isolated inchannels-call. A consumer that wants the multiplexer without the call-protocol orchestration can depend onchannels-coreonly. - The "no special-casing for downstream crates" principle is preserved:
channels-coredoesn't know aboutalknet-call,alknet-tty, or any handler crate. The call-protocol coupling is a channels-crate concern, not a downstream-crate concern. - The sub-crate split matches the existing pattern (
alknet-tty+alknet-tty-local,alknet-docker+ itsttyfeature) — core in one crate, consumer-specific wiring in another. - The hub and worker roles are separated, matching the user's decomposition preference and the bidirectionality of the channels protocol (both sides can open channels; the roles are about who relays vs who dials, not about request/response direction).
Negative:
- Three crates instead of one. The assembly layer must depend on
channels-core+channels-call(and optionallychannels-huborchannels-worker) instead of onealknet-channels. This is the cost of the clean separation; the assembly layer already wires multiple crates, so this is consistent with the existing pattern. - The
ChannelsAdapterinchannels-coredoesn't preinstall channel 0 — the consumer does. This meanschannels-core'sChannelsAdapter::handleexposes a hook (callback or trait method) for the consumer to install channel 0.channels-callprovides thepreinstall_channel_0implementation; a different consumer could provide a different one. This is a slightly more complex adapter shape than "channel 0 is always call," but it's the cost of the clean separation.
Door type
One-way (crate structure) + two-way (role packaging). The three-crate
split (channels-core / channels-call / roles) is one-way — once
consumers depend on channels-core without channels-call, re-merging
them is a breaking change. The hub/worker packaging (separate sub-crates
vs feature-gated modules within channels-call) is two-way — an
implementation detail that can change without breaking the contract.
References
- ADR-003: crate decomposition (no-handler-depends-on-another-handler — preserved; the channels sub-crates depend on core/call, not on handlers)
- ADR-071: channels wire format (revised — substrate simplification; the
wire format is in
channels-core) - ADR-072: channel 0 pre-negotiated (moves to
channels-call) - ADR-073: channel lifecycle operations (move to
channels-call) - ADR-074: ChannelBidiStreamSource (in
channels-core) - ADR-075: ChannelsAdapter and ChannelManager (split: core demux in
channels-core, call coupling inchannels-call) - ADR-079: hub relay (in
channels-huboralknet-hub) - ADR-080: ChannelClient (in
channels-worker) docs/architecture/crates/hub/README.md— the existing hub crate (the relay's consumer)