docs(arch): land ADR-070 BidiStreamSource + OQ-55 AlknetClient deferral + impl task
Three core-crate changes surfaced by the alknet-channels POC: - ADR-070: extract BidiStreamSource trait so Connection holds Box<dyn BidiStreamSource> instead of a closed ConnectionKind enum; downstream crates (channels, future transports) implement the trait to add connection shapes without editing core. Public Connection API preserved verbatim. REQ-CORE-02 (close() params clippy warning under --no-default-features) folded in — the signature stays on the trait, non-QUIC impls ignore the args. - OQ-55: AlknetClient / client establishment extraction deferred(scope). Blocked on a second *transport's* real client (not a second QUIC client) — extracting a QUIC-shaped connector now would bake QUIC in as the establishment shape, the same welding ADR-065 unwound on the server side. Each crate builds its own client standalone for now. - core-types.md / auth.md / overview.md / README indexes updated to reflect ADR-070 and OQ-55. Architecture review: zero critical issues. - tasks/core/bidistreamsource-trait.md: the implementation task (Parts 1-3: BidiStreamSource refactor, close() fix, AuthContext::anonymous).
This commit is contained in:
1 parent
9ea69efba6
commit
f8f5f27ce0
9 files changed
+703
-31
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# Alknet Architecture
|
||||
@@ -67,9 +67,9 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [overview.md](overview.md) | draft | Workspace-level overview, crate graph, shared types, design principles |
|
||||
| [open-questions.md](open-questions.md) | draft | OQ index — theme-grouped tables + Deferred/Blocked section; per-OQ files in [`questions/`](questions/) |
|
||||
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index |
|
||||
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (QUIC + `from_stream`), BiStream, StreamError |
|
||||
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
|
||||
| [crates/core/endpoint.md](crates/core/endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, shutdown |
|
||||
| [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow |
|
||||
| [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext (incl. `anonymous` constructor), Identity, IdentityProvider, AuthToken, resolution flow |
|
||||
| [crates/core/config.md](crates/core/config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle |
|
||||
| [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
|
||||
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
|
||||
@@ -172,10 +172,11 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [067](decisions/067-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring for Hub Deployments | Proposed |
|
||||
| [068](decisions/068-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations Override | Proposed |
|
||||
| [069](decisions/069-from-call-manual-free-function.md) | from_call Is a Manual Free Function, Not Auto-Wired | Proposed |
|
||||
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (54 OQs across 17 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
|
||||
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (55 OQs across 17 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
|
||||
|
||||
## Document Lifecycle
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-27
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# alknet-core
|
||||
@@ -11,9 +11,9 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection, BiStream, StreamError |
|
||||
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
|
||||
| [endpoint.md](endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, graceful shutdown |
|
||||
| [auth.md](auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow, PeerEntry, CredentialStore |
|
||||
| [auth.md](auth.md) | draft | AuthContext (incl. `anonymous` constructor), Identity, IdentityProvider, AuthToken, resolution flow, PeerEntry, CredentialStore |
|
||||
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle, AuthPolicy.peers |
|
||||
|
||||
## Applicable ADRs
|
||||
@@ -33,6 +33,8 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `authorized_fingerprints` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable) |
|
||||
| [031](../../decisions/031-credentialstore-repo-trait.md) | CredentialStore Repo Trait | Second repo trait in core; `InMemoryCredentialStore` default adapter |
|
||||
| [033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines traits + in-memory defaults; persistence adapters are separate crates |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` — Generic Single-Stream Connections | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | `Connection` holds `Box<dyn BidiStreamSource>`; QUIC/iroh/stream wrap crate-private impls; downstream crates implement the trait to add connection shapes (channels, future transports) without editing core |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
@@ -46,6 +48,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
| OQ-35 | ~~API key asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||
| OQ-36 | Concrete persistence adapter shapes | resolved by ADR-035 | Read-sync / write-async split (`IdentityStore`); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `alknet-store-sqlite` crate |
|
||||
| OQ-37 | X.509 outgoing-only case | resolved by ADR-034 | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; client-side verifier by `PeerEntry` presence (CA vs fingerprint pin) |
|
||||
| OQ-55 | AlknetClient / Client Establishment Extraction | deferred(scope) | Blocked on a second *transport's* real client (not a second QUIC client); extracting a QUIC-shaped connector now would bake QUIC in as *the* establishment shape — the welding ADR-065 unwound on the server side |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-05
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# Authentication
|
||||
@@ -84,6 +84,25 @@ The connection-level identity is stable — set once when the handler resolves i
|
||||
- `derive(Clone)` allows handlers to clone `AuthContext` for per-stream or per-channel contexts.
|
||||
- `handle()` receives `&AuthContext` — immutable. Handlers that resolve identity create local variables, they don't mutate the shared context. This prevents cross-contamination between streams on the same connection.
|
||||
|
||||
### `AuthContext::anonymous` constructor
|
||||
|
||||
```rust
|
||||
impl AuthContext {
|
||||
/// Construct an `AuthContext` with no identity, no fingerprint, and no
|
||||
/// remote address — only the ALPN is set. For POCs, tests, and handlers
|
||||
/// that don't require auth.
|
||||
pub fn anonymous(alpn: impl Into<Vec<u8>>) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
A convenience constructor that sets `identity: None`, `remote_addr: None`,
|
||||
`tls_client_fingerprint: None`, and `alpn` to the provided value. This
|
||||
removes the four-`None`-field literal that recurred in every handler POC
|
||||
and test (`poc-summary.md` §"Issues Surfaced" #3). The name is honest about
|
||||
the semantics: no identity, no fingerprint. Not gated behind a
|
||||
`test-utils` feature — it's a plain `pub fn` useful for any caller that
|
||||
constructs an `AuthContext` outside the endpoint's resolution path.
|
||||
|
||||
## Identity
|
||||
|
||||
The authenticated peer identity. Carries authorization information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# Core Types
|
||||
@@ -48,14 +48,15 @@ Handler panics are caught by tokio's task isolation. The connection is dropped,
|
||||
## Connection
|
||||
|
||||
An opaque type wrapping a transport connection. Handlers receive a
|
||||
`Connection` in `handle()`. The connection may be QUIC (quinn or iroh) or a
|
||||
`Connection` in `handle()`. The connection may be QUIC (quinn or iroh), a
|
||||
generic single stream (TCP+TLS, SSH channel, WebTransport stream, wasm
|
||||
stream) — see ADR-065 for the `from_stream` generalization.
|
||||
stream — ADR-065), or any other connection shape a downstream crate
|
||||
implements via `BidiStreamSource` (ADR-070).
|
||||
|
||||
```rust
|
||||
pub struct Connection {
|
||||
// Private: wraps the underlying connection — QUIC (quinn/iroh) or a
|
||||
// generic single-stream pair (ConnectionKind::Stream).
|
||||
source: Box<dyn BidiStreamSource>,
|
||||
alpn: Vec<u8>,
|
||||
// Private: handler-resolved identity for observability (OQ-11)
|
||||
identity: OnceLock<Identity>,
|
||||
}
|
||||
@@ -65,6 +66,12 @@ impl Connection {
|
||||
#[cfg(feature = "quinn")]
|
||||
pub fn from_quinn(conn: quinn::Connection) -> Self;
|
||||
|
||||
/// Construct from a quinn connection with an explicit ALPN (feature-gated
|
||||
/// on quinn). Used by the client path (`CallClient::connect`) and the
|
||||
/// endpoint's quinn accept loop.
|
||||
#[cfg(feature = "quinn")]
|
||||
pub fn from_quinn_with_alpn(conn: quinn::Connection, alpn: Vec<u8>) -> Self;
|
||||
|
||||
/// Construct from an iroh connection (feature-gated on iroh).
|
||||
#[cfg(feature = "iroh")]
|
||||
pub fn from_iroh(conn: iroh::Connection) -> Self;
|
||||
@@ -98,34 +105,93 @@ impl Connection {
|
||||
```
|
||||
|
||||
- `accept_bi()`: Yield the next bidirectional stream this connection
|
||||
provides. **Transport semantics (ADR-065):** QUIC (quinn/iroh) returns a
|
||||
new bidi stream on each call, `ConnectionClosed` when the underlying
|
||||
connection closes; a single-stream connection (TCP+TLS, SSH channel,
|
||||
WebTransport stream, wasm stream) yields the underlying stream on the
|
||||
first call, then `ConnectionClosed` on all subsequent calls. Handlers
|
||||
that loop `accept_bi` (TtyAdapter) get one session per single-stream
|
||||
connection; handlers that call once (HttpAdapter) get the stream
|
||||
directly. Both correct, no branching on transport.
|
||||
provides. **Transport semantics (ADR-065, extended by ADR-070):** QUIC
|
||||
(quinn/iroh) returns a new bidi stream on each call, `ConnectionClosed`
|
||||
when the underlying connection closes; a single-stream connection
|
||||
(TCP+TLS, SSH channel, WebTransport stream, wasm stream) yields the
|
||||
underlying stream on the first call, then `ConnectionClosed` on all
|
||||
subsequent calls; a `BidiStreamSource` implementation (e.g. a channels
|
||||
connection — see ADR-070) yields one stream per channel, then
|
||||
`ConnectionClosed` when the source closes. Handlers that loop `accept_bi`
|
||||
(TtyAdapter) get one session per single-stream connection; handlers that
|
||||
call once (HttpAdapter) get the stream directly. Both correct, no
|
||||
branching on transport.
|
||||
- `open_bi()`: Open a bidirectional stream to the peer. Returns
|
||||
`(SendStream, RecvStream)`. On a single-stream connection, returns
|
||||
`StreamClosed` — a single stream cannot open new application streams.
|
||||
- `remote_alpn()`: The ALPN negotiated for this connection. Always present.
|
||||
- `remote_addr()`: The peer's address, if available. Informational (NAT/proxy).
|
||||
- `close()`: Close the connection with an error code and reason. The
|
||||
`code`/`reason` args are QUIC-specific (application-level close codes);
|
||||
for a raw stream they're ignored — the drop is the close.
|
||||
`code`/`reason` args are QUIC application-level close codes; non-QUIC
|
||||
sources ignore them (the drop is the close — ADR-065). The signature is
|
||||
preserved on the trait so the public `Connection::close` API is
|
||||
unchanged across transports; see ADR-070 §"REQ-CORE-02" for why the
|
||||
QUIC-shaped signature stays on the trait rather than being split.
|
||||
- `set_identity()`: Store the handler-resolved identity for observability (OQ-11). Write-once-read-many — a second call returns an error. Handlers that resolve identity inside `handle()` call this; the identity is read by handler-side logging (the handler logs which identity it resolved) and is available on the `Connection` for any code that holds a reference to it. The endpoint does **not** read `identity()` after `handle()` returns — the `Connection` is moved into the spawned handler task (endpoint.md), so the endpoint no longer has a reference. Connection-level observability (remote addr, ALPN, connection ID) is logged by the endpoint before the move; identity-level observability is logged by the handler. See OQ-11 for the full resolution.
|
||||
|
||||
The `Connection` type does not expose quinn/iroh types in its public API.
|
||||
It wraps the underlying connection internally via `ConnectionKind` enum
|
||||
dispatch (`Quinn` / `Iroh` / `Stream`), with the QUIC variants feature-gated
|
||||
and the `Stream` variant always available (no transport deps). See
|
||||
It holds a `Box<dyn BidiStreamSource>` (ADR-070); the QUIC and
|
||||
single-stream implementations are crate-private (`QuinnBidiStreamSource`,
|
||||
`IrohBidiStreamSource`, `StreamBidiStreamSource`), with the QUIC variants
|
||||
feature-gated and the `Stream` variant always available (no transport
|
||||
deps). Downstream crates implement `BidiStreamSource` directly to add new
|
||||
connection shapes (e.g. the channels crate's `ChannelBidiStreamSource`)
|
||||
without editing `alknet-core`. See
|
||||
[ADR-070](../../decisions/070-bidistreamsource-trait.md) for the trait and
|
||||
the extension model, and
|
||||
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md)
|
||||
for the `from_stream` generalization and the yield-once `accept_bi`
|
||||
contract.
|
||||
contract that the `StreamBidiStreamSource` impl preserves.
|
||||
|
||||
See [ADR-007](../../decisions/007-bistream-type-definition.md) for why handlers receive Connection instead of BiStream.
|
||||
|
||||
## BidiStreamSource
|
||||
|
||||
The trait `Connection` holds. Downstream crates implement it to add new
|
||||
connection shapes (channels, a future transport, a test double beyond the
|
||||
`from_stream` case) without editing `alknet-core`. See
|
||||
[ADR-070](../../decisions/070-bidistreamsource-trait.md) for the full
|
||||
rationale.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait BidiStreamSource: Send + Sync + 'static {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
fn remote_addr(&self) -> Option<SocketAddr>;
|
||||
fn close(&self, code: u32, reason: &str);
|
||||
}
|
||||
```
|
||||
|
||||
- `accept_bi()` / `open_bi()`: the stream-yield operations. Each
|
||||
implementation defines its yield semantics (QUIC: many; single-stream:
|
||||
yield-once then `ConnectionClosed`; channels: one per channel). The
|
||||
contract is that handlers don't branch on transport — see
|
||||
`Connection::accept_bi` above and ADR-065's yield-once contract.
|
||||
- `remote_addr()`: the peer's address, if available. Same semantic as
|
||||
`Connection::remote_addr` (which delegates here).
|
||||
- `close(code, reason)`: QUIC application-level close codes. Non-QUIC
|
||||
sources ignore the args (the drop is the close). The signature matches
|
||||
the public `Connection::close` verbatim — see ADR-070 §"REQ-CORE-02".
|
||||
|
||||
`Connection`-level operations (`remote_alpn`, `set_identity`, `identity`)
|
||||
do **not** appear on `BidiStreamSource` — they live on `Connection` itself
|
||||
(the `alpn` field and the `identity` `OnceLock`), so they carry no new
|
||||
indirection and are not the transport's concern.
|
||||
|
||||
### Built-in implementations (crate-private)
|
||||
|
||||
| Impl | Constructor | Yield semantics |
|
||||
|------|-------------|-----------------|
|
||||
| `QuinnBidiStreamSource` | `Connection::from_quinn` / `from_quinn_with_alpn` (feature `quinn`) | many streams |
|
||||
| `IrohBidiStreamSource` | `Connection::from_iroh` (feature `iroh`) | many streams |
|
||||
| `StreamBidiStreamSource` | `Connection::from_stream` / `from_bidi` (no feature gate) | yield-once, then `ConnectionClosed`; `open_bi` returns `StreamClosed` |
|
||||
|
||||
Downstream crates do not wrap `from_stream` to implement
|
||||
`BidiStreamSource` — they implement the trait directly. `from_stream` is
|
||||
the compatibility path for callers that want a `Connection` over a single
|
||||
pre-split stream (the ADR-065 use case).
|
||||
|
||||
## BiStream
|
||||
|
||||
A trait for bidirectional byte streams. Used primarily for client-side and test scenarios.
|
||||
@@ -197,7 +263,7 @@ When a handler encounters a `StreamError` and needs to return from `handle()`, i
|
||||
|
||||
Handlers that manage multiple streams (SSH, call) may catch `StreamError::StreamClosed` per-stream and continue serving other streams on the same connection — only `ConnectionClosed` forces `handle()` to return.
|
||||
|
||||
**Note on single-stream connections (ADR-065):** `StreamClosed` from `open_bi` on a `ConnectionKind::Stream` (single-stream) connection is terminal for that connection — no other streams exist to continue with. The "connection may still be usable" framing above applies to the QUIC case (a per-stream closure where the connection lives); the single-stream case is a transport property (one stream is all there is), not a mid-operation stream closure. `accept_bi` on a single-stream connection returns `ConnectionClosed` after the first yield (not `StreamClosed`), so handlers that loop `accept_bi` exit cleanly.
|
||||
**Note on single-stream connections (ADR-065, ADR-070):** `StreamClosed` from `open_bi` on a single-stream `BidiStreamSource` (the `StreamBidiStreamSource` backed by `from_stream`/`from_bidi`) is terminal for that connection — no other streams exist to continue with. The "connection may still be usable" framing above applies to the QUIC case (a per-stream closure where the connection lives); the single-stream case is a transport property (one stream is all there is), not a mid-operation stream closure. `accept_bi` on a single-stream source returns `ConnectionClosed` after the first yield (not `StreamClosed`), so handlers that loop `accept_bi` exit cleanly.
|
||||
|
||||
The mapping is provided as a `From` impl so handlers can use the `?` operator:
|
||||
|
||||
@@ -300,6 +366,7 @@ registration bundle.
|
||||
| ProtocolHandler receives Connection, not BiStream | [ADR-007](../../decisions/007-bistream-type-definition.md) | Handlers that need multiple streams (SSH, call) have direct access to the Connection |
|
||||
| BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible |
|
||||
| `Connection::from_stream` — generic single-stream connections | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm; QUIC variants feature-gated, `Stream` variant always available; `MockConnection`/`ConnectionKind::Mock` removed (tests use `from_stream` with `sink`/`empty`) |
|
||||
| `BidiStreamSource` — open `Connection` for extension | [ADR-070](../../decisions/070-bidistreamsource-trait.md) | `Connection` holds `Box<dyn BidiStreamSource>`; QUIC/iroh/stream wrap crate-private impls; downstream crates implement the trait to add connection shapes (channels, future transports) without editing core; public `Connection` API preserved; `close(code, reason)` kept on the trait (non-QUIC impls ignore the args — fixes the ADR-065 leftover clippy warning under `--no-default-features`) |
|
||||
| HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint |
|
||||
| SendStream/RecvStream wrap quinn + iroh + generic streams | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | Internal enum dispatch for QUIC sources and the generic `Stream` variant |
|
||||
| Connection stores handler-resolved identity | OQ-11 (resolved) | `set_identity` via `OnceLock` — write-once-read-many; read by handler-side logging, not by the endpoint (C13 resolved) |
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# ADR-070: BidiStreamSource Trait — Open Connection for Extension
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ADR-065 generalized `Connection` beyond QUIC by adding
|
||||
`ConnectionKind::Stream` (a yield-once read/write pair) and the
|
||||
`Connection::from_stream` / `from_bidi` constructors. That closed the
|
||||
server-side "QUIC-only" gap: TCP+TLS, SSH channels, WebTransport streams,
|
||||
and wasm streams now dispatch through the same `HandlerRegistry` as QUIC
|
||||
connections, unchanged.
|
||||
|
||||
What ADR-065 did **not** change is the *shape* of `Connection` itself. It
|
||||
remains a closed enum:
|
||||
|
||||
```rust
|
||||
enum ConnectionKind {
|
||||
#[cfg(feature = "quinn")] Quinn(quinn::Connection),
|
||||
#[cfg(feature = "iroh")] Iroh(iroh::endpoint::Connection),
|
||||
Stream(StreamConn), // yield-once — ADR-065
|
||||
}
|
||||
```
|
||||
|
||||
Adding a new connection type today requires editing `alknet-core` — adding a
|
||||
variant to `ConnectionKind`, adding match arms to `accept_bi` / `open_bi` /
|
||||
`remote_addr` / `close`. Every downstream crate that introduces a new
|
||||
connection shape (channels, a future transport, a test double beyond the
|
||||
`from_stream` case) forces a core change. `Connection` is closed for
|
||||
extension.
|
||||
|
||||
### The channels crate is the first crate that needs to extend it
|
||||
|
||||
The `alknet-channels` POC (`docs/research/alknet-channels/poc-summary.md`)
|
||||
validated the channels multiplexer and surfaced the concrete blocker. A
|
||||
channels connection carries N logical channels over one transport stream;
|
||||
each channel is a bidirectional byte stream presented to a `ProtocolHandler`
|
||||
as a `Connection`. With the ADR-065 shape, each channel becomes a fresh
|
||||
yield-once `Connection::from_stream`, and the channels endpoint holds a *bag*
|
||||
of these connections (one per channel) rather than one `ChannelConnection`
|
||||
that yields N streams.
|
||||
|
||||
The POC confirmed this is *sufficient* (the yield-once path works — handlers
|
||||
run unchanged) but *awkward*: the channels layer wants to expose a single
|
||||
`ChannelConnection` that is a first-class peer of QUIC (many bidi streams),
|
||||
not a collection of yield-once `Connection`s. The clean shape is for the
|
||||
channels crate to implement the stream-yield interface itself, in its own
|
||||
crate, without a core edit.
|
||||
|
||||
### The extension point is narrow and already implied by ADR-065
|
||||
|
||||
`Connection`'s public surface is four operations: `accept_bi`, `open_bi`,
|
||||
`remote_addr`, `close`. `remote_alpn` / `set_identity` / `identity` are
|
||||
`Connection`-level (not transport-level) and stay on `Connection` itself.
|
||||
The four transport-level operations are the seam. Extracting them into a
|
||||
trait that downstream crates can implement turns `Connection` from a closed
|
||||
enum into an open trait object — the same extensibility `ProtocolHandler`
|
||||
already gives handlers, applied to the connection.
|
||||
|
||||
### What the POC de-risked
|
||||
|
||||
The channels POC (28 tests passing) validated that:
|
||||
|
||||
1. The yield-once `Connection::from_stream` path is sufficient for per-channel
|
||||
presentation — an echo `ProtocolHandler` runs through the full
|
||||
demux→Connection→handler→mux path with zero channels-layer awareness
|
||||
(`poc-summary.md` §"POC Target 2").
|
||||
2. The `BidiStreamSource` trait is **additive** — existing callers keep working
|
||||
via a `from_stream`-backed implementation of the trait, and the trait
|
||||
cleanly supports a `ChannelConnection` that yields N streams
|
||||
(`poc-summary.md` §"Issues Surfaced" #1).
|
||||
3. The trait does not touch the `ProtocolHandler` trait shape (ADR-002) —
|
||||
handlers continue to receive a `Connection` and call `accept_bi` /
|
||||
`open_bi` on it. This is a `Connection` internal refactor, not a handler
|
||||
API change (`poc-summary.md` §"POC Target 2").
|
||||
|
||||
The remaining unknowns are spec-scope (the channels crate's API), not
|
||||
feasibility. This ADR makes the core-side extension point available so the
|
||||
channels spec can build on it.
|
||||
|
||||
## Decision
|
||||
|
||||
### Extract `BidiStreamSource` trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait BidiStreamSource: Send + Sync + 'static {
|
||||
/// Yield the next bidirectional stream this connection provides.
|
||||
///
|
||||
/// Transport semantics (carried from ADR-065):
|
||||
/// - QUIC (quinn/iroh): returns a new bidi stream on each call,
|
||||
/// `ConnectionClosed` when the underlying connection closes.
|
||||
/// - Single-stream (TCP+TLS, SSH channel, WebTransport stream, wasm):
|
||||
/// yields the underlying stream on the first call, then
|
||||
/// `ConnectionClosed` on all subsequent calls.
|
||||
/// - Channels: yields one bidi stream per channel, `ConnectionClosed`
|
||||
/// when the channels connection closes.
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
|
||||
/// Open a bidirectional stream to the peer.
|
||||
///
|
||||
/// Single-stream sources return `StreamClosed` (a single stream cannot
|
||||
/// open new application streams — ADR-065). QUIC and channels sources
|
||||
/// open new streams.
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
|
||||
/// The peer's address, if available. Informational (NAT/proxy).
|
||||
fn remote_addr(&self) -> Option<SocketAddr>;
|
||||
|
||||
/// Close the connection. The `code`/`reason` args are QUIC application-
|
||||
/// level close codes; non-QUIC sources ignore them (the drop is the
|
||||
/// close — ADR-065 §"Negative"). See REQ-CORE-02 below for the
|
||||
/// rationale for keeping the QUIC-shaped signature on the trait.
|
||||
fn close(&self, code: u32, reason: &str);
|
||||
}
|
||||
```
|
||||
|
||||
### `Connection` holds `Box<dyn BidiStreamSource>`
|
||||
|
||||
```rust
|
||||
pub struct Connection {
|
||||
source: Box<dyn BidiStreamSource>,
|
||||
alpn: Vec<u8>,
|
||||
identity: OnceLock<Identity>,
|
||||
}
|
||||
```
|
||||
|
||||
`ConnectionKind` (the private enum) is replaced by the trait object. The
|
||||
public `Connection` API (`accept_bi`, `open_bi`, `remote_alpn`, `remote_addr`,
|
||||
`close`, `set_identity`, `identity`) is preserved verbatim — each method
|
||||
delegates to `self.source`. `remote_alpn` reads `self.alpn` (unchanged).
|
||||
`set_identity` / `identity` read/write `self.identity` (unchanged).
|
||||
|
||||
### Constructors stay; each wraps a `BidiStreamSource` impl
|
||||
|
||||
| Constructor | Wraps |
|
||||
|-------------|-------|
|
||||
| `from_quinn` / `from_quinn_with_alpn` (feature `quinn`) | `QuinnBidiStreamSource` (crate-private) |
|
||||
| `from_iroh` (feature `iroh`) | `IrohBidiStreamSource` (crate-private) |
|
||||
| `from_stream` / `from_bidi` (no feature gate) | `StreamBidiStreamSource` (crate-private, yield-once) |
|
||||
|
||||
The `Stream`-backend implementations are crate-private; downstream crates
|
||||
do not implement `BidiStreamSource` by wrapping `from_stream`. They
|
||||
implement the trait directly (channels: `ChannelBidiStreamSource`), or they
|
||||
use a public constructor that already wraps an impl.
|
||||
|
||||
### `from_stream`-backed default impl is the compatibility path
|
||||
|
||||
The yield-once `StreamBidiStreamSource` is the implementation that keeps
|
||||
existing callers working: `Connection::from_stream(send, recv, alpn, addr)`
|
||||
constructs a `Connection` backed by a `StreamBidiStreamSource` whose
|
||||
`accept_bi` yields once then returns `ConnectionClosed`, whose `open_bi`
|
||||
returns `StreamClosed`, whose `close` drops the stream. Behaviorally identical
|
||||
to the ADR-065 `ConnectionKind::Stream` variant. No caller change.
|
||||
|
||||
### REQ-CORE-02: `close()` keeps the QUIC-shaped signature on the trait
|
||||
|
||||
The `close(&self, code: u32, reason: &str)` signature is preserved on the
|
||||
trait, rather than being split into transport-specific close methods. This
|
||||
resolves the ADR-065 leftover: the `Stream` backend's `close(code, reason)`
|
||||
currently takes both args and uses neither, which clippy flags under
|
||||
`--no-default-features` (the channels POC's build mode) as two unused
|
||||
variable warnings on `crates/alknet-core/src/types.rs:500`.
|
||||
|
||||
Two options were considered:
|
||||
|
||||
- **(a) Split `close`**: `trait BidiStreamSource { fn close(&self); }` plus a
|
||||
separate `fn close_with_code(&self, code: u32, reason: &str)` default-
|
||||
implemented to call `close()`. Non-QUIC impls implement only `close()`;
|
||||
QUIC impls override `close_with_code`. This moves the QUIC-shaped args off
|
||||
the common method.
|
||||
- **(b) Keep the QUIC-shaped signature on the trait**: `fn close(&self, code:
|
||||
u32, reason: &str)`. Non-QUIC impls prefix the args with `_` and document
|
||||
why they're ignored (the drop is the close — ADR-065). The trait method
|
||||
matches the existing public `Connection::close` signature verbatim — no
|
||||
caller change, no `Connection` API split.
|
||||
|
||||
**Decision: (b).** Rationale:
|
||||
|
||||
1. **No caller breakage.** `Connection::close(code, reason)` is the existing
|
||||
public signature; every caller passes both args. Option (a) would force
|
||||
either a `Connection::close` that *always* takes `code`/`reason` and
|
||||
dispatches to the right trait method (which means the trait still has the
|
||||
QUIC-shaped method, just renamed — no actual improvement), or a
|
||||
`Connection::close` that drops the args (which breaks every caller).
|
||||
2. **The args are not QUIC-only in principle.** WebTransport has
|
||||
application-level close codes; a future transport may as well. The
|
||||
signature `close(code, reason)` is a reasonable "close with diagnostic"
|
||||
shape that multiple transports can use. Only raw-stream backends (the
|
||||
ADR-065 `Stream` case) have nothing to do with the args, and they're the
|
||||
degenerate case.
|
||||
3. **The clippy warning is fixed by the trait, not by renaming.** Under the
|
||||
trait, the `StreamBidiStreamSource::close` impl prefixes the args with
|
||||
`_code`/`_reason` and carries a doc comment stating they're ignored
|
||||
because the drop is the close. The warning disappears; the signature
|
||||
matches the public API.
|
||||
|
||||
The trait method's doc comment carries the "QUIC application-level close
|
||||
codes; non-QUIC sources ignore them" note from ADR-065, so implementers know
|
||||
the args are optional for their transport.
|
||||
|
||||
### What does NOT change
|
||||
|
||||
- **`ProtocolHandler` trait shape** — `handle(&self, connection: Connection,
|
||||
auth: &AuthContext)` stays. This is an internal `Connection` refactor, not
|
||||
a handler API change (ADR-009: the handler trait is a one-way door).
|
||||
- **`HandlerRegistry`** — unchanged.
|
||||
- **All handler code** (`HttpAdapter`, `TtyAdapter`, `CallAdapter`,
|
||||
`ChannelsAdapter`) — unchanged. They receive a `Connection` and call
|
||||
`accept_bi` / `open_bi` on it. The dispatch through `Box<dyn
|
||||
BidiStreamSource>` is transparent to them.
|
||||
- **`SendStream` / `RecvStream`** — unchanged. They continue to wrap
|
||||
quinn/iroh/generic-stream sources via their own internal enum dispatch.
|
||||
`BidiStreamSource` implementations construct `SendStream` / `RecvStream`
|
||||
via the existing `from_quinn` / `from_iroh` / `from_stream` constructors.
|
||||
- **`BiStream` trait** — unchanged (ADR-007). `BidiStreamSource` is the
|
||||
server-side / connection-level seam; `BiStream` is a client-side / test
|
||||
convenience trait. Complementary, not competing.
|
||||
- **The endpoint's accept loops** (quinn/iroh) — unchanged. They construct
|
||||
`Connection::from_quinn` / `from_iroh`, which now internally wrap a
|
||||
`QuinnBidiStreamSource` / `IrohBidiStreamSource`. The accept loops
|
||||
themselves don't touch the trait.
|
||||
- **`Connection::remote_alpn` / `set_identity` / `identity`** — unchanged.
|
||||
These are `Connection`-level (the `alpn` field and the `identity` OnceLock),
|
||||
not transport-level. They stay on `Connection` and do not appear on
|
||||
`BidiStreamSource`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- `Connection` is open for extension. The channels crate implements
|
||||
`ChannelBidiStreamSource` in its own crate and constructs `Connection`
|
||||
from it — no core edit. A future transport, test double, or relay
|
||||
connection follows the same path. This is the structural payoff: the
|
||||
connection type is no longer a closed enum that every new connection
|
||||
shape must edit.
|
||||
- A channels connection is a first-class peer of QUIC: one
|
||||
`ChannelConnection` that yields N streams, rather than a bag of yield-
|
||||
once `Connection`s. The channels layer's API matches its actual shape.
|
||||
- The ADR-065 leftover clippy warning (unused `code`/`reason` on the
|
||||
`Stream` backend under `--no-default-features`) is resolved — the
|
||||
`StreamBidiStreamSource::close` impl documents why the args are ignored,
|
||||
and the `_` prefix is intentional, not a missing fix.
|
||||
- Existing callers, handlers, and tests are unchanged. The public
|
||||
`Connection` API is preserved verbatim; the refactor is internal.
|
||||
- No new deps. `async_trait` is already a core dep (used by
|
||||
`ProtocolHandler`).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- One dyn-dispatch indirection per `accept_bi` / `open_bi` / `close` /
|
||||
`remote_addr` call. The previous enum match was also a branch, so the cost
|
||||
is roughly one `Box<dyn>` method call per stream operation — negligible
|
||||
next to the async I/O those operations perform. The `alpn` / `identity`
|
||||
fields stay on `Connection` (not behind the dyn), so `remote_alpn` /
|
||||
`set_identity` / `identity` have no new indirection.
|
||||
- `BidiStreamSource: Send + Sync + 'static` is object-safe. This constrains
|
||||
implementations to `Send + Sync + 'static`, matching `ProtocolHandler` —
|
||||
consistent with the existing handler model.
|
||||
- The `Box<dyn BidiStreamSource>` is one allocation per `Connection`. The
|
||||
enum was stack-allocated (except the `StreamConn`'s inner `Mutex`).
|
||||
Negligible per-connection cost; only matters if connections are
|
||||
constructed in a hot loop, which they are not.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
|
||||
- ADR-007: BiStream type definition (amended by ADR-065; this ADR does not
|
||||
touch `BiStream`)
|
||||
- ADR-009: One-way door decision framework (why `ProtocolHandler` is not
|
||||
changed — this ADR is additive to `Connection`, not a trait revision)
|
||||
- ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s
|
||||
via `from_quinn` / `from_iroh`; those now wrap a `BidiStreamSource` impl,
|
||||
transparently)
|
||||
- ADR-065: `Connection::from_stream` — generic single-stream (this ADR
|
||||
generalizes `Connection` to hold a trait object; the `from_stream` /
|
||||
`from_bidi` constructors and the yield-once contract are preserved via
|
||||
`StreamBidiStreamSource`)
|
||||
- Channels POC summary:
|
||||
[`docs/research/alknet-channels/poc-summary.md`](../../research/alknet-channels/poc-summary.md)
|
||||
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
|
||||
- Channels Phase 0 findings:
|
||||
[`docs/research/alknet-channels/phase-0-findings.md`](../../research/alknet-channels/phase-0-findings.md)
|
||||
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
@@ -75,6 +75,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis
|
||||
| [OQ-12](questions/012-tls-identity-provisioning-in-alknetendpoint.md) | TLS Identity Provisioning in AlknetEndpoint | resolved | one | high |
|
||||
| [OQ-13](questions/013-operation-path-format-and-routing-scope.md) | Operation Path Format and Routing Scope | resolved | two | med |
|
||||
| [OQ-14](questions/014-batch-operation-semantics.md) | Batch Operation Semantics | resolved | two | low |
|
||||
| [OQ-55](questions/055-alknetclient-establishment-extraction.md) | AlknetClient / Client Establishment Extraction | deferred(scope) | two | med |
|
||||
|
||||
### alknet-call
|
||||
|
||||
@@ -230,3 +231,9 @@ filtering the tables above.
|
||||
- **Priority**: medium
|
||||
- **Full file**: [OQ-51](questions/051-container-create-options-surface.md)
|
||||
|
||||
### OQ-55: AlknetClient / Client Establishment Extraction
|
||||
|
||||
- **Blocked on**: a **second transport's** real client existing (not just a second QUIC client). The dial is transport-specific (QUIC, HTTP, TCP+TLS, WebTransport, raw TCP); we have one shape implemented (QUIC). Extracting a QUIC-shaped connector now would bake QUIC in as *the* establishment shape — the same welding ADR-065 unwound on the server side. The blocking condition is met when a non-QUIC client (SSH raw-TCP, HTTP-wrapped call) exists, so the transport-polymorphic seam is extractable from two *different* transport implementations. `ChannelClient` over QUIC does not unblock this — it's the same transport shape.
|
||||
- **Priority**: medium
|
||||
- **Full file**: [OQ-55](questions/055-alknetclient-establishment-extraction.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# Alknet Overview
|
||||
@@ -159,7 +159,8 @@ The following types live in alknet-core and are used across handler crates:
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `ProtocolHandler` | The trait every handler implements |
|
||||
| `Connection` | Transport connection (QUIC via quinn/iroh, or a generic single stream via `from_stream` — ADR-065) — handlers open/accept streams on it |
|
||||
| `Connection` | Transport connection (QUIC via quinn/iroh, a generic single stream via `from_stream` — ADR-065, or any `BidiStreamSource` impl — ADR-070) — handlers open/accept streams on it |
|
||||
| `BidiStreamSource` | The trait `Connection` holds; downstream crates implement it to add connection shapes (channels, future transports) without editing core — ADR-070 |
|
||||
| `BiStream` | Trait: `AsyncRead + AsyncWrite + Send + Unpin` — bidirectional byte stream |
|
||||
| `AuthContext` | Resolved identity for a connection (may be partial) |
|
||||
| `Identity` | Authenticated peer identity (inbound) |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# OQ-55: AlknetClient / Client Establishment Extraction
|
||||
|
||||
- **Origin**: `docs/research/alknet-channels/phase-0-findings.md` OQ-CH-14
|
||||
(the `AlknetClient` clarification); `docs/research/alknet-channels/poc-summary.md`
|
||||
§"Issues Surfaced" #1 (the `BidiStreamSource` finding that motivates
|
||||
separating the client-extraction question from the `Connection` extension
|
||||
question).
|
||||
- **Status**: deferred(scope)
|
||||
- **Door type**: two-way
|
||||
- **Priority**: medium
|
||||
- **Blocked on**: a **second transport's** real client existing, not just a
|
||||
second QUIC client. The blocking condition is met when, e.g., the SSH
|
||||
crate's raw-TCP client or the HTTP-wrapped call client exists — so the
|
||||
transport-polymorphic establishment seam is extractable from two
|
||||
*different* transport implementations, not two QUIC variants.
|
||||
`ChannelClient` over QUIC does not unblock this; it is a second *client*
|
||||
but the same *transport shape*. The deferral is on transport-polymorphism,
|
||||
not on client count.
|
||||
- **Resolution**: Not yet decidable. The shared substance across
|
||||
TLS-carrying transports is ADR-034's verifier-selection rule (PeerEntry
|
||||
presence → fingerprint pin : CA-verify / fail-closed) and the
|
||||
`rustls::ClientConfig` construction — ~20 lines, transport-agnostic in
|
||||
rule. But the *dial* is transport-specific, and we have one of ~5 shapes
|
||||
implemented (QUIC; the others being HTTP, TCP+TLS, WebTransport, raw
|
||||
TCP). Extracting a QUIC-shaped connector to core and naming it
|
||||
`AlknetConnector` would bake QUIC in as *the* establishment shape — the
|
||||
same welding ADR-065 unwound on the server side, repeated on the client
|
||||
side. The dial is transport-polymorphic; the shared rule is narrow. Until
|
||||
a second transport's client exists, the seam between "dial + TLS" (per-
|
||||
transport) and "spawn the dispatcher" (per-crate) is not extractable from
|
||||
two real shapes — it's guessable from one.
|
||||
- **What does NOT block on this**: each crate building its own client
|
||||
standalone. `CallClient` stays QUIC-only; `ChannelClient` will be
|
||||
QUIC-only initially; the SSH crate's TCP client builds standalone; the
|
||||
HTTP call client builds standalone. Core already permits all of this —
|
||||
`Connection::from_stream` / `from_bidi` (ADR-065) handles the non-QUIC
|
||||
transport on the server side, and nothing prevents a client from
|
||||
constructing a `Connection` the same way after its own transport-specific
|
||||
dial. The friction is duplicated boilerplate (each client rebuilds
|
||||
verifier selection), not a missing capability. The bidirectionality
|
||||
criterion (a crate needs a Client type when (a) the endpoint has
|
||||
protocol-level authority — e.g., channels' id allocation — or (b) the
|
||||
protocol needs a reliable establishment interface) is met by each crate
|
||||
independently; `AlknetClient` is the eventual *shared* establishment seam,
|
||||
not a prerequisite for any single client to exist.
|
||||
- **Cross-references**: ADR-034 (verifier selection — currently in
|
||||
`CallClient`, would move to the extracted seam when this is resolved),
|
||||
ADR-065 (server-side transport generalization — the client-side analogue
|
||||
this OQ's deferral avoids preempting), ADR-070 (the `BidiStreamSource`
|
||||
extension point, which is the *Connection* opening and is orthogonal to
|
||||
the *client* establishment question), OQ-CH-14 in
|
||||
`docs/research/alknet-channels/phase-0-findings.md` (the research-scope
|
||||
question this core-scope OQ carries forward).
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
id: core/bidistreamsource-trait
|
||||
name: "Implement BidiStreamSource trait + AuthContext::anonymous (ADR-070, REQ-CORE-01/02/03)"
|
||||
status: pending
|
||||
depends_on: []
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Land the three core-crate changes surfaced by the alknet-channels POC
|
||||
(`docs/research/alknet-channels/poc-summary.md` §"Issues Surfaced" #1-3).
|
||||
The architecture specs and ADR-070 are already written; this task is the
|
||||
implementation.
|
||||
|
||||
### Part 1: `BidiStreamSource` trait (REQ-CORE-01, ADR-070)
|
||||
|
||||
Extract the stream-yield operations from `Connection` into a trait so
|
||||
downstream crates (channels, future transports) can add connection shapes
|
||||
without editing `alknet-core`. `Connection` goes from a closed enum
|
||||
(`ConnectionKind: Quinn | Iroh | Stream`) to holding
|
||||
`Box<dyn BidiStreamSource>`. The public `Connection` API is preserved
|
||||
verbatim — this is an internal refactor, not a handler-facing change.
|
||||
|
||||
#### Current state (`crates/alknet-core/src/types.rs`)
|
||||
|
||||
```rust
|
||||
enum ConnectionKind {
|
||||
#[cfg(feature = "quinn")] Quinn(quinn::Connection),
|
||||
#[cfg(feature = "iroh")] Iroh(iroh::endpoint::Connection),
|
||||
Stream(StreamConn),
|
||||
}
|
||||
|
||||
struct StreamConn {
|
||||
stream: Mutex<Option<(SendStream, RecvStream)>>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
kind: ConnectionKind,
|
||||
alpn: Vec<u8>,
|
||||
identity: OnceLock<Identity>,
|
||||
}
|
||||
```
|
||||
|
||||
`accept_bi`, `open_bi`, `remote_addr`, `close` all match on `self.kind`.
|
||||
|
||||
#### Target state (ADR-070)
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait BidiStreamSource: Send + Sync + 'static {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
fn remote_addr(&self) -> Option<SocketAddr>;
|
||||
fn close(&self, code: u32, reason: &str);
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
source: Box<dyn BidiStreamSource>,
|
||||
alpn: Vec<u8>,
|
||||
identity: OnceLock<Identity>,
|
||||
}
|
||||
```
|
||||
|
||||
`Connection::accept_bi` / `open_bi` / `remote_addr` / `close` delegate to
|
||||
`self.source`. `remote_alpn` reads `self.alpn` (unchanged). `set_identity` /
|
||||
`identity` read/write `self.identity` (unchanged).
|
||||
|
||||
#### Crate-private implementations
|
||||
|
||||
Three impls, each wrapping an existing constructor:
|
||||
|
||||
| Impl | Constructor(s) | Yield semantics |
|
||||
|------|----------------|-----------------|
|
||||
| `QuinnBidiStreamSource` | `from_quinn` / `from_quinn_with_alpn` (feature `quinn`) | many streams |
|
||||
| `IrohBidiStreamSource` | `from_iroh` (feature `iroh`) | many streams |
|
||||
| `StreamBidiStreamSource` | `from_stream` / `from_bidi` (no feature gate) | yield-once, then `ConnectionClosed`; `open_bi` returns `StreamClosed` |
|
||||
|
||||
The `StreamBidiStreamSource` preserves the ADR-065 yield-once contract
|
||||
exactly — `accept_bi` takes the stream from the `Mutex<Option<...>>` on
|
||||
the first call, returns `ConnectionClosed` on subsequent calls. `open_bi`
|
||||
returns `StreamError::StreamClosed`. `close` drops the stream (ignores
|
||||
`code`/`reason` — see Part 2). `remote_addr` returns the stored
|
||||
`SocketAddr`.
|
||||
|
||||
The `QuinnBidiStreamSource` / `IrohBidiStreamSource` impls delegate to
|
||||
the underlying `quinn::Connection` / `iroh::endpoint::Connection` exactly
|
||||
as the current `ConnectionKind::Quinn` / `Iroh` match arms do — same error
|
||||
mapping (`map_quinn_connection_error` / `map_iroh_connection_error`), same
|
||||
`SendStream`/`RecvStream` construction (`from_quinn` / `from_iroh`).
|
||||
|
||||
#### What does NOT change
|
||||
|
||||
- `ProtocolHandler` trait — unchanged.
|
||||
- `HandlerRegistry` — unchanged.
|
||||
- All handler code (`HttpAdapter`, `TtyAdapter`, `CallAdapter`) —
|
||||
unchanged. They receive a `Connection` and call `accept_bi` / `open_bi`
|
||||
on it. The dispatch through `Box<dyn BidiStreamSource>` is transparent.
|
||||
- `SendStream` / `RecvStream` — unchanged (their own internal enum dispatch
|
||||
stays).
|
||||
- `BiStream` trait — unchanged.
|
||||
- The endpoint's accept loops — unchanged (they call `from_quinn` /
|
||||
`from_iroh`, which now internally wrap a `BidiStreamSource` impl).
|
||||
- `Connection::remote_alpn` / `set_identity` / `identity` / `from_bidi` —
|
||||
unchanged.
|
||||
|
||||
### Part 2: `close()` params fix (REQ-CORE-02, folded into ADR-070)
|
||||
|
||||
The `close(&self, code: u32, reason: &str)` signature stays on the trait
|
||||
(non-QUIC impls ignore the args). This resolves the ADR-065 leftover clippy
|
||||
warning: under `--no-default-features`, the `Stream` backend's `close(code,
|
||||
reason)` takes both args and uses neither, which clippy flags as two unused
|
||||
variable warnings on `types.rs:500`.
|
||||
|
||||
Under the trait, the `StreamBidiStreamSource::close` impl prefixes the
|
||||
args with `_code` / `_reason` and carries a doc comment stating they're
|
||||
ignored because the drop is the close (ADR-065). The warning disappears;
|
||||
the signature matches the public `Connection::close` API (no caller
|
||||
breakage).
|
||||
|
||||
See ADR-070 §"REQ-CORE-02" for the full rationale of why the QUIC-shaped
|
||||
signature stays on the trait rather than being split.
|
||||
|
||||
### Part 3: `AuthContext::anonymous` constructor (REQ-CORE-03)
|
||||
|
||||
Add a convenience constructor to `AuthContext` in
|
||||
`crates/alknet-core/src/auth.rs`:
|
||||
|
||||
```rust
|
||||
impl AuthContext {
|
||||
/// Construct an `AuthContext` with no identity, no fingerprint, and no
|
||||
/// remote address — only the ALPN is set. For POCs, tests, and handlers
|
||||
/// that don't require auth.
|
||||
pub fn anonymous(alpn: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
identity: None,
|
||||
alpn: alpn.into(),
|
||||
remote_addr: None,
|
||||
tls_client_fingerprint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Not gated behind a `test-utils` feature — it's a plain `pub fn` useful for
|
||||
any caller that constructs an `AuthContext` outside the endpoint's
|
||||
resolution path. The name is honest about the semantics: no identity, no
|
||||
fingerprint.
|
||||
|
||||
This removes the four-`None`-field literal that recurred in every handler
|
||||
POC and test (`poc-summary.md` §"Issues Surfaced" #3).
|
||||
|
||||
### Commit structure
|
||||
|
||||
This is one task but may be two commits if the implementer prefers to
|
||||
separate concerns:
|
||||
1. `BidiStreamSource` trait + `Connection` refactor + `close()` fix
|
||||
(Parts 1+2, `types.rs`)
|
||||
2. `AuthContext::anonymous` (Part 3, `auth.rs`)
|
||||
|
||||
Or one commit covering all three. Either is acceptable — the changes are
|
||||
in different files but logically related (all from the same POC findings).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Part 1: BidiStreamSource
|
||||
|
||||
- [ ] `BidiStreamSource` trait defined with `accept_bi`, `open_bi`, `remote_addr`, `close` (all async or sync per ADR-070)
|
||||
- [ ] `BidiStreamSource: Send + Sync + 'static` (object-safe)
|
||||
- [ ] `Connection` struct holds `Box<dyn BidiStreamSource>` + `alpn: Vec<u8>` + `identity: OnceLock<Identity>`
|
||||
- [ ] `QuinnBidiStreamSource` implements `BidiStreamSource` (feature-gated `quinn`)
|
||||
- [ ] `IrohBidiStreamSource` implements `BidiStreamSource` (feature-gated `iroh`)
|
||||
- [ ] `StreamBidiStreamSource` implements `BidiStreamSource` (no feature gate)
|
||||
- [ ] `StreamBidiStreamSource::accept_bi` yields once, then `ConnectionClosed` (ADR-065 contract preserved)
|
||||
- [ ] `StreamBidiStreamSource::open_bi` returns `StreamClosed`
|
||||
- [ ] `Connection::from_quinn` / `from_quinn_with_alpn` / `from_iroh` / `from_stream` / `from_bidi` all preserved (same signatures, same behavior)
|
||||
- [ ] `Connection::accept_bi` / `open_bi` / `remote_addr` / `close` delegate to `self.source`
|
||||
- [ ] `Connection::remote_alpn` / `set_identity` / `identity` unchanged (read `self.alpn` / `self.identity`)
|
||||
- [ ] `ConnectionKind` enum removed (replaced by the trait object)
|
||||
- [ ] `map_quinn_connection_error` / `map_iroh_connection_error` preserved (used by the QUIC impls)
|
||||
- [ ] Existing `types.rs` tests pass unchanged (the `test_connection()` helper, `set_identity` tests, `remote_alpn`/`remote_addr` tests, `StreamError` mapping tests)
|
||||
|
||||
### Part 2: close() fix
|
||||
|
||||
- [ ] `StreamBidiStreamSource::close` prefixes `code`/`reason` with `_` and documents why they're ignored
|
||||
- [ ] `cargo clippy -p alknet-core --no-default-features` passes with **no warnings** (the ADR-065 leftover is fixed)
|
||||
- [ ] `cargo clippy -p alknet-core` (default features) passes with no warnings
|
||||
- [ ] `cargo clippy -p alknet-core --all-features` passes with no warnings (if applicable)
|
||||
|
||||
### Part 3: AuthContext::anonymous
|
||||
|
||||
- [ ] `AuthContext::anonymous(alpn: impl Into<Vec<u8>>)` constructor added to `auth.rs`
|
||||
- [ ] Sets `identity: None`, `alpn: alpn.into()`, `remote_addr: None`, `tls_client_fingerprint: None`
|
||||
- [ ] Unit test: `AuthContext::anonymous(b"alknet/test")` produces the expected fields
|
||||
- [ ] Existing `auth.rs` tests pass unchanged
|
||||
|
||||
### All parts
|
||||
|
||||
- [ ] `cargo test -p alknet-core` succeeds (all feature combos)
|
||||
- [ ] `cargo test -p alknet-core --no-default-features` succeeds
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
- [ ] `cargo clippy -p alknet-core --no-default-features` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-core` passes
|
||||
- [ ] Downstream crates compile unchanged: `cargo check -p alknet-call` succeeds (no handler code change needed)
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/070-bidistreamsource-trait.md — ADR-070 (the trait + close() rationale)
|
||||
- docs/architecture/decisions/065-connection-from-stream-generic-single-stream.md — ADR-065 (the yield-once contract the StreamBidiStreamSource preserves)
|
||||
- docs/architecture/crates/core/core-types.md — Connection + BidiStreamSource spec (updated)
|
||||
- docs/architecture/crates/core/auth.md — AuthContext::anonymous spec (updated)
|
||||
- docs/research/alknet-channels/poc-summary.md — §"Issues Surfaced" #1-3 (the POC findings)
|
||||
- docs/research/alknet-channels/phase-0-findings.md — §POC-Validated Requirements (REQ-CORE-01/02/03)
|
||||
|
||||
## Notes
|
||||
|
||||
> The `BidiStreamSource` refactor is the load-bearing change. It touches
|
||||
> `Connection` — the struct every handler depends on — but the public API
|
||||
> is preserved verbatim, so no handler code changes. The risk is in the
|
||||
> internal dispatch: `Box<dyn BidiStreamSource>` adds one method-call
|
||||
> indirection per `accept_bi`/`open_bi`/`close`/`remote_addr`, which is
|
||||
> negligible next to the async I/O those operations perform. The
|
||||
> `ConnectionKind` enum is removed entirely; the three variants become
|
||||
> three trait impls. The clippy warning under `--no-default-features`
|
||||
> (unused `code`/`reason` on the Stream backend's `close`) is fixed
|
||||
> structurally by the trait — the `StreamBidiStreamSource::close` impl
|
||||
> documents why the args are ignored, and the `_` prefix is intentional.
|
||||
> Verify all four feature combinations pass clippy (default, quinn-only,
|
||||
> iroh-only, no-default-features) — the warning only surfaces under
|
||||
> `--no-default-features` today.
|
||||
Reference in new issue
Block a user