The crate-extraction migration (phases 0-5) is complete in the code;
the specs still carried forward/migration framing ("was welded",
"after the refactor", "currently duplicated", "does not exist yet",
"What moves from X to Y" tables, "Implementation ordering") that
described the migration rather than the resulting state. Updated 10
spec files to describe the current state cleanly.
Spec/code mismatches fixed:
- core/README.md: a stale paragraph said CallCredentials "stays in
alknet-call" while ADR-091 Am. 2026-07-17 removed it. Now consistent.
- tls/README.md: TlsClientConfig API described a planned
ClientVerifierContext + for_tcp_tls(&self) + rustls_config(&self);
the actual code is new(&ConnectionCredentials, alpn) +
for_quinn(self) + into_rustls_config(self). Updated to match.
- client/README.md, call/client-and-adapters.md: ConnectionCredentials
field is tls_identity / with_tls_identity in the code, not
local_identity / with_local_identity. Updated the specs describing
the current API (ADR-091 body keeps local_identity as the decided
name).
- client/README.md: dial_iroh description said the local key is
"extracted from creds.local_identity" — the code uses the pre-built
iroh endpoint's key (set at with_iroh time) and reads only
creds.remote_identity for the NodeId. Fixed.
- overview.md: said core has "no quinn/iroh deps" — core keeps
quinn/iroh for Connection::from_quinn/from_iroh. Fixed.
- call/client-and-adapters.md: a /// doc-comment block and
pub struct RemoteIdentity were floating outside any code fence
(orphaned closing backticks). Fixed.
- tls/README.md: TlsError sketch shows the full ADR-088 6-variant
enum; the code has a simplified 3-variant enum. Added an
implementation note flagging the divergence; ADR-088 shape kept as
target.
- call/README.md: review note said "ADR-029 migration pending" (stale
— migration landed). Updated to reflect phase 5 completion (pure
protocol crate, no TLS/transport deps, verified against Cargo.toml).
Migration framing removed (present-state descriptions instead):
- tls/README.md: "What moves from" tables -> module-contents tables;
"Implementation ordering / greenfield" section removed; "after the
refactor" section -> "What AlknetEndpoint does"; references to
extraction-source files (alknet-core/src/endpoint.rs,
alknet-call/src/client/call_client.rs) replaced with current file
locations (alknet-tls/src/{server,client,pem,signing}.rs).
- endpoint/README.md: "was two things welded" framing removed;
"after the extraction" section -> "What alknet-core looks like".
- core/endpoint.md: "Historical summary" section removed; clean
deprecation pointer.
- README.md, overview.md, open-questions.md: dates + present-tense
cleanup.
17 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-17 |
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 a leaf consumer of alknet-core's shared types
(it imports auth, config, types; nothing in core imports from
it), depended on by the assembly layer — a different audience than the
shared types (every handler crate). No handler crate imports
AlknetEndpoint or HandlerRegistry — they depend on alknet-core
for ProtocolHandler, Connection, AuthContext, and types only.
This keeps the heavy transport deps (quinn, iroh, tokio-rustls) out of
the handler crates' dep closure. (EndpointError is removed — see
below.)
Why
Separating the endpoint from the shared-types crate lets alknet-core
be the lightweight types+auth+config crate that every handler crate
wants, while the accept-loop runner (which only the assembly layer
depends on) carries the heavy transport deps. See
ADR-083
§"Amendment 2026-07-15 — crate extraction" for the full rationale,
including the dependency data and the symmetry with alknet-client.
Architecture
AlknetEndpoint
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>);
/// Signal all owned accept loops to stop and drain in-flight handlers
/// for `drain_timeout`. Infallible — the accept loops are owned by
/// the endpoint (quinn, iroh, TCP+TLS), so there is no external
/// coordination and no bind failure path (the assembly layer binds
/// before handing pre-built transports to the endpoint via
/// `with_quinn` / `with_iroh` / `with_tcp_tls`). No-handler matches
/// are swallowed by `dispatch` (ADR-083: close + log, not an error).
/// One owner, one shutdown — no external loop coordination needed.
pub async fn shutdown(&self);
}
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 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.
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 TLSServerConfig's ALPN list (viaalknet-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 — removed
The endpoint previously had an EndpointError { BindFailed(io::Error), HandlerNotFound(Vec<u8>) } enum. Both variants are vestigial after
ADR-083:
BindFailed— the endpoint takes pre-built, pre-bound transports (the assembly layer does the binding); the endpoint performs no bind, so it cannot produce a bind error.HandlerNotFound—dispatchswallows no-handler matches (close + log per ADR-083), so this variant is never returned.
The enum is removed. shutdown() is infallible (async fn shutdown(&self),
no Result). If a future requirement adds a real failure path to
shutdown or dispatch, a fresh error type is cleaner than retrofitting
this one. The EndpointError type, its TlsConfig variant (already
removed by ADR-083), and the BindFailed/HandlerNotFound variants all
move out of the codebase with the endpoint extraction — none survives
into alknet-endpoint.
TcpTlsListener
The type held by the endpoint's tcp_tls field — a tuple of the TCP
listener and the TLS acceptor:
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
tcpfeature) —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
§"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::spawns
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
§"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/calloralknet/channelsmeans — it looks up the ALPN in the registry and spawns the handler.
Feature gates
[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 lives in alknet-endpoint (it moves with the
endpoint from core). EndpointError is removed (both variants were
vestigial — see "EndpointError — removed" above). 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:
// 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 TlsServerConfigs. The endpoint does not see
alknet-tls — it sees quinn::Endpoint and TlsAcceptor.
What alknet-core looks like
Core is the lightweight types+auth+config+ownership+store+fingerprint
crate (~3200 LOC, no quinn/iroh/rcgen/rustls-pemfile/
rustls-acme deps). The endpoint module is not in core; the accept
loops are here. 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 is here).
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 083 | 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 | Endpoint types and entry points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
| 010 | ALPN Router and Endpoint | HandlerRegistry, accept loop, static registration (amended by ADR-083) |
Open Questions
See open-questions.md for full details.
- OQ-60 (resolved): Where does transport construction live? The
TCP+TLS accept loop lives in
alknet-endpointbehind atcpfeature as an owned transport. Builder functions are inlined by the assembly layer. See ADR-083. - 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 — the decision this spec implements (including the Amendment 2026-07-15 crate extraction)
- ADR-082 —
TlsServerConfig(the TLS config the assembly layer builds; the endpoint does not see it) - ADR-086 — endpoint types (web/native/iroh); entry-point vs. endpoint ALPN
- ADR-089 —
alknet-client(the client-side analogue — symmetric extraction) - ADR-065
—
Connection::from_stream/from_bidi(the TCP+TLS path) - ADR-070 —
BidiStreamSource(theConnectionextension point) crates/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—ProtocolHandler,Connection,AuthContext(the shared types the endpoint imports from core)crates/client/README.md—alknet-client(the client-side complement)crates/tls/README.md—TlsServerConfig(the assembly-layer TLS config that produces the transports the endpoint takes)crates/hub/README.md— the hub (the first multi-transport consumer of the endpoint)