docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
# ADR-038: ChannelConnection — BidiStreamSource over Chunk Reassembly
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (**amended 2026-07-18 by ADR-035: `into_sub_streams()` removed;
|
||||
`accept_bi` is the only accessor, yields one `BiStream` per channel —
|
||||
see "Amendment (ADR-035, 2026-07-18)" below**)
|
||||
|
||||
## Amendment (ADR-035, 2026-07-18)
|
||||
|
||||
`into_sub_streams()`, `ChannelSubStreams`, and `SubStreamHandle` are
|
||||
**removed**. The channels layer exposes one accessor: `accept_bi()`,
|
||||
which yields one `BiStream` per channel (per ADR-009, already landed).
|
||||
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
|
||||
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
||||
wants. The "typed handler path" (this ADR's motivating case for TTY) is
|
||||
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
|
||||
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
|
||||
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
|
||||
handlers) collapses to one accessor.
|
||||
|
||||
The body below describes the **original** (two-accessor) shape; the
|
||||
amendment above is the operative decision. The two-accessor description
|
||||
is kept as the historical context for the amendment. See ADR-035 for
|
||||
the resolution rationale (the channels layer has no `stream_type`
|
||||
concept; the handler owns its sub-stream multiplexing) and the
|
||||
cross-ADR impacts.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-008 landed the `BidiStreamSource` trait and `Connection::from_source`
|
||||
extension point so downstream crates can implement their own connection
|
||||
shapes without a core edit. The channels crate is the first downstream
|
||||
consumer: a channels connection carries N logical channels, each a
|
||||
bidirectional byte stream presented to a `ProtocolHandler` as a `Connection`.
|
||||
|
||||
The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
|
||||
§The Channel Connection Abstraction, §OQ-CH-10) proposed that
|
||||
`ChannelConnection` *implements* the `Connection` interface (for recursion
|
||||
and generic handlers) **and** can be destructured into typed sub-stream
|
||||
handles (`TtyChannel { stdin, stdout, stderr, control }`). The research
|
||||
recommended "the TTY crate destructures; channels exposes `(channel_id,
|
||||
stream_type) → (SendStream, RecvStream)` accessors" but did not pin the
|
||||
exact API shape. This ADR pins it.
|
||||
|
||||
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §POC Target
|
||||
2) validated that `Connection::from_stream` (the yield-once path) is
|
||||
sufficient — an echo `ProtocolHandler` runs through the full
|
||||
demux→Connection→handler→mux path with zero channels-layer awareness. But
|
||||
the POC deliberately used the yield-once path (one `Connection` per channel)
|
||||
rather than the N-stream `ChannelBidiStreamSource` shape. This ADR commits
|
||||
to the N-stream shape that ADR-008 unblocked.
|
||||
|
||||
## Decision
|
||||
|
||||
### `ChannelBidiStreamSource` implements `BidiStreamSource`
|
||||
|
||||
The channels crate defines a `ChannelBidiStreamSource` that implements
|
||||
`alknet-core`'s `BidiStreamSource` trait (ADR-008). One
|
||||
`ChannelBidiStreamSource` instance represents **one channel** (not the
|
||||
whole channels connection). Its `accept_bi()` yields one bidi stream — the
|
||||
`(stream_type 0, stream_type 1)` pair for that channel — then returns
|
||||
`ConnectionClosed` on subsequent calls (yield-once per channel, matching
|
||||
the POC's validated shape).
|
||||
|
||||
```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-039).
|
||||
...
|
||||
}
|
||||
|
||||
#[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. This is the yield-once
|
||||
// contract 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-007's Stream backend). Additional sub-streams
|
||||
// (stream_type 2, 3) are accessed via sub_streams(), not open_bi().
|
||||
}
|
||||
fn remote_addr(&self) -> Option<SocketAddr> { ... }
|
||||
fn close(&self, _code: u32, _reason: &str) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Each channel is presented to its handler as a `Connection` constructed via
|
||||
`Connection::from_source(ChannelBidiStreamSource::new(...), alpn)`. The
|
||||
handler calls `accept_bi()` once, gets the main data pair, and drives its
|
||||
session — exactly as the POC's `EchoHandler` and `TtyAdapter` do today.
|
||||
|
||||
### Sub-stream accessor for typed destructure (OQ-CH-10)
|
||||
|
||||
Some handlers need access to `stream_type` 2 (stderr), 3 (control in), and
|
||||
4 (control out) in addition to the main 0/1 pair. The `Connection`
|
||||
interface alone (accept_bi) only exposes the 0/1 pair. The channels crate
|
||||
provides a typed-accessor extension:
|
||||
|
||||
```rust
|
||||
// In alknet-channels:
|
||||
pub struct ChannelSubStreams {
|
||||
/// (stream_type, handle) for each active stream_type. Each handle is
|
||||
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream;
|
||||
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream.
|
||||
/// See ADR-034 §stream_type decomposition.
|
||||
pub streams: Vec<(u8, SubStreamHandle)>,
|
||||
}
|
||||
|
||||
pub enum SubStreamHandle {
|
||||
Send(SendStream), // write half (stream_type % 3 == 0)
|
||||
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
|
||||
}
|
||||
|
||||
impl ChannelBidiStreamSource {
|
||||
/// Returns the typed sub-streams for this channel, keyed by stream_type.
|
||||
/// Consumes the source — call this instead of accept_bi() if the handler
|
||||
/// needs direct access to stream_types 2/3/4. For handlers that only need
|
||||
/// the main 0/1 pair, accept_bi() is the path (and sub_streams() is not
|
||||
/// called).
|
||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The handler crate (e.g., `alknet-tty`) 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_send(0).unwrap(); // SendStream (write, client→server)
|
||||
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
|
||||
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
|
||||
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
|
||||
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
|
||||
```
|
||||
|
||||
**Every stream_type is unidirectional** (ADR-034). Write stream_types
|
||||
(`% 3 == 0`) carry a `SendStream`; read stream_types (`% 3 == 1 or 2`) carry
|
||||
a `RecvStream`. There is no "bidirectional" stream_type — bidirectionality
|
||||
is two halves (e.g., control is 3 write + 4 read). This resolves the TTY
|
||||
control channel's "not actually bidirectional" flaw: the TTY adapter reads
|
||||
exit/keepalive from `ctrl_out` (stream_type 4) and writes resize/signal/eof
|
||||
to `ctrl_in` (stream_type 3), each with its own flow control and EOF.
|
||||
|
||||
**The channels crate does not know about TTY's `stream_type` semantics.**
|
||||
It exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
|
||||
stream_types to its typed names. This preserves ADR-031's
|
||||
no-handler-depends-on-another-handler rule and keeps the channels crate
|
||||
ALPN-blind.
|
||||
|
||||
### When to use `accept_bi` vs `into_sub_streams`
|
||||
|
||||
| Handler shape | Path | Example |
|
||||
|---------------|------|---------|
|
||||
| Main data pair only (0/1) | `accept_bi()` | tunnel handler, SSH handler (SSH multiplexes internally) |
|
||||
| Needs stderr/control (2/3) | `into_sub_streams()` | TTY handler (stdin/stdout/stderr/control) |
|
||||
|
||||
The handler chooses at construction time based on its ALPN's `stream_type`
|
||||
set (declared at `channel/open` time, ADR-037). The `ChannelsAdapter` passes
|
||||
the handler a `Connection` (via `from_source`); handlers that need sub-
|
||||
streams downcast or receive the `ChannelBidiStreamSource` directly via a
|
||||
channels-crate extension trait. The exact ergonomics (downcast vs. a
|
||||
channels-crate constructor that hands the source directly to handlers that
|
||||
opt in) 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. This is recursive composition:
|
||||
`alknet/channels` inside `alknet/channels`. It 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.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- `ChannelConnection` is a first-class peer of QUIC: one
|
||||
`BidiStreamSource` impl per channel, constructed via `from_source` — no
|
||||
core edit (the ADR-008 extension point).
|
||||
- Handlers that only need the main data pair use `accept_bi()` — identical
|
||||
to how they work on top-level QUIC connections. Zero handler changes for
|
||||
the tunnel/SSH shape.
|
||||
- Handlers that need typed sub-streams (TTY) use `into_sub_streams()` — the
|
||||
channels crate provides the accessor, the handler crate maps to typed
|
||||
names. No channels-crate knowledge of TTY semantics.
|
||||
- The POC's validated yield-once shape is preserved per-channel; the N-stream
|
||||
generalization is at the connection level (one channels connection = N
|
||||
channels = N `ChannelBidiStreamSource` instances), not per-channel.
|
||||
|
||||
**Negative:**
|
||||
- Two paths to access channel data (`accept_bi` vs `into_sub_streams`). This
|
||||
is a necessary divergence: the `Connection` interface alone can't express
|
||||
"give me four named sub-streams" without four `accept_bi` calls (which
|
||||
would violate the yield-once contract). The two-path design is the
|
||||
minimum-complexity solution; the alternative (a new `Connection` variant
|
||||
with multi-stream semantics) would touch `alknet-core` and break the
|
||||
ADR-008 extension-point model.
|
||||
- `into_sub_streams()` consumes the source, so 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.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The `ChannelBidiStreamSource` shape (one source per channel,
|
||||
yield-once `accept_bi`, `into_sub_streams` accessor) is the handler-facing
|
||||
API surface. Changing it after handlers exist (TTY, tunnel, SSH) is a
|
||||
rewrite of those handlers' integration code. The trait impl is in the
|
||||
channels crate (not core), so the one-way door is the channels crate's API,
|
||||
not a core type.
|
||||
|
||||
**Amended by ADR-035 (2026-07-18):** `into_sub_streams()` is removed;
|
||||
`accept_bi` is the only accessor. The one-way door is re-cast (the
|
||||
channels crate is not yet implemented, so this is the right time). See
|
||||
ADR-035 for the amended door-type discussion.
|
||||
|
||||
The choice of `into_sub_streams()` returning `Vec<(u8, SendStream,
|
||||
RecvStream)>` (vs a typed struct, vs a map) is a two-way-door implementation
|
||||
detail — the return type can change without breaking the contract as long
|
||||
as the handler crate's destructure code updates.
|
||||
|
||||
## References
|
||||
|
||||
- **ADR-035**: channels pure channel multiplexing (amends this ADR —
|
||||
`into_sub_streams()` removed; `accept_bi` is the only accessor, yields
|
||||
one `BiStream` per channel; the handler owns its sub-stream
|
||||
multiplexing)
|
||||
- ADR-009: `BiStream` as the handler leaf (the transport-leaf decision
|
||||
this ADR's amendment builds on — `accept_bi` returns `BiStream`)
|
||||
- ADR-008: BidiStreamSource trait (the extension point this implements)
|
||||
- ADR-007: Connection::from_stream (the yield-once path this generalizes for
|
||||
channels)
|
||||
- ADR-034: channels wire format (the chunks this reassembles)
|
||||
- ADR-039: ChannelsAdapter and ChannelManager (the components that construct
|
||||
`ChannelBidiStreamSource` instances)
|
||||
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2, §Issues
|
||||
Surfaced #1
|
||||
Reference in New Issue
Block a user