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:
glm-5.2 committed 2026-07-12 09:43:39 +00:00
1 parent 9ea69efba6
commit f8f5f27ce0
9 files changed
+703 -31

No files matched your search

+6 -3
View File
@@ -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
+20 -1
View File
@@ -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.
+87 -20
View File
@@ -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) |