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