From f8f5f27ce0c48cf168b5c4fe686bfbb4fac2bbf3 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Sun, 12 Jul 2026 09:43:39 +0000 Subject: [PATCH] docs(arch): land ADR-070 BidiStreamSource + OQ-55 AlknetClient deferral + impl task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three core-crate changes surfaced by the alknet-channels POC: - ADR-070: extract BidiStreamSource trait so Connection holds Box 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). --- docs/architecture/README.md | 9 +- docs/architecture/crates/core/README.md | 9 +- docs/architecture/crates/core/auth.md | 21 +- docs/architecture/crates/core/core-types.md | 107 +++++-- .../decisions/070-bidistreamsource-trait.md | 287 ++++++++++++++++++ docs/architecture/open-questions.md | 9 +- docs/architecture/overview.md | 5 +- ...5-alknetclient-establishment-extraction.md | 53 ++++ tasks/core/bidistreamsource-trait.md | 234 ++++++++++++++ 9 files changed, 703 insertions(+), 31 deletions(-) create mode 100644 docs/architecture/decisions/070-bidistreamsource-trait.md create mode 100644 docs/architecture/questions/055-alknetclient-establishment-extraction.md create mode 100644 tasks/core/bidistreamsource-trait.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 30b1513..4a011b8 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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` — 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 diff --git a/docs/architecture/crates/core/README.md b/docs/architecture/crates/core/README.md index e4bc9c1..5c186ac 100644 --- a/docs/architecture/crates/core/README.md +++ b/docs/architecture/crates/core/README.md @@ -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` — 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`; `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`; 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 diff --git a/docs/architecture/crates/core/auth.md b/docs/architecture/crates/core/auth.md index 3c853ae..0e33ba5 100644 --- a/docs/architecture/crates/core/auth.md +++ b/docs/architecture/crates/core/auth.md @@ -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>) -> 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. diff --git a/docs/architecture/crates/core/core-types.md b/docs/architecture/crates/core/core-types.md index 46e0333..6055e8c 100644 --- a/docs/architecture/crates/core/core-types.md +++ b/docs/architecture/crates/core/core-types.md @@ -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, + alpn: Vec, // Private: handler-resolved identity for observability (OQ-11) identity: OnceLock, } @@ -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) -> 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` (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; + 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`; 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) | diff --git a/docs/architecture/decisions/070-bidistreamsource-trait.md b/docs/architecture/decisions/070-bidistreamsource-trait.md new file mode 100644 index 0000000..336a4c8 --- /dev/null +++ b/docs/architecture/decisions/070-bidistreamsource-trait.md @@ -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; + + /// 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` + +```rust +pub struct Connection { + source: Box, + alpn: Vec, + identity: OnceLock, +} +``` + +`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` 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` 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` 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 \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index b7d66fa..a48ed88 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -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) + diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e0cf497..afea92c 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.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) | diff --git a/docs/architecture/questions/055-alknetclient-establishment-extraction.md b/docs/architecture/questions/055-alknetclient-establishment-extraction.md new file mode 100644 index 0000000..86e1a22 --- /dev/null +++ b/docs/architecture/questions/055-alknetclient-establishment-extraction.md @@ -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). \ No newline at end of file diff --git a/tasks/core/bidistreamsource-trait.md b/tasks/core/bidistreamsource-trait.md new file mode 100644 index 0000000..e50be1f --- /dev/null +++ b/tasks/core/bidistreamsource-trait.md @@ -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`. 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>, + remote_addr: Option, +} + +pub struct Connection { + kind: ConnectionKind, + alpn: Vec, + identity: OnceLock, +} +``` + +`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; + fn close(&self, code: u32, reason: &str); +} + +pub struct Connection { + source: Box, + alpn: Vec, + identity: OnceLock, +} +``` + +`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>` 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` 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>) -> 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` + `alpn: Vec` + `identity: OnceLock` +- [ ] `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>)` 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` 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. \ No newline at end of file