docs(arch): sync specs to transport generalization sweep (ADR-064, ADR-065)

Three code commits landed a clean sweep discovered when building an
external app against the crates. This syncs the architecture specs to
match the codebase and amends the affected decisions.

ADR-064 (supersedes ADR-005): irpc was never integrated — no .rs file in
the workspace ever imported it. The wire protocol (wire.rs) is hand-rolled
length-prefixed JSON; the EventEnvelope shape was derived from the
@alkdev/pubsub TypeScript prior art (ADR-013), not from irpc. ADR-005's
premise ('irpc as the call protocol foundation') was never implemented as
stated. Superseded with a clear header; the body is kept as historical
record.

ADR-065: Connection::from_stream / from_bidi — Connection now accepts any
AsyncRead + AsyncWrite pair via ConnectionKind::Stream (yield-once
accept_bi contract: QUIC yields many streams, everything else yields one
then ConnectionClosed). Unblocks TCP+TLS, SSH channel dispatch,
WebTransport streams, and wasm streams through the same HandlerRegistry
as QUIC connections, with zero handler code changes. MockConnection /
ConnectionKind::Mock removed (tests use from_stream with sink/empty).
Stream-level Mock variants renamed to Stream (they were already generic).

Amended ADRs: ADR-003 (irpc removed from dep table), ADR-007 (from_stream
opens the server-side door; MockConnection removed), ADR-010 (TCP+TLS can
now dispatch through the registry via from_bidi; iroh 1.0 migration noted).

Updated specs: core-types.md (Connection/SendStream/RecvStream sections),
endpoint.md (TCP section, iroh note), call-protocol.md, operation-registry
(irpc Integration section replaced), overview.md, http-server.md,
webtransport.md, call README. OQ-09 (WASM) resolution amended: the
Connection door is now open via from_stream; the accept-loop runtime door
remains closed (tokio doesn't run on WASM).

See docs/research/transport-generalization/findings.md for the full trace.
This commit is contained in:
glm-5.2 committed 2026-07-09 07:40:51 +00:00
1 parent 865fef6210
commit 2aa6363e57
20 files changed
+782 -87

No files matched your search

@@ -24,10 +24,10 @@ The workspace decomposes into the following crates:
| Crate | Responsibility | Depends on |
|-------|---------------|------------|
| `alknet-core` | ProtocolHandler trait, ALPN router, endpoint, BiStream, AuthContext, IdentityProvider, config, ArcSwap dynamic config | tokio, quinn, rustls, irpc, iroh (feature-gated, added by ADR-010) |
| `alknet-core` | ProtocolHandler trait, ALPN router, endpoint, BiStream, AuthContext, IdentityProvider, config, ArcSwap dynamic config | tokio, quinn, rustls, iroh (feature-gated, added by ADR-010) |
| `alknet-vault` | Local key vault: BIP39/SLIP-0010/AES-GCM key derivation, encryption | (standalone, no alknet-core) |
| `alknet-ssh` | SshAdapter (russh, SOCKS5, port forwarding) | alknet-core, russh |
| `alknet-call` | CallAdapter (JSON-RPC via irpc, operation registry, pub/sub, access control, call protocol client, adapter traits) | alknet-core, irpc |
| `alknet-call` | CallAdapter (JSON-RPC via hand-rolled EventEnvelope framing, operation registry, pub/sub, access control, call protocol client, adapter traits) | alknet-core |
| `alknet-agent` | Agent service: LLM execution loop (forked aisdk), tool dispatch via call protocol, provider key retrieval via vault | alknet-call |
| `alknet-git` | GitAdapter (gix, pkt-line protocol) | alknet-core, gix |
| `alknet-sftp` | SftpAdapter (russh-sftp protocol core) | alknet-core, russh-sftp |
@@ -72,7 +72,7 @@ alknet-napi is a thin projection layer — it exposes the Rust call protocol cli
- ADR-001: ALPN-based protocol dispatch
- ADR-002: ProtocolHandler trait
- ADR-004: Auth as shared core (IdentityProvider)
- ADR-005: irpc as call protocol foundation
- ADR-005: irpc as call protocol foundation (superseded by ADR-064)
## Amendments
@@ -125,4 +125,19 @@ exception remains for alknet-http/agent/napi (which use alknet-call's
not framing glue); it no longer covers alknet-tty. See
[ADR-057](057-alknet-tty-no-alknet-call-dep.md) for the full decision
and the three options considered (duplicate / promote to core / use
alknet-call).
alknet-call).
### Amendment 3 (2026-07-09): irpc is not a dependency of any crate
The Decision table listed `irpc` as a dependency of `alknet-core` ("tokio,
quinn, rustls, irpc, iroh") and `alknet-call` ("alknet-core, irpc"). This
was carried over from the previous architecture and never verified against
the implementation: **no `.rs` file in the workspace ever imported irpc**.
The call protocol's wire format (`crates/alknet-call/src/protocol/wire.rs`)
is hand-rolled length-prefixed JSON; the `EventEnvelope` shape was derived
from the `@alkdev/pubsub` TypeScript prior art (ADR-013), not from irpc.
The dead `irpc` / `irpc-derive` workspace deps and the `alknet-call` consumer
dep were removed in commit `668d777`. See
[ADR-064](064-irpc-never-integrated-hand-rolled-framing.md) for the full
record (ADR-005, which accepted "irpc as the call protocol foundation," is
superseded).
@@ -2,7 +2,22 @@
## Status
Accepted
~~Accepted~~ → **Superseded** by [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md)
> **Superseded 2026-07-09.** This ADR accepted "irpc as the call protocol
> foundation" based on the previous architecture's use of irpc. When the
> call protocol was implemented, it turned out that **no `.rs` file in the
> workspace ever imported irpc** — the `irpc` / `irpc-derive` workspace deps
> were a Cargo.toml entry with no corresponding import. The wire protocol
> (`crates/alknet-call/src/protocol/wire.rs`) is hand-rolled length-prefixed
> JSON; the `EventEnvelope` shape was derived from the `@alkdev/pubsub`
> TypeScript prior art (ADR-013), not from irpc. ADR-064 supersedes this
> ADR and records the actual state: hand-rolled framing, no irpc
> integration. The architectural properties this ADR sought (proven
> length-prefixed JSON framing, cross-language JSON wire format, streaming)
> are preserved by the hand-rolled implementation. The text below is kept
> as the historical record of the decision that was made (and never
> implemented as stated).
## Context
@@ -53,8 +68,11 @@ local-only by construction, not remote-capable by default).
## References
- **Superseding ADR**: [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md) — irpc was never integrated; hand-rolled framing is the actual state
- ADR-013: Rust as canonical implementation (the `@alkdev/pubsub` prior art the `EventEnvelope` shape was actually derived from)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; ADR-064 confirms irpc was never in alknet-call either)
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-003: Crate decomposition
- ADR-004: Auth as shared core (IdentityProvider)
- irpc reference: `docs/research/references/iroh/irpc/` (see individual docs in that directory)
- Call protocol wire format (actual): `crates/alknet-call/src/protocol/wire.rs`
- The previous architecture had an equivalent decision in ADR-024 (bidirectional call protocol with EventEnvelope framing), which is archived in the reference implementation at `/workspace/@alkdev/alknet-main/`.
@@ -116,4 +116,54 @@ The BiStream trait is a thin convenience — `AsyncRead + AsyncWrite + Send + Un
- ADR-006: ALPN string convention and connection model
- OQ-01: BiStream type definition (resolved by this ADR)
- iroh ProtocolHandler pattern: `docs/research/references/iroh/iroh/`
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
## Amendments
### Amendment 1 (2026-07-09): `Connection::from_stream` opens the server-side door
This ADR's "WASM constraint" section argued that if `Connection` (or
BiStream) were bound to a QUIC library, WASM targets and alternative
transports couldn't implement it — and that a trait-based `BiStream`
preserves the *client-side* door. That argument was correct for `BiStream`
(the trait) but incomplete for `Connection`: until ADR-065, `Connection`
was a concrete type with only QUIC variants (`ConnectionKind::Quinn` /
`ConnectionKind::Iroh`), plus a `ConnectionKind::Mock` test stub. There was
no way to construct a `Connection` from a non-QUIC stream, which meant
TCP+TLS, SSH channels, WebTransport streams, and wasm streams could not
be dispatched through the `HandlerRegistry` — the *server-side* dispatch
door was closed.
**Resolved by [ADR-065](065-connection-from-stream-generic-single-stream.md):**
`Connection::from_stream(send, recv, alpn, remote_addr)` and
`Connection::from_bidi(stream, alpn, remote_addr)` construct a `Connection`
from any `AsyncRead + AsyncWrite` pair. A new `ConnectionKind::Stream`
variant holds a single read/write pair behind a `Mutex<Option<...>>` with a
yield-once `accept_bi` contract (QUIC yields many streams; everything else
yields one, then `ConnectionClosed`). Every existing `ProtocolHandler`
works over the new kind **unchanged** — handlers that loop `accept_bi`
(TtyAdapter) get one iteration; handlers that call once (HttpAdapter) get
the stream directly. Both correct, no branching on transport.
The stream-level `SendStreamKind::Mock` / `RecvStreamKind::Mock` variants
(already generic `Box<dyn AsyncRead/Write>` — the name was wrong) are
renamed to `Stream` and made load-bearing: `from_stream` calls
`SendStream::from_stream` / `RecvStream::from_stream`.
### Amendment 2 (2026-07-09): `MockConnection` / `ConnectionKind::Mock` removed
This ADR's Decision section and the `Connection` sketch referenced "test
mock" as one of the things `Connection` wraps. The implementation had a
`MockConnection` trait and `ConnectionKind::Mock` variant for test-only
full-connection mocks. ADR-065 removed both entirely: test stubs now use
`Connection::from_stream(tokio::io::sink(), tokio::io::empty(), alpn,
addr)` — `tokio::io::empty()` yields immediate EOF on read (handler exits
cleanly), and `accept_bi` returns `ConnectionClosed` after the first take
(run loop exits). One connection kind for production and tests, not two.
The "test mock" concept this ADR references is now subsumed by
`from_stream` — a test connection is just a single-stream connection with
EOF-on-read.
The server-side WASM door (OQ-09) is no longer closed by `Connection` being
QUIC-bound — `from_stream` accepts any `AsyncRead + AsyncWrite`, including
wasm-compatible streams. See OQ-09 for the updated resolution.
@@ -64,7 +64,7 @@ This is analogous to the reverse-proxy admin key pattern (ADR-028 in the reverse
## References
- ADR-003: Crate decomposition (alknet-vault is standalone)
- ADR-005: irpc as call protocol foundation (for alknet-call; the vault no longer uses irpc — see ADR-025)
- ADR-005: irpc as call protocol foundation (superseded by ADR-064 — irpc was never integrated; the vault no longer uses irpc — see ADR-025)
- ADR-009: One-way door decision framework
- ADR-014: Secret material flow and capability injection (specifies the mechanism this ADR described in prose)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; direct method calls only)
@@ -198,4 +198,60 @@ pub enum HandlerError {
- iroh Router pattern: `docs/research/references/iroh/`
- Reference implementation: `alknet-main/crates/alknet-core/src/server/serve.rs`
- Reference stealth mode: `alknet-main/crates/alknet-core/src/server/stealth.rs`
- Reference iroh transport: `alknet-main/crates/alknet-core/src/transport/iroh_transport.rs`
- Reference iroh transport: `alknet-main/crates/alknet-core/src/transport/iroh_transport.rs`
## Amendments
### Amendment 1 (2026-07-09): TCP+TLS can dispatch through the ALPN router via `from_stream`
This ADR's Decision section states: **"TCP mode is not an endpoint concern."**
The rationale was that bare TCP (SSH over port 22) does not use QUIC or
ALPN, so TCP access is handled by individual handlers listening on a TCP
socket independently — a handler-specific concern, not a core endpoint
concern.
That rationale holds for *bare TCP* (no TLS, no ALPN). But
**[ADR-065](065-connection-from-stream-generic-single-stream.md)** adds
`Connection::from_stream` / `from_bidi`, which construct a `Connection`
from any `AsyncRead + AsyncWrite` pair — including a
`TlsStream<TcpStream>`. A TCP+TLS accept loop can now call
`Connection::from_bidi(tls_stream, alpn, remote_addr)` and dispatch through
the **same `HandlerRegistry`** as QUIC connections, by the ALPN negotiated
in the TLS handshake. This is not a parallel listener bypassing the core —
it's the same ALPN dispatch, over a non-QUIC transport.
**Revised reading of "TCP is not an endpoint concern":** the
`AlknetEndpoint` struct (quinn + iroh) remains QUIC-only — the endpoint
does not own a TCP+TLS accept loop. But a TCP+TLS accept loop can be
constructed *outside* the endpoint (by the assembly layer or a handler)
and feed connections into the same `HandlerRegistry` the endpoint uses.
The endpoint is one accept-loop source; a TCP+TLS loop is another source
that shares the registry. The "not an endpoint concern" framing is
preserved at the struct level (no `tcp: Option<TcpListener>` on
`AlknetEndpoint`); the "TCP can't participate in ALPN dispatch" framing
is **reversed** — `from_stream` is the primitive that lets TCP+TLS
participate without changing the endpoint design.
The unblocked follow-ups (not part of ADR-065, but enabled by it):
- **Standard HTTP over TCP+TLS** (`api.alk.dev`'s requirement): a TLS
accept loop wraps each `TlsStream<TcpStream>` as a `Connection` via
`from_bidi` and dispatches to `HttpAdapter` by the negotiated ALPN
(`h2`/`http/1.1`). `HttpAdapter::handle` calls `accept_bi` once (yielded
by the single stream), then runs hyper over it — unchanged from the
QUIC path. No handler code changes.
- **SSH channel dispatch**: an SSH handler wraps each russh channel as a
`Connection` via `from_stream` and dispatches by channel-type (treated as
the ALPN string) through `HandlerRegistry`. One SSH connection carries
heterogeneous channels — a multiplexing power QUIC's per-connection ALPN
doesn't provide natively.
- **WebTransport stream dispatch** (parked per ADR-044, unblocked
structurally): the WT handler wraps each WT stream via `from_stream`.
The `iroh 0.35 → 1.0.2` migration (commit `acd049e`, 2026-07-09) is a
related cleanup: it bumps the iroh dep to 1.0, unblocking `alknet-blobs`
(which pulls `iroh 1.0` transitively). It is not an architectural change —
6 API surface edits in `endpoint.rs` / `types.rs` (the `Endpoint::builder`
preset, `SecretKey::from_bytes`/`generate` signatures,
`Connection::remote_id`/`alpn` return types). No ADR needed; the endpoint
design is unchanged.
@@ -201,8 +201,9 @@ makes the freeze explicit and enforceable by review.
## References
- ADR-003: Crate decomposition (alknet-vault is standalone)
- ADR-005: irpc as call protocol foundation (irpc remains the foundation
for alknet-*call*; the vault no longer uses irpc — see ADR-025)
- ADR-005: irpc as call protocol foundation (superseded by ADR-064 — irpc
was never integrated into alknet-call; the vault no longer uses irpc
either — see ADR-025)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; the
vault uses direct method calls, no actor, no remote capability)
- ADR-008: Vault integration point (CLI-embedded, assembly-layer only)
@@ -312,8 +312,11 @@ version of ADR-018's intent.
## References
- ADR-005: irpc as call protocol foundation (this ADR amends the vault
reference in ADR-005's Decision and Consequences; irpc remains the
foundation for alknet-*call*, just not for alknet-*vault*)
reference in ADR-005's Decision and Consequences; ~~irpc remains the
foundation for alknet-*call*, just not for alknet-*vault*~~ — **this
claim is itself superseded by [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md)**,
which records that irpc was never integrated into alknet-call either;
neither the vault nor the call protocol uses irpc)
- ADR-008: Vault integration point (the vault is a capability source
accessed at assembly time — this ADR makes that the *only* mode)
- ADR-014: Secret material flow and capability injection (`DerivedKey`
@@ -0,0 +1,164 @@
# ADR-064: irpc Was Never Integrated — Hand-Rolled EventEnvelope Framing
## Status
Accepted
## Context
ADR-005 accepted "irpc as the call protocol foundation" based on the
previous architecture's use of irpc. When the call protocol was implemented,
it turned out that **no `.rs` file in the workspace ever imported irpc**.
The workspace `Cargo.toml` declared `irpc = "0.16"` / `irpc-derive = "0.16"`
as workspace dependencies, and `crates/alknet-call/Cargo.toml` declared
`irpc = { workspace = true }`, but the import was never written. The wire
protocol (`crates/alknet-call/src/protocol/wire.rs`) is hand-rolled
length-prefixed JSON — 4-byte big-endian length prefix + UTF-8 JSON body —
not an irpc service.
This was discovered when building an external app against the crates: the
`irpc 0.16` workspace dep was a version-gap blocker for `alknet-blobs` (which
pulls `irpc 0.17` transitively via `iroh-blobs 0.103`). A grep for any `irpc`
import in the workspace found zero hits — the dep was dead weight carried
over from the previous architecture without verification.
The framing, operation registry, dispatch, and subscription patterns that
ADR-005 attributed to irpc are all hand-rolled in alknet-call:
- **Framing**: `FrameFramedReader` / `FrameFramedWriter` in `wire.rs` —
length-prefixed JSON, hand-written against `tokio::io::AsyncRead`/
`AsyncWrite`. Not an irpc service.
- **Operation registry**: `OperationSpec`, `Handler`, `OperationRegistry`,
`AccessControl` — hand-rolled in alknet-call, not irpc's `Service` trait.
- **Event types**: `call.requested`, `call.responded`, `call.completed`,
`call.aborted`, `call.error` — the alknet call protocol's own event
vocabulary, not irpc's.
- **Subscription/streaming**: `StreamingHandler` / `invoke_streaming()`
(ADR-049) — hand-rolled, not irpc's streaming patterns.
The `EventEnvelope { type, id, payload }` shape was derived from the
`@alkdev/pubsub` TypeScript `EventEnvelope` (`/workspace/@alkdev/pubsub/src/
types.ts`), not from irpc. ADR-005's claim that "the wire format is irpc's
EventEnvelope framing" was wrong — irpc was never imported, and the envelope
shape has a different origin (the pubsub prior art, ADR-013). The framing
coincidentally resembles irpc's (both are length-prefixed JSON), which is
how the misattribution went unnoticed.
### What ADR-005 got right
Despite the irpc misattribution, ADR-005's *architectural* decisions are
correct and stand unchanged:
- The call protocol uses length-prefixed JSON `EventEnvelope` framing
(hand-rolled, not irpc-supplied).
- The wire format is cross-language and consumable from TypeScript, Python,
any language (JSON is inherently cross-language — ADR-005's "mitigated:
it's length-prefixed JSON" note was the load-bearing point, not the irpc
attribution).
- Operations use JSON Schema discovery. The `OperationSpec` shape is
hand-rolled, JSON-Schema-compatible — the same property ADR-005 attributed
to irpc, achieved without irpc.
### Why a new ADR rather than an amendment
ADR-005's Decision and Consequences are built on the premise "alknet-call
uses irpc as its foundation — irpc IS the call protocol's core." That
premise is false. Amending ADR-005 to say "actually it's hand-rolled" would
leave an ADR whose Context, Decision, and Consequences sections all argue
for a choice that was never made. The correct record is: ADR-005 is
superseded; the call protocol uses hand-rolled framing (this ADR-064); the
architectural properties ADR-005 sought (proven framing, cross-language
JSON, streaming) are preserved, but the mechanism is hand-rolled, not
irpc-sourced.
### The dead dep removal
The `irpc` / `irpc-derive` workspace deps and the `alknet-call` consumer dep
were removed in commit `668d777` (2026-07-09). `irpc` may be re-added as
`0.17` when `alknet-blobs` lands (it pulls `irpc 0.17` transitively via
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
not import irpc and has no plans to. See
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
§3.1 for the removal trace.
## Decision
1. **ADR-005 is superseded.** The call protocol does not use irpc. irpc was
never imported by any `.rs` file in the workspace. The dead `irpc` /
`irpc-derive` workspace and crate deps are removed.
2. **The call protocol uses hand-rolled `EventEnvelope` framing.** The wire
format is length-prefixed JSON (4-byte big-endian length + UTF-8 JSON
body), implemented in `crates/alknet-call/src/protocol/wire.rs`. The
`EventEnvelope { type, id, payload }` shape was derived from the
`@alkdev/pubsub` TypeScript prior art (ADR-013), not from irpc. The
framing, operation registry, dispatch, and streaming patterns are all
hand-rolled in alknet-call.
3. **The architectural properties ADR-005 sought are preserved by the
hand-rolled implementation:**
- Proven framing — length-prefixed JSON is a well-understood,
battle-tested pattern; the hand-rolled implementation is tested (207
lib + 2 integration tests passing).
- Cross-language — JSON is inherently consumable from any language;
NAPI, WASM, and browser clients speak the same wire format.
- Streaming — `StreamingHandler` / `invoke_streaming()` (ADR-049) provide
the subscription/streaming patterns ADR-005 attributed to irpc,
hand-rolled.
4. **irpc is not a planned dependency for alknet-call.** If `alknet-blobs`
pulls irpc transitively, it will be a transitive dependency of that
crate, not a direct dependency of alknet-call. alknet-call's framing,
registry, and dispatch are hand-rolled and will remain so. The "mitigated:
irpc is lightweight and we can fork if needed" caveat in ADR-005 is moot
— there is nothing to fork because nothing was integrated.
5. **The vault's irpc drop (ADR-025) stands.** ADR-025 dropped irpc from
alknet-vault. With this ADR, irpc is also confirmed absent from
alknet-call. The vault and call decisions are now consistent: neither
crate uses irpc. The only difference is that ADR-025 *removed* a real
(but unused-for-its-primary-path) irpc dependency from the vault, while
this ADR records that alknet-call's irpc dependency was never integrated
at all — it was a Cargo.toml entry with no corresponding import.
## Consequences
**Positive:**
- The spec matches the code. ADR-005's irpc claims were a spec/code
divergence that surfaced only when the `irpc 0.16` version gap blocked
`alknet-blobs`. This ADR closes the divergence.
- `alknet-blobs` is unblocked — the dead `irpc 0.16` workspace dep is
gone; `iroh-blobs 0.103` (which pulls `irpc 0.17` transitively) no longer
conflicts with a workspace-pinned older irpc.
- The call protocol's framing, registry, and dispatch are documented
accurately as hand-rolled — readers of the spec aren't sent looking for
an irpc integration that doesn't exist.
- The cross-language story is unchanged: JSON wire format, JSON Schema
discovery. The mechanism changed (hand-rolled vs irpc), but the property
ADR-005 sought is preserved.
**Negative:**
- The call protocol does not inherit irpc's testing or production pedigree
for its framing. Mitigation: length-prefixed JSON is a trivial,
well-understood pattern; the hand-rolled implementation is tested; and
the framing is small enough to audit completely (~30 lines in `wire.rs`).
- ADR-005's claim that "the call protocol inherits irpc's streaming and
subscription patterns" was wrong — those patterns are hand-rolled
(ADR-049). The streaming implementation is younger and less battle-tested
than irpc's would have been, but it is also simpler and fully owned.
## References
- ADR-005: irpc as call protocol foundation (superseded by this ADR)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; this ADR
records irpc was never integrated into alknet-call either)
- ADR-013: Rust as canonical implementation (the `@alkdev/pubsub` prior art
that the `EventEnvelope` shape was actually derived from)
- ADR-049: Streaming handler for subscriptions (the hand-rolled streaming
dispatch path)
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
- Transport generalization findings:
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
§3.1 (dead `irpc` dep removal)
- Removal commit: `668d777` (2026-07-09)
@@ -0,0 +1,236 @@
# ADR-065: Connection::from_stream — Generic Single-Stream Connections
## Status
Accepted
## Context
ADR-007 defines `Connection` as a concrete type wrapping a QUIC connection
(quinn or iroh). ADR-010 establishes the endpoint as a multi-connectivity
QUIC acceptor — quinn and iroh, both producing QUIC connections dispatched by
ALPN. The `ProtocolHandler` trait (ADR-002) receives a `Connection`, and
handlers call `accept_bi()` / `open_bi()` to get bidirectional streams.
This design is **welded to QUIC**. Both real `ConnectionKind` variants
(`Quinn`, `Iroh`) are QUIC. The `HttpAdapter::handle` method calls
`connection.accept_bi().await` to get a bidi stream and serves HTTP over it
— "HTTP over QUIC," not "HTTP over TCP+TLS." There is no way to serve the
standard HTTP interface that `api.alk.dev` (an external app being built
against the crates) requires without either bypassing the
`HandlerRegistry` (a parallel listener, defeating the ALPN-router design)
or generalizing `Connection` to accept a non-QUIC stream.
The same welding blocks `alknet-ssh` (needs to dispatch SSH channels —
each channel is a read/write pair — through the same `HandlerRegistry` as
QUIC connections) and WebTransport stream dispatch (each WT stream is a
read/write pair). The `TtyAdapter` and `CallAdapter` dispatch loops are
already transport-agnostic in their inner logic — only the
`connection.accept_bi()` call is QUIC-coupled, because `accept_bi` only
works when `Connection` is QUIC-kind.
### The yield-once contract composes
QUIC's `accept_bi` returns a new bidi stream per call (many). A generic
single-stream connection's `accept_bi` returns the underlying stream on the
first call, then `ConnectionClosed` on all subsequent calls. This is the
contract that makes the abstraction compose:
- Handlers that loop `accept_bi` (TtyAdapter) get one session per
single-stream connection — the loop body runs once, then
`ConnectionClosed` breaks the loop. Correct.
- Handlers that call `accept_bi` once (HttpAdapter) get the stream
directly. Correct.
No branching on transport. The handler code is unchanged across QUIC
(many streams) and TCP+TLS / SSH channels / WebTransport streams (one
stream). The `ProtocolHandler` trait shape is not touched — this is an
additive change to `Connection`, not a trait revision.
### The stream-level Mock variants were already generic
`SendStreamKind::Mock(Box<dyn AsyncWrite>)` and `RecvStreamKind::Mock(Box<dyn
AsyncRead>)` were already generic stream holders — the name was wrong
(carried over from a test-only context). The generalization renames them
to `Stream` and makes them load-bearing: `Connection::from_stream` calls
`SendStream::from_stream` / `RecvStream::from_stream` to wrap the halves of
the single stream.
### The connection-level Mock is removed
The findings doc (`docs/research/transport-generalization/findings.md`)
proposed keeping `ConnectionKind::Mock` / `MockConnection` for test-only
full-connection mocks. The implementation went further: `MockConnection`
and `ConnectionKind::Mock` are removed entirely. Test stubs that
previously used `Connection::from_mock(Arc<StubConnection>)` now use
`Connection::from_stream(tokio::io::sink(), tokio::io::empty(), alpn,
addr)` — `tokio::io::empty()` yields immediate EOF on the read side,
causing the handler's `handle_stream` to exit cleanly, and `accept_bi`
returns `ConnectionClosed` after the first take (driving the run loop to
exit). This is simpler (one connection kind, not two) and the test stubs
are shorter. `from_stream` subsumes the test-mock use case because a test
connection is just a single-stream connection with EOF-on-read.
### Why not change the ProtocolHandler trait
An earlier analysis proposed changing `ProtocolHandler::handle` to take a
single `Channel` instead of a `Connection`, moving the multiplexing loop
from the handler to the endpoint. This ADR does **not** do that:
1. **TtyAdapter already establishes the pattern.** The handler loops
`accept_bi` and dispatches each stream internally. SSH does the same —
parse channels, dispatch each. The multiplexing loop belongs in the
handler, not the endpoint.
2. **The trait shape is a one-way door (ADR-009).** Changing
`handle(Connection)` → `handle(Channel)` would require migrating every
handler and would lock in a specific multiplexing model. `from_stream`
is additive — it extends `Connection` without touching the trait. If a
trait change is ever warranted, it can come later; `from_stream` doesn't
preclude it.
See `docs/research/transport-generalization/findings.md` §6 for the full
argument against the trait shape change.
## Decision
### Add `ConnectionKind::Stream`
A new variant holding a single read/write pair behind a
`Mutex<Option<(SendStream, RecvStream)>>` — the yield-once semantic. No
feature gate (generic, no transport deps). `StreamConn` is always
available; the quinn/iroh variants remain feature-gated.
### Add `Connection::from_stream` and `Connection::from_bidi`
```rust
/// Construct a Connection from a pre-split read/write pair.
/// `accept_bi()` yields this pair once, then returns `ConnectionClosed`.
/// `open_bi()` returns `StreamClosed` (a single stream can't open new streams).
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
/// Convenience for a single bidirectional stream (e.g. TlsStream<TcpStream>).
/// Splits internally via tokio::io::split.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
```
### Make `accept_bi`'s yield-once contract explicit
The `accept_bi` doc comment now states the transport semantics: QUIC yields
many streams, single-stream yields once then `ConnectionClosed`. This is
the contract that makes the abstraction compose — handlers don't branch
on transport.
### Rename stream-level `Mock` → `Stream`
`SendStreamKind::Mock` → `SendStreamKind::Stream`,
`RecvStreamKind::Mock` → `RecvStreamKind::Stream`.
`SendStream::from_mock` → `from_stream`, `RecvStream::from_mock` →
`from_stream`. The variants were already generic stream holders; the name
was wrong. Drop the `#[allow(dead_code)]` — `from_stream` is now
load-bearing.
### Remove `MockConnection` / `ConnectionKind::Mock`
The connection-level test mock trait and variant are removed. Test stubs
use `Connection::from_stream` with `tokio::io::sink()` / `tokio::io::empty()`
(immediate EOF on read → handler exits cleanly → `accept_bi` returns
`ConnectionClosed` → run loop exits). One connection kind for both
production and tests, not two.
### `open_bi` on `Stream` returns `StreamClosed`
A single stream cannot open new application streams. `open_bi` on
`ConnectionKind::Stream` returns `StreamError::StreamClosed`. Handlers that
call `open_bi` (the call protocol's server→client direction) work over
QUIC but not over a single-stream connection — this is inherent to the
transport, not a flaw. A handler that needs `open_bi` should not be
dispatched over a single-stream connection (or should multiplex its own
sub-streams within the one stream, as the call protocol does over a single
WebTransport stream).
### What does NOT change
- `ProtocolHandler` trait shape — `handle(&self, connection: Connection,
auth: &AuthContext)` stays. This is an additive change to `Connection`,
not a trait revision (ADR-009: the trait is a one-way door).
- `HandlerRegistry` — unchanged.
- All handler code (`HttpAdapter`, `TtyAdapter`, `CallAdapter`) —
unchanged. `HttpAdapter` is one `accept_bi` call away from
transport-agnostic (it already is — the call works over `from_stream`).
- `BiStream` trait — unchanged (ADR-007). `from_stream` is a server-side
connection constructor; `BiStream` is a client-side/test convenience
trait. They're complementary, not competing.
- The endpoint's accept loops (quinn/iroh) — unchanged. The TCP+TLS accept
loop that *uses* `from_stream` is a follow-up, not this ADR.
## Consequences
**Positive:**
- Every existing `ProtocolHandler` works over TCP+TLS, SSH channels,
WebTransport streams, and wasm streams **unchanged** — dispatch through
the same `HandlerRegistry` by ALPN string, no handler code changes.
- `api.alk.dev`'s HTTP blocker is resolved: a TCP+TLS accept loop can call
`Connection::from_bidi(tls_stream, alpn, remote_addr)` and dispatch
through `HandlerRegistry` — `HttpAdapter` works unchanged over the
single stream. (The accept loop itself is a follow-up commit; the
primitive it needs is now in place.)
- `alknet-ssh` is unblocked: the SSH handler wraps each russh channel via
`from_stream` and dispatches by channel-type (treated as the ALPN string)
through `HandlerRegistry`. One SSH connection carries heterogeneous
channels (`alknet/tty`, `alknet/call`, `h2`, ...) — a multiplexing power
QUIC's per-connection ALPN doesn't give natively.
- WebTransport stream dispatch is unblocked: the WT handler wraps each WT
stream via `from_stream` and dispatches through `HandlerRegistry` (the
primitive exists; WT itself is parked per ADR-044).
- The server-side WASM door (OQ-09) is no longer closed by `Connection`
being QUIC-bound — `from_stream` accepts any `AsyncRead + AsyncWrite`,
including wasm-compatible streams. (The accept-loop runtime remains
tokio-bound; the *connection* door is now open.)
- One connection kind for production and tests (no `MockConnection`
trait) — simpler type, shorter test stubs.
- No new deps, no `Cargo.toml` change — `tokio::io::split` is already
available via the existing tokio dep.
**Negative:**
- `open_bi` on a single-stream connection returns `StreamClosed` —
handlers that need server→client stream initiation (the call protocol's
bidirectional call direction) don't work over a single-stream
connection. This is inherent to the transport, not a design flaw: a
single TCP+TLS stream is not a multiplexed transport. Handlers that need
`open_bi` should run over QUIC, or multiplex their own sub-streams within
the one stream (as the call protocol does over a single WebTransport
stream — the `EventEnvelope` framing is stream-agnostic, ADR-012).
- The `close()` method's `code`/`reason` args are QUIC-specific
(application-level close codes). For a raw stream they're ignored — the
drop is the close. This is the same best-effort semantic `close` already
had for the removed `Mock` variant.
- A `Mutex` on the `StreamConn` — a single lock per `accept_bi` / `close`
call. Negligible cost (one `take()`), but it is a lock where the QUIC
variants have none.
## References
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-007: BiStream type definition (amended by this ADR — `Connection` is
no longer QUIC-only; the server-side WASM door is open)
- ADR-009: One-way door decision framework (why the trait shape is not
changed — `from_stream` is additive)
- ADR-010: ALPN router and endpoint (amended by this ADR — "TCP is not an
endpoint concern" is revised; `from_stream` lets TCP+TLS participate in
ALPN dispatch via a handler-internal accept loop)
- ADR-012: Call protocol stream model (the `EventEnvelope` framing is
stream-agnostic — composes over `from_stream`)
- OQ-09: WASM target boundaries (resolution amended — the server-side
dispatch door is no longer closed by `Connection` being QUIC-bound)
- Transport generalization findings:
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
- Implementation commit: `865fef6` (2026-07-09)