docs(arch): extract AlknetEndpoint into alknet-endpoint (ADR-083 Am. 2026-07-15)

Amend ADR-083 with the crate-extraction decision: the endpoint moves
from alknet-core into a new crate alknet-endpoint, mirroring the
alknet-client extraction (ADR-089). The ADR's shape (new + builder
methods + public dispatch + run/shutdown) is unchanged; only the
location changes.

The extraction is structural pruning, not an inline refactor. The
endpoint is a leaf consumer of core's shared types (zero handler crates
import it; 124 import sites for the other core modules). Extracting it
lets core shed quinn/iroh/rcgen/rustls-acme — handler crates no longer
transitively link those. A pure worker (client-only) does not pull
alknet-endpoint at all. The dep graph is symmetric: alknet-core is the
shared types crate; alknet-endpoint and alknet-client are the
server-side and client-side establishment crates.

New spec: crates/endpoint/README.md (the canonical endpoint spec).
core/endpoint.md is deprecated to a stub. Cross-references updated
across 8 docs (README, overview, tls, hub, client, core README, ADR-083,
ADR-089 references). Architecture review passed (3 critical, 9 warnings
— all addressed).
This commit is contained in:
glm-5.2 committed 2026-07-15 13:19:30 +00:00
1 parent ce7de57973
commit 8669594661
9 files changed
+667 -457

No files matched your search

+4 -3
View File
@@ -154,9 +154,9 @@ adapter location map is now consistent: all HTTP-backed adapters
|----------|--------|-------------| |----------|--------|-------------|
| [overview.md](overview.md) | draft | Workspace-level overview, crate graph (core mono-repo scope per ADR-085), hub/worker model, shared types, design principles | | [overview.md](overview.md) | draft | Workspace-level overview, crate graph (core mono-repo scope per ADR-085), hub/worker model, 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/) | | [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/README.md](crates/core/README.md) | draft | alknet-core crate index — shared types + auth + config (endpoint extracted to `alknet-endpoint` per ADR-083 Am. 2026-07-15) |
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError | | [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
| [crates/core/endpoint.md](crates/core/endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, shutdown | | [crates/core/endpoint.md](crates/core/endpoint.md) | deprecated | Endpoint spec — **moved to `alknet-endpoint`** (ADR-083 Am. 2026-07-15); see [`crates/endpoint/README.md`](crates/endpoint/README.md) |
| [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext (incl. `anonymous` constructor), 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/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/README.md](crates/call/README.md) | draft | alknet-call crate index |
@@ -188,6 +188,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — composes a subset of three endpoint types (web/native/iroh — ADR-086), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery | | [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — composes a subset of three endpoint types (web/native/iroh — ADR-086), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery |
| [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) | | [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) |
| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); produces `Connection` for `CallClient`/`ChannelClient` take-over; `alknet/register` named (wire protocol deferred, OQ-66) | | [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); produces `Connection` for `CallClient`/`ChannelClient` take-over; `alknet/register` named (wire protocol deferred, OQ-66) |
| [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; handler crates no longer transitively link quinn/iroh |
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream | | [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream |
| [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates | | [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates |
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) | | [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
@@ -282,7 +283,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted | | [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted | | [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Accepted (amended — endpoint signature superseded by ADR-083) | | [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Accepted (amended — endpoint signature superseded by ADR-083) |
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Accepted (revised — TCP+TLS is an owned transport, not external) | | [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Accepted (revised — TCP+TLS is an owned transport, not external; amended 2026-07-15 — endpoint extracted from `alknet-core` into `alknet-endpoint`) |
| [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted | | [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted |
| [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Accepted | | [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Accepted |
| [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Accepted | | [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Accepted |
+2 -2
View File
@@ -426,7 +426,7 @@ alknet-hub (uses AlknetClient for outbound worker dials)
├── alknet-client (the dial — the hub's dial_worker closure calls it) ├── alknet-client (the dial — the hub's dial_worker closure calls it)
├── alknet-channels-call (ChannelClient — the take-over) ├── alknet-channels-call (ChannelClient — the take-over)
├── alknet-call (CallAdapter, Dispatcher) ├── alknet-call (CallAdapter, Dispatcher)
└── alknet-core (AlknetEndpoint) └── alknet-endpoint (AlknetEndpoint)
alknet-worker (uses AlknetClient to dial a hub) alknet-worker (uses AlknetClient to dial a hub)
├── alknet-client (the dial) ├── alknet-client (the dial)
@@ -533,7 +533,7 @@ See [open-questions.md](../../open-questions.md) for full details.
`ChannelClient::from_connection` (the take-over the dial feeds) `ChannelClient::from_connection` (the take-over the dial feeds)
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) - [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
— `CallClient::spawn_dispatch` (the take-over the dial feeds) — `CallClient::spawn_dispatch` (the take-over the dial feeds)
- [`crates/core/endpoint.md`](../core/endpoint.md) — `AlknetEndpoint` - [`crates/endpoint/README.md`](../endpoint/README.md) — `AlknetEndpoint`
(the server-side complement) (the server-side complement)
- [`crates/tls/README.md`](../tls/README.md) — `TlsClientConfig` - [`crates/tls/README.md`](../tls/README.md) — `TlsClientConfig`
- [`crates/call/client-and-adapters.md`](../call/client-and-adapters.md) - [`crates/call/client-and-adapters.md`](../call/client-and-adapters.md)
+18 -8
View File
@@ -1,18 +1,27 @@
--- ---
status: draft status: draft
last_updated: 2026-07-12 last_updated: 2026-07-15
--- ---
# alknet-core # alknet-core
Core library for ALPN-based protocol dispatch. Every handler crate depends on alknet-core. Shared types, auth, config, and identity for ALPN-based protocol
dispatch. Every handler crate depends on `alknet-core` for
`ProtocolHandler`, `Connection`, `AuthContext`, `IdentityProvider`, and
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`,
`EndpointError`) has been extracted to
[`alknet-endpoint`](../endpoint/README.md) (ADR-083 Amendment
2026-07-15); core no longer carries the accept-loop runner or its
transport deps (quinn, iroh, rcgen, rustls-acme). `Connection::from_quinn`
/ `from_iroh` stay in core's `types.rs` as shared constructors (gated
on core's `quinn` / `iroh` features).
## Documents ## Documents
| Document | Status | Description | | Document | Status | Description |
|----------|--------|-------------| |----------|--------|-------------|
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, 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 | | [endpoint.md](endpoint.md) | deprecated | Endpoint spec — **moved to [`alknet-endpoint`](../endpoint/README.md)** (ADR-083 Am. 2026-07-15); this file is a stub |
| [auth.md](auth.md) | draft | AuthContext (incl. `anonymous` constructor), 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 | | [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle, AuthPolicy.peers |
@@ -27,7 +36,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | ALPN format, one-ALPN-per-connection | | [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | ALPN format, one-ALPN-per-connection |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | Connection, BiStream trait, SendStream, RecvStream | | [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | Connection, BiStream trait, SendStream, RecvStream |
| [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Framework | Decision classification | | [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Framework | Decision classification |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Endpoint, HandlerRegistry, accept loop | | [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | HandlerRegistry, accept loop — endpoint extracted to `alknet-endpoint` per ADR-083 Am. 2026-07-15 |
| [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow | | [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload | | [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload |
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `authorized_fingerprints` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable) | | [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `authorized_fingerprints` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable) |
@@ -35,25 +44,26 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| [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 | | [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 | | [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; `from_source` is the public constructor for downstream crates that implement the trait (channels, future transports) | | [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | `Connection` holds `Box<dyn BidiStreamSource>`; QUIC/iroh/stream wrap crate-private impls; `from_source` is the public constructor for downstream crates that implement the trait (channels, future transports) |
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as accept-loop runner + crate extraction | The endpoint is extracted from core into `alknet-endpoint`; core loses `quinn`/`iroh`/`rcgen`/`rustls-acme` deps; `Connection::from_quinn`/`from_iroh` stay in core as shared constructors |
## Relevant Open Questions ## Relevant Open Questions
| OQ | Title | Status | Relevance | | OQ | Title | Status | Relevance |
|----|-------|--------|-----------| |----|-------|--------|-----------|
| OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup | | OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup (now in `alknet-endpoint`) |
| OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated | | OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated (now in `alknet-endpoint`) |
| OQ-11 | Handler-level auth resolution observability | resolved | Handlers store resolved identity on Connection; two identity scopes (connection-level for observability, per-request for ACL) | | OQ-11 | Handler-level auth resolution observability | resolved | Handlers store resolved identity on Connection; two identity scopes (connection-level for observability, per-request for ACL) |
| OQ-33 | PeerId — logical id vs crypto identity | resolved by ADR-030 | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable across key rotation) | | OQ-33 | PeerId — logical id vs crypto identity | resolved by ADR-030 | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable across key rotation) |
| OQ-34 | Persistent peer registry (storage boundary) | resolved by ADR-030+031+033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates | | OQ-34 | Persistent peer registry (storage boundary) | resolved by ADR-030+031+033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| OQ-35 | ~~API key asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity | | 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-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-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 | resolved by ADR-089 | The native dial seam is extracted as `alknet-client` — the client-side analogue of `AlknetEndpoint`. Three dial methods (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key). The client take-over APIs (`CallClient::spawn_dispatch`, `ChannelClient::from_connection` — ADR-080) are transport-agnostic and decided; the shared dial is now extracted. | | OQ-55 | AlknetClient / Client Establishment Extraction | resolved by ADR-089 | The native dial seam is extracted as `alknet-client` — the client-side analogue of `AlknetEndpoint` (now in `alknet-endpoint`). Three dial methods (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key). |
## Key Design Principles ## Key Design Principles
1. **One trait, one dispatch point**: `ProtocolHandler` is the only abstraction handlers implement. No StreamInterface/MessageInterface split. 1. **One trait, one dispatch point**: `ProtocolHandler` is the only abstraction handlers implement. No StreamInterface/MessageInterface split.
2. **ALPN does the routing**: The endpoint dispatches by ALPN string. No byte-peeking, no ListenerConfig enum. 2. **ALPN does the routing**: The endpoint (in `alknet-endpoint`) dispatches by ALPN string. No byte-peeking, no ListenerConfig enum.
3. **Handlers own their wire format**: Each handler manages its own protocol parsing. alknet-core provides the Connection, not the framing. 3. **Handlers own their wire format**: Each handler manages its own protocol parsing. alknet-core provides the Connection, not the framing.
4. **Auth is hybrid**: The endpoint provides what it can (TLS-level auth). Handlers complete what they need. AuthContext may be partial. 4. **Auth is hybrid**: The endpoint provides what it can (TLS-level auth). Handlers complete what they need. AuthContext may be partial.
5. **WASM door preserved**: BiStream is a trait, Connection is an opaque type. Core types don't assume tokio or quinn in public APIs. 5. **WASM door preserved**: BiStream is a trait, Connection is an opaque type. Core types don't assume tokio or quinn in public APIs.
+40 -389
View File
@@ -1,393 +1,44 @@
--- ---
status: draft status: deprecated
last_updated: 2026-07-15 last_updated: 2026-07-15
--- ---
# Endpoint # Endpoint (moved to `alknet-endpoint`)
ALPN router, handler registry, connection accept loops, multi-connectivity, and graceful shutdown. > **This document is deprecated.** The `AlknetEndpoint`,
> `HandlerRegistry`, and `EndpointError` types have been extracted from
See [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) for the full rationale. > `alknet-core` into a new crate `alknet-endpoint` (ADR-083 Amendment
> 2026-07-15). The canonical spec is now
## AlknetEndpoint > [`crates/endpoint/README.md`](../endpoint/README.md).
>
The central runtime type. Manages one or more QUIC connection sources, each feeding into the same ALPN router. > The shared types the endpoint imports (`ProtocolHandler`,
> `Connection`, `AuthContext`, `IdentityProvider`, `DynamicConfig`) stay
```rust > in `alknet-core` — see [`core-types.md`](core-types.md),
pub struct AlknetEndpoint { > [`auth.md`](auth.md), [`config.md`](config.md).
// One or more connection sources — all optional, all can be active simultaneously
quinn: Option<quinn::Endpoint>, // Public QUIC+TLS ## Historical summary
iroh: Option<iroh::Endpoint>, // P2P relay-assisted
#[cfg(feature = "tcp")] The endpoint was originally in `alknet-core/endpoint.rs` as the central
tcp_tls: Option<TcpTlsListener>, // TCP+TLS (TcpListener + TlsAcceptor) runtime type — a multi-transport accept-loop runner that dispatches
incoming connections by ALPN (ADR-010, ADR-083). ADR-082 extracted the
handlers: Arc<HandlerRegistry>, TLS setup code to `alknet-tls`; ADR-083 restructured the endpoint to
dynamic: Arc<ArcSwap<DynamicConfig>>, take pre-built transports via `with_quinn` / `with_iroh` /
identity_provider: Arc<dyn IdentityProvider>, `with_tcp_tls` (no TLS config); ADR-083 Amendment 2026-07-15 extracted
shutdown_tx: watch::Sender<bool>, the endpoint itself into `alknet-endpoint` so that handler crates no
shutdown_rx: watch::Receiver<bool>, longer transitively link quinn/iroh/rcgen via core.
drain_timeout: Duration,
} The endpoint's semantics — ALPN dispatch, `HandlerRegistry`, accept
``` loops, public `dispatch` for SSH/WT, graceful shutdown — are unchanged
by the extraction. See
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) for [`crates/endpoint/README.md`](../endpoint/README.md) for the current
the full design (the endpoint takes no `StaticConfig` or TLS config; spec and [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
transports are built by the assembly layer and handed via for the full decision.
`with_quinn` / `with_iroh` / `with_tcp_tls`).
## What stayed in `alknet-core`
### Why multiple connection sources?
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` — they
A node can be reachable through different paths depending on its network context: are shared-type constructors used by both the endpoint's accept loop
(server) and `alknet-client`'s dial (client, ADR-089), gated on core's
| Source | Requires | Identity source | Use case | `quinn` / `iroh` features. See
|--------|----------|-----------------|----------| [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) §"The
| `quinn::Endpoint` | Public IP, TLS cert | TLS cert (network), SSH key (auth) | VPS, replicators, service hosts | `quinn` feature split".
| `iroh::Endpoint` | Relay access | NodeId (Ed25519) | Home servers, NAT, IoT |
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.
> **Terminology — hub, worker, hub-worker.** A *hub* accepts inbound
> connections from workers and browsers (see
> [`crates/hub/README.md`](../hub/README.md) for the hub-and-spoke
> topology). A *worker* dials out to a hub. A *hub-worker* does both. A
> *pure worker* has no inbound endpoints. These terms come from ADR-029
> / ADR-034; "assembly layer" (ADR-014) is the deployment binary that
> wires crates — in practice, today, usually a hub or hub-worker.
### TCP+TLS is a first-class owned transport
TCP+TLS is a listener transport, same shape as quinn and iroh. The
endpoint owns it via `with_tcp_tls(listener, acceptor)` (behind a `tcp`
feature) and runs its accept loop inside `run()` — `tcp.accept()` →
`tls.accept()` → extract ALPN + fingerprint → `Connection::from_bidi` →
`dispatch`. No external sibling loop, no duplicated dispatch logic.
This reverses ADR-010's original "TCP is not an endpoint struct concern."
The reason TCP was excluded — the endpoint built transports internally,
and TCP+TLS couldn't fit that shape — is gone (ADR-083: the endpoint no
longer builds transports; it runs accept loops on whatever it's given).
TCP+TLS fits the same listener shape as quinn and iroh.
The `dispatch` method is public for transports the endpoint **can't
own** — SSH channels (one connection, many channels with different
ALPNs — a multiplexing shape, not a listener) and future WebTransport
streams (one QUIC connection, many WT streams). These are
connection-internal multiplexing, not listener transports.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
for the full design.
## HandlerRegistry
Maps ALPN byte strings to `ProtocolHandler` instances.
```rust
pub struct HandlerRegistry {
handlers: HashMap<&'static [u8], Arc<dyn ProtocolHandler>>,
}
impl HandlerRegistry {
pub fn new() -> Self;
pub fn register(&mut self, handler: Arc<dyn ProtocolHandler>);
pub fn get(&self, alpn: &[u8]) -> Option<&Arc<dyn ProtocolHandler>>;
pub fn alpn_strings(&self) -> Vec<Vec<u8>>;
}
```
- `register()`: Insert a handler. Panics if the ALPN is already registered.
- `get()`: Look up a handler by ALPN string.
- `alpn_strings()`: Return all registered ALPN strings. Used to build the TLS `ServerConfig` (for quinn) and the ALPN list (for iroh).
Registration is static at startup (see [OQ-04](../../open-questions.md)). The CLI builds a `HandlerRegistry`, inserts all handlers, and passes it to `AlknetEndpoint::new()`.
### ALPN strings in TLS ServerConfig and iroh endpoint
ALPN-list construction is the **assembly layer's** responsibility, not
the endpoint's. After ADR-083, the endpoint takes no TLS config and
builds no transports — the assembly layer reads `registry.alpn_strings()`
and passes the appropriate ALPN list to each `TlsServerConfig::new()`
(see [`crates/tls/README.md`](../tls/README.md)).
The ALPN list is **split by endpoint type** (ADR-086 §3, resolving
OQ-62): each `TlsServerConfig` advertises only the ALPNs its endpoint
type's client class can negotiate. The native config (raw key) gets
`alknet/channels`, `alknet/call`, `alknet/ssh` (future); the web config
(X.509/ACME) gets `h2`, `http/1.1`, `alknet/channels` (for
WebSocket-carrying-channels, OQ-65); the iroh builder gets
`alknet/channels`, `alknet/call`. The distinction is between
**entry points** (ALPNs accepted without identity — `h2`/`http/1.1`,
future `alknet/register`) and **endpoints** (ALPNs requiring identity —
`alknet/channels`, `alknet/call`, `alknet/ssh`). See
[ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) for
the full model and ALPN-list table.
The iroh endpoint's ALPN list is set via `iroh::Endpoint::builder().alpns()`
by the assembly layer at construction time, from the same
`registry.alpn_strings()` source (filtered to the iroh endpoint type's
ALPNs).
## Accept Loops
Each active connection source runs its own accept loop. All loops dispatch through the same `HandlerRegistry`:
### Quinn accept loop (public QUIC+TLS)
```
loop {
tokio::select! {
incoming = quinn_endpoint.accept() => {
let connection = incoming.await; // TLS handshake + ALPN negotiation
match connection {
Ok(conn) => dispatch(conn),
Err(e) => { /* log TLS handshake failure, continue */ }
}
}
_ = shutdown.changed() => break,
}
}
```
### iroh accept loop (P2P relay-assisted)
iroh's `Endpoint` natively supports ALPN negotiation (step 4 of its connection establishment). The `iroh::Endpoint::set_alpns()` method configures which ALPNs the endpoint advertises — the same mechanism iroh's own `Router` uses internally with its `ProtocolMap`.
We use `iroh::Endpoint` directly (not iroh's `Router`) because our `HandlerRegistry` is shared between quinn and iroh connection sources, and our `AuthContext` construction differs per source. Our accept loop replaces iroh's `Router` accept loop with our own dispatch:
```
loop {
tokio::select! {
incoming = iroh_endpoint.accept() => {
// incoming is an iroh::endpoint::Incoming
let accepting = incoming.accept(); // Accepting state
let alpn = accepting.alpn().await; // ALPN from TLS handshake
match alpn {
Ok(alpn) => dispatch(alpn, accepting),
Err(e) => { /* log handshake failure, continue */ }
}
}
_ = shutdown.changed() => break,
}
}
```
See iroh's `protocol.rs` (`/workspace/iroh/iroh/src/protocol.rs`) for the reference implementation of this pattern — `handle_connection()` reads the ALPN, looks up the handler in `ProtocolMap`, and calls `handler.accept(connection)`. Our dispatch is the same pattern with our `HandlerRegistry`.
### Dispatch function (shared)
The public `dispatch` method is the shared dispatch path for every
transport — the endpoint's own accept loops (quinn, iroh, TCP+TLS) call
it after transport-specific extraction, and external dispatch callers
(SSH channels, future WebTransport streams) call it after their own
extraction.
```
pub fn dispatch(&self, connection: Connection, alpn: Vec<u8>,
fingerprint: Option<String>, remote_addr: Option<SocketAddr>) {
// ACME guard (transport-agnostic — ADR-083)
if alpn == b"acme-tls/1" {
connection.close(0, "acme done");
return;
}
match handlers.get(&alpn) {
Some(handler) => {
let auth = build_auth_context(&alpn, remote_addr, fingerprint, &identity_provider);
tokio::spawn(async move {
if let Err(e) = handler.handle(connection, &auth).await {
// log error, connection closes
}
});
}
None => { connection.close(0, "no handler"); /* log warning */ }
}
}
```
Synchronous (non-async): spawns the handler on its own task and returns
immediately. The caller's accept loop is not blocked. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) for the
full dispatch contract.
### What the accept loops do NOT do
- **No byte-peeking**: ALPN negotiation handles protocol detection. The old `stealth` module's `detect_protocol()` is unnecessary.
- **No per-handler accept loops**: The old `ListenerConfig` enum had Stream/Http/Dns variants with different accept paths. ALPN unifies this.
- **No SSH-specific logic**: The accept loop is ALPN-agnostic. It doesn't know or care what protocol the handler speaks.
## Stealth Mode as ALPN Dispatch
The reference implementation's "stealth mode" is SSH-over-TLS on port 443. The TLS cert is **camouflage**, not identity — it makes the port look like a web server to port scanners and DPI systems. Non-SSH traffic gets a fake nginx 404.
In the ALPN model, this maps to:
- The `alknet/http` handler is registered for standard HTTP ALPNs (`h2`, `http/1.1`)
- The HTTP handler can serve a decoy website or a fake 404
- Real services use `alknet/ssh`, `alknet/call`, etc.
- Clients that don't offer alknet ALPNs get the HTTP handler — just like port scanners in stealth mode
No byte-peeking, no `ProtocolDetection` enum. ALPN does the routing.
## Network Identity vs Auth Identity
A key distinction that the ALPN model makes explicit:
| Layer | Purpose | Mechanism |
|-------|---------|-----------|
| **Network identity** | How a client finds and verifies the node | X.509 cert (domain) or RFC 7250 raw key (Ed25519) or iroh NodeId |
| **Auth identity** | Who the peer is and what they can do | SSH key, API token, certificate (handlers) |
The TLS cert (or raw public key, or NodeId) is the node's network-facing identity. It's NOT the node's authentication identity. Auth happens inside the handler via `IdentityProvider`.
This matches the reference implementation: the TLS cert encrypts and camouflages, but SSH key exchange handles the actual authentication.
## RFC 7250: Raw Public Keys in TLS
RFC 7250 raw public keys are the **default TLS identity mode** for most alknet nodes. They eliminate the need for domain names, CAs, and certificate renewal — the Ed25519 public key IS the node's identity.
iroh uses this model with its `NodeId`. The implementation is ~100 lines (see `iroh/iroh/src/tls/resolver.rs`): take an Ed25519 key, wrap its SPKI public key as a `CertificateDer`, tell rustls `only_raw_public_keys() -> true`. No X.509, no CAs, no domain names, no cert renewal.
Key implications:
- **Default for alknet-native clients**: SSH, git, and alknet-native clients all work with raw Ed25519 keys out of the box. The same key type used for SSH auth can serve as the TLS identity. This is the most common deployment mode.
- **No domain required**: A node without a domain name uses raw public keys for the quinn path — key-based identity with direct QUIC over UDP.
- **Key = identity**: The Ed25519 public key IS the node's identity. No CA trust chain, no cert expiry. The key can be derived from alknet-vault.
- **X.509 is for domain-hosted services**: Domain-facing identity (replicators, public services, browsers) uses X.509 certs. This is a separate use case, not the default.
- **Browser limitation**: Browsers don't support RFC 7250. For browser/WebTransport clients, X.509 certs are needed. For all other clients, raw public keys work fine.
The quinn and iroh paths share the same key-based identity model via RFC 7250. They're distinguished by **connection establishment** (direct UDP vs relay-assisted), not by identity:
| Path | Connection establishment | Default identity | Alternative identity |
|------|------------------------|-----------------|---------------------|
| quinn | Direct UDP, public IP | RFC 7250 raw key (most nodes) | X.509 cert (domain-hosted, browsers) |
| iroh | Relay-assisted P2P | RFC 7250 raw key (NodeId) | N/A |
## TLS Identity
TLS identity in alknet has two distinct use cases, each with a different trust model and provisioning mechanism. See OQ-12 for the full rationale.
### Use case 1: P2P / Key-based identity (default)
Most alknet nodes use RFC 7250 raw Ed25519 public keys for TLS identity. No domain name, no CA, no certificate renewal. The Ed25519 public key IS the node's identity — the same key model as iroh's `NodeId`, but for direct QUIC connections.
`TlsIdentity::RawKey` in `StaticConfig` configures this mode. The endpoint builds a `rustls::ServerConfig` with `only_raw_public_keys() -> true` and a `ResolvesServerCert` that generates the certificate on-the-fly from the key, exactly as iroh does (see `iroh/iroh/src/tls/resolver.rs`).
This mode works natively with SSH auth (same key type) and git (SSH key-based auth). It is the default for alknet-native clients. **Browser/WebTransport clients do not support RFC 7250** — they require X.509 certificates.
### Use case 2: Domain-hosted services
Nodes that serve browser/WebTransport clients, or nodes with public domain names, use X.509 certificates. This has two sub-cases:
- **Manual**: Provide cert/key file paths via `TlsIdentity::X509`. The endpoint loads them at startup and builds a standard `rustls::ServerConfig`.
- **ACME auto-provisioning**: Let's Encrypt via `rustls-acme`. `TlsIdentity::Acme { domains, cache_dir, directory, contact }` carries the static config; the endpoint constructs the `AcmeState` async state machine and `ResolvesServerCertAcme` at setup time (ADR-027). The `acme` feature gate keeps `rustls-acme` out of non-ACME builds. See [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) for the full design.
`TlsIdentity::SelfSigned` is for development only — the endpoint generates a self-signed cert on startup. External clients will not trust it.
### iroh endpoint identity
The iroh endpoint does not need TLS certificate configuration — it uses `NodeId` (Ed25519) for identity, which is RFC 7250 raw key identity built into the iroh endpoint.
### Identity model comparison
| Path | Identity model | Client compatibility | Use case |
|------|---------------|---------------------|----------|
| quinn + `TlsIdentity::RawKey` | RFC 7250 Ed25519 raw key | alknet-native, SSH, git | Personal nodes, P2P, most deployments |
| quinn + `TlsIdentity::X509` | X.509 domain certificate (manual) | All clients including browsers | Relays, public services, WebTransport |
| quinn + `TlsIdentity::Acme` | X.509 via ACME auto-provisioning | All clients including browsers | Public relays, domain-hosted services |
| quinn + `TlsIdentity::SelfSigned` | X.509 self-signed cert | None (dev only) | Local development |
| iroh | NodeId (Ed25519, RFC 7250 built-in) | alknet-native, iroh clients | NAT traversal, home servers |
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). 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
```rust
impl AlknetEndpoint {
pub fn shutdown_sender(&self) -> watch::Sender<bool>;
pub async fn shutdown(&self) -> Result<(), EndpointError>;
}
```
- `shutdown_sender()` returns a clone of the shutdown channel sender. Call `send(true)` to signal shutdown. The assembly layer uses this for any external dispatch callers (SSH, future WT); the endpoint's own loops are signaled internally.
- `shutdown()` signals all owned accept loops (quinn, iroh, TCP+TLS) to stop, waits for in-flight dispatched handlers with a drain timeout, then forcefully closes remaining connections. One owner, one shutdown — no external loop coordination (ADR-083).
- SIGTERM/SIGINT are wired to the shutdown channel by the CLI binary.
The drain timeout is passed to `AlknetEndpoint::new()` directly (as
`drain_timeout: Duration`), not via `StaticConfig` — the endpoint no
longer takes `StaticConfig` (ADR-083). The assembly layer reads
`StaticConfig::drain_timeout` and passes it in.
## Error Handling
### EndpointError
Fatal errors that prevent the endpoint from starting or continuing.
```rust
pub enum EndpointError {
BindFailed(io::Error),
HandlerNotFound(Vec<u8>), // ALPN string with no registered handler
}
```
After ADR-083, the endpoint takes no TLS config and constructs no
transports — TLS config errors now surface as `TlsError` in
`alknet-tls` / the assembly layer (see
[`crates/tls/README.md`](../tls/README.md) and OQ-62), not as
`EndpointError`. The `TlsConfig(io::Error)` variant that existed when
the endpoint built TLS internally is removed. `BindFailed` covers
listener bind failures (quinn, iroh, TCP+TLS `TcpListener::bind`).
### HandlerError
Non-fatal errors within a handler. See [core-types.md](core-types.md) for details.
### Accept loop errors
- **TLS handshake failure**: Log and continue. The client may have offered no compatible ALPN, or the cert may be untrusted.
- **Handler panic**: Caught by tokio's task isolation. The connection is dropped. Other connections continue.
- **Connection-level errors** (quinn/iroh `ConnectionError`): Log and continue. The accept loop keeps running.
## Key Differences from Reference Implementation
| Aspect | Reference (`alknet-main`) | New Model |
|--------|---------------------------|-----------|
| Transport | `TransportAcceptor` trait, `TransportKind` enum | `quinn::Endpoint` + `iroh::Endpoint`, ALPN dispatch |
| Listener config | `ListenerConfig` enum (Stream/Http/Dns) | Single `HandlerRegistry`, ALPN dispatch |
| Protocol detection | Byte-peeking (`stealth::detect_protocol`) | ALPN negotiation (TLS layer) |
| Stealth mode | SSH-over-TLS with byte-peek | HTTP handler on `h2`/`http/1.1` serves decoy |
| Accept loop | Per-transport, SSH-centric | Per-connection-source, ALPN-agnostic |
| Handler model | `ServerHandler` + `russh::server::Handler` | `ProtocolHandler::handle(Connection, &AuthContext)` |
| Config | `ServeOptions` builder | `StaticConfig` + `HandlerRegistry` + `AlknetEndpoint::new()` |
| iroh | Separate `IrohAcceptor` + `IrohTransport` | `Option<iroh::Endpoint>` on `AlknetEndpoint` |
| Network vs auth identity | Conflated (TLS cert + SSH key both "auth") | Explicitly separated (TLS/NodeId = network, SSH key/token = auth) |
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Multi-connectivity endpoint (quinn + iroh + TCP+TLS) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | All three optional, all feed same dispatch; endpoint owns all accept loops |
| Endpoint takes no TLS config; assembly layer builds transports | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `new()` takes `drain_timeout` + builder methods, no `StaticConfig` or `Arc<TlsServerConfig>` |
| TCP+TLS is a first-class owned transport | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `with_tcp_tls(listener, acceptor)` — reverses ADR-010's "TCP is not an endpoint struct concern" |
| Public `dispatch` for SSH/WT (multiplexing shapes) | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `dispatch` is public for connection-internal multiplexing, not for listener transports |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Two-way door, start static, add ArcSwap later |
| 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 |
| Handler panics isolated | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | tokio task isolation, connection closes |
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-04**: Resolved — HandlerRegistry is static at startup.
- **OQ-05**: Resolved — multi-connectivity endpoint with quinn + iroh, both feature-gated.
- **OQ-12**: Resolved — two distinct TLS identity use cases: RFC 7250 raw keys (default, P2P) and X.509 certs (domain-hosted, browsers). ACME auto-provisioning designed in [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md); RawKey decoupled from the `iroh` feature (available in quinn-only builds).
- **OQ-60**: Resolved — transport construction is inlined by the assembly layer; the TCP+TLS loop lives in `alknet-core` behind a `tcp` feature as an owned transport. See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
- **OQ-61**: Dissolved — the multi-owner shutdown problem does not arise; the endpoint owns all its accept loops. See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
+384
View File
@@ -0,0 +1,384 @@
---
status: draft
last_updated: 2026-07-15
---
# alknet-endpoint
The server-side establishment crate — the multi-transport accept-loop
runner that dispatches incoming connections by ALPN. The server-side
analogue of `alknet-client` (ADR-089): `alknet-endpoint` accepts and
dispatches; `alknet-client` dials and produces a `Connection`. Both
depend on `alknet-core` for shared types; neither depends on the other.
## What
`AlknetEndpoint` is the central runtime type for any node that accepts
inbound connections. It takes pre-built transport endpoints (quinn,
iroh, TCP+TLS) via builder methods, runs their accept loops inside a
single `run()` method, and dispatches each accepted connection to the
registered `ProtocolHandler` by the ALPN the TLS handshake negotiated.
It does not build transports and does not build TLS configs — the
assembly layer does both (transports from `alknet-tls`'s
`TlsServerConfig`, per ADR-082).
`alknet-endpoint` is extracted from `alknet-core` (ADR-083 Amendment
2026-07-15). The extraction is structural pruning, not a refactor: the
endpoint is a leaf consumer of core's shared types (it imports `auth`,
`config`, `types`; nothing in core imports from it), depended on by a
different audience (the assembly layer) than the shared types (every
handler crate). No handler crate imports `AlknetEndpoint`,
`HandlerRegistry`, or `EndpointError` — they depend on `alknet-core`
for `ProtocolHandler`, `Connection`, `AuthContext`, and types only.
## Why
`alknet-core` was two things welded: shared types (depended on by every
handler crate) + the endpoint (depended on by zero handler crates).
Extracting the endpoint into `alknet-endpoint` lets core shed the heavy
transport deps (quinn, iroh, rcgen, rustls-acme) and become the
lightweight types+auth+config crate the handler crates actually want.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Amendment 2026-07-15 — crate extraction" for the full rationale,
including the dependency data and the symmetry with `alknet-client`.
## Architecture
### `AlknetEndpoint`
```rust
pub struct AlknetEndpoint {
quinn: Option<quinn::Endpoint>,
iroh: Option<iroh::Endpoint>,
#[cfg(feature = "tcp")]
tcp_tls: Option<TcpTlsListener>, // (TcpListener, TlsAcceptor)
handlers: Arc<HandlerRegistry>,
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
shutdown_tx: watch::Sender<bool>,
shutdown_rx: watch::Receiver<bool>,
drain_timeout: Duration,
}
impl AlknetEndpoint {
pub fn new(
handlers: HandlerRegistry,
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
drain_timeout: Duration,
) -> Self;
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self;
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self;
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(
mut self,
listener: tokio::net::TcpListener,
acceptor: tokio_rustls::TlsAcceptor,
) -> Self;
pub fn shutdown_sender(&self) -> watch::Sender<bool>;
pub fn dispatch(
&self,
connection: Connection,
alpn: Vec<u8>,
fingerprint: Option<String>,
remote_addr: Option<SocketAddr>,
);
pub async fn run(self: Arc<Self>);
pub async fn shutdown(&self) -> Result<(), EndpointError>;
}
```
`new` takes **no `StaticConfig`** and **no TLS config** — the assembly
layer reads `StaticConfig` (in `alknet-core`), builds the transports
(via `alknet-tls`'s `TlsServerConfig` + the transport's own builder),
and hands them to the endpoint via `with_quinn` / `with_iroh` /
`with_tcp_tls`. The endpoint's job is to run accept loops and dispatch;
transport construction is not its concern. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) for
the full design.
### `HandlerRegistry`
Maps ALPN byte strings to `ProtocolHandler` instances. Registered
statically at startup by the assembly layer; the endpoint dispatches by
looking up the negotiated ALPN.
```rust
pub struct HandlerRegistry {
handlers: HashMap<&'static [u8], Arc<dyn ProtocolHandler>>,
}
impl HandlerRegistry {
pub fn new() -> Self;
pub fn register(&mut self, handler: Arc<dyn ProtocolHandler>);
pub fn get(&self, alpn: &[u8]) -> Option<&Arc<dyn ProtocolHandler>>;
pub fn alpn_strings(&self) -> Vec<Vec<u8>>;
}
```
- `register()`: Insert a handler. Panics if the ALPN is already registered.
- `get()`: Look up a handler by ALPN string.
- `alpn_strings()`: Return all registered ALPN strings. Used by the
assembly layer to build the TLS `ServerConfig`'s ALPN list (via
`alknet-tls`, filtered by endpoint type per ADR-086).
Registration is static at startup (ADR-010, OQ-04). The assembly layer
builds a `HandlerRegistry`, inserts all handlers, and passes it to
`AlknetEndpoint::new()`.
### `EndpointError`
The error type for `AlknetEndpoint::shutdown` and listener bind
failures. Lives in `alknet-endpoint` (moves with the endpoint from
core).
```rust
pub enum EndpointError {
BindFailed(io::Error),
HandlerNotFound(Vec<u8>), // ALPN string with no registered handler
}
```
After ADR-083, the endpoint takes no TLS config and constructs no
transports — TLS config errors surface as `TlsError` in `alknet-tls` /
the assembly layer, not as `EndpointError`. The `TlsConfig(io::Error)`
variant that existed when the endpoint built TLS internally is removed.
`BindFailed` covers listener bind failures (quinn, iroh, TCP+TLS
`TcpListener::bind`).
### `TcpTlsListener`
The type held by the endpoint's `tcp_tls` field — a tuple of the TCP
listener and the TLS acceptor:
```rust
type TcpTlsListener = (tokio::net::TcpListener, tokio_rustls::TlsAcceptor);
```
The endpoint owns both halves: `tcp.accept()` produces a `TcpStream`,
`tls.accept()` wraps it, then ALPN + fingerprint extraction →
`Connection::from_bidi` → `dispatch`. Feature-gated on `tcp`.
### Accept loops
Each active transport runs its own accept loop inside `run()`:
- **Quinn** — `quinn.accept()` → TLS handshake → extract ALPN +
fingerprint → `Connection::from_quinn_with_alpn` → `dispatch`.
- **Iroh** — `iroh.accept()` → `accepting.alpn().await` → extract
fingerprint → `Connection::from_iroh` → `dispatch`.
- **TCP+TLS** (behind `tcp` feature) — `tcp.accept()` →
`tls.accept()` → extract ALPN + fingerprint →
`Connection::from_bidi` → `dispatch`.
All three feed the same `dispatch` method. The transport-specific
extraction (ALPN, fingerprint, remote address) is private to the
endpoint; `dispatch` receives the extracted values. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Accept Loops" for the loop pseudocode.
### `dispatch` (public)
The shared dispatch path for every transport — the endpoint's own
accept loops call it after transport-specific extraction, and external
dispatch callers (SSH channels, future WebTransport streams) call it
after their own extraction. Synchronous (non-async): performs the
ACME guard, handler lookup, `build_auth_context`, and `tokio::spawn`s
the handler. Returns immediately after spawning.
`dispatch` is public for **connection-internal multiplexing shapes**
that the endpoint can't own (SSH channels: one connection, many
channels with different ALPNs; future WT streams: one QUIC connection,
many WT streams). Listener transports (quinn, iroh, TCP+TLS) are owned
by the endpoint and call `dispatch` internally; they are not external
dispatch callers. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"`dispatch` is public — for genuinely external shapes".
### Shutdown
`shutdown()` signals all owned accept loops (quinn, iroh, TCP+TLS) to
stop, waits for in-flight dispatched handlers with a drain timeout,
then forcefully closes remaining connections. One owner, one shutdown
— no external loop coordination. SIGTERM/SIGINT are wired to the
shutdown channel by the assembly layer (the deployment binary).
### What `alknet-endpoint` does NOT do
- **No transport construction.** The endpoint takes pre-built
transports via builder methods. The assembly layer builds them
(`TlsServerConfig::for_quinn()` → `quinn::Endpoint::server()`, etc.).
- **No TLS config.** The endpoint does not depend on `alknet-tls`. TLS
configs are built by the assembly layer; the endpoint receives the
resulting transport endpoints.
- **No protocol logic.** The endpoint dispatches by ALPN; the handler
runs the protocol. The endpoint does not know what `alknet/call` or
`alknet/channels` means — it looks up the ALPN in the registry and
spawns the handler.
### Feature gates
```toml
[features]
default = []
quinn = ["dep:quinn", "alknet-core/quinn"] # with_quinn — quinn accept loop
iroh = ["dep:iroh", "alknet-core/iroh"] # with_iroh — iroh accept loop
tcp = ["dep:tokio-rustls"] # with_tcp_tls — TCP+TLS accept loop
```
The `quinn`/`iroh` features pull the corresponding features on
`alknet-core` (for `Connection::from_quinn` / `from_iroh` — the
constructors stay in core; see ADR-083 §"The `quinn` feature split"). A
deployment enables the features for the transports it runs. A
pure-QUIC node enables `quinn` + `iroh`; a hub serving HTTPS enables
`quinn` + `tcp`; a hub-worker enables all three.
### Dependencies
```
alknet-endpoint
├── alknet-core (Connection, ProtocolHandler, AuthContext,
│ IdentityProvider, DynamicConfig)
├── quinn (optional — quinn accept loop)
├── iroh (optional — iroh accept loop)
├── tokio-rustls (optional — TCP+TLS accept loop, tcp feature)
├── tokio (spawn, watch, TcpListener)
├── arc-swap (DynamicConfig)
└── tracing (logging)
```
`alknet-endpoint` depends on `alknet-core` (for `Connection`,
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`).
`HandlerRegistry` and `EndpointError` live in `alknet-endpoint` (they
move with the endpoint from core). The endpoint does **not** depend on
`alknet-tls` — it takes pre-built transports, so TLS config
construction stays at the assembly layer.
### Crate dependencies (in the dep graph)
```
alknet-endpoint
└── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider, DynamicConfig)
alknet-hub (uses AlknetEndpoint for inbound)
├── alknet-endpoint (the accept-loop runner)
├── alknet-client (the dial — for outbound worker dials, ADR-089)
├── alknet-channels-call (ChannelsAdapter — registered on the HandlerRegistry)
├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter)
└── alknet-core (shared types)
alknet-worker (uses AlknetEndpoint if it accepts inbound)
├── alknet-endpoint (if the worker accepts inbound — a hub-worker)
├── alknet-client (the dial — to reach the hub, ADR-089)
└── alknet-core (shared types)
```
`alknet-call`, `alknet-http`, `alknet-tty`, and other handler crates do
**not** depend on `alknet-endpoint`. They depend on `alknet-core` for
`ProtocolHandler` and `Connection`; the endpoint dispatches to them via
the trait, without a dependency edge. This is the dep-weight win: a
handler crate no longer transitively links quinn, iroh, rcgen, or
rustls-acme.
## Assembly layer integration
A downstream hub uses `alknet-endpoint` like this:
```rust
// 1. Build the HandlerRegistry — register all handlers by ALPN.
let mut registry = HandlerRegistry::new();
registry.register(Arc::new(channels_adapter)); // alknet/channels
registry.register(Arc::new(http_adapter)); // h2, http/1.1
// 2. Build the TlsServerConfig(s) via alknet-tls (assembly layer).
let raw_key_tls = TlsServerConfig::new(&raw_key_identity, &native_alpns).await?;
let x509_tls = TlsServerConfig::new(&x509_identity, &web_alpns).await?;
// 3. Build the transport endpoints from the TLS configs.
let quinn_endpoint = raw_key_tls.for_quinn()?.into_endpoint(listen_addr)?;
let tcp_listener = TcpListener::bind(web_addr).await?;
let tls_acceptor = x509_tls.for_tcp_tls();
// 4. Construct the endpoint with all owned transports.
let endpoint = Arc::new(
AlknetEndpoint::new(registry, dynamic, identity_provider, drain_timeout)
.with_quinn(quinn_endpoint)
.with_tcp_tls(tcp_listener, tls_acceptor),
);
// 5. Run — all accept loops run inside run(), shutdown() stops them all.
endpoint.clone().run().await;
```
The endpoint takes the pre-built transports; the assembly layer built
them from `alknet-tls`'s `TlsServerConfig`s. The endpoint does not see
`alknet-tls` — it sees `quinn::Endpoint` and `TlsAcceptor`.
## What `alknet-core` looks like after the extraction
Core loses the endpoint module (~1600 LOC) and 5 heavy deps (`quinn`,
`iroh`, `rcgen`, `rustls-pemfile`, `rustls-acme`). The remaining surface
is the lightweight types+auth+config+ownership+store+fingerprint crate.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Amendment 2026-07-15 — crate extraction" §"What `alknet-core` looks
like after" for the module-level table and the `quinn` feature split
(`Connection::from_quinn` stays in core; the accept loop moves here).
## Design Decisions
All design decisions are documented as ADRs in
[decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner + crate extraction | `AlknetEndpoint` takes no TLS config; `with_quinn`/`with_iroh`/`with_tcp_tls` builder methods; public `dispatch` for SSH/WT; extracted from `alknet-core` into `alknet-endpoint` (Amendment 2026-07-15) |
| [086](../../decisions/086-endpoint-types-and-entry-points.md) | Endpoint types and entry points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | `HandlerRegistry`, accept loop, static registration (amended by ADR-083) |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-60** (resolved): Where does transport construction live? The
TCP+TLS accept loop lives in `alknet-endpoint` behind a `tcp` feature
as an owned transport. Builder functions are inlined by the assembly
layer. See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
- **OQ-61** (dissolved): Multi-owner shutdown coordination. The problem
does not arise — the endpoint owns all its accept loops (quinn, iroh,
TCP+TLS); `shutdown()` stops them all. See ADR-083.
## References
- [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) —
the decision this spec implements (including the Amendment
2026-07-15 crate extraction)
- [ADR-082](../../decisions/082-alknet-tls-extraction.md) —
`TlsServerConfig` (the TLS config the assembly layer builds; the
endpoint does not see it)
- [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) —
endpoint types (web/native/iroh); entry-point vs. endpoint ALPN
- [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) —
`alknet-client` (the client-side analogue — symmetric extraction)
- [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md)
— `Connection::from_stream` / `from_bidi` (the TCP+TLS path)
- [ADR-070](../../decisions/070-bidistreamsource-trait.md) —
`BidiStreamSource` (the `Connection` extension point)
- [`crates/core/endpoint.md`](../core/endpoint.md) — the endpoint
design (will be updated to reflect the extraction; the endpoint
semantics stay, the location moves)
- [`crates/core/core-types.md`](../core/core-types.md) —
`ProtocolHandler`, `Connection`, `AuthContext` (the shared types the
endpoint imports from core)
- [`crates/client/README.md`](../client/README.md) — `alknet-client`
(the client-side complement)
- [`crates/tls/README.md`](../tls/README.md) — `TlsServerConfig` (the
assembly-layer TLS config that produces the transports the endpoint
takes)
- [`crates/hub/README.md`](../hub/README.md) — the hub (the first
multi-transport consumer of the endpoint)
+5 -2
View File
@@ -657,15 +657,18 @@ alknet-hub (Hub struct deps)
│ from_call, FromCallConfig, AdapterError, ClientError) │ from_call, FromCallConfig, AdapterError, ClientError)
├── alknet-http (HttpAdapter — for the registration endpoint and browser access) ├── alknet-http (HttpAdapter — for the registration endpoint and browser access)
├── alknet-core (IdentityProvider, Connection, OperationRegistry, ├── alknet-core (IdentityProvider, Connection, OperationRegistry,
│ HandlerRegistry, AuthContext) │ AuthContext)
├── tokio (spawn, time::sleep) ├── tokio (spawn, time::sleep)
└── tracing (logging) └── tracing (logging)
(assembly-layer deps — used by the hub's composition code, not by (assembly-layer deps — used by the hub's composition code, not by
the Hub struct itself) the Hub struct itself)
├── alknet-endpoint (AlknetEndpoint with quinn + iroh + tcp features,
│ │ HandlerRegistry — the accept-loop runner)
├── alknet-client (AlknetClient — outbound worker dials, ADR-089)
├── alknet-tls (TlsServerConfig — builds the raw-key + X.509/ACME ├── alknet-tls (TlsServerConfig — builds the raw-key + X.509/ACME
│ configs handed to the endpoint's transports) │ configs handed to the endpoint's transports)
└── alknet-core [tcp feature] (with_tcp_tls — the TCP+TLS accept loop) └── alknet-core [quinn/iroh features] (Connection::from_quinn/from_iroh)
``` ```
`alknet-hub` depends on `alknet-channels-call`, `alknet-call`, and `alknet-hub` depends on `alknet-channels-call`, `alknet-call`, and
+30 -20
View File
@@ -364,20 +364,25 @@ endpoint section below ("What `AlknetEndpoint` does after the refactor")
describes the **post-refactor target**, not the current source. The describes the **post-refactor target**, not the current source. The
current `crates/alknet-core/src/endpoint.rs` is the **extraction current `crates/alknet-core/src/endpoint.rs` is the **extraction
source** — `AlknetEndpoint::new(static_config, ...)` builds TLS source** — `AlknetEndpoint::new(static_config, ...)` builds TLS
internally, the shape ADR-083 replaces. The extraction and refactor internally, the shape ADR-083 replaces. The endpoint is extracted into
are **sequenced**, not simultaneous: a new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15) as part of
this work. The extraction and refactor are **sequenced**, not
simultaneous:
1. **`alknet-tls` first** — build the crate in isolation. `TlsServerConfig` 1. **`alknet-tls` first** — build the crate in isolation. `TlsServerConfig`
and `TlsClientConfig` are unit-testable against `TlsIdentity` without and `TlsClientConfig` are unit-testable against `TlsIdentity` without
touching the endpoint. This is the greenfield step. touching the endpoint. This is the greenfield step.
2. **Endpoint refactor second** — `endpoint.rs` is refactored to the 2. **`alknet-endpoint` second** — build the new endpoint crate fresh
ADR-083 shape (`new(handlers, dynamic, identity_provider, drain_timeout)` against the ADR-083 shape (`new(handlers, dynamic,
+ `with_quinn` / `with_iroh` / `with_tcp_tls`), importing from identity_provider, drain_timeout)` + `with_quinn` / `with_iroh` /
`alknet-tls` instead of building TLS internally. The call sites move `with_tcp_tls`), importing `Connection`/`ProtocolHandler`/`AuthContext`
to the assembly layer here. from `alknet-core` and taking pre-built transports (no TLS config —
the assembly layer builds those via `alknet-tls`). The old
`crates/alknet-core/src/endpoint.rs` is deleted.
3. **Assembly layer last** — the deployment binary (hub/worker) builds 3. **Assembly layer last** — the deployment binary (hub/worker) builds
the `TlsServerConfig`(s) and `TlsClientConfig`(s), the transports, and the `TlsServerConfig`(s) and `TlsClientConfig`(s), the transports, and
hands them to `AlknetEndpoint` via the builder methods. hands them to `AlknetEndpoint` (in `alknet-endpoint`) via the builder
methods.
A compilable intermediate state exists after step 1: `alknet-tls` built A compilable intermediate state exists after step 1: `alknet-tls` built
and tested standalone, with `endpoint.rs` still in its old shape. The and tested standalone, with `endpoint.rs` still in its old shape. The
@@ -385,11 +390,12 @@ call sites for `TlsServerConfig` / `TlsClientConfig` do not exist until
step 2/3 — an implementer testing step 1 writes tests against the TLS step 2/3 — an implementer testing step 1 writes tests against the TLS
types directly, not against a wired-up endpoint. types directly, not against a wired-up endpoint.
### What `AlknetEndpoint` does after the refactor ### What `AlknetEndpoint` (in `alknet-endpoint`) does after the refactor
`AlknetEndpoint::new()` currently builds `TlsSetup` internally. After `AlknetEndpoint::new()` currently builds `TlsSetup` internally. After
the refactor (see [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)), the refactor (see [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)),
the endpoint takes **no TLS config at all** — it is a multi-transport the endpoint (extracted into `alknet-endpoint` per ADR-083 Amendment
2026-07-15) takes **no TLS config at all** — it is a multi-transport
accept-loop runner. TCP+TLS is an owned transport (via `with_tcp_tls`), accept-loop runner. TCP+TLS is an owned transport (via `with_tcp_tls`),
not an external loop: not an external loop:
@@ -449,11 +455,12 @@ shutdown is single-owner — the endpoint owns all its accept loops
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP `alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` → accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`Connection::from_bidi` → `endpoint.dispatch()`) lives in `alknet-core` `Connection::from_bidi` → `endpoint.dispatch()`) lives in
behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via `alknet-endpoint` behind a `tcp` feature, as an owned transport on
`with_tcp_tls(listener, acceptor)` — see ADR-083). `alknet-tls` is the `AlknetEndpoint` (via `with_tcp_tls(listener, acceptor)` — see ADR-083,
cert provider, not the accept loop. This keeps `alknet-tls` focused on Amendment 2026-07-15). `alknet-tls` is the cert provider, not the
TLS setup and cert sharing, not transport accept logic. accept loop. This keeps `alknet-tls` focused on TLS setup and cert
sharing, not transport accept logic.
### Client-side — `TlsClientConfig` (ADR-087) ### Client-side — `TlsClientConfig` (ADR-087)
@@ -654,10 +661,12 @@ alknet-call (client-side verifier — unchanged)
alknet-hub (multi-transport endpoint) alknet-hub (multi-transport endpoint)
├── alknet-tls (TlsServerConfig — shared across quinn + TCP) ├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
├── alknet-endpoint (AlknetEndpoint with quinn + iroh + tcp features, HandlerRegistry)
├── alknet-client (AlknetClient — outbound worker dials, ADR-089)
├── alknet-channels-call (ChannelClient) ├── alknet-channels-call (ChannelClient)
├── alknet-call (CallAdapter, Dispatcher) ├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter) ├── alknet-http (HttpAdapter)
├── alknet-core (AlknetEndpoint with quinn + iroh + tcp features, HandlerRegistry, Connection) ├── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider)
``` ```
`alknet-tls` depends on `alknet-core` only. No handler crate depends on `alknet-tls` depends on `alknet-core` only. No handler crate depends on
@@ -694,8 +703,8 @@ See [open-questions.md](../../open-questions.md) for full details.
`rustls::sign` usage is a test helper. `alknet-tls` re-exports the `rustls::sign` usage is a test helper. `alknet-tls` re-exports the
fingerprint functions for convenience. fingerprint functions for convenience.
- **OQ-60** (resolved): Where does transport construction live? The - **OQ-60** (resolved): Where does transport construction live? The
TCP+TLS accept loop lives in `alknet-core` behind a `tcp` feature as TCP+TLS accept loop lives in `alknet-endpoint` behind a `tcp` feature
an owned endpoint transport (`with_tcp_tls`). Builder functions are as an owned endpoint transport (`with_tcp_tls`). Builder functions are
inlined by the assembly layer. See ADR-083. inlined by the assembly layer. See ADR-083.
- **OQ-61** (dissolved): Multi-owner shutdown coordination. The - **OQ-61** (dissolved): Multi-owner shutdown coordination. The
problem does not arise — the endpoint owns all its accept loops problem does not arise — the endpoint owns all its accept loops
@@ -774,8 +783,9 @@ named by ADR-089; its wire protocol is deferred (OQ-66).
- `docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md` - `docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md`
— `TlsClientConfig` (client-side); not blocked on the dial seam; — `TlsClientConfig` (client-side); not blocked on the dial seam;
breaks the circular hedge; hub-as-client requirement breaks the circular hedge; hub-as-client requirement
- `docs/architecture/crates/core/endpoint.md` — current endpoint design - `docs/architecture/crates/endpoint/README.md` — `AlknetEndpoint`
(TLS section will be amended to point to `alknet-tls`) (the endpoint spec; TLS config is built by `alknet-tls`, not the
endpoint — per ADR-083)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig` - `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
- `crates/alknet-core/src/endpoint.rs` — the server-side code being - `crates/alknet-core/src/endpoint.rs` — the server-side code being
extracted (`build_rustls_server_config`, `TlsSetup`, `RawKeyCertResolver`, extracted (`build_rustls_server_config`, `TlsSetup`, `RawKeyCertResolver`,
@@ -10,6 +10,12 @@ WebTransport streams. See §"TCP+TLS is a first-class owned transport".
All TLS-scope OQs resolved; advanced to Accepted ahead of the All TLS-scope OQs resolved; advanced to Accepted ahead of the
task-decomposition session.) task-decomposition session.)
Accepted (amended 2026-07-15: the endpoint is extracted from
`alknet-core` into a new crate `alknet-endpoint`. The ADR's shape
(`new` + builder methods + public `dispatch` + `run` / `shutdown`) is
unchanged; the *location* changes. See §"Amendment 2026-07-15 — crate
extraction".)
## Context ## Context
`AlknetEndpoint` (ADR-010) conflates two concerns: `AlknetEndpoint` (ADR-010) conflates two concerns:
@@ -136,9 +142,9 @@ that's primarily the hub. A pure worker (no inbound endpoints) has a
trivial assembly layer; a hub-worker combines both. Putting trivial assembly layer; a hub-worker combines both. Putting
hub-specific composition in the hub crate is appropriate; putting hub-specific composition in the hub crate is appropriate; putting
transport loops that any node might need in the hub crate is not. The transport loops that any node might need in the hub crate is not. The
TCP+TLS loop belongs in `alknet-core` (behind a `tcp` feature), where TCP+TLS loop belongs in `alknet-endpoint` (behind a `tcp` feature),
any node that wants it can enable the feature and call `with_tcp_tls` — where any node that wants it can enable the feature and call
no hub dependency. `with_tcp_tls` — no hub dependency.
## Decision ## Decision
@@ -251,11 +257,11 @@ iroh loops. It is not an external sibling. This means:
accepts inbound TCP+TLS enables the `tcp` feature and calls accepts inbound TCP+TLS enables the `tcp` feature and calls
`with_tcp_tls(listener, acceptor)`. No hub dependency. The loop isn't `with_tcp_tls(listener, acceptor)`. No hub dependency. The loop isn't
duplicated per binary. duplicated per binary.
- **The TCP+TLS loop's home is `alknet-core`** (behind a `tcp` feature), - **The TCP+TLS loop's home is `alknet-endpoint`** (behind a `tcp`
not the hub crate or the assembly layer. Core already has `quinn` and feature), not the hub crate or the assembly layer. The endpoint
`iroh` as feature-gated transport deps; adding `tcp` (pulling crate has `quinn` and `iroh` as feature-gated transport deps; adding
`tokio-rustls`) is the same pattern. A deployment that doesn't use `tcp` (pulling `tokio-rustls`) is the same pattern. A deployment that
TCP+TLS doesn't enable the feature. doesn't use TCP+TLS doesn't enable the feature.
### `dispatch` is public — for genuinely external shapes ### `dispatch` is public — for genuinely external shapes
@@ -328,7 +334,7 @@ The `tcp` feature pulls `tokio-rustls`. A deployment enables the
features for the transports it runs. A pure-QUIC node enables `quinn` + features for the transports it runs. A pure-QUIC node enables `quinn` +
`iroh`; a hub serving HTTPS enables `quinn` + `tcp`; a hub-worker `iroh`; a hub serving HTTPS enables `quinn` + `tcp`; a hub-worker
enables all three. This matches the existing pattern (`quinn` and enables all three. This matches the existing pattern (`quinn` and
`iroh` are already feature-gated transport deps on `alknet-core`). `iroh` are already feature-gated transport deps on `alknet-endpoint`).
### `StaticConfig` stays in core; the endpoint drops it ### `StaticConfig` stays in core; the endpoint drops it
@@ -380,11 +386,14 @@ live in the hub crate, not a generic transport crate.
| `load_cert_chain()` / `load_private_key()` | `alknet-tls` (ADR-082) | | `load_cert_chain()` / `load_private_key()` | `alknet-tls` (ADR-082) |
| `build_quinn_server_config_from_rustls()` | `alknet-tls` (`for_quinn()`, ADR-082) | | `build_quinn_server_config_from_rustls()` | `alknet-tls` (`for_quinn()`, ADR-082) |
| `build_iroh_endpoint()` | Assembly layer (inlined; 15 lines of iroh API calls) | | `build_iroh_endpoint()` | Assembly layer (inlined; 15 lines of iroh API calls) |
| `AlknetEndpoint`, `HandlerRegistry`, `EndpointError`, all dispatch/loop/extraction code | `alknet-endpoint` (this ADR, §"Amendment 2026-07-15") |
### What stays in `alknet-core/endpoint.rs` ### What stays in `alknet-endpoint` (the new crate)
- `AlknetEndpoint` struct (multi-transport accept-loop runner + public `dispatch`) - `AlknetEndpoint` struct (multi-transport accept-loop runner + public `dispatch`)
- `HandlerRegistry` - `HandlerRegistry`
- `EndpointError`
- `TcpTlsListener` (the `(TcpListener, TlsAcceptor)` tuple for the TCP+TLS field)
- `dispatch` (public — ACME guard, handler lookup, `build_auth_context`, spawn) - `dispatch` (public — ACME guard, handler lookup, `build_auth_context`, spawn)
- `dispatch_quinn` / `dispatch_iroh` / `dispatch_tcp_tls` (private — transport-specific extraction, then call `dispatch`) - `dispatch_quinn` / `dispatch_iroh` / `dispatch_tcp_tls` (private — transport-specific extraction, then call `dispatch`)
- `run_quinn_accept_loop` / `run_iroh_accept_loop` / `run_tcp_tls_accept_loop` - `run_quinn_accept_loop` / `run_iroh_accept_loop` / `run_tcp_tls_accept_loop`
@@ -402,6 +411,132 @@ to the endpoint via `with_quinn` / `with_iroh` / `with_tcp_tls`. ADR-082
should reference this ADR for the endpoint signature and focus on what should reference this ADR for the endpoint signature and focus on what
`alknet-tls` provides (`TlsServerConfig` and its accessors). `alknet-tls` provides (`TlsServerConfig` and its accessors).
### Amendment 2026-07-15 — crate extraction (`alknet-endpoint`)
The endpoint is extracted from `alknet-core` into a new crate
`alknet-endpoint`. The ADR's shape — `new(handlers, dynamic,
identity_provider, drain_timeout)` + `with_quinn` / `with_iroh` /
`with_tcp_tls` + public `dispatch` + `run` / `shutdown` — is **unchanged**
by this amendment. Only the *location* changes: `endpoint.rs`,
`HandlerRegistry`, and `EndpointError` move from `alknet-core` to
`alknet-endpoint`.
#### Why extract (the dependency data)
`alknet-core` is currently two things welded together with different
audiences:
| Concern | Modules | Depended on by |
|---------|---------|----------------|
| **Shared types + auth + config** | `types.rs`, `auth.rs`, `config.rs`, `fingerprint.rs`, `ownership.rs`, `store.rs` | every handler crate (call, http, tty, tty-local) — 124 import sites |
| **Endpoint (accept-loop runner)** | `endpoint.rs` (1606 LOC) | **zero handler crates** — only the assembly layer (hub/worker) |
The handler crates import `alknet_core::types` (57×), `alknet_core::auth`
(41×), `alknet_core::config` (13×), `alknet_core::ownership` (7×),
`alknet_core::fingerprint` (6×). They import `alknet_core::endpoint`
**zero times**. `endpoint.rs` is a one-way leaf consumer of the shared
types (it imports `auth`, `config`, `types`; nothing in core imports
*from* it). This is the textbook shape for an extraction: a leaf
consumer depended on by a different audience than the shared types.
#### The dependency-weight win
`alknet-core`'s `Cargo.toml` currently carries `quinn`, `iroh`,
`rustls-pemfile`, `rcgen`, `rustls-acme`, `rustls` — heavy transport/TLS
deps — because `endpoint.rs` uses them. Every handler crate transitively
pulls these via `alknet-core`, even though none of them use the
endpoint. `alknet-tty` is a pure wire-format handler — it has no business
linking quinn.
After extraction:
- **`alknet-core`** loses `quinn`, `iroh`, `rcgen`, `rustls-pemfile`,
`rustls-acme` from its `[dependencies]` (the TLS-setup deps already
move to `alknet-tls` per ADR-082; the transport deps move with the
endpoint). It keeps `rustls`/`rustls-pki-types` (narrow — for
`fingerprint.rs` types, per OQ-59), `ed25519-dalek`, `sha2`, `tokio`,
`serde`, `arc-swap`. It becomes a **lightweight types+auth+config
crate** — what the handler crates actually want.
- **`alknet-endpoint`** gains `quinn`, `iroh` (the accept-loop
transports). It depends on `alknet-core` for `Connection`,
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`.
It does **not** depend on `alknet-tls` — the endpoint takes pre-built
transports (per this ADR), so TLS config construction stays in
`alknet-tls` at the assembly layer.
A handler crate (call, http, tty) no longer transitively links quinn,
iroh, rcgen, or rustls-acme. A pure worker (client-only, no inbound)
depends on `alknet-client` + handler crates + `alknet-core` and does
**not** pull `alknet-endpoint` at all.
#### The `quinn` feature split
`alknet-core`'s `quinn` feature currently gates two unrelated things:
(1) `endpoint.rs`'s quinn accept loop and (2) `types.rs`'s
`Connection::from_quinn` / `QuinnBidiStreamSource` constructor. After
extraction:
- **`Connection::from_quinn` stays in `alknet-core`** (in `types.rs`).
It's a shared-type constructor used by both the endpoint's accept
loop (server) and `alknet-client`'s `dial_quic` (client, ADR-089) and
`CallClient` tests. The `quinn` feature on `alknet-core` gates this
constructor — "does `Connection` support quinn-constructed
connections" — not "does core run a quinn accept loop." Same for
`from_iroh` and the `iroh` feature.
- **The quinn accept loop moves to `alknet-endpoint`**, which has its
own `quinn` feature for the accept loop. The heavy quinn accept-loop
code is in `alknet-endpoint`, not core.
#### Symmetry with `alknet-client` (ADR-089)
The extraction mirrors the client-side extraction (ADR-089):
| Server side | Client side | Audience |
|-------------|-------------|----------|
| `alknet-endpoint` (this ADR) | `alknet-client` (ADR-089) | the assembly layer (hub, worker, hub-worker) |
| `alknet-core` (shared types) | `alknet-core` (shared types) | every handler crate |
A **hub** depends on `alknet-endpoint` + `alknet-client` + handler
crates + `alknet-core` (transitively). A **pure worker** depends on
`alknet-client` + handler crates + `alknet-core` — no `alknet-endpoint`.
A **handler crate** depends on `alknet-core` only — neither endpoint
nor client.
#### Implementation: build new, delete old
The endpoint's `endpoint.rs` is 1606 lines of `#[cfg(feature = "quinn")]`
/ `#[cfg(feature = "iroh")]` / `#[cfg(feature = "acme")]` conditionals —
the TLS-setup code that ADR-082 moves to `alknet-tls`, the
`StaticConfig`-taking constructor that this ADR replaces, the
`acme-tls/1` guard that this ADR moves to shared `dispatch`. Building
`alknet-endpoint` fresh against this ADR's shape — `new()` + builder
methods + public `dispatch` + `run` / `shutdown`, importing
`Connection`/`ProtocolHandler`/`AuthContext` from `alknet-core`, no TLS
setup (that's `alknet-tls`'s job now) — is cleaner than editing the
1606-line conditional file in place. The old `endpoint.rs` becomes a
deletion, not a refactor. This is pruning (cutting the endpoint + the
TLS deps that already move to `alknet-tls`), not a massive inline
refactor.
#### What `alknet-core` looks like after
`alknet-core` after the extraction + the ADR-082 TLS pruning:
| Module | LOC | Stays? |
|--------|-----|--------|
| `types.rs` (`Connection`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `StreamError`, `Capabilities`) | ~1100 | yes — shared by every crate |
| `auth.rs` (`AuthContext`, `Identity`, `IdentityProvider`, `IdentityStore`, `AuthToken`) | ~640 | yes — shared by every crate |
| `config.rs` (`StaticConfig`, `DynamicConfig`, `TlsIdentity`, `Ed25519SecretKey`) | ~710 | yes — shared config types |
| `fingerprint.rs` | ~260 | yes — shared by server + client (OQ-59) |
| `ownership.rs` (`OwnershipStore`, `OwnershipProvider`) | ~280 | yes — shared |
| `store.rs` (`CredentialStore`, `EncryptedData`) | ~200 | yes — shared |
| `endpoint.rs` (`AlknetEndpoint`, `HandlerRegistry`, `EndpointError`) | ~1600 | **no** — moves to `alknet-endpoint` |
Core loses ~1600 LOC (the endpoint) and 5 heavy deps (`quinn`, `iroh`,
`rcgen`, `rustls-pemfile`, `rustls-acme`). The remaining ~3200 LOC is
the lightweight types+auth+config+ownership+store+fingerprint surface
that every handler crate depends on.
## Consequences ## Consequences
**Positive:** **Positive:**
@@ -430,23 +565,30 @@ should reference this ADR for the endpoint signature and focus on what
- `dispatch` stays public for genuinely external shapes (SSH channels, - `dispatch` stays public for genuinely external shapes (SSH channels,
future WT streams) — transports the endpoint can't own because they're future WT streams) — transports the endpoint can't own because they're
connection-internal multiplexing, not listener-based. connection-internal multiplexing, not listener-based.
- **The endpoint is extracted into `alknet-endpoint`** (Amendment
2026-07-15). Handler crates no longer transitively link quinn, iroh,
rcgen, or rustls-acme. A pure worker (client-only) does not pull the
endpoint at all. The dep graph is symmetric with `alknet-client`
(ADR-089): `alknet-core` is the shared types crate; `alknet-endpoint`
and `alknet-client` are the server-side and client-side establishment
crates that depend on it.
**Negative:** **Negative:**
- `AlknetEndpoint::new` signature changes (breaking). Pre-1.0, in-repo - `AlknetEndpoint::new` signature changes (breaking). Pre-1.0, in-repo
consumers only — the assembly layer and tests must update. Expected; consumers only — the assembly layer and tests must update. Expected;
this is the point of the refactor. this is the point of the refactor.
- `alknet-core` gains a `tcp` feature (pulls `tokio-rustls`). This is - `alknet-endpoint` gains a `tcp` feature (pulls `tokio-rustls`). This is
the same pattern as the existing `quinn` and `iroh` features — a the same pattern as the `quinn` and `iroh` features — a transport that
transport that the endpoint can own. A deployment that doesn't use the endpoint can own. A deployment that doesn't use TCP+TLS doesn't
TCP+TLS doesn't enable it. `alknet-core` already depends on `rustls` enable it. `alknet-core` keeps a narrow `rustls` dep (for
(for `fingerprint.rs` types, per OQ-59); `tokio-rustls` is the `fingerprint.rs` types, per OQ-59); `tokio-rustls` is the acceptor
acceptor wrapper over `rustls::ServerConfig`, not a separate TLS wrapper over `rustls::ServerConfig`, not a separate TLS stack, and
stack. lives in `alknet-endpoint`, not core.
- `build_iroh_endpoint` leaves core (inlined by the assembly layer). - `build_iroh_endpoint` leaves core (inlined by the assembly layer).
This is a one-way dep-graph change: the binary that uses iroh depends This is a one-way dep-graph change: the binary that uses iroh depends
on `iroh` directly, not via `alknet-core`. This is correct — the on `iroh` directly, not via `alknet-core` or `alknet-endpoint`. This
binary *is* the thing that knows which transports it wants. The 15 is correct — the binary *is* the thing that knows which transports it
lines are pure iroh API calls; no shared logic is lost. wants. The 15 lines are pure iroh API calls; no shared logic is lost.
- This ADR revises ADR-010's "TCP is not an endpoint struct concern" - This ADR revises ADR-010's "TCP is not an endpoint struct concern"
more deeply than the original ADR-083 draft. The reason TCP was more deeply than the original ADR-083 draft. The reason TCP was
excluded (the endpoint built transports internally, TCP+TLS couldn't excluded (the endpoint built transports internally, TCP+TLS couldn't
@@ -456,12 +598,14 @@ should reference this ADR for the endpoint signature and focus on what
## Door type ## Door type
**One-way.** The endpoint's `new` signature, the public `dispatch` **One-way.** The endpoint's `new` signature, the public `dispatch`
contract, the "endpoint owns dispatch, not construction" boundary, and contract, the "endpoint owns dispatch, not construction" boundary, the
the "endpoint owns TCP+TLS as a first-class transport" decision are "endpoint owns TCP+TLS as a first-class transport" decision, and the
structural. Reversing would mean re-welding transport construction to extraction of the endpoint into `alknet-endpoint` (a separate crate
the endpoint, re-privatizing `dispatch`, and pushing TCP+TLS back outside from `alknet-core`) are structural. Reversing would mean re-welding
— breaking every multi-transport consumer (hub, hub-worker, future SSH, transport construction to the endpoint, re-privatizing `dispatch`,
future WT). pushing TCP+TLS back outside, and re-merging the endpoint into core —
breaking every multi-transport consumer (hub, hub-worker, future SSH,
future WT) and re-coupling every handler crate to quinn/iroh/rcgen.
The `dispatch` signature (`connection, alpn, fingerprint, remote_addr`) The `dispatch` signature (`connection, alpn, fingerprint, remote_addr`)
is one-way — changing it after consumers exist is a rewrite. The is one-way — changing it after consumers exist is a rewrite. The
@@ -492,9 +636,14 @@ tasks, the TCP+TLS loop's internal structure) is two-way.
client pattern this ADR mirrors on the server side) client pattern this ADR mirrors on the server side)
- `docs/research/alknet-endpoint-refactor/findings.md` — the analysis - `docs/research/alknet-endpoint-refactor/findings.md` — the analysis
that surfaced the conflation and the two-config hub case that surfaced the conflation and the two-config hub case
- `crates/alknet-core/src/endpoint.rs` — the code being refactored - `docs/architecture/crates/endpoint/README.md` — the canonical spec for
`alknet-endpoint` (this ADR's amendment is the decision; that spec is
the description)
- `crates/alknet-core/src/endpoint.rs` — the code being extracted into
`alknet-endpoint` (the old module becomes a deletion after the
extraction)
- OQ-60: resolved — transport construction is inlined by the assembly - OQ-60: resolved — transport construction is inlined by the assembly
layer (the deployment binary); the TCP+TLS loop lives in layer (the deployment binary); the TCP+TLS loop lives in
`alknet-core` behind a `tcp` feature as an owned transport `alknet-endpoint` behind a `tcp` feature as an owned transport
- OQ-61: dissolved — the multi-owner shutdown problem does not arise; - OQ-61: dissolved — the multi-owner shutdown problem does not arise;
the endpoint owns all its accept loops (quinn, iroh, TCP+TLS) the endpoint owns all its accept loops (quinn, iroh, TCP+TLS)
+7 -5
View File
@@ -94,15 +94,17 @@ graph — they depend on the published core crates from their own repos
alknet-vault (standalone — foundational to ACL: key derivation, identity) alknet-vault (standalone — foundational to ACL: key derivation, identity)
│ │
├── Substrate ├── Substrate
│ alknet-core ProtocolHandler, endpoint (multi-transport accept loop), │ alknet-core ProtocolHandler, Connection, BidiStreamSource, AuthContext,
│ │ Connection, BidiStreamSource, AuthContext, IdentityProvider, │ │ IdentityProvider, StaticConfig, DynamicConfig, fingerprint
│ │ StaticConfig, DynamicConfig │ │ (endpoint extracted to alknet-endpoint; core is now lightweight
│ │ types+auth+config — no quinn/iroh/rcgen deps)
│ ├── alknet-tls TlsServerConfig + TlsClientConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082/087) │ ├── alknet-tls TlsServerConfig + TlsClientConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082/087)
│ ├── alknet-call CallAdapter on alknet/call, CallClient, OperationRegistry, adapters │ ├── alknet-call CallAdapter on alknet/call, CallClient, OperationRegistry, adapters
│ ├── alknet-channels │ ├── alknet-channels
│ │ ├── alknet-channels-core pure multiplexer (wire format, demux/mux) — ADR-081 │ │ ├── alknet-channels-core pure multiplexer (wire format, demux/mux) — ADR-081
│ │ └── alknet-channels-call channel 0 pre-negotiation + lifecycle ops — ADR-081 │ │ └── alknet-channels-call channel 0 pre-negotiation + lifecycle ops — ADR-081
│ └── alknet-client AlknetClient — native client dial seam (QUIC + TCP+TLS + iroh); produces Connection for CallClient/ChannelClient take-over (ADR-089) │ ├── alknet-client AlknetClient — native client dial seam (QUIC + TCP+TLS + iroh); produces Connection for CallClient/ChannelClient take-over (ADR-089)
│ └── alknet-endpoint AlknetEndpoint — multi-transport accept-loop runner, extracted from core (ADR-083 Am. 2026-07-15); takes pre-built transports; public dispatch for SSH/WT
│ │
├── Deployment shapes ├── Deployment shapes
│ ├── alknet-hub channels hub — accepts workers, relays, aggregates (ADR-079); dials workers via AlknetClient │ ├── alknet-hub channels hub — accepts workers, relays, aggregates (ADR-079); dials workers via AlknetClient
@@ -368,7 +370,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format | 9-byte chunk header; N channels over one transport stream | | [071](decisions/071-channels-wire-format.md) | alknet-channels Wire Format | 9-byte chunk header; N channels over one transport stream |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate `channel/open`, byte-forward data channels; the hub never runs protocol-specific handlers | | [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate `channel/open`, byte-forward data channels; the hub never runs protocol-specific handlers |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Shared `TlsServerConfig` across quinn + TCP+TLS + iroh; one ACME state machine | | [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Shared `TlsServerConfig` across quinn + TCP+TLS + iroh; one ACME state machine |
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner | Endpoint takes no TLS config; TCP+TLS is an owned transport; public `dispatch` for SSH/WT | | [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner | Endpoint takes no TLS config; TCP+TLS is an owned transport; public `dispatch` for SSH/WT; endpoint extracted from `alknet-core` into `alknet-endpoint` (Am. 2026-07-15) |
| [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Core mono-repo (substrate + deployment shapes + foundational handlers + vault) vs. consumer repos (docker, agent) | | [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Core mono-repo (substrate + deployment shapes + foundational handlers + vault) vs. consumer repos (docker, agent) |
| [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type (resolves OQ-62) | | [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type (resolves OQ-62) |
| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | `alknet-tls` provides client-side TLS config; not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case | | [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | `alknet-tls` provides client-side TLS config; not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case |