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:
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)
|
||||
Reference in new issue
Block a user