Files
alkcall/docs/architecture/decisions/035-channels-pure-channel-multiplexing.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

26 KiB

ADR-035: alknet-channels — Pure Channel Multiplexing (8-Byte Header, No stream_type)

Status

Accepted (amends ADR-034 — wire format is 8 bytes, not 9, and the channels layer has no stream_type concept; amends ADR-038 — into_sub_streams() removed, accept_bi is the only accessor and yields one BiStream per channel; reverses ADR-077 — TTY always uses its 5-byte format, the channels layer carries it transparently in the payload)

Context

ADR-034 committed the channels wire format as a 9-byte chunk header ([channel_id:u32][stream_type:u8][length:u32]) — a 4-byte extension of TTY's 5-byte format, with stream_type carried in the channels header and decomposed into unidirectional halves (0/1/2 = data write/read/err, 3/4/5 = control write/read/err, % 3 formula). ADR-038 added a second accessor (into_sub_streams()) alongside accept_bi for handlers that need typed sub-streams (TTY's stdin/stdout/stderr/ctrl-in/ctrl-out). ADR-077 split TTY's wire format into two modes — direct (5-byte) and inside-channels (the channels layer de-chunks and the adapter destructures via into_sub_streams()).

The stream-unification research (docs/research/stream-unification/findings.md, 2026-07-18) surfaced that these three decisions share one root: the channels layer carries a concept (stream_type) it doesn't own. The 9-byte header bakes TTY's sub-stream multiplexing into the channels wire format. The into_sub_streams() accessor exists because the channels layer reassembles per-stream_type and needs to expose the result. The two-mode TTY design exists because the channels layer's stream_type overlaps with TTY's own stream_type. The mod 2/mod 3/mod 4 numbering question (settled as mod 3 in ADR-034 revised) was a symptom of this overlap — a numbering convention for a concept the channels layer shouldn't carry.

The structural question

The channels layer has two objectives in tension:

  1. "Pass a stream to/from any ALPN" — every channel is a BiStream; any handler gets accept_bi() and treats the channel as a duplex stream. Uniform, transport-agnostic, recursive-composition-friendly.
  2. "Channels carry N sub-streams" — a TTY channel carries stdin/stdout/stderr/control; the handler destructures via into_sub_streams(). Carries what the source produces.

The tension is real when a sub-stream is unidirectional (stderr). You can't represent stderr as a BiStream without wasting the write half; you can't make it a "third half" (mod 3) without breaking pair symmetry; you can't make the channel a single BiStream without losing the stdout/stderr distinction.

ADR-038's two-accessor design resolves this by making the "pass a stream to/from any ALPN" objective qualified — it applies to single-stream channels (tunnel, SSH, call), not multi-stream channels (TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified design.

The resolution: channels layer is pure channel multiplexing

The channels layer's job is "one connection carries N channels, routed by channel_id." It does not know about TTY's sub-streams, SSH's channel protocol, or how call frames its JSON. Handlers own their sub-multiplexing on the BiStream the channels layer gives them.

  • Every channel is a BiStream. accept_bi() yields one BiStream per channel (per ADR-009, already landed). No into_sub_streams(), no second-class accessor.
  • Handlers sub-multiplex their BiStream however they want. TTY sub-demuxes stream_type from its BiStream (its 5-byte format). Tunnel uses the BiStream as raw bytes. Call length-prefixes JSON. SSH runs its own channel protocol. The channels layer carries the bytes transparently.
  • The mod 2/mod 3/mod 4 question dissolves at the channels layer. The channels layer has no stream_type concept — not in its header, not in its code, not in its mental model. stream_type is the inner layer's framing byte, carried transparently.
  • The control channel is handler-internal. TTY sub-demuxes control from its io BiStream using its 5-byte format (STREAM_CTRL_IN = 3, STREAM_CTRL_OUT = 4 — ADR-052 amended by Phase 7). The channels layer doesn't carry control. The "control isn't actually bidirectional" flaw is fixed at the TTY layer, not the channels layer.
  • Recursive composition is literal. A channel with ALPN alknet/channels runs another channels demux on its BiStream. The outer layer strips its 8-byte header; the inner layer parses its own 8-byte header from the payload. Each level is the same shape — BiStream → accept_bi → N BiStreams.

The wire format decision: 8 bytes

The channels wire format is 8 bytes: [channel_id:u32 BE][length:u32 BE] followed by an opaque payload. The channels layer owns channel_id and length; the payload is the handler's framing, carried transparently.

The 9-byte alternative ([channel_id:u32][stream_type:u8][length:u32]) was considered and rejected. The 9-byte format puts stream_type in the channels header, which means the channels layer carries a concept it doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's 5-byte header with channel_id prepended), but for non-TTY handlers (tunnel, call, SSH) the stream_type byte is dead weight — the channels layer carries a byte it doesn't understand, and the handler ignores a byte in a header it doesn't control.

The 8-byte format is uniform across all handlers: the channels layer carries channel_id + length + opaque payload. Every handler parses its own framing from the payload. The cost is that TTY's wire.rs is called from a payload buffer rather than directly from the wire, and the total header for a TTY chunk is 13 bytes (8 channels + 5 TTY) instead of 9. The two length fields are close but not identical (ch_len = tty_len + 5); for typical TTY chunks (4 KiB+), the 5-byte overhead is ~0.1%, and the trade is clean separation of concerns. See "Consequences" for the full cost/benefit.

The add/strip composition

Each layer has its own add/strip pair. The channels layer: add_channel_id(channel_id, payload_bytes) -> chunk on write (prepends the 8-byte header); strip_channel_id(chunk) -> (channel_id, payload_bytes) on read (strips the 8-byte header, returns the payload). The handler layer (e.g. TTY) parses its own framing from the payload bytes per its existing wire.rs. The handler doesn't know or care that a channel_id was stripped before it saw the bytes.

The composition is uniform — the same shape at every level. This is SSH's model (layered headers, each layer strips its own at its boundary), applied to channels. A alknet/channels-inside-alknet/channels recursive composition is the outer layer stripping its 8-byte header, the inner layer parsing its own 8-byte header from the payload — same code, same shape, each level.

Why this can land now

Three things changed since ADR-034/074/077 were accepted:

  1. ADR-009 landed BiStream as the handler leaf. accept_bi() returns a BiStream (a concrete AsyncRead + AsyncWrite newtype), not a split (SendStream, RecvStream) pair. The join moves into core's quinn/iroh/stream impls (once per source, invisible to handlers). This ADR's "every channel is a BiStream" is the channels-layer consequence of ADR-009's handler-leaf decision — the research-then-sync pattern applied: ADR-009 settled the transport leaf, this ADR settles the multiplexing layer above it.
  2. Phase 7 fixed the TTY control channel at the TTY layer. The STREAM_CONTROL = 3 "bidirectional" flaw is fixed by splitting it into STREAM_CTRL_IN = 3 / STREAM_CTRL_OUT = 4inside TTY's 5-byte format, not at the channels layer. This removed the load-bearing reason for the channels layer to carry stream_type: the control bidirectionality fix is a TTY-internal concern, not a channels-layer concern. ADR-077's two-mode TTY design was motivated by the channels layer carrying control; with control moved inside TTY, the motivation dissolves.
  3. No production constraint. The develop branch is a rewrite of main (pre-alpha). The channels crate doesn't exist yet (per ADR-044, it's planned as alknet-channels-core + alknet-channels-call). The decision is purely "what's cleanest," not "what's least disruptive." The 9-byte POC validated the per-channel_id/stream_type routing mechanism; the 8-byte spec update changes the header before implementation begins.

What this ADR does NOT decide

  • The add/strip API shape (built into read/write vs. a separate utility): the stream-unification research proposed add_channel_id / strip_channel_id as standalone functions. Ideally the header is built into the read/write path so the utility isn't needed at the handler boundary — but there may be a generalized reason to expose it (recursive composition, test helpers, the hub relay's channel_id rewrite). The exact API shape is an implementation detail for the channels crate, tracked as OQ-68. The contract — the channels layer strips its 8-byte header on read and the handler parses its own framing from the payload — is decided here; the function surface is not.
  • TTY's wire.rs adaptation: TTY's ChunkReader currently reads from an AsyncRead. Adapting it to read from a payload buffer (&[u8] or Cursor<Bytes>) is a small, well-scoped change (the framing logic — stream_type constants, length validation, control message parsing — is unchanged). This is an implementation concern for the channels + TTY integration, not an architecture decision.
  • Full channel-level flow-control windowing (OQ-56): unchanged. The bounded-buffer backpressure (ADR-040) is the v1 mechanism; full windowing is an additive extension that doesn't change the wire format. OQ-56 stays deferred(scope).

Decision

1. The channels wire format is 8 bytes

[channel_id: u32 BE][length: u32 BE][payload bytes]

8 bytes of header, followed by length bytes of opaque payload. The channels layer owns channel_id and length; the payload is the handler's framing, carried transparently.

field offset width meaning
channel_id 0 4 (BE) The logical channel this chunk belongs to. Channel 0 is pre-negotiated as alknet/call (ADR-036). Channels 1..N are opened dynamically via channel/open (ADR-037).
length 4 4 (BE) The payload length in bytes. 0 = EOF sentinel. Max MAX_CHUNK_LEN (16 MiB, matching TTY's cap — ADR-052 §5).

The stream_type byte is removed from the channels header. The channels layer has no stream_type concept — not in its header, not in its code, not in its mental model. What was the channels header's stream_type byte is now the first byte of the payload, owned by the handler's framing (TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw bytes, SSH's channel protocol).

This amends ADR-034: the wire format is 8 bytes, not 9; the stream_type decomposition (mod 3, unidirectional halves, 85 groups) is removed from the channels layer. The stream_type concept survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the channels layer carries transparently.

2. into_sub_streams() is removed; accept_bi is the only accessor

ADR-038's into_sub_streams() / ChannelSubStreams / SubStreamHandle are removed. The channels layer exposes one accessor: accept_bi(), which yields one BiStream per channel (per ADR-009). Every handler — TTY, tunnel, SSH, call — receives a Connection, calls accept_bi() once, gets a BiStream, and sub-multiplexes it however it wants.

This amends ADR-038: the two-accessor design (accept_bi for generic handlers, into_sub_streams for typed handlers) collapses to one accessor. The "typed handler path" (ADR-038's motivating case for TTY) is replaced by TTY sub-demuxing its BiStream via its own 5-byte format — the same code TTY runs in direct mode. ADR-038's yield-once accept_bi contract is preserved; the into_sub_streams() accessor is the amended part.

3. TTY always uses its 5-byte format; the channels layer carries it transparently

ADR-077's two-mode TTY design (direct vs inside-channels) is reversed. TTY's 5-byte format ([stream_type:u8][length:u32][payload], ADR-052) is TTY's internal format, used in both direct mode and inside-channels mode. The two modes differ only in where the BiStream comes from (a top-level alknet/tty connection vs a channel/open with ALPN alknet/tty), not in how TTY parses it. The same wire.rs code runs in both modes.

When TTY is inside channels, the channels layer strips its 8-byte header and hands TTY the payload bytes. TTY parses its 5-byte header from the payload. The channels layer carries TTY's 5-byte chunks transparently in its payload — no shared fields, no leaked abstraction, no double-chunking concern (the 13-byte total header is 8 channels + 5 TTY, not 8 + 9; the channels length is always tty_len + 5).

This reverses ADR-077: the 5-byte format is NOT scoped to direct — it's TTY's internal format, carried transparently in the channels payload. The channels feature on alknet-tty becomes "run TTY's sub-demux on a channels-backed BiStream" — the same code as direct mode, different BiStream source. The control channel split (STREAM_CTRL_IN / STREAM_CTRL_OUT, Phase 7) is TTY-internal; the channels layer doesn't know about it.

4. The add/strip composition

The channels layer's read path strips the 8-byte header and hands the payload to the handler. The write path prepends the 8-byte header (add_channel_id) onto the handler's output. The handler never sees the channel_id; it sees only its own framing (the payload bytes).

channels:  [channel_id:u32 BE][length:u32 BE][payload]
                        = 8-byte header + opaque payload
                            8 bytes

TTY inside channels:
  [channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
      4 bytes      4 bytes       1 byte        4 bytes     N bytes
      \_________  __________/    \_________  _____________/
                 |                           |
          channels header              TTY chunk (5+N bytes)
            (8 bytes)              carried as channels payload

The composition is uniform — the same shape at every level. A alknet/channels-inside-alknet/channels recursive composition is the outer layer stripping its 8-byte header, the inner layer parsing its own 8-byte header from the payload — same code, same shape, each level.

5. What does NOT change

  • ADR-009's BiStream leaf — unchanged. This ADR is the channels-layer consequence of ADR-009: accept_bi yields a BiStream, handlers sub-multiplex it. The two ADRs compose (ADR-009 settles the transport leaf; this ADR settles the multiplexing layer above it).
  • ProtocolHandler trait shape (ADR-002) — unchanged. Handlers receive a Connection and call accept_bi().
  • Channel 0 pre-negotiated as alknet/call (ADR-036) — unchanged. Channel 0's chunks have channel_id = 0 in the 8-byte header. The call protocol's EventEnvelope framing is the payload; the channels layer carries it transparently.
  • Channel lifecycle operations (ADR-037) — unchanged. The four operations (channel/open/close/control/resources/subscribe) and their direction semantics are call-protocol operations on channel 0, not channels-wire-format concerns.
  • ChannelsAdapter / ChannelManager split (ADR-039) — structurally unchanged. The demux loop reads 8-byte headers (not 9-byte); the ChannelManager is ALPN-blind, auth-blind, transport-blind. The stream_types field on channel/open and ChannelState is removed (the channels layer doesn't track per-stream-type reassembly buffers; it tracks one reassembly buffer per channel_id, yielding a BiStream).
  • Backpressure, channel limits, ID reuse (ADR-040) — unchanged. The bounded-buffer backpressure is per-channel_id (was per- (channel_id, stream_type); now per-channel_id since there's one reassembly buffer per channel). The 256-channel cap, 1 MiB default, and monotonic-ID-with-wrap strategy are unchanged.
  • Two-pump shutdown-on-completion (ADR-078) — unchanged. Tunnel/SSH handlers call tokio::io::split(bidi) for their two pump halves; the shutdown-on-completion contract applies to the ReadHalf / WriteHalf unchanged.
  • Hub relay (ADR-042) — unchanged in contract. The hub translates channel/open on channel 0 and byte-forwards data channels with channel_id rewrite. The relay reads 8-byte headers (not 9-byte) and rewrites the channel_id field (a 4-byte rewrite within the 8-byte header, not a 9-byte header). The relay does not parse the payload.
  • ChannelClient (ADR-043) — unchanged in API. from_connection primary, open_channel returns a Channel. The stream_types field on open_channel and Channel is removed (the channels layer doesn't negotiate per-stream-type sets; the handler owns its sub-stream multiplexing). The channel:stream_type_unavailable error code is removed (the channels layer can't refuse a stream_type it doesn't know about).
  • Sub-crate decomposition (ADR-044) — unchanged. channels-core (pure multiplexer, depends on alknet-core only) / channels-call (channel 0 pre-negotiation + lifecycle op registrations, depends on channels-core + alknet-call). The 8-byte wire format, demux/mux, and ChannelBidiStreamSource are in channels-core; the call-protocol coupling is in channels-call.
  • BidiStreamSource trait (ADR-008) — unchanged in shape. ChannelBidiStreamSource implements it; accept_bi yields a BiStream (per ADR-009, already landed).

Consequences

Positive:

  • Clean separation of concerns. The channels layer has no stream_type concept — not in its header, not in its code, not in its mental model. The handler owns its framing entirely. This dissolves the mod 2/mod 3/mod 4 question at the channels layer (there's nothing to decompose) and fixes the "control isn't actually bidirectional" TTY flaw at the TTY layer (where it lives, not the channels layer).
  • Uniform across all handlers. Tunnel, call, SSH, and TTY all receive the same shape: a BiStream. No handler gets a stream_type byte it doesn't use; no handler needs a second accessor (into_sub_streams) to reach its sub-streams. The channels layer's API surface is accept_bi -> BiStream, period.
  • Recursive composition is literal. A alknet/channels channel runs another channels demux on its BiStream. The outer layer strips its 8-byte header; the inner layer parses its own 8-byte header from the payload. Same code, same shape, each level. This is a property, not a feature — the primary use case is one level of multiplexing, but the add/strip composition makes the recursion cleaner than ADR-034's group framing did.
  • The into_sub_streams() accessor and its consuming handler code are removed. This is a net simplification: one accessor, one handler path, no downcast / extension trait / "two paths" ergonomics question (which ADR-038 left as an implementation detail). The handler crate destructures its BiStream via its own framing (TTY's 5-byte format), not via a channels-crate-provided typed accessor.
  • TTY's wire.rs runs unchanged in both modes. Direct mode and inside-channels mode use the same code; only the BiStream source differs. ADR-077's drive_session_direct / drive_session_channels split collapses to one drive_session function. The channels feature on alknet-tty becomes a thin wrapper that gets the BiStream from a channels-backed Connection instead of a top-level one.
  • The channels layer is WASM-compatible by construction. The 8-byte header's core is pure byte manipulation (the sync core compiles under wasm32-unknown-unknown, validated by the POC). The 8-byte format is simpler than the 9-byte (one fewer field to parse), strengthening the WASM-clean property.

Negative:

  • 5 extra bytes per TTY chunk. The total header for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not 9. The two length fields are close but not identical (ch_len = tty_len + 5). For typical TTY chunks (4 KiB+), this is ~0.1% overhead. For extreme multiplexing scenarios, the clean separation is worth the trade-off; for high-throughput bulk transfer, the escape hatch is multi-connection (one channels connection per leg), not stripping the header. This is the documented cost of the clean separation; the alternative (9-byte header with stream_type in the channels layer) carries a concept the channels layer doesn't own, which is the root cause this ADR addresses.
  • TTY's wire.rs needs a small adaptation. ChunkReader currently reads from an AsyncRead (the transport stream). Inside channels, it reads from a payload buffer (&[u8] or Cursor<Bytes>) — the bytes the channels layer handed it after stripping its 8-byte header. The framing logic (stream_type constants, length validation, control message parsing) is unchanged. This is a bounded, well-scoped implementation change, not an architecture change. The same adaptation applies to any handler that parses its own framing from a payload buffer (call's EventEnvelope framing already reads from a buffer; tunnel and SSH don't parse the payload, so no adaptation).
  • channel/open loses the stream_types field. ADR-037's channel/open input included stream_types: [u8] (the active sub-stream set) and the response echoed the negotiated set. Under this ADR, the channels layer doesn't negotiate sub-stream sets — the handler owns its sub-stream multiplexing. The stream_types field is removed from channel/open (and from the channel:stream_type_unavailable error code). The alpn and params fields remain; the handler's sub-stream set is implicit in its ALPN's wire format. This is a small wire-format change to channel/open (one field removed); since the channels crate isn't implemented yet, there's no migration cost.
  • ChannelState.streams: HashMap<u8, ReassemblyBuffer> becomes ChannelState.reassembly: ReassemblyBuffer (one per channel, not per (channel_id, stream_type)). This is an internal simplification (fewer reassembly buffers, simpler drain logic) but is an implementation change, not an architecture one. The bounded-buffer backpressure (ADR-040) is per-channel_id now, not per- (channel_id, stream_type) — the 1 MiB default and the 256-channel cap are unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a TTY channel with 5 active stream_types). This is a net improvement (lower memory ceiling per channel), not a regression.

Door type

One-way (wire format, accessor removal, two-mode reversal). The 8-byte chunk header layout (channel_id:u32 + length:u32), the removal of stream_type from the channels header, and the removal of into_sub_streams() are wire-format and API commitments. Changing them after the channels crate is implemented and handlers are written against them requires a version migration. Since the channels crate doesn't exist yet, the one-way door is being cast now, before implementation — the right time to cast a one-way door.

The reversal of ADR-077 (TTY always uses its 5-byte format) is one-way in the same sense: once TTY's wire.rs runs in both modes (direct and inside-channels), re-introducing a separate inside-channels mode would be a rewrite of TTY's session driver. The trade is one unified session driver now vs. two-mode maintenance forever.

The add/strip API shape (OQ-68) is a two-way door — whether the header add/strip is built into the read/write path or exposed as a standalone utility is an implementation detail that can change without breaking the wire format or the handler contract.

References

  • ADR-034: channels wire format (amended — wire format is 8 bytes, not 9; stream_type removed from the channels header; the stream_type decomposition is removed from the channels layer)
  • ADR-038: ChannelBidiStreamSource (amended — into_sub_streams() removed; accept_bi is the only accessor, yields one BiStream per channel)
  • ADR-077: TTY inside channels (reversed — TTY always uses its 5-byte format; the channels layer carries it transparently in the payload; the two-mode design is preserved but differs only in BiStream source, not in parsing)
  • ADR-009: BiStream as the handler leaf (the transport-leaf layer this ADR builds on — accept_bi returns BiStream; from_bidi is the only public stream constructor)
  • ADR-008: BidiStreamSource trait (the extension point ChannelBidiStreamSource implements; accept_bi yields BiStream)
  • ADR-036: channel 0 pre-negotiated alknet/call (unchanged — channel 0's chunks have channel_id = 0 in the 8-byte header; the call protocol's framing is the payload)
  • ADR-037: channel lifecycle operations (amended — stream_types field removed from channel/open; channel:stream_type_unavailable error code removed)
  • ADR-039: ChannelsAdapter and ChannelManager (structurally unchanged — demux reads 8-byte headers; one reassembly buffer per channel)
  • ADR-040: backpressure, channel limits, ID reuse (unchanged — bounded-buffer is per-channel_id; 256-channel cap, 1 MiB default, monotonic IDs)
  • ADR-078: two-pump shutdown-on-completion (unchanged — the contract applies to tokio::io::split(bidi) halves)
  • ADR-042: hub relay (unchanged in contract — 8-byte header, 4-byte channel_id rewrite, payload byte-forwarded)
  • ADR-043: ChannelClient (amended — stream_types field removed from open_channel and Channel)
  • ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in channels-core; call-protocol coupling in channels-call)
  • ADR-052: alknet-tty wire format (the 5-byte format carried transparently in the channels payload; the control channel split from Phase 7 is TTY-internal)
  • docs/research/stream-unification/findings.md — the research that surfaced the structural question and the resolution this ADR commits
  • docs/research/alknet-crate-extraction/findings.md Phase 8 — the spec-cleanup phase this ADR is the substance of
  • /workspace/alknet-channels-poc/ — the POC that validated the per-channel_id/stream_type routing mechanism (the mechanism supports any convention; this ADR says the channels layer doesn't have a convention, the handler does)