Files
alknet/docs/architecture/decisions/081-channels-subcrate-decomposition.md
T
glm-5.2 fd83fc1685 docs(arch): channels substrate simplification + stream_type decomposition + sub-crate split
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.
2026-07-12 15:47:42 +00:00

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:

  1. The dependency graph conflates the pure multiplexer with the call- protocol coupling. The wire format, demux/mux, and ChannelBidiStreamSource are ALPN-blind and call-protocol-blind — they depend on alknet-core only. 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 be alknet/call.

  2. 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 (implements BidiStreamSource — ADR-074)
  • ChannelSubStreams / SubStreamHandle (the typed destructure accessor)
  • ChannelManager (the shared state — channel_id → ChannelState, but without the call_ops: Arc<OperationRegistry> field; the manager is ALPN-blind and call-protocol-blind)
  • ChannelsAdapter (the ProtocolHandler on alknet/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) — the preinstall_channel_0 logic that constructs channel 0's reassembly buffers with stream_types [0, 1] and hands the Connection to the CallAdapter.
  • The four lifecycle operations (ADR-073): channel/open, channel/close, channel/control, channel/resources/subscribe — registered on the call protocol's OperationRegistry at assembly time.
  • ChannelOperations (the registration helper that closes over a ChannelManager clone).

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). Translates channel/open on channel 0, byte-forwards data channels with channel_id rewrite. Depends on channels-call (for the call-protocol translation) + the hub's own CallAdapter / from_call machinery.
  • channels-worker (or the worker/client role): ChannelClient (ADR-080). Dials a transport, establishes the channels connection, runs the demux/mux, exposes open_channel(alpn, params) -> Channel. Depends on channels-call (for channel 0 pre-negotiation) + alknet-call's CallClient-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-core is the pure multiplexer with no call dependency; the call-protocol coupling is isolated in channels-call. A consumer that wants the multiplexer without the call-protocol orchestration can depend on channels-core only.
  • The "no special-casing for downstream crates" principle is preserved: channels-core doesn't know about alknet-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 + its tty feature) — 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 optionally channels-hub or channels-worker) instead of one alknet-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 ChannelsAdapter in channels-core doesn't preinstall channel 0 — the consumer does. This means channels-core's ChannelsAdapter::handle exposes a hook (callback or trait method) for the consumer to install channel 0. channels-call provides the preinstall_channel_0 implementation; 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 in channels-call)
  • ADR-079: hub relay (in channels-hub or alknet-hub)
  • ADR-080: ChannelClient (in channels-worker)
  • docs/architecture/crates/hub/README.md — the existing hub crate (the relay's consumer)