Files
alknet/docs/research/alknet-channels/phase-0-findings.md
T
glm-5.2 9ea69efba6 docs(research): graduate POC findings into phase-0 — REQ-CH/REQ-CORE, resolved OQs, AlknetClient clarification
Incorporates the completed de-risk POC (poc-summary.md, 28 tests passing)
into phase-0-findings.md:

- Marks OQ-CH-12 RESOLVED (lenient drop, validated by POC), OQ-CH-13
  CONFIRMED +EV (do the BidiStreamSource refactor), and clarifies OQ-CH-14
  with the AlknetClient context: a general downstream-facing client in
  alknet-core paired with whichever ALPN handler a crate provides, with
  server/client bidirectionality preserved (hub/worker can act as both,
  each side fills its registry with the other's resources).

- Adds §POC-Validated Requirements carrying the POC's surfaced invariants
  into Phase 1 as requirements (not open questions):
  - REQ-CH-01..07: wire-level invariants for the channels crate spec
    (shutdown emits zero-length sentinel, transport close drops all
    senders, mux handle/runner split, lenient unknown channel_id, bounded
    backpressure, two-pump shutdown-on-completion, PollSender adapter).
  - REQ-CORE-01..04: the light alknet-core refactor to land alongside the
    channels crate (BidiStreamSource trait, Connection::close fix,
    AuthContext test helper, AlknetClient).

- Updates the De-risk POC section to COMPLETE status with what was
  validated, what was surfaced, and what remains out of scope.

- Updates the opening revision note to reflect the POC pushed the core
  mechanics beyond speculation into validated territory.
2026-07-12 08:08:51 +00:00

1768 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 2026-07-12
---
# alknet-channels — Phase 0 Research Findings
This document captures Phase 0 (Exploration) findings for the `alknet-channels`
crate. The objective of Phase 0 per `docs/sdd_process.md` is: *"Capture vision
and guiding principles; research options; validate approaches; converge on a
recommended approach."* It is the input to Phase 1 (Architecture), where the
Architect will produce `docs/architecture/crates/channels/*.md` specs, ADRs,
and open questions.
This document was drafted 2026-07-10, emerging from a discussion about the
conceptual tangle between the call protocol, the TTY crate, and the coming
docker and SSH crates. The core issue: most "call clients" will use more than
just the call protocol, and not everything is well described by JSON. The TTY
crate's chunk format already solves sub-stream multiplexing for terminal
sessions — this document generalizes that pattern into a universal channel
multiplexer that the call protocol can orchestrate.
The 2026-07-11 revision adds the hub motivation (§Hub Motivation), the
concrete channel-open negotiation protocol (§Channel Open Negotiation), and
the channel-manager/connection internals (§Channel Manager and Connection
Internals) — driven by the realization that the hub crate's multi-transport
complexity is what channels specifically collapses, and that the call
protocol's auth/operation-overlay model carries over to channel lifecycle
with no new machinery.
The 2026-07-12 revision incorporates the **completed de-risk POC**
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing). The POC
validated the three core mechanics (chunk format + demux/mux, per-channel
`Connection` presentation, tunnel handler) and surfaced invariants that
are now captured as REQ-CH-01 through REQ-CH-07 and REQ-CORE-01 through
REQ-CORE-04 (§POC-Validated Requirements). It also confirmed OQ-CH-12
(resolved: lenient drop), OQ-CH-13 (confirmed +EV: do the
`BidiStreamSource` refactor), and clarified OQ-CH-14 (the broader
`AlknetClient` in core + the channels crate's `ChannelClient`, with
server/client bidirectionality preserved). The POC pushed this beyond
speculation into validated territory.
## Vision Recap
`alknet-channels` is a **multiplexing proxy** — a `ProtocolHandler` on
`alknet/channels` that decomposes a single bidirectional stream into multiple
logical channels, each carrying a different protocol (ALPN). It is the
generalization of two existing patterns:
1. **SSH's channel multiplexer** (RFC 4254): `ChannelId(u32)` with
string-named types negotiated per channel, all traffic interleaved on one
encrypted transport stream.
2. **alknet-tty's chunk format**: `[stream_type: u8][length: u32 be][payload]`
with a fixed set of four sub-streams (stdin/stdout/stderr/control).
The generalization: a chunk header of `(channel_id: u32, stream_type: u8,
length: u32)` — 9 bytes — that multiplexes an arbitrary number of channels,
each with up to 256 sub-stream types, over a single transport. Channel 0 is
pre-negotiated as `alknet/call` — the call protocol. Every other channel is
opened dynamically via call operations on channel 0, and channel open is
**bidirectional**: either side can open a channel to the other, just like
the call protocol's operation overlay where each side populates what
operations they can call.
The guiding insight:
> **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
> connection, a QUIC bidi stream — they're all just `AsyncRead + AsyncWrite`
> handles. The differences are only in how they're *opened* (negotiation) and
> what *multiplexing layer* carries them. Once you normalize to
> `AsyncRead + AsyncWrite`, the architecture collapses into a simple shape:
> every channel is just an ALPN routed through the same `HandlerRegistry`,
> channel 0 is just `alknet/call` pre-negotiated, and ACL governs everything.
## The Problem This Solves
### The conceptual tangle
Today, alknet's architecture has three different multiplexing models that
don't compose well:
| Model | Where | Mechanism |
|-------|-------|-----------|
| Connection-level | ALPN router | One ALPN per QUIC connection |
| Stream-level | QUIC native | Many bidi streams per connection |
| Sub-stream-level | TTY chunk format | 4 logical channels within one bidi stream |
A docker client needs both JSON call operations (`docker/container/list`,
`docker/container/create`) and raw byte streams (interactive exec, container
attach). Today that means **two separate QUIC connections** with different
ALPNs (`alknet/call` and `alknet/tty`). The call protocol has no way to say
"for this operation, open a TTY stream." There's no cross-ALPN coordination.
### The deeper issue
The call protocol is positioned as the universal RPC layer, but it's
fundamentally **JSON-only**. The `EventEnvelope` framing can't carry raw
bytes. So anything non-JSON (TTY sessions, SSH channels, file transfers, git
pack protocols) needs its own ALPN and its own connection. The call protocol
can't even *reference* these other streams — there's no "stream token" or
"channel open" mechanism.
### What channels provides
A single `alknet/channels` connection carries:
- Channel 0: `alknet/call` (pre-negotiated — both sides route it to the `CallAdapter`)
- Channel N: `alknet/tty` (opened dynamically via `channel/open`)
- Channel M: `alknet/ssh` (opened dynamically via `channel/open`)
- Channel K: `alknet/tunnel` (opened dynamically via `channel/open`)
All on one QUIC connection (or one TCP connection, or one WebTransport
session). Channel 0 is not a special "control plane" — it's just the
`alknet/call` ALPN, pre-negotiated so both sides know to route it to the
`CallAdapter` without an explicit `channel/open` exchange. Every other
channel works exactly the same way: reassemble chunks into a stream, look up
the ALPN in the `HandlerRegistry`, hand off to the handler. Channel open is
bidirectional — either side can open a channel to the other, just like the
call protocol's operation overlay where each side populates what operations
they can call.
## Hub Motivation: The Multi-Transport Collapse
This design was forced by the hub crate. A hub is the architectural role
(ADR-029, ADR-034) that bridges peers and browsers: it terminates QUIC from
spokes, terminates WebTransport/WSS from browsers, and may itself be a spoke
of an upstream hub. The hub's job is to **route and relay**, not to
re-implement every protocol's framing per transport.
Without channels, the hub quickly becomes a mess:
### The mess, concretely
A hub serving a browser over WebTransport and a spoke over QUIC wants to
offer the browser three things on the same logical session:
1. Call operations on the spoke (e.g., `docker/container/list` — JSON).
2. A TTY session on the spoke (interactive exec — raw bytes, ADR-052 chunk
format).
3. An SSH session to the spoke (raw bytes, SSH binary protocol).
Today each of these is a **separate ALPN** on a **separate connection**, and
each transport multiplexes differently:
| Need | Today's mechanism | Transport | Multiplexing |
|------|-------------------|-----------|--------------|
| JSON ops | `alknet/call` ALPN | QUIC stream or WSS | one op per bidi stream |
| TTY | `alknet/tty` ALPN | QUIC stream or WSS | TTY chunk format *within* one bidi stream |
| SSH | `alknet/ssh` ALPN (future) | QUIC stream or WSS | SSH multiplexes *within* one bidi stream |
So the hub faces a matrix: **3 needs × 2 transports × N spokes**, and the
multiplexing layer is different in each cell. The hub would have to:
- Maintain a `quinn::Connection` per spoke AND a WebTransport session per
browser, AND decide which one carries which ALPN.
- For a browser→spoke TTY, the hub can't just "forward a stream" — the
browser's WSS stream carries `alknet/tty` chunks, and the spoke's QUIC
stream also carries `alknet/tty` chunks, so the hub *can* relay bytes —
but it had to negotiate two separate `alknet/tty` connections (one per
leg) and correlate them by out-of-band state. There's no "one session that
spans the relay."
- For a browser→spoke SSH, same problem with a different ALPN and a
different internal multiplexer.
- The call protocol that *orchestrates* all this lives on a **third** ALPN
(`alknet/call`), so the hub is juggling three connections per (browser,
spoke) pair, each with its own framing, and the correlation across them is
implicit.
- Every new protocol (tunnel, future file-transfer, future git-pack) adds
**another column** to the matrix and **another ALPN** the hub must
register, dispatch, and relay.
This is the "PITA to maintain a bunch of connections" problem: the hub's
complexity is **O(protocols × transports × spokes)**, when it should be
**O(spokes)**.
### What channels collapses
With `alknet/channels`, the hub's per-spoke and per-browser state is **one
channels connection each**, and everything else is routing:
```
Browser ──WebTransport──► Hub ──QUIC──► Spoke
alknet/channels alknet/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
```
The hub's relay logic is **channel-by-channel byte forwarding**: read chunks
for `(channel_id)` off the browser's channels connection, write the same
payload (with the spoke's `channel_id` substituted) onto the spoke's
channels connection. The hub does **not** parse `alknet/tty` chunks, does
**not** understand SSH's internal multiplexer, does **not** run a separate
`CallAdapter` per leg — it forwards opaque `(channel_id, stream_type,
payload)` tuples and lets the endpoints at each end do the protocol work.
The collapse is at three levels:
1. **One connection per leg, not one per protocol.** The browser holds one
WebTransport session to the hub; the hub holds one QUIC connection to the
spoke. All three needs (call, TTY, SSH) ride as channels on those two
connections. The hub correlates by channel, not by ALPN-per-connection.
2. **One multiplexing model, not three.** Connection-level (ALPN router),
stream-level (QUIC native), and sub-stream-level (TTY chunks) collapse
into one: channels chunks. The hub's relay code is one loop, not three.
3. **The call protocol orchestrates from inside.** Channel 0 is `alknet/call`
on both legs. The browser calls `channel/open` on its channel 0; the hub
forwards that call to the spoke's channel 0 (via `from_call`); the spoke
allocates the channel and returns the ID; the hub opens a matching channel
on the browser's side and bridges them. The hub never runs a handler for
`alknet/tty` or `alknet/ssh` — it only runs `alknet/channels` (the relay)
and `alknet/call` (for its own hub-level operations like routing and
resource lookup).
### Why the auth model reuses cleanly
The call protocol's `OperationContext` (identity, scopes, capabilities,
ownership) already gates every operation. `channel/open` is just another
operation on channel 0, so the same `OperationContext` flows through:
- The browser's `channel/open` carries the browser's identity (bearer token
resolved by the hub per ADR-043).
- The hub forwards via `from_call`, which populates `forwarded_for` from the
hub's `OperationContext.identity` (ADR-032 §3) — exactly the kernel/user-
land + forwarded-for model from ADR-050.
- The spoke's `AccessControl::check` sees the hub as the caller and the
browser as `forwarded_for`. The spoke authorizes the hub (its direct
peer); the hub's "who is this for" is its own app state, carried as
`forwarded_for`.
No new auth machinery. The hub doesn't authenticate channels — it
authenticates the call operations that *open* channels, which it already
does. The channels layer inherits the call protocol's ACL by being one of
its operation types.
### What the hub *does* still own
Channels does not eliminate the hub's responsibilities; it relocates them:
- **Routing**: which spoke serves `container:abc123`? That's the hub's
resource registry / ownership store (ADR-050), queried via call operations
on channel 0. Channels doesn't touch this.
- **ACL at the hub**: does this browser's identity have `channel:open` scope
for `alknet/ssh` to `spoke-X`? That's the call protocol's
`AccessControl::check` on the `channel/open` operation, run by the hub's
`CallAdapter` before it forwards. Channels doesn't touch this either.
- **Relay lifecycle**: when a browser disconnects, the hub tears down the
spoke-side channels (and vice versa). This is `channel/close` on each
channel, or a transport-level close that the channels layer observes.
What the hub *no longer* owns: per-protocol framing parsers, per-protocol
relay loops, per-ALPN connection management, and the correlation state
across multiple connections per (browser, spoke) pair. Those move into the
channels layer, which is transport-agnostic and protocol-agnostic.
## The Wire Format
### Chunk header
```
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
```
9 bytes of header, compared to TTY's current 5 bytes (`[stream_type: u8]
[length: u32 be]`). The `channel_id` field is the addition — it's what turns
a fixed 4-channel multiplexer into an arbitrary N-channel multiplexer.
### Channel 0: pre-negotiated as `alknet/call`
Channel 0 is not a special "control plane" with its own framing. It is
simply the `alknet/call` ALPN, pre-negotiated: both sides know that channel
0 is routed to the `CallAdapter` without an explicit `channel/open`
exchange. The call protocol runs on channel 0 exactly as it runs on a
top-level `alknet/call` QUIC connection — `EventEnvelope` frames on a
bidirectional stream.
Channel 0's chunks use `stream_type` to disambiguate framing:
| stream_type | purpose |
|-------------|---------|
| 0 | `EventEnvelope` frame (JSON, length-prefixed — the call protocol wire format) |
| 1-255 | reserved for future sub-streams |
### Data channels (1..N)
Every other channel works exactly the same way as channel 0: reassemble
chunks into a stream, look up the ALPN in the `HandlerRegistry`, hand off to
the handler. The `stream_type` byte decomposes the channel into sub-streams,
following the TTY crate's proven model:
| stream_type | direction | purpose |
|-------------|-----------|---------|
| 0 | write half | data flowing in (stdin equivalent) |
| 1 | read half | data flowing out (stdout equivalent) |
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
| 3 | bidirectional | control messages (protocol-specific JSON) |
| 4-255 | reserved | future sub-stream types |
Not all channels use all sub-streams. A TTY session uses 0-3. A raw tunnel
might use only 0 and 1. An SSH connection uses only 0 and 1 (SSH multiplexes
internally). The `stream_type` set is fixed per channel type, declared at
open time.
### Channel lifecycle
1. **Open**: either side sends a `channel/open` call operation on channel 0
with `{ alpn, params }`. The receiving side validates ACL, looks up the
ALPN in the `HandlerRegistry`, allocates the handler, and returns
`{ channel_id, stream_types }`. The `stream_types` field declares which
sub-streams are active for this channel (e.g., `[0, 1, 2, 3]` for TTY,
`[0, 1]` for a tunnel). Channel open is **bidirectional** — a hub can
expose a resource a worker consumes, or a worker can expose a resource
the hub consumes, just like the call protocol's operation overlay.
2. **Data**: both sides read/write chunks on the assigned `channel_id`. Each
side reassembles chunks for a given `(channel_id, stream_type)` into a
stream. The reassembled streams are presented to the handler as
`AsyncRead + AsyncWrite` handles.
3. **Control**: channel-specific control messages ride on `stream_type 3` as
JSON. For TTY channels this is resize/signal/eof. For tunnel channels
this might be keepalive or connection-close. The call protocol on channel
0 can also send control operations scoped to a specific channel (e.g.,
`tty/resize` with a `channel_id` parameter).
4. **Close**: either side sends a `channel/close` call operation on channel
0, or the channel's handler signals completion (e.g., TTY exit). The
channel's chunks stop; the `channel_id` may be reused.
### Framing disambiguation
The TTY crate's framing disambiguation trick (ADR-052 §5) carries forward.
Channel 0 is just another channel — its chunks have `channel_id=0` in the
header. The disambiguation between channel 0 (call protocol) and data
channels is by `channel_id`, not by a special first-byte trick. Within a
channel, `stream_type` 0 (stdin) from the server is invalid, so `0x00` as
the first byte of a chunk payload from the server is unambiguous.
## The Channel Connection Abstraction
### Reassembling chunks into streams
On each end of a channels connection, chunks for a given `(channel_id,
stream_type)` are reassembled into a byte stream. The reassembly is
order-preserving within a `(channel_id, stream_type)` pair — chunks arrive
in order because they ride on a single ordered transport (QUIC stream, TCP
connection).
Channel 0 is reassembled exactly like any other channel. The reassembled
stream for `(channel_id=0, stream_type=0)` is handed to the `CallAdapter`
as a `Connection`. The `CallAdapter` doesn't know it's inside a channels
connection — it sees a `Connection` yielding bidi streams, same as if it
were a top-level QUIC connection.
For data channels (1..N), the reassembled streams are presented through the
same `Connection` abstraction that alknet-core already defines
(`crates/alknet-core/src/types.rs`):
```rust
// A channels connection presents the same interface as a QUIC connection
impl ChannelConnection {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
}
```
Internally, `accept_bi` / `open_bi` map to `channel/open` call operations on
channel 0, and the returned `SendStream` / `RecvStream` are backed by chunk
reassembly on the assigned `channel_id`.
### ALPN routing inside channels
A `ChannelConnection` holds a reference to the same `HandlerRegistry` that
the outer `AlknetEndpoint` uses. When a `channel/open` arrives with an ALPN
string, the channels handler looks up the ALPN in the registry, validates
ACL, and hands the reassembled stream to the handler. The handler doesn't
know it's inside a channels connection — it sees a `Connection` yielding bidi
streams, same as if it were a top-level QUIC connection.
Channel 0 is not special — it's just `alknet/call` pre-negotiated. Both
sides know to route channel 0 to the `CallAdapter` without an explicit
`channel/open` exchange. Every other channel is opened dynamically via
`channel/open` on channel 0, and the open is bidirectional: either side can
initiate.
This means every existing and future `ProtocolHandler` works inside channels
without modification. The `CallAdapter`, `TtyAdapter`, the future
`SshAdapter`, a tunnel handler — they all just see a `Connection`. The
channels layer is a transparent proxy.
### Recursive composition
A `ChannelConnection` is itself a `Connection`. A channels handler can open a
sub-channels connection on a data channel. This is recursive composition:
`alknet/channels` inside `alknet/channels`. It's powerful (arbitrary nesting
of multiplexing layers) and potentially confusing (infinite recursion). The
recommendation is to allow it but not encourage it — the primary use case is
one level of multiplexing. Recursive composition is a natural consequence of
the `Connection` abstraction, not a feature to design for.
## Channel Open Negotiation
Channel lifecycle is orchestrated by the call protocol on channel 0. The
call protocol is JSON-only — it carries `channel/open`,
`channel/close`, `channel/control`, and `channel/resources` as **new
operation types** in the existing `OperationRegistry`, dispatched through
the existing `OperationContext` (identity, scopes, capabilities, ownership,
`forwarded_for`). No wire-format change to `EventEnvelope`, no new carriage
type. The channels layer registers these operations on the call protocol's
`OperationRegistry` at assembly time.
This is where the call protocol's auth model is reused: `channel/open` is
gated by `AccessControl::check` exactly like any other operation, and the
operation's `AccessControl` can declare `required_scopes` (e.g.
`channel:open:alknet/tty`), `resource_type` + `resource_action` (e.g.
`container` + `tty`), or `required_scopes_any` for multi-scope gates. The
channels layer doesn't re-implement auth — it leans on the
`OperationRegistry::invoke` path that already runs the check before the
handler runs.
### `channel/open` — request a channel
Either side sends a `call.requested` on channel 0 with operation name
`channel/open`:
```json
{
"type": "call.requested",
"id": "req-abc",
"payload": {
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"stream_types": [0, 1, 2, 3],
"params": {
"backend": "docker",
"cmd": ["bash"],
"container": "abc123"
},
"direction": "initiator-to-responder"
}
}
}
```
Fields:
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry, e.g. `alknet/tty`, `alknet/ssh`, `alknet/tunnel`. The responder looks this up in its `HandlerRegistry`. |
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. Declared at open time so both sides size their reassembly buffers and know which `(channel_id, stream_type)` pairs are valid. E.g. `[0, 1, 2, 3]` for TTY, `[0, 1]` for a raw tunnel. |
| `params` | object | ALPN-specific parameters passed to the handler. For `alknet/tty` this is the `NegotiateRequest` (backend, cmd, env, size). For `alknet/tunnel` this is the target resource (`{"resource": "container:abc123", "port": 5432}`). For `alknet/ssh` this is the SSH auth/method hints. The channels layer does not interpret `params` — it hands the JSON to the handler's allocate entry point. |
| `direction` | string | `initiator-to-responder` (the initiator wants the responder to expose a resource) or `responder-to-initiator` (the initiator wants to expose a resource *to* the responder — the "worker exposes, hub consumes" case). See "Bidirectional open" below. |
The `direction` field is what makes channel open match the call protocol's
operation-overlay symmetry: just as either side can populate operations the
other can call, either side can expose resources the other can open
channels to. `initiator-to-responder` is the common case ("open me a TTY on
your docker container"). `responder-to-initiator` is the reverse ("I'm a
worker exposing my local Postgres as a tunnel target; hub, you can open a
channel to it") — which is how a hub consumes a worker's resources without
the worker initiating.
### `channel/open` — response
The responder validates ACL, looks up the ALPN in `HandlerRegistry`, and —
crucially — **allocates the `channel_id` before returning** (DP-1: server-
assigned). The response is a normal `call.responded`:
```json
{
"type": "call.responded",
"id": "req-abc",
"payload": {
"output": {
"channel_id": 7,
"stream_types": [0, 1, 2, 3]
}
}
}
```
| field | type | meaning |
|-------|------|---------|
| `channel_id` | u32 | The server-assigned channel ID. Both sides now know to route chunks with this `channel_id` to the new channel. |
| `stream_types` | `[u8]` | The *negotiated* set — the responder may narrow the initiator's requested set (e.g. refuse stderr for a backend that doesn't produce it). The intersection of requested and supported. |
Once the initiator receives the response, data may flow on
`(channel_id=7, stream_type=*)`. The responder's handler is already
allocated and reading from its reassembled streams.
### `channel/open` — error cases
Errors use the call protocol's existing `CallError` shape, dispatched as
`call.error`. The channels layer defines a small set of error codes:
| code | meaning | retryable |
|------|---------|-----------|
| `channel:unknown_alpn` | The `alpn` is not in the responder's `HandlerRegistry`. | false |
| `channel:forbidden` | `AccessControl::check` denied the open (missing scope, not the resource owner, `forwarded_for` not trusted). | false |
| `channel:allocation_failed` | The handler's allocate failed (e.g. `DockerTtyBackend::allocate` couldn't start the exec). The `details` carry the backend's error message. | true (often transient) |
| `channel:invalid_params` | The `params` JSON didn't satisfy the ALPN's expectations (e.g. missing `cmd` for TTY). | false |
| `channel:too_many_channels` | The responder hit its per-connection channel limit (OQ-CH-06). | false |
| `channel:stream_type_unavailable` | The responder's handler can't provide one of the requested `stream_types`. The `details` carry the supported set. | false |
These are new `CallError.code` strings, not new framing. The call protocol
already carries `code`/`message`/`retryable`/`details`.
### `channel/close` — tear down a channel
Either side sends:
```json
{
"operation": "channel/close",
"input": { "channel_id": 7, "reason": "exit" }
}
```
The responder (the side that *didn't* send the close) drains its
reassembled streams for `channel_id`, signals EOF to the handler, and
returns `call.responded` with `{ "closed": true }`. The `channel_id` is now
eligible for reuse (OQ-CH-04). `reason` is a free-form string for
observability — `"exit"`, `"cancel"`, `"error"` — and is not semantically
required.
The ordering invariant (ADR-055 for TTY): the channel's **data chunks** must
be written and flushed before the `channel/close` operation is sent on
channel 0. This is an implementation constraint on the side closing — the
channels layer's close handler must observe the data-channel pump complete
before issuing the call operation. For TTY this is the exit-chunk-is-last
invariant carried forward.
### `channel/control` — out-of-band control on channel 0
For control that doesn't need ordering relative to data (resize, signal,
keepalive), a call operation on channel 0:
```json
{
"operation": "channel/control",
"input": {
"channel_id": 7,
"stream_type": 3,
"message": { "type": "resize", "cols": 80, "rows": 24 }
}
}
```
The channels layer routes `message` to the handler's control handle for
`channel_id`. This is the "call protocol for orchestration" half of DP-4.
The `message` JSON is ALPN-specific; the channels layer doesn't interpret
it — it hands it to the handler's control entry point, same as `params` on
open.
### `stream_type 3` — in-band control on the data channel
For control that **does** need ordering relative to data (EOF before exit,
flush before close), a chunk with `stream_type = 3` on the data channel
itself:
```
[channel_id: u32 be][stream_type: 0x03][length: u32 be][json payload]
```
This is the "stream_type 3 for data-ordered control" half of DP-4. The
handler parses the JSON; the channels layer just reassembles and delivers
it in-order with the data. The TTY crate's `ControlMessage::Eof` is the
canonical example — it must arrive after the last stdin chunk, which is
guaranteed by chunk ordering within `(channel_id, stream_type)`, not by a
call-protocol round-trip.
### `channel/resources` — populate the resource overlay
This is the conceptual gap the doc is closing. The call protocol already
has `services/list` and `services/list-peers` for discovering **operations**
each side exposes. Channels needs the equivalent for **resources** — what
ALPNs each side is willing to open channels for, and with what constraints.
A side calls `channel/resources` on channel 0 to ask the other side what it
exposes:
```json
{
"operation": "channel/resources",
"input": {}
}
```
Response:
```json
{
"output": {
"resources": [
{
"alpn": "alknet/tty",
"backends": ["docker", "local"],
"access": { "required_scopes": ["tty:open"] }
},
{
"alpn": "alknet/ssh",
"access": { "required_scopes": ["ssh:open"] }
},
{
"alpn": "alknet/tunnel",
"targets": ["container:*", "service:postgres"],
"access": { "required_scopes_any": ["tunnel:open", "admin"] }
}
]
}
}
```
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN this side will accept `channel/open` for. |
| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available — TTY backends, tunnel target resource patterns. The channels layer doesn't interpret these; they're for the *initiator* to know what `params` to send. |
| `access` | object | A preview of the `AccessControl` that `channel/open` will check. Lets the initiator fail fast (e.g. a browser without `tty:open` doesn't bother trying to open a TTY channel). This is advisory — the real check happens on `channel/open`. |
This is the resource-discovery analogue of `services/list`. It's the
mechanism by which both sides populate what resources they expose, matching
the bidirectional symmetry of the operation overlay. The hub, after
`from_call` discovers the spoke's operations, also calls
`channel/resources` to discover what channels the spoke will accept — and
the hub aggregates that into what it exposes to the browser.
### Bidirectional open — who initiates
Channel open is bidirectional. Two cases:
1. **Initiator wants responder's resource** (`direction:
initiator-to-responder`): the browser opens a TTY channel on a spoke.
The initiator sends `channel/open`; the responder allocates the handler
and returns the ID. This is the common case.
2. **Initiator exposes a resource to the responder** (`direction:
responder-to-initiator`): a worker wants the hub to be able to open a
tunnel channel *to* the worker's local service. Here the worker sends
`channel/open` with `direction: responder-to-initiator` — it's saying
"I'm making myself available; when *you* want to connect, use this
channel." The semantics: the channel is created, but the worker's handler
is the *server* side of the ALPN, and the hub is the *client* side. The
`params` describe what the worker is exposing (e.g.
`{"target": "service:postgres", "port": 5432}`), not what it's asking
for.
This is the mirror of the call protocol's operation overlay: a worker
registers `bash/exec` (the hub can call it); a worker opens a
`responder-to-initiator` channel (the hub can use it). In both cases the
worker is the server, the hub is the client, and the *worker* initiates
the registration/open because it's the one that knows what it has.
### ACL flow end-to-end
Putting it together, a browser opening a TTY channel to a spoke through a
hub:
1. Browser's channel 0 → hub's channel 0: `channel/open`
`{ alpn: "alknet/tty", params: { backend: "docker", cmd: ["bash"], container: "abc123" } }`.
The browser's identity is a bearer token (ADR-043).
2. Hub's `CallAdapter` runs `AccessControl::check` on `channel/open` with
the browser's identity. If the browser lacks `channel:open:alknet/tty`
or the hub's policy forbids it → `channel:forbidden`.
3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for` handler
constructs a `call.requested` with the hub as caller and the browser as
`forwarded_for` (ADR-032 §3). The spoke receives
`channel/open` with `caller = hub`, `forwarded_for = browser`.
4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as caller
(the spoke authorizes the hub, its direct peer — ADR-050). The spoke's
ownership store (ADR-050) verifies the hub (or the `forwarded_for`
browser, if the spoke's policy says so) owns `container:abc123`.
5. Spoke allocates the channel via `TtyAdapter` / `DockerTtyBackend`, returns
`channel_id`.
6. Hub opens a matching channel on the browser's side (it's now the
*responder* for the browser leg, *initiator* for the spoke leg) and
bridges them: read chunks off browser channel, write onto spoke channel
(with `channel_id` remapped), and vice versa.
The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
layer inherited the auth model by being a call-protocol operation.
## Channel Manager and Connection Internals
This section fills the second conceptual gap: what state the channels layer
holds, how `ChannelsAdapter::handle` is wired, and how the `channel/open`
handler threads back into the `CallAdapter`'s `OperationRegistry`. The
guiding constraint is that **the channels layer is a re-framing proxy** — it
converts between "one transport stream carrying N channels" (the wire) and
"N independent `AsyncRead + AsyncWrite` handles" (what handlers see) — and
it does **no protocol work itself**.
### The two halves: ChannelsAdapter and ChannelManager
The channels crate has two internal components, split by responsibility:
1. **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alknet/channels`. Its `handle()` receives one `Connection` (the
transport), reads 9-byte chunk headers off the single bidi stream, and
routes each chunk to the `ChannelManager`. It is the *read/demux* half.
It does not know what ALPNs exist or what a handler is — it just splits
streams.
2. **`ChannelManager`** — the shared state both halves touch. It holds the
map of `channel_id → ChannelState`, the `HandlerRegistry` reference, and
the `OperationRegistry` reference. It is the *reassemble/allocate* half.
It is what the `channel/open` operation handler closes over.
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
pattern, generalized: the adapter no longer drives one session — it drives
N channels, and the channel-0 session is special only in that it's pre-
allocated.
### ChannelManager state
```rust
pub struct ChannelManager {
/// channel_id → per-channel state. Channel 0 is pre-inserted at construction.
channels: Mutex<HashMap<u32, ChannelState>>,
/// The handler registry for looking up ALPNs on channel/open.
handlers: Arc<HandlerRegistry>,
/// The call protocol's operation registry, so channel/open etc. can be
/// registered at assembly time. The ChannelsAdapter holds a clone.
call_ops: Arc<OperationRegistry>,
/// Next server-assigned channel_id. Monotonic; wraps at u32::MAX.
next_id: AtomicU32,
/// Per-channel reassembly buffer cap (DP-5). Default 1 MiB.
buffer_cap: usize,
/// Per-connection channel limit (OQ-CH-06). Default 256.
max_channels: usize,
}
struct ChannelState {
/// The ALPN this channel carries, for routing and observability.
alpn: String,
/// Reassembly buffers per active stream_type. Each is a bounded
/// channel feeding a `SendStream`/`RecvStream` returned to the handler.
streams: HashMap<u8, ReassemblyBuffer>,
/// The handler task driving this channel (TtyAdapter::drive_session,
/// SshAdapter::handle, etc.). Dropping this aborts the channel.
handler_task: JoinHandle<()>,
/// Which stream_types are active (from the open negotiation).
stream_types: Vec<u8>,
}
```
The `ChannelManager` is `Clone` (cheap — it's an `Arc` internally) so that
the `ChannelsAdapter`, the `channel/open` operation handler, and any relay
logic can all hold a handle to it.
### ChannelsAdapter::handle — the read/demux loop
```rust
#[async_trait]
impl ProtocolHandler for ChannelsAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/channels" }
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
// One bidi stream carries all channels.
let (send, recv) = connection.accept_bi().await?;
// Channel 0 is pre-negotiated as alknet/call. Construct its
// reassembly buffers and hand the reassembled Connection to the
// CallAdapter (looked up in the registry, same as every other ALPN).
self.manager.preinstall_channel_0(send, recv, auth).await?;
// Now read chunks off the transport and route them.
self.manager.run_demux_loop().await
}
}
```
The `preinstall_channel_0` step is the only special case: it constructs the
reassembly buffers for `channel_id = 0`, wraps them as a `Connection` (via
`Connection::from_stream`, which already exists in `crates/alknet-core/src/
types.rs`), and hands that `Connection` to the `CallAdapter` — exactly as if
`alknet/call` had been the top-level ALPN. The `CallAdapter` is none the
wiser: it calls `accept_bi()` on its `Connection`, gets one bidi stream (the
channel-0 reassembled stream), and runs its dispatch loop. The call
protocol's `EventEnvelope` frames ride on `stream_type = 0` of channel 0.
The `run_demux_loop` reads 9-byte headers, looks up `channel_id` in
`channels`, and pushes the payload into the right `ReassemblyBuffer` for
`(channel_id, stream_type)`. If the buffer is full (DP-5), the loop
**stops reading that channel's chunks** until the consumer drains — this is
the backpressure mechanism. Other channels keep flowing. This is the one
place the channels layer does flow control, and it's deliberately simple.
### The channel/open handler — threading into OperationRegistry
The `channel/open` (and `channel/close`, `channel/control`,
`channel/resources`) operations are registered on the call protocol's
`OperationRegistry` at **assembly time**, before the endpoint starts. The
handler closures close over a `ChannelManager` clone:
```rust
// At assembly time, after both adapters are constructed:
let channel_ops = ChannelOperations::new(manager.clone());
channel_ops.register_on(&mut call_registry)?;
```
Where `ChannelOperations::register_on` inserts four
`HandlerRegistration`s (one per operation) into the `OperationRegistry`.
The `channel/open` handler is the interesting one:
```rust
fn channel_open_handler(mgr: ChannelManager) -> Handler {
Arc::new(move |input: Value, ctx: OperationContext| {
let mgr = mgr.clone();
Box::pin(async move {
let req: ChannelOpenRequest = serde_json::from_value(input)?;
// 1. ACL is already checked by OperationRegistry::invoke before
// this handler runs — AccessControl::check on the operation.
// 2. Look up the ALPN in the HandlerRegistry.
let handler = mgr.handlers.get(req.alpn.as_bytes())
.ok_or_else(|| channel_error("channel:unknown_alpn"))?;
// 3. Allocate the channel_id (server-assigned, DP-1).
let channel_id = mgr.next_id.fetch_add(1, Relaxed);
// 4. Construct the reassembly buffers for the negotiated
// stream_types and wrap as a Connection.
let conn = mgr.build_channel_connection(channel_id, &req.stream_types);
// 5. Spawn the handler task — same as TtyAdapter::handle does
// today, but on a ChannelConnection instead of a QUIC conn.
let task = tokio::spawn(async move {
let _ = handler.handle(conn, &auth_from_ctx(&ctx)).await;
});
// 6. Record the channel state.
mgr.insert_channel(channel_id, ChannelState { /* ... */ });
// 7. Return the channel_id.
ResponseEnvelope::ok(ctx.request_id, json!({
"channel_id": channel_id,
"stream_types": req.stream_types,
}))
})
})
}
```
The key insight: **step 5 is identical to what `TtyAdapter::handle` does
today** (`crates/alknet-tty/src/adapter.rs:130`) — `tokio::spawn` a
`drive_session` task per bidi stream. The only difference is the `Connection`
passed in is a `ChannelConnection` (backed by reassembly) rather than a
quinn connection. The handler doesn't care.
### ChannelConnection as a Connection
`ChannelConnection` is a new `ConnectionKind` variant, or — to avoid
touching `alknet-core` — a `Connection` constructed via the existing
`Connection::from_stream` path, where the "stream" is a reassembly buffer
pair. The reassembly buffer exposes `AsyncRead` (drained by the handler's
`RecvStream`) and the write side exposes `AsyncWrite` (the handler's
`SendStream`, which the channels layer reads from and re-chunks onto the
transport with the right `channel_id`).
The flow for a handler writing to its `SendStream`:
```
handler writes bytes
→ SendStream::poll_write (AsyncWrite on the reassembly write-half)
→ channels layer's per-channel pump reads from the write-half
→ frames as [channel_id][stream_type][length][payload] onto the transport
```
And for reading:
```
transport arrives with a chunk for (channel_id, stream_type)
→ ChannelsAdapter::run_demux_loop routes payload to ReassemblyBuffer
→ handler's RecvStream::poll_read (AsyncRead on the reassembly read-half) yields bytes
```
The `stream_type` is chosen by *which* `SendStream`/`RecvStream` the handler
writes to. A TTY handler gets four handles (stdin=0, stdout=1, stderr=2,
control=3); a tunnel handler gets two (data-in=0, data-out=1). The
`ChannelConnection::accept_bi()` call returns the pair for the *next*
expected stream_type, or — more concretely — the handler is handed a typed
struct (`TtyChannel { stdin, stdout, stderr, control }`) rather than a
generic `Connection`, because the channel's stream_types are known at open
time.
This is a slight divergence from "every handler sees a `Connection`": TTY
wants four named handles, not a `Connection` you call `accept_bi` on four
times. The resolution (carried into Phase 1): the `ChannelConnection`
*implements* the `Connection` interface (for recursion and generic
handlers), **and** can be destructured into typed sub-stream handles for
handlers that know their ALPN's shape. The typed destructure is a
convenience layer over the same reassembly buffers; it's not a separate
abstraction.
### The hub relay: ChannelManager-to-ChannelManager
For the hub use case (§Hub Motivation), the hub holds *two* `ChannelManager`
instances per (browser, spoke) pair — one for the browser leg, one for the
spoke leg — and a relay task per channel that bridges them:
```rust
// For channel_id=7 on browser side, channel_id=12 on spoke side:
tokio::spawn(async move {
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await;
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await;
tokio::join!(
pump(b_recv, s_send), // browser → spoke
pump(s_recv, b_send), // spoke → browser
);
});
```
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream
and writes them onto the other's write-half, which re-chunks them with the
other leg's `channel_id`. The relay does not parse the bytes — it doesn't
know if they're TTY chunks, SSH frames, or tunnel data. The channels layer
on each end does the chunk↔stream conversion; the relay just moves bytes
between two `AsyncRead + AsyncWrite` pairs.
This is why the hub's complexity collapses: the relay is **one pump
function** applied per channel, not per (protocol × transport) cell. The
`ChannelManager` is the uniform interface on both ends.
### What is NOT in the ChannelManager
To keep the boundary clean, the `ChannelManager` deliberately does **not**
hold:
- **No `ProtocolHandler` implementations.** It holds a `HandlerRegistry`
reference for ALPN lookup, but it doesn't *be* a handler. The handlers
live in their crates and register on the same registry.
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
SSH binary frames, or tunnel target strings. It hands `params` JSON to
the handler and gets back a handler task; it hands `stream_type 3` JSON
to the handler's control handle. The `ChannelManager` is ALPN-blind.
- **No auth state.** Auth lives in the `OperationContext` that the call
protocol passes to `channel/open`. The `ChannelManager` doesn't check
scopes or ownership — that's `AccessControl::check` in
`OperationRegistry::invoke`, run before the `channel/open` handler is
called.
- **No transport coupling.** The `ChannelManager` talks to the transport
only through the `ChannelsAdapter`'s read loop and the per-channel write
pumps, both of which use `AsyncRead + AsyncWrite`. QUIC, TCP+TLS,
WebTransport, SSH channel — all look the same.
This is what makes the channels layer WASM-compatible (§WASM Compatibility)
and transport-agnostic (§Transport Agnosticism): the `ChannelManager` is
pure byte routing with no platform or protocol dependencies.
## Relationship to Existing Crates
### alknet-tty
The TTY crate's chunk format becomes a special case of the channels format
(single-channel, no `channel_id` header). Two paths forward:
**Path A (recommended): TTY becomes a channel consumer.** The `TtyAdapter`
no longer owns its own chunk format or its own ALPN. It receives a
`Connection` from the channels layer and drives sessions the same way it does
today. The chunk format becomes the channels layer's concern, not TTY's. The
`alknet/tty` ALPN remains for direct connections (no channels layer), but the
primary path is through channels.
**Path B: TTY keeps its own ALPN and chunk format.** The channels layer and
TTY coexist. TTY's chunk format is a fixed 4-channel subset of the channels
format. A future migration could unify them. This is lower-risk but leaves
two chunk formats in the codebase.
The `TtyBackend` trait and `TtyHandle` are unchanged. The `DockerTtyBackend`
and `LocalTtyBackend` don't need to know about channels — they still
implement `TtyBackend::allocate()` and return a `TtyHandle`. The channels
layer handles the wire format; the TTY adapter handles the session
lifecycle.
### alknet-call
The call protocol is unchanged. It remains JSON-only, `EventEnvelope`-based.
It runs on channel 0 exactly as it runs on a top-level `alknet/call` QUIC
connection — the `CallAdapter` receives a `Connection` and dispatches
operations. The only difference is that the `Connection` is backed by chunk
reassembly on channel 0 rather than by a QUIC bidi stream.
What changes is that the call protocol gains a new class of operations:
channel lifecycle operations.
New call operations:
- `channel/open` — open a data channel with a given ALPN and params.
Bidirectional: either side can initiate.
- `channel/close` — close a data channel
- `channel/control` — send a control message on a channel's stream_type 3
Existing call operations are unchanged. The call protocol doesn't need to
know about raw bytes — it just hands out channel tokens and dispatches
control operations. The `from_call` operation overlay (where each side
populates what operations they can call) extends naturally to channels:
either side can expose resources the other can open channels to.
### alknet-ssh
The SSH crate's relationship to channels is twofold:
1. **SSH as a channel type**: an `alknet/ssh` channel carries the SSH binary
protocol over stream_types 0 and 1. The channels layer hands the
reassembled stream to `SshAdapter`, which feeds it to russh. Russh
multiplexes SSH channels inside. This is SSH-over-channels — one level of
multiplexing (channels) carrying another (SSH).
2. **SSH as a channels transport**: an SSH `direct-tcpip` channel could
carry a channels connection. This is channels-over-SSH — the reverse
composition. Useful for tunneling alknet through an SSH bastion.
The SSH crate doesn't need to know about channels. It implements
`ProtocolHandler` for `alknet/ssh` and accepts a `Connection`. Whether that
`Connection` is a top-level QUIC connection or a channels data channel is
transparent to the handler.
### alknet-docker
The docker crate registers call operations on the call protocol
(`docker/container/list`, `docker/container/create`, etc.) and a
`DockerTtyBackend` for interactive sessions. With channels:
- Docker lifecycle operations are call operations on channel 0 (unchanged
from ADR-058).
- Interactive exec/attach opens a TTY channel via `channel/open` with ALPN
`alknet/tty` and backend `docker`. The channels layer routes to the
`TtyAdapter`, which dispatches to `DockerTtyBackend`.
- No separate `alknet/tty` connection needed. One `alknet/channels`
connection handles both JSON operations and raw TTY sessions.
## Transport Agnosticism
The channels wire format works over any ordered, reliable bidirectional byte
stream:
| Transport | How |
|-----------|-----|
| QUIC bidi stream | `alknet/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
| TCP+TLS | `alknet/channels` ALPN on a TLS connection; the TCP stream carries all channels |
| WebTransport | `alknet/channels` session proxied through the h3 handler (ADR-040) |
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel |
| Another channels connection | recursive composition (channel type `alknet/channels`) |
The same wire format, the same chunk reassembly, the same `Connection`
abstraction. The transport is a parameter, not a design constraint.
## WASM Compatibility
The channels wire format is pure byte manipulation: read a 9-byte header,
split the payload, route to the right reassembly buffer. No platform
dependencies, no tokio-specific types in the core splitting/recombining
logic. A WASM build can:
1. Read chunks from a WebTransport `BiStream`
2. Reassemble `(channel_id, stream_type)` streams
3. Present them as `AsyncRead + AsyncWrite` handles to WASM-compatible
handlers
The handlers themselves may or may not be WASM-compatible (russh's client is;
`portable_pty` is not). But the channels layer — the multiplexing proxy —
is WASM-compatible by construction. A browser can run a channels client in
WASM over WebTransport, opening TTY channels, SSH channels, and tunnel
channels, with the call protocol on channel 0 orchestrating everything.
## ACL and Security Model
### Channel open is gated by the call protocol's ACL
Every `channel/open` operation goes through the call protocol's existing
`OperationContext` (identity, scopes, capabilities, ownership). No channel
opens without authorization. The ACL check happens before the handler is
invoked.
### No open ports by default
A core design principle carried from the SSH research (DP-5: default-deny
baseline): alknet endpoints never expose ports, sockets, or listeners unless
explicitly configured. Tunnels are channels, not ports. A `tunnel/open`
operation targets a resource (`container:abc123`, `service:postgres`), not a
port number. The hub resolves the resource to a stream. No listener, no
socket, no port — just a channel.
This is the "VPN-like without being a VPN" property: bidirectional stream
proxying through channels, gated by ACL, with no network-level exposure.
### Resources, not addresses
Forwarding destinations and tunnel targets are expressed as resources in the
alknet identity/ownership model, not as IP:port pairs. A docker container
hosting a service is a resource (`container:abc123`). Access is granted
through ownership or scopes, not through firewall rules. This avoids the
global trend of VPN restrictions — there are no VPN ports to block because
there are no VPN ports.
## Decision Points
### DP-1: Channel ID allocation — server-assigned vs client-assigned vs negotiated
*(Recommended: server-assigned, returned in the `channel/open` response)*
Three options:
- **(a) Client-assigned**: client picks a `channel_id` in the open request.
Risk of collision if two clients open channels simultaneously.
- **(b) Server-assigned**: server returns the `channel_id` in the open
response. No collision risk. Slight latency (one round-trip before data
can flow).
- **(c) Negotiated ranges**: client and server each own half the ID space
(e.g., client uses odd IDs, server uses even). SSH uses this. More
complex, but zero-round-trip channel open.
**Recommendation**: **(b) server-assigned** for simplicity. The round-trip
for channel open is acceptable — it's the same round-trip the call protocol
already makes for every operation. If zero-round-trip channel open becomes a
performance requirement, option (c) can be added as an extension (two-way
door).
### DP-2: Channel 0 — pre-negotiated `alknet/call`, not a special control plane
*(Recommended: channel 0 is just `alknet/call` pre-negotiated — no special framing)*
Channel 0 is not a "control plane" with its own wire format. It is simply
the `alknet/call` ALPN, pre-negotiated: both sides know that channel 0 is
routed to the `CallAdapter` without an explicit `channel/open` exchange. The
call protocol runs on channel 0 exactly as it runs on a top-level
`alknet/call` QUIC connection — `EventEnvelope` frames on a bidirectional
stream backed by chunk reassembly.
This means:
- Channel 0 uses the same chunk format as every other channel
(`channel_id=0` in the header)
- The `CallAdapter` receives a `Connection` and dispatches operations —
unchanged from today
- Channel lifecycle operations (`channel/open`, `channel/close`) are just
call operations on channel 0, dispatched through the `OperationRegistry`
- No special framing disambiguation is needed — `channel_id` already
distinguishes channel 0 from data channels
**Recommendation**: channel 0 is `alknet/call` pre-negotiated. No special
framing, no separate control-plane wire format. The channels layer is a
transparent proxy: every channel (including channel 0) is just an ALPN
routed through the `HandlerRegistry`.
### DP-3: TTY chunk format — absorb into channels vs keep separate
*(Recommended: absorb into channels, keep `alknet/tty` ALPN as a direct-connect shortcut)*
The TTY crate's chunk format (`[stream_type: u8][length: u32 be]`) is a
subset of the channels format (it's a channels chunk with `channel_id`
implicitly 0 and only stream_types 0-3). Two paths:
- **(a) Absorb**: TTY sessions use the channels format. The `TtyAdapter`
becomes a channel consumer. The `alknet/tty` ALPN remains as a
direct-connect shortcut (no channels layer needed for simple cases), but
the primary path is through channels.
- **(b) Keep separate**: TTY keeps its own chunk format and ALPN. Channels
and TTY coexist as separate multiplexing strategies. Two chunk formats in
the codebase.
**Recommendation**: **(a) absorb**. The TTY crate's chunk format was always
described as "a deliberately impoverished version of SSH's channel
multiplexer" (alknet-tty phase-0-findings.md). Channels is the
non-impoverished version. The `alknet/tty` ALPN stays for direct connections
(useful for simple deployments, browser terminals), but the chunk format
unifies under channels.
### DP-4: Control messages — call protocol operations vs stream_type 3 chunks
*(Recommended: both, with clear division of responsibility)*
Channel-specific control can flow through two paths:
- **(a) Call protocol operations**: `tty/resize`, `tty/signal`,
`channel/close` — dispatched through the `OperationRegistry` on channel 0.
Benefits from the call protocol's existing auth, routing, and
observability.
- **(b) stream_type 3 chunks**: inline control messages on the data
channel. Lower latency (no channel 0 round-trip), but requires the data
channel handler to parse and dispatch them.
**Recommendation**: **both**. Use call protocol operations for
channel lifecycle (open, close) and infrequent control (resize, signal). Use
stream_type 3 for control that must be ordered relative to data (EOF before
exit). The TTY crate's exit-chunk-is-last invariant (ADR-055) is an example
of ordering-sensitive control that belongs on the data channel. The division
is: call protocol for orchestration, stream_type 3 for data-ordered control.
### DP-5: Stream reassembly — backpressure and flow control
*(Recommended: rely on the transport's flow control; add channel-level
windowing only if needed)*
QUIC provides per-stream flow control. TCP provides connection-level flow
control. The channels layer adds a third multiplexing level. If one data
channel's consumer is slow, it could block all other channels on the same
transport (head-of-line blocking).
Options:
- **(a) No channel-level flow control**: rely on the transport's flow
control. Simple, but susceptible to head-of-line blocking.
- **(b) Channel-level windowing**: each channel has its own flow-control
window (like SSH's `ChannelParams.window_size`). Prevents head-of-line
blocking but adds complexity.
- **(c) Bounded channels with backpressure**: each channel's reassembly
buffer has a maximum size; when full, the sender stops reading chunks for
that channel. Simpler than full windowing, prevents unbounded memory
growth.
**Recommendation**: **(c) bounded channels** for v1. A configurable maximum
buffer size per channel (default 1 MiB). When a channel's buffer is full, the
channels layer stops reading chunks for that `channel_id` until the consumer
drains it. This prevents memory exhaustion without the complexity of SSH's
sliding-window protocol. If head-of-line blocking becomes a real problem
(which it likely won't for the intended use cases — TTY sessions and tunnels
are not high-throughput), full windowing can be added as an extension
(two-way door).
### DP-6: Crate structure — new crate vs extension of alknet-tty
*(Recommended: new `alknet-channels` crate)*
The channels layer is a generalization of TTY's chunk format, but it's a
different thing: a multiplexing proxy, not a terminal session handler. Two
options:
- **(a) New crate `alknet-channels`**: depends on `alknet-core` (for
`ProtocolHandler`, `Connection`, `HandlerRegistry`). Defines the chunk
format, the `ChannelConnection`, and the `ChannelsAdapter`. TTY becomes a
consumer.
- **(b) Extend `alknet-tty`**: add `channel_id` to the chunk format, add
the `ChannelConnection` abstraction. TTY is the base; channels is the
generalization. Simpler crate graph, but blurs the conceptual boundary.
**Recommendation**: **(a) new crate**. The channels layer is a distinct
concept (multiplexing proxy) from TTY (terminal session handler). Keeping
them separate follows the existing pattern (ADR-003: no-handler-depends-on-
another-handler) and makes the dependency graph clear: `alknet-channels`
depends on `alknet-core`; `alknet-tty` depends on `alknet-core` (unchanged);
both register on the same `HandlerRegistry`.
## Straightforward Parts
### 1. The chunk format is a thin extension of a proven design
The TTY crate's chunk format (`[stream_type: u8][length: u32 be]`) is
validated by two POCs (alknet-docker-poc, alknet-tty-poc) and implemented in
production code (`crates/alknet-tty/src/wire.rs`). Adding a `channel_id: u32`
prefix is a 4-byte extension to a proven format. The `ChunkReader`/
`ChunkWriter` pattern, the framing disambiguation trick, and the
bidirectional pump pattern all carry forward.
### 2. The `Connection` abstraction already supports this
`alknet-core`'s `Connection` type already wraps multiple backends (QUIC,
iroh, raw stream) behind a uniform `accept_bi()` / `open_bi()` interface. A
`ChannelConnection` is a new backend for the same interface. The
`HandlerRegistry` and all `ProtocolHandler` implementations work unchanged.
### 3. The call protocol needs no wire-format changes
The call protocol stays JSON-only, `EventEnvelope`-based. The new
`channel/open`, `channel/close`, and channel-scoped control operations are
just new operation types in the `OperationRegistry`. No new framing, no new
carriage types, no protocol version bump.
### 4. WASM compatibility is by construction
Chunk splitting and reassembly is pure byte manipulation. The channels layer
has no platform dependencies. A WASM build can read chunks from a
WebTransport stream, reassemble them, and present `AsyncRead + AsyncWrite`
handles to WASM-compatible handlers. The browser case (xterm.js over
WebTransport to a TTY channel) works without the browser implementing SSH.
### 5. ACL is inherited from the call protocol
Channel open goes through the call protocol's existing `OperationContext`.
Identity, scopes, capabilities, and ownership checks apply to channel open
exactly as they apply to any other call operation. No new auth machinery.
## Less Straightforward Parts
### The TTY control channel is currently not bidirectional
The TTY crate's `ControlMessage` enum has server→client variants (`Exit`)
and client→server variants (`Resize`, `Signal`, `Eof`), but the adapter
ignores `Exit` from the client. The control channel *could* be fully
bidirectional — there's no wire-format reason it isn't. With channels, the
call protocol on channel 0 handles bidirectional control, and stream_type 3
on the data channel handles data-ordered control. The TTY crate's
`ControlMessage` enum may need to be split or generalized.
### The TTY adapter's session lifecycle assumes it owns the stream
The `TtyAdapter::handle()` currently loops `accept_bi()`, spawning a
`drive_session` task for each stream. Inside a channels connection, the
adapter receives a `Connection` that yields bidi streams — but those streams
are already channel-demuxed. The adapter doesn't need to know this. The
question is whether the adapter's current "one session per bidi stream"
model maps cleanly to "one session per channel." It should — a channel is
just a bidi stream with extra metadata.
### The exit-chunk-is-last invariant (ADR-055) with channel close
The TTY crate guarantees the exit chunk is the last chunk before stream
close. With channels, the exit chunk is the last chunk on that channel, and
then a `channel/close` operation on channel 0 signals completion. The
ordering between the exit chunk and the close operation must be preserved:
the exit chunk must be written and flushed before the close operation is
sent. This is an implementation constraint, not a design change.
### Single-stream throughput ceiling and the recursive escape hatch
All channels on one `alknet/channels` connection share one transport
stream's flow-control window. On QUIC that's one bidi stream's window; on
TCP it's the connection's. A slow consumer on one channel can backpressure
others (DP-5's bounded-buffer mitigation handles memory exhaustion but not
throughput). For high-throughput bulk transfer (e.g., a large file
download), parallel QUIC bidi streams or parallel connections will beat one
multiplexed stream — same trade-off as HTTP/2-over-TLS vs HTTP/3-over-QUIC.
This is **not a channels concern**. The 9-byte chunk overhead is
negligible; the cost is the shared transport window, which is a property of
the underlying transport, not the channels multiplexer. The channels layer
is deliberately not a throughput optimizer — it's a multiplexing proxy.
The **recursive composition** (§Recursive Composition) is the escape hatch,
but not in the "cross-connection token" sense. The answer is simpler: a
client that wants parallel throughput opens **N independent
`alknet/channels` connections** and load-balances across them (e.g., a file
transfer splits byte ranges across channels on different connections).
Each connection is self-contained — its own channel 0, its own flow-control
window, its own backpressure. No token, no cross-wiring between
connections, no shared state across the connections. The `Connection`
abstraction already treats each one uniformly.
What the channels layer deliberately does **not** do:
- **No cross-connection channels.** A channel on connection B is not
openable from connection A's channel 0. Each channels connection is
self-contained; its channel 0 orchestrates only its own channels.
- **No cross-connection coordination primitives.** No token exchange, no
shared `channel_id` namespace, no "open a channel on that other
connection" operation. That coordination belongs at the call-protocol /
application layer ("open another channels connection, here's its
address"), not inside the channels multiplexer.
- **No parallel-transfer optimization in the channels layer.** Splitting a
download across N connections and N channels is a **client optimization for
a specific edge case** (fast file transfer). It's a real and useful
concern, but it's a downstream client concern — the fs/storage client
decides to open N connections and chunk the byte ranges. The channels
layer's job is to carry one channel's bytes well; the client's job is to
decide how many channels connections it needs.
For the intended use cases (TTY sessions, SSH, tunnels, call operations)
the single-stream ceiling will not bite — network RTT and handler work
dominate, and SSH tunneling (which has the same single-stream property) works
fine even for port-forwarding Redis. The throughput ceiling only matters for
bulk file transfer, and there the application uses multiple connections —
same as any multiplexer.
## Open Questions to Carry into Phase 1
- **OQ-CH-01 (channel open round-trip)**: server-assigned channel IDs mean
one round-trip before data can flow. Is this acceptable for all channel
types? For TTY sessions the negotiation frame already requires a
round-trip. For tunnels it adds latency to connection establishment. If
zero-round-trip open is needed, negotiated ID ranges (DP-1 option c) can
be added.
- **OQ-CH-02 (TTY migration path)**: if the TTY chunk format is absorbed
into channels, what's the migration path for existing `alknet/tty`
deployments? The `alknet/tty` ALPN stays as a direct-connect shortcut.
Does the TTY adapter's wire format change, or does it keep its current
chunk format for direct connections and use the channels format when
inside a channels connection?
- **OQ-CH-03 (channel-level flow control)**: is bounded-buffer backpressure
(DP-5 option c) sufficient, or will head-of-line blocking be a real
problem? The intended use cases (TTY sessions, tunnels, SSH connections)
are not high-throughput. If a use case emerges that saturates a channel
(e.g., file transfer over a tunnel), channel-level windowing may be
needed.
- **OQ-CH-04 (channel ID reuse)**: after a channel is closed, can its
`channel_id` be reused? SSH allows reuse. The channels format should
allow it too, but the reassembly buffers must be fully drained before
reuse to prevent data from the old channel leaking into the new one.
- **OQ-CH-05 (maximum channels per connection)**: is there a limit? SSH
uses `u32` channel IDs, effectively unlimited. The channels format should
match. The practical limit is memory (reassembly buffers per channel) and
the transport's flow control.
- **OQ-CH-06 (channel open denial-of-service)**: an authenticated peer
could open many channels and never read from them, exhausting memory.
Bounded per-channel buffers (DP-5) limit the damage. A per-connection
channel limit (configurable, default e.g. 256) provides defense in depth.
- **OQ-CH-07 (POC scope)**: what should a Phase 0 POC validate? Minimum:
chunk format round-trip with multiple channels, channel open/close via
call protocol, TTY session inside a channel. Stretch: SSH connection
inside a channel, tunnel inside a channel, recursive composition.
- **OQ-CH-08 (channel/resources staleness)**: `channel/resources` is a
snapshot of what a side exposes at call time. Resources can appear and
disappear (a docker container starts/stops, a worker connects/disconnects).
Does the channels layer need a `channel/resources/subscribe` streaming
operation (analogous to a subscription), or is polling `channel/resources`
sufficient for v1? The call protocol already has streaming
`OperationType::Subscription`; reusing it here is natural but adds a
long-lived stream on channel 0 per interested peer. Recommendation for
Phase 1: poll for v1, add subscription if staleness bites.
- **OQ-CH-09 (responder-to-initiator channel lifecycle)**: in the
`responder-to-initiator` open case (worker exposes, hub consumes), who
drives the data first? The channel is allocated on the worker's side, but
the hub is the client of the ALPN — does the hub's handler start pumping
immediately, or does the worker's handler wait for the hub to write first?
For TTY the server writes the negotiation response first; for a tunnel the
client connects first. This is ALPN-specific and probably doesn't need a
channels-layer rule, but the `direction` field's effect on which side's
`ChannelConnection` is the "server" vs "client" of the ALPN needs to be
pinned down in Phase 1.
- **OQ-CH-10 (ChannelConnection: typed destructure vs generic Connection)**:
the doc proposes that `ChannelConnection` implements the `Connection`
trait (for recursion and generic handlers) **and** can be destructured
into typed sub-stream handles (e.g. `TtyChannel { stdin, stdout, stderr,
control }`). Is the typed destructure a `ChannelConnection` method, a
separate trait, or an ALPN-specific constructor in the handler crate?
This affects whether `alknet-channels` needs to know about TTY's
`stream_type` semantics or whether the TTY crate does the destructure
itself given a `ChannelConnection`. Recommendation: the TTY crate
destructures — `alknet-channels` exposes `(channel_id, stream_type) →
(SendStream, RecvStream)` accessors and the handler crate maps those to
its typed names.
- **OQ-CH-11 (hub relay channel_id remapping)**: when the hub bridges a
browser channel (`channel_id=7`) to a spoke channel (`channel_id=12`),
the relay rewrites the `channel_id` field in each chunk header. But
`channel/control` and `channel/close` operations on channel 0 carry
`channel_id` in their *JSON payload*, not in the chunk header. Does the
hub's call-protocol forwarding (via `from_call`) need to rewrite
`channel_id` inside the `call.requested` payload too? This is a
relay-level concern: the hub is terminating channel 0 on both legs (it
runs its own `CallAdapter`), so it likely translates `channel/open` from
the browser into a *new* `channel/open` on the spoke (not a raw forward),
and the spoke's returned `channel_id` is mapped back to the browser's
side. Phase 1 must specify whether the hub translates or transparently
forwards, and how the `channel_id` mapping is maintained.
- **OQ-CH-12 (unknown channel_id on demux)** — **RESOLVED by POC (lenient
drop).** The POC validated the recommended v1 behavior: a chunk with an
unallocated `channel_id` (or an unallocated `stream_type` on an allocated
channel) is dropped with a debug log and an error counter, and the demux
continues (`demux_unknown_channel_drops_lenient`). This matches SSH's
behavior and survives transient mis-ordering during teardown. The error
counter is exposed via `Demux::stats()` for observability. See
`poc-summary.md` §"POC Target 1".
- **OQ-CH-13 (core trait for bidi-stream sources)** — **CONFIRMED +EV by
POC; do the refactor.** The POC validated the yield-once
`Connection::from_stream` path is sufficient (as the plan predicted), but
wiring `ChannelEndpoint::open_echo_channel` made the awkwardness concrete:
each channel constructs a fresh yield-once `Connection`, and the endpoint
holds a bag of these rather than a single `ChannelConnection` that yields
N streams. The `BidiStreamSource` trait is the fix:
```rust
trait BidiStreamSource: Send + Sync {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
}
```
with `Connection` holding `Box<dyn BidiStreamSource>`, and QUIC/Iroh/
Stream/Channels all implementing it. The POC confirms this is **additive**
(existing callers keep working via a `from_stream`-backed default impl)
and cleans up both the channels layer and the client-side endpoint
(OQ-CH-14). **Recommendation: do this refactor alongside the channels
crate.** It is not a major/pita break and makes downstream substantially
cleaner. See `poc-summary.md` §"Issues Surfaced" #1.
- **OQ-CH-14 (client-side channels endpoint + core `AlknetClient`)**:
both sides of a channels connection do the demux/mux work. The server
side is a `ProtocolHandler` (`ChannelsAdapter::handle`). The client side
needs a symmetric type — `ChannelClient` — that opens a transport, runs
the demux/mux, and exposes `open_channel(alpn, params) -> Channel` to the
application.
**Clarification on the broader core concern:** the original mention of a
"client in the core similar to `AlknetEndpoint`" was referring to a
general **`AlknetClient`** in `alknet-core` — the downstream-facing client
that crates use to connect to an alknet endpoint, paired with whichever
ALPN handler the downstream crate provides. Today there is only one
client (`CallClient` in `alknet-call`), so adding `AlknetClient` and
refactoring the one client onto it is not a massive undertaking. The
point is to make the overall interface **uniform**: downstream crates use
`AlknetClient` + their ALPN handler, and the server/client symmetry is
visible at the core level. This is broader than the channels crate and
may be partly out of scope here, but it's the context for OQ-CH-14.
The channels crate will **also** need that server/client concept. The
terminology gets muddy ("server" isn't quite right since a hub/worker can
act as both depending on the use case) but it's a clear mental model and
helps decide which side determines the channel id (the server does, per
DP-1). Crucially — like the call crate — **nothing at the core level
prevents a hub/worker from acting as both server and client**. Each side
populates its registry with the resources available to it from the other
side. The only current friction is that each client is built manually
today (which is why the basic `AlknetClient` was proposed). Phase 1 must
keep that bidirectional capability — each side fills its registry with
the resources available to it from the other side, same as the call
protocol's operation overlay.
The POC's `ChannelEndpoint` (POC-local client/server wiring, `poc-summary.md`
§"POC Target 2") is the shape Phase 1 will generalize into
`ChannelsAdapter::handle` (server) and `ChannelClient` (client). The
`BidiStreamSource` trait (OQ-CH-13) factors into this: if `AlknetEndpoint`
and `AlknetClient` (and `ChannelClient`) all produce `BidiStreamSource`s,
the client/server symmetry is cleaner. See `poc-summary.md` §"Issues
Surfaced" #1 and #5.
## POC-Validated Requirements (Carry into Phase 1)
The POC (`docs/research/alknet-channels/poc-summary.md`, 28 tests passing)
validated the core mechanics and surfaced invariants that Phase 1 must
specify. These are **requirements**, not open questions — the POC proved they
are necessary.
### Wire-level invariants (the channels crate spec)
**REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel.**
`MpscSendStream::poll_shutdown` must send an empty `Bytes` before dropping
the sender. Without this, the demux never sees EOF on the channel's
`stream_type`, and `tokio::io::copy` in the handler never completes — the
session hangs. The TTY crate's `pump_session` emits the zero-length stdout
sentinel explicitly via `Chunk::stdout(Bytes::new())`; the channels layer's
per-channel write pump does NOT forward a sentinel on sender-drop, so the
send adapter must. This is a **wire-level invariant** — both sides must
agree, or channels hang on clean shutdown. (poc-summary §"Issues Surfaced"
#4)
**REQ-CH-02: transport close → all channel senders drop → all handlers see
EOF.** `Demux::run` must clear its `channels` map on exit (transport closed)
so every handler's `MpscRecvStream` sees EOF even without an explicit
zero-length sentinel on the wire. Without this, `read_to_end` /
`tokio::io::copy` in handlers hangs forever waiting for a sender that never
drops because the demux task is holding the map. This is a teardown
invariant of the `ChannelsAdapter::handle` contract. (poc-summary §"Issues
Surfaced" #6)
**REQ-CH-03: mux needs dynamic registration (handle/runner split).** The
mux must support registering channels *after* the run loop has started
(not pre-registering all channels before run). The POC split `Mux` into
`MuxHandle` (clone-able, `register(channel_id, stream_type) -> Sender<Bytes>`
at any time) and `MuxRunner` (owns the transport, `select!`s on new-pump
registrations). The runner's `select!` loop exits when all `MuxHandle`
clones drop (the `new_pumps` sender closes), which is the natural shutdown
signal. This matches the dynamic `channel/open` model. (poc-summary §"Issues
Surfaced" #5)
**REQ-CH-04: lenient unknown-`channel_id` handling with error counter.**
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped
with a debug log and an error counter (exposed via `Demux::stats()`), and
the demux continues. This resolves OQ-CH-12. (poc-summary §"POC Target 1")
**REQ-CH-05: bounded-buffer backpressure (DP-5 option c) works and does
not deadlock.** Each `(channel_id, stream_type)` has an independent bounded
`mpsc` buffer. A slow reader on one channel does not block another. The
1 MiB tunnel test exercises this end-to-end with no deadlock. (poc-summary
§"POC Target 1", §"POC Target 3")
### Handler-level pattern (carry into the channels spec or a core helper)
**REQ-CH-06: two-pump handlers need explicit shutdown-on-completion.**
`tokio::try_join!(c2t, t2c)` deadlocks for a tunnel (each pump waits for
the other's EOF, which only comes once the *opposite* pump completes and
shuts down its sink). The fix: shut down the peer's sink when one pump
completes. The TTY adapter avoids this because its three pumps coordinate
via the `exit_code` future; a two-pump handler has no such third signal.
This pattern will recur (e.g. SSH `direct-tcpip` is the same two-pump
shape). Phase 1 should document it in the channels spec, or — if
`BidiStreamSource` lands — provide a helper in `alknet-core` that
encapsulates "two-pump with shutdown-on-completion" so handlers don't
reimplement it. (poc-summary §"Issues Surfaced" #7)
### Core refactor requirements (land alongside the channels crate)
**REQ-CORE-01: add `BidiStreamSource` trait to `alknet-core` (OQ-CH-13,
confirmed +EV).** `Connection` holds `Box<dyn BidiStreamSource>`;
QUIC/Iroh/Stream/Channels implement it. Existing callers keep working
via a `from_stream`-backed default impl. This is additive and not a
major/pita break. Makes `ChannelConnection` a first-class peer of QUIC
instead of a bag of yield-once Connections, and cleans up the client-side
endpoint (OQ-CH-14). (poc-summary §"Issues Surfaced" #1)
**REQ-CORE-02: fix `Connection::close` unused `code`/`reason` for the
stream backend.** `crates/alknet-core/src/types.rs:500` — the `Stream`
backend takes `code: u32, reason: &str` and does nothing with them (clippy
flags both). The `BidiStreamSource` refactor is the natural place to give
the stream backend a `close()` that doesn't take QUIC-shaped args. Low
priority but worth fixing while the trait is open. (poc-summary §"Issues
Surfaced" #2)
**REQ-CORE-03: add `AuthContext` test/POC helper.** Every POC handler test
constructs an `AuthContext` literal with four `None` fields and a hardcoded
ALPN. An `AuthContext::test(alpn)` or `AuthContext::anonymous(alpn)` helper
(behind a `test-utils` feature or a `pub fn`) would remove this recurring
boilerplate. Small, but it recurs in every handler POC. (poc-summary
§"Issues Surfaced" #3)
**REQ-CORE-04 (broader, may be partly out of scope): add `AlknetClient` to
`alknet-core`.** A general downstream-facing client that crates use to
connect to an alknet endpoint, paired with whichever ALPN handler the
downstream crate provides. Today there is only one client (`CallClient` in
`alknet-call`), so adding `AlknetClient` and refactoring the one client
onto it is not a massive undertaking. The point is to make the overall
interface **uniform**: downstream crates use `AlknetClient` + their ALPN
handler, and the server/client symmetry is visible at the core level. The
channels crate's `ChannelClient` (OQ-CH-14) would be a specialization of
this. Nothing at the core level should prevent a hub/worker from acting as
both server and client — each side fills its registry with the resources
available to it from the other side. (See OQ-CH-14 clarification.)
### Clippy / cosmetic (low priority)
**REQ-CH-07: `PollSender`-style adapter for mpsc-backed `AsyncWrite`.**
`MpscSendStream`'s in-flight `reserve_owned()` future is boxed as a complex
anonymous type that clippy flags. A `tokio_util::io::PollSender`-style
helper (or a small ~50-line adapter) would clean this up and will recur in
any mpsc-backed stream. Phase 1 should consider whether to extract this
to `alknet-core` or keep it crate-local. (poc-summary §"Issues Surfaced"
#8)
## Recommended Approach
### Crate
`alknet-channels`, depends on `alknet-core` (for `ProtocolHandler`,
`Connection`, `HandlerRegistry`, `SendStream`, `RecvStream`). Defines:
- The chunk format (`[channel_id: u32 be][stream_type: u8][length: u32 be]`)
- `ChunkReader` / `ChunkWriter` (generalized from TTY's wire module)
- `ChannelConnection` (implements the `Connection` interface over chunk
reassembly)
- `ChannelsAdapter` (`ProtocolHandler` for `alknet/channels`)
- Channel open/close call operations
Does not depend on `alknet-tty`, `alknet-call`, `alknet-ssh`, or any other
handler crate. The dependency direction is: handlers depend on
`alknet-core`; `alknet-channels` depends on `alknet-core`; nothing depends
on `alknet-channels` except the assembly layer that registers it on the
`HandlerRegistry`.
### Build order
**Step 1: Wire format + mock channels.**
- Implement the 9-byte chunk format with `channel_id`.
- Implement `ChunkReader` / `ChunkWriter` (generalized from TTY's
`wire.rs`).
- Implement `ChannelConnection` with mock channels (in-memory pipes).
- Validate chunk round-trip with multiple channels, concurrent reads/writes.
- **Result**: a working chunk multiplexer with no real handlers.
**Step 2: Channel open/close via call protocol.**
- Implement `channel/open` and `channel/close` call operations.
- Integrate with `HandlerRegistry` — `channel/open` looks up the ALPN,
validates ACL, invokes the handler.
- Validate: open a channel, pump data, close the channel.
- **Result**: call protocol orchestrating channel lifecycle.
**Step 3: TTY inside channels.**
- Route `alknet/tty` channel opens to the `TtyAdapter`.
- Validate: `channel/open` with ALPN `alknet/tty` → TTY session inside a
channel.
- **Result**: TTY sessions work through channels. The docker use case (call
operations + TTY sessions on one connection) is validated.
**Step 4: Tunnel channels.**
- Implement a simple tunnel handler: `channel/open` with ALPN
`alknet/tunnel` and a target resource → bidirectional byte proxy.
- Validate: tunnel a TCP connection through a channel.
- **Result**: the "VPN-like without being a VPN" property is proven.
**Step 5: SSH inside channels (stretch).**
- Route `alknet/ssh` channel opens to the `SshAdapter`.
- Validate: SSH connection inside a channel.
- **Result**: SSH-over-channels works. The full stack (call + TTY + SSH +
tunnel on one connection) is validated.
### De-risk POC — COMPLETE
**Status: complete.** The POC (`docs/research/alknet-channels/poc-summary.md`,
28 tests passing) validated the three highest-leverage unknowns. The plan
lives in `docs/research/alknet-channels/poc-plan.md`; the summary lives in
`docs/research/alknet-channels/poc-summary.md`.
What the POC proved:
1. **Chunk format + N-channel demux/mux** — the 9-byte format is a clean
generalization of TTY's 5-byte format. The sync core is pure and
WASM-compatible by construction (compiles under `wasm32-unknown-unknown`).
The mpsc-bridged async shell scales to N concurrent channels with
per-channel order preservation and cross-channel isolation. Bounded-
buffer backpressure works.
2. **Per-channel `Connection` presentation** — `Connection::from_stream` is
sufficient. An echo `ProtocolHandler` runs through the full
demux→Connection→handler→mux path with zero channels-layer awareness.
The yield-once `accept_bi` contract composes for looping handlers.
3. **Tunnel handler** — the bidirectional pump pattern (TTY's
`pump_session` shape, two pumps) works as a generic port proxy. A ~15-
line `TunnelHandler` proxies a channel to a local TCP echo server with
no chunk-format awareness, and coexists with an echo channel on one
connection without interference.
What the POC surfaced (now requirements, §POC-Validated Requirements):
- REQ-CH-01 through REQ-CH-07 — wire-level invariants and handler patterns
for the channels crate spec.
- REQ-CORE-01 through REQ-CORE-04 — the light `alknet-core` refactor
(chiefly the `BidiStreamSource` trait, OQ-CH-13 confirmed +EV).
- OQ-CH-12 resolved (lenient drop); OQ-CH-13 confirmed (do the refactor);
OQ-CH-14 clarified (`AlknetClient` in core + channels `ChannelClient`,
server/client bidirectionality preserved).
What the POC did NOT validate (out of scope, carried to Phase 1): the call
protocol, real transport, ACL, real adapters, recursive composition, hub
relay. The POC pushed the core mechanics beyond speculation into validated
territory; the remaining unknowns are spec-scope, not feasibility.
The POC lives at `/workspace/@alkdev/alknet-channels-poc/` (mirroring the
`alknet-tty-poc` convention), depends on `alknet-core` only.
## References
- `docs/research/alknet-channels/poc-summary.md` — the completed POC summary
(28 tests passing, the three validated targets, issues surfaced).
- `docs/research/alknet-channels/poc-plan.md` — the detailed de-risk POC
plan (Step 1: chunk format + demux/mux, Step 2: per-channel Connection,
Step 3: tunnel handler).
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
format, control channel, and backend trait. The seed of the channels
generalization.
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
multiplexer, the channel decomposition (Layers 1-7), the default-deny
baseline.
- `docs/research/alknet-docker/poc-summary.md` — the docker POC that
validated the raw chunk format and the two-carriage model.
- `crates/alknet-tty/src/wire.rs` — the current chunk format implementation
(`ChunkReader`, `ChunkWriter`, `[stream_type: u8][length: u32 be]`).
- `crates/alknet-tty/src/control.rs` — the `ControlMessage` enum (resize,
signal, eof, exit).
- `crates/alknet-tty/src/adapter.rs` — the `TtyAdapter` and `drive_session`
bidirectional pump.
- `crates/alknet-core/src/types.rs` — `ProtocolHandler`, `Connection`,
`SendStream`, `RecvStream`, `HandlerRegistry`.
- `crates/alknet-call/src/protocol/wire.rs` — `EventEnvelope`,
`FrameFramedReader` / `FrameFramedWriter`.
- `crates/alknet-call/src/protocol/dispatch.rs` — `Dispatcher::run_loop`,
`handle_stream`, `pump_stream`.
- `/workspace/russh/russh/src/channels/` — russh's channel implementation
(`ChannelMsg`, `Channel`, `ChannelReadHalf`, `ChannelWriteHalf`,
`WindowSizeRef`).
- `docs/architecture/decisions/001-alpn-protocol-dispatch.md` — ALPN
dispatch.
- `docs/architecture/decisions/002-protocol-handler-trait.md` —
ProtocolHandler.
- `docs/architecture/decisions/003-crate-decomposition.md` —
no-handler-depends-on-another-handler.
- `docs/architecture/decisions/040-webtransport-alpn-stream-proxy.md` —
WebTransport stream proxy (the browser path for channels).
- `docs/architecture/decisions/052-tty-wire-format.md` — TTY wire format
(ADR-052).
- `docs/architecture/decisions/055-tty-exit-chunk-ordering.md` — exit-chunk-
is-last invariant (ADR-055).
- `docs/architecture/decisions/058-docker-call-alpn.md` — docker on
`alknet/call` (ADR-058).