docs(arch): sync specs to transport generalization sweep (ADR-064, ADR-065)

Three code commits landed a clean sweep discovered when building an
external app against the crates. This syncs the architecture specs to
match the codebase and amends the affected decisions.

ADR-064 (supersedes ADR-005): irpc was never integrated — no .rs file in
the workspace ever imported it. The wire protocol (wire.rs) is hand-rolled
length-prefixed JSON; the EventEnvelope shape was derived from the
@alkdev/pubsub TypeScript prior art (ADR-013), not from irpc. ADR-005's
premise ('irpc as the call protocol foundation') was never implemented as
stated. Superseded with a clear header; the body is kept as historical
record.

ADR-065: Connection::from_stream / from_bidi — Connection now accepts any
AsyncRead + AsyncWrite pair via ConnectionKind::Stream (yield-once
accept_bi contract: QUIC yields many streams, everything else yields one
then ConnectionClosed). Unblocks TCP+TLS, SSH channel dispatch,
WebTransport streams, and wasm streams through the same HandlerRegistry
as QUIC connections, with zero handler code changes. MockConnection /
ConnectionKind::Mock removed (tests use from_stream with sink/empty).
Stream-level Mock variants renamed to Stream (they were already generic).

Amended ADRs: ADR-003 (irpc removed from dep table), ADR-007 (from_stream
opens the server-side door; MockConnection removed), ADR-010 (TCP+TLS can
now dispatch through the registry via from_bidi; iroh 1.0 migration noted).

Updated specs: core-types.md (Connection/SendStream/RecvStream sections),
endpoint.md (TCP section, iroh note), call-protocol.md, operation-registry
(irpc Integration section replaced), overview.md, http-server.md,
webtransport.md, call README. OQ-09 (WASM) resolution amended: the
Connection door is now open via from_stream; the accept-loop runtime door
remains closed (tokio doesn't run on WASM).

See docs/research/transport-generalization/findings.md for the full trace.
This commit is contained in:
glm-5.2 committed 2026-07-09 07:40:51 +00:00
1 parent 865fef6210
commit 2aa6363e57
20 files changed
+782 -87

No files matched your search

+10 -8
View File
@@ -1,19 +1,19 @@
---
status: draft
last_updated: 2026-06-27
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration pending.
last_updated: 2026-07-09
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration pending. Transport generalization sweep (ADR-064 supersedes ADR-005; ADR-065 `from_stream`) synced 2026-07-09.
---
# alknet-call
Structured RPC over QUIC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`.
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-065), over any `AsyncRead + AsyncWrite` transport.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, irpc integration |
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-064) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call / from_jsonschema, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern |
## Applicable ADRs
@@ -22,10 +22,12 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alknet/call` |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core and irpc |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core (no irpc — ADR-064) |
| [013](../../decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | irpc provides framing and service dispatch |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
| [064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alknet-call; supersedes ADR-005 |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alknet/call` ALPN, one ALPN per connection |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
@@ -72,7 +74,7 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
2. **Protocol is symmetric**: Both sides can initiate calls. The server calling a client uses the same EventEnvelope format and correlation.
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-024.
5. **irpc is one dispatch backend**: Local operations dispatch directly. irpc service calls (in-process, type-safe) are internal. The call protocol is the external interface.
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-064). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-049), not an irpc service.
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014.
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
+12 -6
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-02
last_updated: 2026-07-09
---
# Call Protocol
@@ -23,7 +23,10 @@ The protocol must be:
- **Stream-agnostic**: QUIC provides stream multiplexing; the protocol shouldn't impose additional constraints
- **Discoverable**: Clients can query what operations exist and their schemas
See ADR-005 for the decision to use irpc as the call protocol's foundation and ADR-012 for the stream model decision.
See ADR-064 for the decision that the call protocol uses hand-rolled
`EventEnvelope` framing (irpc was never integrated — ADR-005, which
accepted "irpc as the call protocol foundation," is superseded) and ADR-012
for the stream model decision.
## Architecture
@@ -210,7 +213,10 @@ The `Value` type is `serde_json::Value`. The envelope is JSON because it must be
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
This is the same framing used by irpc. The Rust implementation in alknet-call is canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference and browser adaptation, not a parallel implementation (see ADR-013).
This is hand-rolled length-prefixed JSON framing (ADR-064), coincidentally
the same shape irpc uses. The Rust implementation in alknet-call is
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
and browser adaptation, not a parallel implementation (see ADR-013).
### Event Types
@@ -546,7 +552,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-064 and OQ-13.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.
@@ -554,7 +560,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch |
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-005 (irpc was never imported) |
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| ALPN per connection | [ADR-006](../../decisions/006-alpn-convention-and-connection-model.md) | `alknet/call` is a distinct ALPN, one connection per ALPN |
| ProtocolHandler receives Connection | [ADR-007](../../decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
@@ -615,7 +621,7 @@ See [open-questions.md](../../open-questions.md) for full details.
- [operation-registry.md](operation-registry.md) — OperationSpec, Handler, AccessControl, service discovery
- [client-and-adapters.md](client-and-adapters.md) — CallClient, from_call, OperationAdapter, peer-keyed composition env
- ADR-005: irpc as call protocol foundation
- ADR-064: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-005)
- ADR-012: Call protocol stream model
- ADR-029: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source)
@@ -5,7 +5,7 @@ last_updated: 2026-07-05
# Operation Registry
OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, and irpc integration.
OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, and the hand-rolled framing (no irpc — ADR-064).
## What
@@ -185,7 +185,11 @@ pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
```
Both handlers are async — many operations (file I/O, HTTP service calls,
irpc service calls, LLM streaming) are inherently asynchronous. A handler
LLM streaming) are inherently asynchronous. A handler (whether it wraps a
local function, an HTTP-backed OpenAPI operation, an LLM stream, or a
`from_call` remote) is a `Future` (for `Query`/`Mutation`) or a `Stream`
(for `Subscription`, ADR-049). The registry's `Handler` /
`StreamingHandler` trait objects (ADR-049) abstract over this. A handler
receives:
- `input: Value` — the deserialized `payload` from the `call.requested` event
@@ -447,7 +451,7 @@ The CLI binary (or assembly layer) constructs the registry and passes it to the
### OperationEnv
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally or on a remote node.
**`OperationEnv` is request/response-only** (ADR-049). It returns a single `ResponseEnvelope` — no streaming variant exists. Calling `invoke()` on a `Subscription` op produces `CallError { code: "INVALID_OPERATION_TYPE", ... }` — composition cannot truncate a stream to its first value. Stream composition (filter, map, combine, window, dedupe) is a handler-level concern, not a protocol composition concern; see ADR-049 for the rationale and the `@alkdev/pubsub` `operators.ts` prior art.
@@ -736,7 +740,7 @@ Two things happen in `invoke()`:
1. **Reachability check**: before constructing the child context, `invoke()` checks whether the requested op is in the parent's scoped env. If not, `NOT_FOUND`. This is the reachability control — a handler can only compose declared ops.
2. **Authority propagation**: the child's `identity` is the parent's `handler_identity` (the parent's composition authority becomes the caller). The child's `handler_identity` is the child's own registration's `composition_authority` — so if the child itself composes further, its children inherit the child's authority. This is the principal/agent chain from ADR-015, now wired via ADR-022.
Future work may add irpc service dispatch and remote call protocol dispatch as additional backends. The handler-facing API stays the same.
Future work may add remote call protocol dispatch as an additional backend. The handler-facing API stays the same.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-017 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-029) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-004). See OQ-19, ADR-024, and ADR-029.
@@ -774,18 +778,21 @@ from wire `operationId`s before lookup, so `services/schema` accepts both
client reading the schema can produce typed error enums instead of generic
error handling.
### irpc Integration
### Operation Registry (hand-rolled, no irpc)
irpc and the operation registry serve different scopes:
The operation registry is hand-rolled in alknet-call. ADR-005 accepted
"irpc as the call protocol foundation," but no `.rs` file in the workspace
ever imported irpc — the wire format (`wire.rs`), the operation registry,
and the dispatch are all hand-rolled. ADR-064 supersedes ADR-005 and
records the actual state. The table that previously contrasted "call
protocol (external, JSON)" with "irpc services (internal, postcard)" is
moot — there is no irpc layer.
| Layer | Mechanism | Serialization | Scope |
|-------|-----------|---------------|-------|
| Call protocol (external) | `EventEnvelope` over QUIC streams | JSON | Cross-language, cross-node |
| irpc services (internal) | `#[rpc_requests]` derive macro, `Service` trait | postcard (binary) | Rust-to-Rust, in-process or in-cluster |
irpc services are an internal dispatch mechanism — they are not directly exposed on the call protocol. alknet-call itself uses irpc for its call-protocol framing (ADR-005); the vault no longer uses irpc (ADR-025 — direct method calls on `VaultServiceHandle`). The vault is accessed by the assembly layer (CLI binary) at startup, not by handlers at call time. See ADR-008 and ADR-014.
If a handler internally uses an irpc-based service, the handler bridges the two: it receives JSON input from the call protocol, calls the irpc service in-process (postcard, type-safe), and serializes the result back to JSON for the call protocol response. This layering preserves irpc's type safety for internal calls while keeping the external interface cross-language.
If a handler internally uses a postcard/binary RPC for in-process calls,
that's a handler-internal choice, not an alknet-call integration. The
operation registry's external interface is always JSON (the `EventEnvelope`
wire format); the internal handler dispatch is a `Handler` /
`StreamingHandler` trait object (ADR-049), not an irpc `Service`.
### Operation Registration at Startup
@@ -874,8 +881,8 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
## Constraints
- The registry is **layered by trust boundary** (ADR-024). The curated layer (`Local` provenance) is immutable after construction — adding a `Local` op requires restarting the process, which re-enters the startup trust boundary. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic at their respective scopes (per-session, per-connection). The pre-ADR-024 blanket immutability claim was inherited by analogy from ADR-010's `HandlerRegistry` (ALPN-level) and did not apply to the operation registry — the TLS-config argument that justifies `HandlerRegistry` immutability does not touch the operation registry, which lives behind the single ALPN `alknet/call`.
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built.
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. Internal handler dispatch is via `Handler` / `StreamingHandler` trait objects (ADR-049), not a binary RPC framework.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths.
- The call protocol does not depend on any database. Operation specs are in-memory, populated at startup.
- `OperationContext.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (composition authority for ACL), it does not skip ACL — see ADR-015, ADR-022.
- **Operations have External/Internal visibility.** `Internal` operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The assembly layer declares visibility at registration. See ADR-015.
@@ -890,7 +897,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch |
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-005 |
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-024 (curated immutable, session/imported dynamic) |
| Vault integration via assembly layer | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
@@ -949,7 +956,7 @@ See [open-questions.md](../../open-questions.md) for full details.
## References
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
- ADR-005: irpc as call protocol foundation
- ADR-064: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-005)
- ADR-008: Vault integration point
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-024 for the distinction)
- ADR-012: Call protocol stream model
+70 -15
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-23
last_updated: 2026-07-09
---
# Core Types
@@ -47,11 +47,15 @@ Handler panics are caught by tokio's task isolation. The connection is dropped,
## Connection
An opaque type wrapping a QUIC connection. Handlers receive a `Connection` in `handle()`.
An opaque type wrapping a transport connection. Handlers receive a
`Connection` in `handle()`. The connection may be QUIC (quinn or iroh) or a
generic single stream (TCP+TLS, SSH channel, WebTransport stream, wasm
stream) — see ADR-065 for the `from_stream` generalization.
```rust
pub struct Connection {
// Private: wraps the underlying QUIC connection or test mock
// Private: wraps the underlying connection — QUIC (quinn/iroh) or a
// generic single-stream pair (ConnectionKind::Stream).
// Private: handler-resolved identity for observability (OQ-11)
identity: OnceLock<Identity>,
}
@@ -65,6 +69,24 @@ impl Connection {
#[cfg(feature = "iroh")]
pub fn from_iroh(conn: iroh::Connection) -> Self;
/// Construct from any pre-split read/write pair. `accept_bi()` yields
/// this pair once, then returns `ConnectionClosed`. `open_bi()` returns
/// `StreamClosed`. No feature gate — generic, no transport deps.
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
/// Convenience for a single bidirectional stream (e.g.
/// `TlsStream<TcpStream>`). Splits internally via `tokio::io::split`.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
pub fn remote_alpn(&self) -> &[u8];
@@ -75,14 +97,32 @@ impl Connection {
}
```
- `accept_bi()`: Wait for the peer to open a bidirectional stream. Returns `(SendStream, RecvStream)`.
- `open_bi()`: Open a bidirectional stream to the peer. Returns `(SendStream, RecvStream)`.
- `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.
- `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.
- `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.
- `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 types in its public API. It wraps `quinn::Connection` internally, but the wrapper allows test implementations.
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
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md)
for the `from_stream` generalization and the yield-once `accept_bi`
contract.
See [ADR-007](../../decisions/007-bistream-type-definition.md) for why handlers receive Connection instead of BiStream.
@@ -96,18 +136,21 @@ pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
Handlers that only need a single stream can obtain one via `connection.accept_bi()` and treat the `(SendStream, RecvStream)` pair as a BiStream. The `BiStream` trait is a convenience for:
- Client-side code that has a single bidirectional stream
- Test mocks that need to simulate a stream
- Future transport abstractions (WebTransport, raw TCP) that produce bidirectional byte streams
- Test scenarios that need to simulate a stream
- Transports that produce a single bidirectional byte stream (TCP+TLS via `from_bidi`, SSH channels, WebTransport streams, wasm streams) — all dispatchable through the same `HandlerRegistry` as QUIC connections via `Connection::from_stream` (ADR-065)
See [ADR-007](../../decisions/007-bistream-type-definition.md) for why BiStream is a trait.
## SendStream and RecvStream
Concrete types wrapping QUIC stream halves. Both quinn and iroh produce QUIC connections — `SendStream` and `RecvStream` need to wrap either source.
Concrete types wrapping transport stream halves. Both quinn and iroh
produce QUIC connections; `from_stream` adds a generic single-stream source.
`SendStream` and `RecvStream` wrap any of the three via internal enum
dispatch.
```rust
pub struct SendStream { /* wraps quinn::SendStream or iroh::SendStream or test mock */ }
pub struct RecvStream { /* wraps quinn::RecvStream or iroh::RecvStream or test mock */ }
pub struct SendStream { /* wraps quinn::SendStream, iroh::SendStream, or a generic Box<dyn AsyncWrite> */ }
pub struct RecvStream { /* wraps quinn::RecvStream, iroh::RecvStream, or a generic Box<dyn AsyncRead> */ }
impl AsyncWrite for SendStream { ... }
impl AsyncRead for RecvStream { ... }
@@ -115,9 +158,18 @@ impl AsyncRead for RecvStream { ... }
- `SendStream` implements `AsyncWrite`. Write bytes to the peer.
- `RecvStream` implements `AsyncRead`. Read bytes from the peer.
- These are concrete wrapper types that use internal enum dispatch to delegate to the appropriate QUIC stream type (quinn or iroh) in production, and to test mocks in tests.
- These are concrete wrapper types that use internal enum dispatch to
delegate to the appropriate stream source: quinn or iroh (QUIC,
feature-gated) in production, or `Stream` (a generic
`Box<dyn AsyncRead/Write + Send + Unpin>`, no feature gate) for
single-stream connections constructed via `from_stream` / `from_bidi`.
Since the endpoint supports both quinn and iroh connection sources (ADR-010), streams may come from either. `Connection::from_quinn()` / `Connection::from_iroh()` wrap the appropriate stream source based on where the connection came from.
Since the endpoint supports both quinn and iroh connection sources
(ADR-010), and `from_stream` adds the generic single-stream source
(ADR-065), streams may come from any of the three. `Connection::from_quinn()`
/ `from_iroh()` wrap the appropriate QUIC stream source based on where the
connection came from; `Connection::from_stream()` / `from_bidi()` wrap a
generic `AsyncRead + AsyncWrite` pair as the `Stream` variant.
## StreamError
@@ -145,6 +197,8 @@ 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.
The mapping is provided as a `From` impl so handlers can use the `?` operator:
```rust
@@ -245,8 +299,9 @@ 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`) |
| 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 | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Internal enum dispatch for both QUIC sources |
| 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) |
| Capabilities type | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Non-serializable, zeroized, immutable after construction; `Clone` for composition propagation |
+26 -5
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-22-17
last_updated: 2026-07-09
---
# Endpoint
@@ -37,9 +37,27 @@ A node can be reachable through different paths depending on its network context
These are not interchangeable transports — they are **complementary connectivity modes**. A node behind NAT that also has a public IP can use both simultaneously. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string.
### TCP is NOT an endpoint concern
### TCP is NOT an endpoint struct concern (but CAN dispatch through the registry)
Bare TCP (SSH over port 22) does not use QUIC or ALPN. In the new model, TCP access is handled by individual handlers — the SSH handler can listen on a TCP socket independently. This is a handler-specific concern, not a core endpoint concern.
Bare TCP (SSH over port 22) does not use QUIC or ALPN. TCP access is not
owned by the `AlknetEndpoint` struct — there is no `tcp:
Option<TcpListener>` field. The endpoint manages QUIC connection sources
(quinn + iroh) only.
This does **not** mean TCP+TLS can't participate in ALPN dispatch. Since
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md),
`Connection::from_bidi(tls_stream, alpn, remote_addr)` constructs a
`Connection` from any `TlsStream<TcpStream>` (or any `AsyncRead +
AsyncWrite` pair). A TCP+TLS accept loop built *outside* the endpoint (by
the assembly layer or a handler) can wrap each TLS stream as a
`Connection` and dispatch through the **same `HandlerRegistry`** the
endpoint uses, by the ALPN negotiated in the TLS handshake. This is not a
parallel listener bypassing the core — it's the same ALPN dispatch, over
a non-QUIC transport. `HttpAdapter`, `TtyAdapter`, and the call handler
all work over the single stream unchanged (ADR-065's yield-once
`accept_bi` contract). The TCP+TLS accept loop itself is a follow-up
commit, not part of `AlknetEndpoint`; the primitive it needs
(`from_bidi`) is in place.
The reference implementation's TCP transport (`alknet-main/crates/alknet-core/src/transport/tcp.rs`) is SSH-specific. It doesn't generalize to the ALPN model.
@@ -228,7 +246,10 @@ Note: `TlsIdentity::RawKey` uses `Ed25519SecretKey` (alknet-core-owned,
backed by `ed25519-dalek`), not `iroh::SecretKey`. It is available in
quinn-only builds without the `iroh` feature. When the iroh transport is
also configured, `build_iroh_endpoint` converts the key to
`iroh::SecretKey::from_bytes` (ADR-027).
`iroh::SecretKey::from_bytes` (ADR-027). The iroh dep is on `1.0`
(`default-features = false, features = ["tls-aws-lc-rs"]`, matching the
quinn path's aws-lc-rs crypto provider); migrated from `0.35` in commit
`acd049e` (2026-07-09) — 6 API surface edits, no architectural change.
## Graceful Shutdown
@@ -289,7 +310,7 @@ Non-fatal errors within a handler. See [core-types.md](core-types.md) for detail
|----------|-----|---------|
| Multi-connectivity endpoint (quinn + iroh) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Both optional, both feed same ALPN router |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Two-way door, start static, add ArcSwap later |
| TCP is not an endpoint concern | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | TCP SSH is a handler concern, not core |
| TCP is not an endpoint struct concern (but dispatches via `from_stream`) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `AlknetEndpoint` is QUIC-only (no `tcp` field); a TCP+TLS loop outside the endpoint wraps streams via `from_bidi` and shares the registry |
| No byte-peeking, ALPN dispatch only | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | TLS layer handles protocol detection |
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy via ALPN routing, not byte-peek |
| Network identity ≠ auth identity | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | TLS cert/NodeId = network, SSH key/token = auth |
+10 -4
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-02
last_updated: 2026-07-09
---
# HTTP Server
@@ -84,12 +84,18 @@ that do know about alknet.
## Architecture
### Running axum over a QUIC stream
### Running axum over a bidirectional stream
The `HttpAdapter::handle()` method for `h2`/`http/1.1`:
1. Accepts one bidirectional stream from the QUIC connection
(`connection.accept_bi()` → `(SendStream, RecvStream)`).
1. Accepts one bidirectional stream from the connection
(`connection.accept_bi()` → `(SendStream, RecvStream)`). Over QUIC
this is one of many streams the connection provides; over a
single-stream connection (TCP+TLS via `Connection::from_bidi`,
ADR-065) it is the one stream, yielded once then `ConnectionClosed`.
Either way, `accept_bi` returns the `(SendStream, RecvStream)` pair
the adapter needs — the handler code is transport-agnostic (ADR-065's
yield-once contract).
2. Wraps the `(SendStream, RecvStream)` pair as a hyper
`TokioIo`-compatible duplex stream — the same byte stream hyper
expects for an HTTP connection.
@@ -259,9 +259,14 @@ protocol (SSH, SFTP, git) reads/writes the WebTransport stream as a
`BiStream` (ADR-007). The `BiStream` trait (`AsyncRead + AsyncWrite +
Send + Unpin`) was designed for this — a browser implements it over a
WebTransport stream, and the WASM parser speaks the protocol over it.
The WASM parsers are downstream artifacts (the SSH WASM client, the
SFTP WASM client), not part of `alknet-http`; `russh-sftp`'s WASM
targeting demonstrates feasibility, SSH is the next target.
On the server side, the `h3` handler wraps each WebTransport stream as a
`Connection` via `Connection::from_stream` (ADR-065) before handing it to
the target ALPN handler — the target handler runs its normal protocol over
the stream, unchanged from its QUIC path (the yield-once `accept_bi`
contract makes a single WebTransport stream look like a one-stream
connection). The WASM parsers are downstream artifacts (the SSH WASM
client, the SFTP WASM client), not part of `alknet-http`; `russh-sftp`'s
WASM targeting demonstrates feasibility, SSH is the next target.
**Auth for proxied ALPN sessions:** the browser authenticates by bearer
token on the WebTransport session request (the HTTP `Authorization`