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).
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:
- "Pass a stream to/from any ALPN" — every channel is a
BiStream; any handler getsaccept_bi()and treats the channel as a duplex stream. Uniform, transport-agnostic, recursive-composition-friendly. - "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 oneBiStreamper channel (per ADR-009, already landed). Nointo_sub_streams(), no second-class accessor. - Handlers sub-multiplex their
BiStreamhowever they want. TTY sub-demuxesstream_typefrom itsBiStream(its 5-byte format). Tunnel uses theBiStreamas 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_typeconcept — not in its header, not in its code, not in its mental model.stream_typeis the inner layer's framing byte, carried transparently. - The control channel is handler-internal. TTY sub-demuxes control
from its io
BiStreamusing 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/channelsruns another channels demux on itsBiStream. 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:
- ADR-009 landed
BiStreamas the handler leaf.accept_bi()returns aBiStream(a concreteAsyncRead + AsyncWritenewtype), 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 aBiStream" 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. - Phase 7 fixed the TTY control channel at the TTY layer. The
STREAM_CONTROL = 3"bidirectional" flaw is fixed by splitting it intoSTREAM_CTRL_IN = 3/STREAM_CTRL_OUT = 4— inside TTY's 5-byte format, not at the channels layer. This removed the load-bearing reason for the channels layer to carrystream_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. - 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_typerouting 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_idas 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'schannel_idrewrite). 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.rsadaptation: TTY'sChunkReadercurrently reads from anAsyncRead. Adapting it to read from a payload buffer (&[u8]orCursor<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
BiStreamleaf — unchanged. This ADR is the channels-layer consequence of ADR-009:accept_biyields aBiStream, handlers sub-multiplex it. The two ADRs compose (ADR-009 settles the transport leaf; this ADR settles the multiplexing layer above it). ProtocolHandlertrait shape (ADR-002) — unchanged. Handlers receive aConnectionand callaccept_bi().- Channel 0 pre-negotiated as
alknet/call(ADR-036) — unchanged. Channel 0's chunks havechannel_id = 0in the 8-byte header. The call protocol'sEventEnvelopeframing 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 theirdirectionsemantics are call-protocol operations on channel 0, not channels-wire-format concerns. ChannelsAdapter/ChannelManagersplit (ADR-039) — structurally unchanged. The demux loop reads 8-byte headers (not 9-byte); theChannelManageris ALPN-blind, auth-blind, transport-blind. Thestream_typesfield onchannel/openandChannelStateis removed (the channels layer doesn't track per-stream-type reassembly buffers; it tracks one reassembly buffer perchannel_id, yielding aBiStream).- 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_idsince 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 theReadHalf/WriteHalfunchanged. - Hub relay (ADR-042) — unchanged in contract. The hub translates
channel/openon channel 0 and byte-forwards data channels withchannel_idrewrite. The relay reads 8-byte headers (not 9-byte) and rewrites thechannel_idfield (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_connectionprimary,open_channelreturns aChannel. Thestream_typesfield onopen_channelandChannelis removed (the channels layer doesn't negotiate per-stream-type sets; the handler owns its sub-stream multiplexing). Thechannel:stream_type_unavailableerror code is removed (the channels layer can't refuse astream_typeit doesn't know about).- Sub-crate decomposition (ADR-044) — unchanged.
channels-core(pure multiplexer, depends onalknet-coreonly) /channels-call(channel 0 pre-negotiation + lifecycle op registrations, depends onchannels-core+alknet-call). The 8-byte wire format, demux/mux, andChannelBidiStreamSourceare inchannels-core; the call-protocol coupling is inchannels-call. BidiStreamSourcetrait (ADR-008) — unchanged in shape.ChannelBidiStreamSourceimplements it;accept_biyields aBiStream(per ADR-009, already landed).
Consequences
Positive:
- Clean separation of concerns. The channels layer has no
stream_typeconcept — 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 astream_typebyte it doesn't use; no handler needs a second accessor (into_sub_streams) to reach its sub-streams. The channels layer's API surface isaccept_bi -> BiStream, period. - Recursive composition is literal. A
alknet/channelschannel runs another channels demux on itsBiStream. 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 itsBiStreamvia its own framing (TTY's 5-byte format), not via a channels-crate-provided typed accessor. - TTY's
wire.rsruns unchanged in both modes. Direct mode and inside-channels mode use the same code; only theBiStreamsource differs. ADR-077'sdrive_session_direct/drive_session_channelssplit collapses to onedrive_sessionfunction. Thechannelsfeature onalknet-ttybecomes a thin wrapper that gets theBiStreamfrom a channels-backedConnectioninstead 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 withstream_typein the channels layer) carries a concept the channels layer doesn't own, which is the root cause this ADR addresses. - TTY's
wire.rsneeds a small adaptation.ChunkReadercurrently reads from anAsyncRead(the transport stream). Inside channels, it reads from a payload buffer (&[u8]orCursor<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'sEventEnvelopeframing already reads from a buffer; tunnel and SSH don't parse the payload, so no adaptation). channel/openloses thestream_typesfield. ADR-037'schannel/openinput includedstream_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. Thestream_typesfield is removed fromchannel/open(and from thechannel:stream_type_unavailableerror code). Thealpnandparamsfields remain; the handler's sub-stream set is implicit in its ALPN's wire format. This is a small wire-format change tochannel/open(one field removed); since the channels crate isn't implemented yet, there's no migration cost.ChannelState.streams: HashMap<u8, ReassemblyBuffer>becomesChannelState.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_idnow, 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_typeremoved from the channels header; the stream_type decomposition is removed from the channels layer) - ADR-038: ChannelBidiStreamSource (amended —
into_sub_streams()removed;accept_biis the only accessor, yields oneBiStreamper 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
BiStreamsource, not in parsing) - ADR-009:
BiStreamas the handler leaf (the transport-leaf layer this ADR builds on —accept_bireturnsBiStream;from_bidiis the only public stream constructor) - ADR-008:
BidiStreamSourcetrait (the extension pointChannelBidiStreamSourceimplements;accept_biyieldsBiStream) - ADR-036: channel 0 pre-negotiated
alknet/call(unchanged — channel 0's chunks havechannel_id = 0in the 8-byte header; the call protocol's framing is the payload) - ADR-037: channel lifecycle operations (amended —
stream_typesfield removed fromchannel/open;channel:stream_type_unavailableerror code removed) - ADR-039:
ChannelsAdapterandChannelManager(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_idrewrite, payload byte-forwarded) - ADR-043:
ChannelClient(amended —stream_typesfield removed fromopen_channelandChannel) - ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
channels-core; call-protocol coupling inchannels-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 commitsdocs/research/alknet-crate-extraction/findings.mdPhase 8 — the spec-cleanup phase this ADR is the substance of/workspace/alknet-channels-poc/— the POC that validated the per-channel_id/stream_typerouting mechanism (the mechanism supports any convention; this ADR says the channels layer doesn't have a convention, the handler does)