Files
deepseek-v4-pro 08e7df2aa0 feat: rename ALPN prefix from alknet/ to alk/ (v0.1.1)
- CHANNELS_ALPN: b"alknet/channels" → b"alk/channels"
- CallAdapter::alpn(): b"alknet/call" → b"alk/call"
- derive_alpn_from_op_name: alknet/ prefix → alk/ prefix
- All ALPN string literals in src/ and docs/ updated
- ADR-004 amended with prefix rename rationale
- AGENTS.md, README.md updated
- Version bumped to 0.1.1

Review: docs/reviews/003-alpn-prefix-rename.md

Verification:
- cargo test: 542 passed, 0 failed
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean
2026-08-14 13:55:28 +00:00

287 lines
15 KiB
Markdown
Raw Permalink 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-18
---
# alknet-channels — Overview
## What
`alknet-channels` is a multiplexing proxy crate. It implements
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
bidirectional transport stream, reads 8-byte chunk headers, and routes each
chunk's payload to the right logical channel. Each channel is reassembled
into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
ADR-009) and presented to its handler as a `Connection` — the handler
doesn't know it's inside a channels connection.
Channel 0 is pre-negotiated as `alk/call` (ADR-036). Every other channel
is opened dynamically via `channel/open` on channel 0 (ADR-037) and routed
through the same `HandlerRegistry` as top-level connections. The channels
layer does no protocol work itself — it is a re-framing proxy that converts
between "one transport stream carrying N channels" (the wire) and "N
independent `BiStream` handles" (what handlers see). The channels layer has
no `stream_type` concept (ADR-035) — the handler owns its sub-stream
multiplexing on the `BiStream` it receives.
## Why
### The problem: three multiplexing models that don't compose
Before channels, alknet had three multiplexing models:
| 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 needing both JSON call operations and raw TTY sessions
required **two separate QUIC connections** with different ALPNs. The call
protocol can't say "for this operation, open a TTY stream." The hub,
bridging browsers and spokes over multiple transports, faced an
O(protocols × transports × spokes) matrix of per-protocol framing parsers
and per-ALPN connection management.
### The collapse: one multiplexing model, one connection per leg
With `alk/channels`, one connection carries everything:
```
Browser ──WebTransport──► Hub ──QUIC──► Spoke
alk/channels alk/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
```
The hub's relay is channel-by-channel byte forwarding (with `channel_id`
rewrite — ADR-042), not per-protocol framing parsers. The hub's complexity
collapses from O(protocols × transports × spokes) to O(channels).
The collapse is at three levels:
1. **One connection per leg, not one per protocol.** All needs (call, TTY,
SSH, tunnel) ride as channels on one connection per leg.
2. **One multiplexing model, not three.** Connection-level, stream-level,
and sub-stream-level all become channels chunks.
3. **The call protocol orchestrates from inside.** Channel 0 is
`alk/call` on both legs. The call protocol's `OperationRegistry`,
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
with no new auth.
### The separation: 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 (ADR-035).
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-009). The handler sub-multiplexes it however it
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
bytes, SSH's channel protocol.
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
header, not in its code, not in its mental model. `stream_type` is the
inner layer's framing byte, carried transparently in the payload.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control.
- **Recursive composition is literal.** A channel with ALPN
`alk/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload.
## Architecture
The crate has two internal components (ADR-039):
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alk/channels`. Its `handle()` receives one `Connection`, reads 8-byte
chunk headers, and routes chunks to the `ChannelManager`. The read/demux
half.
- **`ChannelManager`** — the shared state. Holds `channel_id →
ChannelState`, the `HandlerRegistry` reference, and the
`OperationRegistry` reference. The reassemble/allocate half. What the
`channel/open` operation handler closes over.
Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
channel) and gets a `BiStream` — identical to how it works on a top-level
QUIC connection.
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
design.
## Crate dependencies
alkcall is a single crate with two internal subsystems (call + channels).
The channels subsystem is split into two modules:
- **`channels` (core)** — the pure multiplexer: wire format, demux/mux,
`ChannelBidiStreamSource`, `ChannelManager`. ALPN-blind,
call-protocol-blind, transport-blind. This is where the "streams are
streams" insight lives.
- **`channels` (call coupling)** — channel 0 pre-negotiation as
`alk/call` (ADR-036), the lifecycle operations (ADR-037) registered
on the call protocol's `OperationRegistry`, `ChannelCore` (ADR-047 §3),
and `ChannelClient` (ADR-043). This is where the call-protocol coupling
lives, isolated from the pure multiplexer.
Downstream crates depend on alkcall as a single dependency:
```
alktty / alktunnels / alktrader (protocol crates)
└── alkcall (call + channels protocols, no networking)
alknet / alknode (networking + composition)
├── alkcall
├── alktty (optional — whichever protocol crates are wired in)
├── alktunnels (optional)
└── quinn / tokio-rustls (transport)
```
The hub and worker are **consumers** of alkcall, not sub-crates. The
downstream alknet crate IS the hub — it depends on alkcall and uses
channels as its substrate, with the relay logic (ADR-042) living
alongside its peer lifecycle and service discovery responsibilities.
A worker is any crate that uses `ChannelClient` to dial. There are no
`channels-hub` or `channels-worker` sub-crates.
## ALPN
`alk/channels` — the ALPN the `ChannelsAdapter` registers on. One ALPN
per channels connection; the connection carries N logical channels, each
with its own ALPN (negotiated via `channel/open`).
## Transport agnosticism
The channels wire format works over any ordered, reliable bidirectional byte
stream:
| Transport | How |
|-----------|-----|
| QUIC bidi stream | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
| WebTransport | `alk/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alk/channels`) |
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
The same wire format, the same chunk reassembly, the same `Connection`
abstraction. The transport is a parameter, not a design constraint.
`Connection::from_bidi` / `from_source` (ADR-007/070/092) handles the
transport-agnostic `Connection` construction.
## WASM compatibility
The wire format's core is pure byte manipulation — `parse_header` /
`write_header` are pure functions with no platform dependencies. The de-risk
POC validated the sync core compiles under `wasm32-unknown-unknown`. The
async shell (demux/mux) wraps this core with `read_exact`/`write_all` and
`mpsc` routing.
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
075) — pure byte routing with no platform or protocol dependencies. A WASM
build can read chunks from a WebTransport `BiStream`, reassemble them, and
present `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 is WASM-compatible by
construction.
The async shell and `alknet-core` dep graph are not fully WASM-clean yet
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
not an architecture concern. The sync core's WASM compatibility is validated.
## Relationship to downstream crates
alkcall is a pure protocol crate — no networking, no transport
dependencies. Downstream crates compose on top of it. See
[README.md](README.md) §"Roles and Composition" for the full dependency
layering and the producer/consumer/hub/spoke role definitions.
### Protocol crates (alktty, alktunnels, etc.)
Protocol crates depend only on alkcall. They provide:
- **Producer half** — a `register_*()` function that takes an
`&mut OperationRegistry` and registers ops with their handlers. For
channels-based protocols, an `OpenHandler` factory registered via
`ChannelCore::register_openable`.
- **Consumer half** — a typed client wrapper around `CallConnection`
(or `ChannelClient`) that exposes the crate's ops as async methods.
A protocol crate can support two paths for its wire format:
| Path | How it connects | Wire format |
|------|----------------|-------------|
| Direct ALPN | `ProtocolHandler` on its own ALPN (e.g. `alk/tty`), gets a `Connection`, loops `accept_bi` | Own wire format (e.g. TTY's 5-byte header) |
| Through channels | Registered via `ChannelCore::register_openable`, gets a `BiStream` per session | Own wire format rides inside channels 8-byte payload |
The protocol crate doesn't know which path it's on — it takes a
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
two adapters are thin and live either in the protocol crate (behind
feature flags) or in the downstream alknet crate.
### alknet-call
The call protocol runs on channel 0 exactly as on a top-level
`alk/call` connection. The `Dispatcher` receives a `Connection`
backed by channel-0 chunk reassembly and dispatches operations — it
doesn't know it's inside channels. The call protocol's `EventEnvelope`
framing (ADR-014) is the channels payload; the channels layer carries
it transparently.
### alknet-hub / alknode
The hub is the primary consumer. With channels, the hub holds one
channels connection per leg (browser↔hub, hub↔spoke) and relays
channels between them. The hub translates per-ALPN open ops on channel
0 (re-issues on the spoke leg with `forwarded_for` — ADR-042) and
byte-forwards data channels with `channel_id` rewrite. The hub's
complexity collapses from O(protocols × transports × spokes) to
O(channels).
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [034](decisions/034-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no `stream_type` concept; one-way door |
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alk/call` |
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-035 — `into_sub_streams` removed) |
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this crate:
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
— extracted as `alknet-client` with three dial methods.
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
dial is the shared seam, now extracted. See
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
bounded-buffer is decided (ADR-040); full windowing is an extension
blocked on a real HOL-blocking deployment observation.
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (ADR-078); the *helper* is blocked on a second
two-pump handler existing.
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided (ADR-035); the *function surface* is not.