docs(arch): add alknet-channels specs — ADRs 071-080, 7 spec docs, OQ-56/57

Phase 1 architecture for alknet-channels (multiplexing proxy on
alknet/channels). Grounded in the completed de-risk POC (28 tests) and the
landed ADR-070 (BidiStreamSource trait + Connection::from_source).

ADRs:
- 071: 9-byte chunk wire format (generalizes TTY's 5-byte)
- 072: channel 0 pre-negotiated as alknet/call (no special control plane)
- 073: channel lifecycle operations on the call protocol — channel/open,
  close, control, resources/subscribe; direction field pinned; subscribe
  from day one (not poll-for-v1 — StreamingHandler machinery exists)
- 074: ChannelBidiStreamSource implements BidiStreamSource (ADR-070);
  into_sub_streams() typed accessor for TTY; accept_bi() generic path
- 075: ChannelsAdapter + ChannelManager; REQ-CH-01..04 wire invariants
- 076: bounded-buffer backpressure (1 MiB), 256-channel cap, monotonic IDs
- 077: TTY inside channels uses sub-streams, not own wire format;
  amends ADR-052 scope to direct-connect TTY; channels feature on tty
- 078: two-pump shutdown-on-completion contract (handler-level)
- 079: hub relay translates channel 0, byte-forwards data channels
- 080: ChannelClient (QUIC-only); AlknetClient core extraction deferred (OQ-55)

Spec docs: overview, channels-wire, channels-connection, channels-adapter,
channel-operations, channel-client.

OQ-56 (full windowing) and OQ-57 (two-pump helper extraction) are genuine
deferred(scope) deferrals with concrete blocking conditions; the contracts
are decided, only the extensions are deferred.

Hedging audit converted three research hedges into decisions:
resources/subscribe (not poll), server-assigned IDs (not if-zero-RTT),
bounded-buffer (not if-HOL-becomes-a-problem).
This commit is contained in:
glm-5.2 committed 2026-07-12 12:57:18 +00:00
1 parent f997c81d2f
commit 2313c51f12
21 files changed
+3411

No files matched your search

@@ -0,0 +1,210 @@
---
status: draft
last_updated: 2026-07-12
---
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access
How a reassembled channel is presented to its handler as a `Connection`.
ADR-074 is the decision; this doc specifies the API shape and the two
access paths.
## What
Each channel is reassembled into a set of `AsyncRead + AsyncWrite` handles
—one per active `stream_type` (declared at `channel/open` time, ADR-073).
These handles are wrapped as a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
constructed from it via `Connection::from_source(source, alpn)`.
The handler receives a `Connection` and can either:
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
the generic handler path (tunnel, SSH).
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
active sub-streams as typed `(stream_type, SendStream, RecvStream)`
tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/
control).
Both paths operate on the same reassembly buffers; the difference is how the
handler accesses them.
## `ChannelBidiStreamSource`
```rust
// In alknet-channels:
pub struct ChannelBidiStreamSource {
// The reassembly buffers for this channel's active stream_types,
// plus the mux handle for writing back onto the transport.
// Constructed by ChannelManager::build_channel_connection (ADR-075).
...
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self)
-> Result<(SendStream, RecvStream), StreamError>
{
// Yields the (stream_type 0, stream_type 1) pair on first call,
// ConnectionClosed on subsequent calls. Yield-once per channel,
// matching the POC's validated shape.
}
async fn open_bi(&self)
-> Result<(SendStream, RecvStream), StreamError>
{
// StreamClosed — a single channel cannot open new application
// streams (same as ADR-065's Stream backend). Additional sub-streams
// (stream_type 2, 3) are accessed via into_sub_streams(), not
// open_bi().
}
fn remote_addr(&self) -> Option<SocketAddr> { ... }
fn close(&self, _code: u32, _reason: &str) { ... }
}
```
One `ChannelBidiStreamSource` instance represents **one channel** (not the
whole channels connection). The `ChannelManager` (ADR-075) constructs one
per channel at `channel/open` time and wraps it in a `Connection` via
`from_source`.
## The generic path: `accept_bi()`
For handlers that only need the main data pair (`stream_type` 0 = data-in,
`stream_type` 1 = data-out):
```rust
// Tunnel handler — ~15 lines, zero channels-layer awareness
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let (mut send, mut recv) = connection.accept_bi().await?;
let mut tcp = TcpStream::connect(target).await?;
let (mut tcp_read, mut tcp_write) = tcp.into_split();
// Two-pump with shutdown-on-completion (ADR-078)
let c2t = async {
tokio::io::copy(&mut recv, &mut tcp_write).await?;
tcp_write.shutdown().await.ok();
Ok::<_, std::io::Error>(())
};
let t2c = async {
tokio::io::copy(&mut tcp_read, &mut send).await?;
send.shutdown().await.ok(); // emits zero-length sentinel (REQ-CH-01)
Ok::<_, std::io::Error>(())
};
tokio::try_join!(c2t, t2c)?;
Ok(())
}
```
The handler calls `accept_bi()` once, gets the `(SendStream, RecvStream)`
pair, and pumps. It does not know it's inside a channels connection — the
`Connection` looks like any other. This is the path the POC's `EchoHandler`
and `TunnelHandler` validated.
`accept_bi()` is yield-once: the first call returns the 0/1 pair; subsequent
calls return `ConnectionClosed`. This matches the POC's validated shape and
the `StreamBidiStreamSource` yield-once contract (ADR-070).
## The typed path: `into_sub_streams()`
For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
to 0/1:
```rust
// In alknet-channels:
pub struct ChannelSubStreams {
/// (stream_type, send_half, recv_half) for each active stream_type.
pub streams: Vec<(u8, SendStream, RecvStream)>,
}
impl ChannelSubStreams {
pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... }
}
impl ChannelBidiStreamSource {
/// Returns all active sub-streams, keyed by stream_type. Consumes the
/// source — call this instead of accept_bi() if the handler needs
/// direct access to stream_types 2/3.
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
}
```
The handler crate destructures `ChannelSubStreams` into its typed names:
```rust
// In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams();
let stdin = sub.get(0).unwrap(); // SendStream
let stdout = sub.get(1).unwrap(); // RecvStream
let stderr = sub.get(2); // Option<&RecvStream>
let control = sub.get(3).unwrap(); // RecvStream (JSON control)
```
**The channels crate does not know about TTY's `stream_type` semantics.**
It exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
maps stream_types to its typed names. This preserves ADR-003's
no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind.
`into_sub_streams()` consumes the source — a handler can't call both
`accept_bi()` and `into_sub_streams()`. This is by design: the sub-streams
include the 0/1 pair, so `into_sub_streams()` is the superset.
## Choosing the path
| Handler shape | Path | Examples |
|---------------|------|---------|
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
| Needs stderr/control (2/3) | `into_sub_streams()` | TTY (stdin/stdout/stderr/control) |
The handler chooses based on its ALPN's `stream_type` set (declared at
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
`Connection` (via `from_source`); handlers that need sub-streams access the
`ChannelBidiStreamSource` via a channels-crate extension trait or downcast
(exact ergonomics are an implementation detail for the channels crate; the
contract is that both paths are available and the handler crate chooses).
## Recursive composition
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection::
from_source` wraps it. A handler that is itself `alknet/channels` can open a
sub-channels connection on a data channel — `alknet/channels` inside
`alknet/channels`. This is allowed (the `Connection` abstraction permits it)
but not a feature designed for. The primary use case is one level of
multiplexing. Recursive composition is a natural consequence of the
abstraction, not a goal.
## What does NOT change
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
internal to the channels crate; handlers see a `Connection`.
- **`SendStream` / `RecvStream`** (ADR-007) — unchanged. They continue to
wrap their internal sources. `ChannelBidiStreamSource` constructs them via
the existing `from_stream` constructors, backed by mpsc reassembly
buffers.
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
the same registry as top-level connections.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
## References
- ADR-074: ChannelConnection (the decision)
- ADR-070: BidiStreamSource trait
- ADR-065: `Connection::from_stream`
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
yield-once `Connection::from_stream` validation)