ADR-083 revised: TCP+TLS moves from an external sibling loop calling public dispatch to a first-class owned transport via with_tcp_tls(listener, acceptor), running inside run() alongside the quinn and iroh accept loops. The endpoint owns all its accept loops; shutdown() stops them all. The multi-owner shutdown problem (OQ-61) does not arise — dissolved. The reason TCP+TLS was structurally excluded (ADR-010 Am. 1: the endpoint built transports internally, TCP+TLS couldn't fit) is gone after ADR-083 — the endpoint no longer builds transports; it runs accept loops on whatever it's given. TCP+TLS is a listener transport, same shape as quinn and iroh. ADR-010 Amendment 2 supersedes Am. 1's struct-level exclusion. dispatch stays public — but for genuinely external shapes (SSH channels, future WebTransport streams), which are connection-internal multiplexing, not listener transports. The listener-vs-multiplexing distinction is now explicit. OQ-60 resolved: the TCP+TLS loop lives in alknet-core behind a tcp feature (owned by the endpoint); builder functions are inlined by the assembly layer. A alknet-transport crate was rejected — it would contain only trivial builders; the real component (the loop) is in core. Hub- specific composition lives in the hub crate; transport runtimes that any node might need live in core. Updated: ADR-010 (Amendment 2), ADR-082 (TCP+TLS loop location), ADR-083 (revised), core/endpoint.md (struct + dispatch + shutdown), hub/README.md (transport table + assembly example + stale sibling references), tls/README.md (endpoint section + TCP+TLS loop location + references), open-questions.md (OQ-60 resolved, OQ-61 dissolved). Review: zero critical issues, five warnings fixed (stale hub README prose, stale core endpoint.md struct/dispatch listings, stale ADR-082 TCP+TLS loop location, stale TLS README reference entry, hub front-matter date).
34 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-14 |
alknet-hub
The hub pattern as a reusable crate: multi-transport endpoint that accepts workers and browsers over TCP+TLS and QUIC, relays channels between legs (ADR-079), manages peer lifecycle, aggregates operations, and exposes service discovery. One channels connection per peer; multiple endpoint types coexisting.
What
alknet-hub is the crate that wires the hub role (ADR-029, ADR-034,
ADR-079) into a reusable library. A hub is the central node in a
hub-and-spoke (head/worker) topology: it accepts inbound connections from
workers and browsers, relays channels between legs, aggregates the
workers' operations into a shared environment, and serves the discovery
API. It depends on alknet-channels (the substrate), alknet-call
(the protocol), and alknet-core. It does not introduce new protocol
types — it wires the existing ChannelsAdapter, ChannelClient,
CallAdapter, PeerCompositeEnv, from_call, and Dispatcher types
into a coherent hub runtime.
The hub is multi-transport
A hub must support TCP+TLS and QUIC endpoints simultaneously. This is not a convenience — it is the structure of the primary use case. A hub that provisions a worker (in a container, on vast.ai, on runpod) uses TCP+TLS for registration and QUIC (or another transport) for the ongoing channels session. The hub's HTTP endpoint (registration, browser access) runs over TCP+TLS; the hub's channels endpoint runs over QUIC or TCP+TLS depending on the worker's transport. These coexist on one hub.
The channels protocol is transport-agnostic (ADR-071; ADR-065
Connection::from_stream/from_bidi). The hub's accept and dial paths
inherit that property — they take a Connection, not a SocketAddr
welded to a dial. See "Transport" below.
What the hub provides
-
Multi-transport endpoint — accepts channels connections over QUIC and over TCP+TLS (both owned transports on
AlknetEndpointviawith_quinn/with_tcp_tls, per ADR-083). Both feed the same dispatch path. Also serves HTTP (h2/http/1.1) over TCP+TLS for registration and browser access. All three coexist; the endpoint owns all accept loops andshutdown()stops them all. -
Peer lifecycle — accept, dial, disconnect, reconnect with backoff. Identity resolution via
IdentityProvider(fingerprint or bearer token, depending on transport — see "Identity over transports"). One channels connection per peer. -
Aggregated operation env — a shared
PeerCompositeEnvacross all connected workers (ADR-067). Operations discovered viafrom_callare registered in each peer's connection overlay and aggregated into the shared env.invoke_peerroutes operation calls to the right peer. -
Service discovery —
services/list-peersreturns each connected worker's operation list viaPeerCompositeEnv::peer_operations(ADR-068). -
Channel relay — translates
channel/openon channel 0 and byte-forwards data channels withchannel_idrewrite (ADR-079). Lets a browser reach a spoke's channels through the hub without the hub parsing any protocol-specific framing. -
Worker registration (in scope of the hub) — the HTTP endpoint that lets a freshly-provisioned worker enroll its key with a one-time registration token. The registration flow is what makes worker provisioning over TCP+TLS a hard requirement, not an option. See "Worker registration" below and OQ-58.
Why
The hub pattern requires wiring that alknet-channels and
alknet-call do not provide out of the box:
- The hub accepts channels connections over multiple transports
simultaneously. The channels crate is transport-agnostic (ADR-071,
ADR-065); the hub composes the multi-transport endpoint
(
AlknetEndpointwithwith_quinn/with_tcp_tls, ADR-083). The accept loops themselves live inalknet-core; the hub provides the handlers and wiring. - The hub relays channels between legs (ADR-079) — terminating
channel 0 on each leg, translating
channel/open, byte-forwarding data channels. The channels crate is ALPN-blind and does not know it is being relayed; the relay is a hub-crate concern. Dispatcher::compose_root_envbuilds a freshPeerCompositeEnvper call with only the current connection — multi-worker aggregation is not wired (ADR-067).PeerCompositeEnv::peer_operationsis not overridden —services/list-peersreturns empty operation lists for non-local peers (ADR-068).from_callis a free function, not wired intoChannelClient— the assembly layer must call it manually after every connect (ADR-069).- There is no worker supervision loop — reconnection, backoff, and re-discovery are assembly-layer concerns.
- There is no registration endpoint — a freshly-provisioned worker has no way to enroll its key with the hub before establishing a channels connection.
These are not design flaws; they are the correct separation of
concerns. The channels and call crates provide the types and the
routing logic; the hub wiring is a consumer concern. But it is a
concern every hub consumer shares. Rather than each downstream
project (alkapi, future hubs) building the same wiring independently,
alknet-hub provides it once, as a reusable crate.
Architecture
The hub is built on channels, not call-directly
The hub's substrate is alknet/channels, not alknet/call directly.
Each peer (worker or browser) holds one channels connection to the
hub. Channel 0 on each connection is alknet/call (ADR-072); the
hub's CallAdapter runs on channel 0 for the hub's own operations,
for channel/open translation (ADR-079), and for from_call
discovery. Data channels carry the actual protocol work (TTY, SSH,
tunnels) and are relayed byte-for-byte across legs.
This is the post-channels hub model. The pre-channels model (one QUIC
connection per peer carrying alknet/call directly) is replaced: the
connection is alknet/channels, and alknet/call rides channel 0.
The hub's CallClient-direct dial path is replaced by
ChannelClient::from_connection (ADR-080).
Hub struct
The Hub owns the aggregated PeerCompositeEnv, the
OperationRegistry, and the Dispatcher:
pub struct Hub {
registry: Arc<OperationRegistry>,
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
dispatcher: Dispatcher,
identity_provider: Arc<dyn IdentityProvider>,
}
Construction:
impl Hub {
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
let base: Arc<dyn OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(®istry)));
let aggregated_env = Arc::new(RwLock::new(PeerCompositeEnv::new(base)));
let dispatcher = Dispatcher::new(Arc::clone(®istry), Arc::clone(&identity_provider))
.with_aggregated_env(Arc::clone(&aggregated_env));
Self {
registry,
aggregated_env,
dispatcher,
identity_provider,
}
}
/// The shared aggregated PeerCompositeEnv. The assembly layer wires
/// this into CallAdapter::with_aggregated_env so every call's
/// compose_root_env sees all connected workers.
pub fn aggregated_env(&self) -> &Arc<RwLock<PeerCompositeEnv>> {
&self.aggregated_env
}
}
The Hub exposes builder methods for optional hooks
(with_session_source, with_ownership_provider,
with_timeout) that delegate to the Dispatcher.
Transport
The hub accepts and dials channels connections over any transport
the channels protocol supports (ADR-071). In practice, a hub runs
multiple accept loops simultaneously — all owned by AlknetEndpoint
via builder methods (ADR-083):
| Builder method | Transport | What it carries |
|---|---|---|
with_quinn |
QUIC (quinn) | Channels connections from workers with QUIC reachability |
with_iroh |
QUIC (iroh, relay-assisted) | Channels connections from workers behind NAT |
with_tcp_tls |
TCP + TLS (h2/http/1.1/alknet/channels) |
HTTP registration endpoint, browser access, channels-over-TCP from workers |
| (Future) WebTransport | WebTransport | Browser bidirectional path (deferred per ADR-044; WebSocket is the v1 browser path) |
All accept loops run inside endpoint.run() and feed the same dispatch
path. The hub's HttpAdapter serves h2/http/1.1 over the TCP+TLS
path for registration and browser access; the ChannelsAdapter
(registered on the hub's HandlerRegistry) serves alknet/channels
over the QUIC path (and over TCP+TLS when a worker dials
channels-over-TCP). Both ALPNs are registered on the same registry;
the TLS handshake on each connection negotiates the ALPN and dispatches
to the right adapter.
The TCP+TLS accept loop constructs a Connection per accepted
TlsStream<TcpStream> via Connection::from_bidi (ADR-065) and hands
it to the same dispatch path the quinn endpoint uses. After ADR-083,
TCP+TLS is a first-class owned transport on AlknetEndpoint — the hub
hands a TcpListener + TlsAcceptor to the endpoint via
with_tcp_tls(listener, acceptor), and the endpoint runs the accept
loop inside run() alongside the quinn/iroh loops. No external sibling
loop; the endpoint owns all its accept loops and shutdown() stops them
all.
Dial (outbound workers) — transport-agnostic
The hub dials outbound workers via ChannelClient, not CallClient.
The dial path mirrors the from_connection / connect_quic split
(ADR-080):
impl Hub {
/// Take over a pre-established channels `Connection` as a worker
/// connection. Transport-agnostic — the caller (or a transport
/// helper) produces the `Connection`. This is the primary path;
/// `connect_quic_worker` and future `connect_tcp_tls_worker` are
/// conveniences over it.
pub async fn dial_worker_connection(
&self,
connection: Connection,
config: FromCallConfig,
) -> Result<(PeerId, ChannelClient), HubError>;
/// QUIC convenience: dial a worker over QUIC, then
/// `dial_worker_connection`. Two-way door — additive over
/// `dial_worker_connection`.
pub async fn connect_quic_worker(
&self,
addr: SocketAddr,
credentials: CallCredentials,
config: FromCallConfig,
) -> Result<(PeerId, ChannelClient), HubError>;
}
dial_worker_connection is the transport-agnostic primary. After
taking over the Connection, it runs from_call on channel 0 to
discover the worker's operations, registers the discovered bundles in
the connection's Layer 2 overlay, and attaches the peer to the
aggregated env. connect_quic_worker is the "I just want QUIC"
convenience — it calls ChannelClient::connect_quic, then
dial_worker_connection. A future connect_tcp_tls_worker dials
TCP+TLS and calls dial_worker_connection the same way. The
one-way-door surface is dial_worker_connection; the dial helpers
are two-way-door conveniences.
Accept (inbound workers and browsers) — transport-agnostic
Inbound connections arrive over whatever transport the accept loop
yielded them from. The ChannelsAdapter (registered on
alknet/channels in the HandlerRegistry) receives a Connection and runs the demux loop
(ADR-075); the CallAdapter (running on channel 0) handles
hub-level operations and channel/open translation (ADR-079).
The WorkerConnectedCallback fires inside ChannelsAdapter::handle
between channel-0 establishment and dispatch start:
/// Callback invoked by ChannelsAdapter::handle when a peer connects
/// inbound. Fires after identity resolution and before the dispatch
/// loop starts. Carries both on_connected (runs from_call, registers
/// discovered ops, attaches peer to aggregated env) and on_disconnected
/// (detaches peer on run_loop exit).
pub struct WorkerConnectedCallback {
hub: Arc<Hub>,
config: FromCallConfig,
}
impl WorkerConnectedCallback {
pub fn new(hub: Arc<Hub>, config: FromCallConfig) -> Self {
Self { hub, config }
}
pub(crate) async fn on_connected(
&self,
connection: &CallConnection,
) -> Result<PeerId, HubError> {
let peer_id = connection.identity()
.map(|id| id.id.clone())
.ok_or(HubError::NoPeerIdentity)?;
let bundles = from_call(connection, self.config.clone()).await?;
connection.register_imported_all(bundles);
self.hub.aggregated_env
.write()
.expect("aggregated env lock poisoned")
.attach_peer(peer_id.clone(), connection.overlay_env());
Ok(peer_id)
}
pub(crate) fn on_disconnected(&self, peer_id: &PeerId) {
self.hub.on_worker_disconnected(peer_id);
}
}
The callback is wired into ChannelsAdapter (the channels substrate)
via a builder method. The ChannelsAdapter::handle flow becomes:
- Channel 0 (
alknet/call) is preinstalled (ADR-072). The channel-0Connectionis handed to theCallAdapter. - Identity resolution — two paths depending on transport (see
"Identity over transports"):
- Fingerprint path (QUIC + raw key, or a transport with a
client cert): the
AuthContextcarried intoChannelsAdapter::handlecarries the peer's TLS fingerprint. TheCallAdapterresolves it viaresolve_from_fingerprint. - Bearer-token path (TCP+TLS with no client cert, WebTransport,
WebSocket): the
CallAdapteron channel 0 extractsauth_tokenfrom the first call frame (auth.md) and resolves it viaresolve_from_token. The transport carries no identity of its own — the call protocol's first frame does.
- Fingerprint path (QUIC + raw key, or a transport with a
client cert): the
- The
CallConnectionis constructed with the resolved identity. - Invoke
on_connected— runsfrom_call, registers bundles, attaches peer to aggregated env. - Run the call-protocol dispatch loop on channel 0; the demux loop continues on the other channels.
- On disconnect, invoke
on_disconnected— detaches peer from aggregated env.
The assembly layer constructs the callback and passes it to
ChannelsAdapter:
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(Arc::clone(®istry), /* ... */)
.with_worker_connected_callback(callback);
// Register channels_adapter on alknet/channels in the HandlerRegistry.
// The endpoint dispatches alknet/channels connections to it — whether
// they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
Identity over transports
A peer's identity is resolved differently depending on the transport the connection arrived over. This is not a hub invention — it is the existing identity model (ADR-030, ADR-034, auth.md) applied to the channels substrate.
| Transport | Identity source | Resolution path |
|---|---|---|
| QUIC + RFC 7250 raw key | TLS fingerprint (automatic from handshake) | resolve_from_fingerprint — both sides present raw keys |
| QUIC + X.509 (client cert) | TLS fingerprint (from client cert) | resolve_from_fingerprint — worker presents a client cert |
| TCP+TLS (client cert) | TLS fingerprint (from client cert) | resolve_from_fingerprint — worker presents a client cert |
| TCP+TLS (no client cert) | Bearer token (call-protocol auth_token in first frame on channel 0) |
resolve_from_token |
| WebTransport / WebSocket | Bearer token | resolve_from_token — browsers have no fingerprint (ADR-034 §4) |
The fingerprint path is the QUIC+raw-key optimization — identity is
"free" because the TLS handshake carries it. The X.509-client-cert
rows (QUIC and TCP+TLS) are the same path with a different cert
format — the hub matches the client cert's fingerprint via
resolve_from_fingerprint against the SHA256:<hex> entry in the
peer's PeerEntry (the mixed-fingerprint case from ADR-034 §3). A
worker may present an X.509 client cert over QUIC or TCP+TLS when the
hub's deployment uses X.509 rather than raw keys; the identity model
handles both identically.
The token path is the transport-agnostic fallback — it works over any
transport that can carry a call-protocol first frame, which is all of
them (channel 0 is alknet/call). resolve_from_token matches the
token against PeerEntry.auth_token_hash and returns the same
PeerId the fingerprint path would (ADR-030).
This is why channels-over-TCP is not a special case. The channels
protocol runs alknet/call on channel 0 (ADR-072); the call
protocol's auth_token path resolves identity from the first frame;
the transport carries no identity burden of its own. A worker that
registered its key over HTTP (TCP+TLS, no client cert) and then
connects via channels-over-TCP authenticates with the bearer token it
received at registration. A worker that connects via channels-over-QUIC
authenticates with its TLS fingerprint. Both resolve to the same
PeerEntry and the same PeerId.
Worker registration
The registration flow is what makes TCP+TLS a hard requirement for the hub. A freshly-provisioned worker (container, vast.ai, runpod) does not yet have an established peer relationship with the hub — it has a one-time registration token supplied via the provisioning config. The flow:
- The hub provisions a worker instance (docker, vast.ai, runpod —
platform-specific) and supplies a registration token via
onStartCMD(or equivalent). - The instance downloads the worker binary.
- The instance generates an Ed25519 key pair (its future identity).
- The instance POSTs to the hub's HTTP registration endpoint over TCP+TLS, sending its public key and the registration token.
- The hub validates the token, creates a
PeerEntryfor the worker recording both the fingerprint (from the public key) and anauth_token_hash(from a session token the hub issues), and returns the session credential. ThePeerEntryis the mixed-fingerprint shape from ADR-034 §3 — fingerprint for the QUIC path,auth_token_hashfor the TCP+TLS path. - The instance connects to the hub via channels — over QUIC
(fingerprint identity) or over TCP+TLS (bearer-token identity) —
and both resolve to the same
PeerEntryand the samePeerId. The ongoing session begins.
Step 4 is HTTP over TCP+TLS. Step 6 is channels over QUIC or TCP+TLS.
Both happen; they are not alternatives. The registration endpoint is
an HTTP route on the HttpAdapter (registered on the hub's
HandlerRegistry, served on h2/http/1.1
over TCP+TLS), not a call-protocol operation — the worker has no
CallConnection yet at step 4.
The registration endpoint and the enrollment-token model are a
one-way-door API (the endpoint shape, the token semantics). That
decision is tracked as OQ-58 — it is decision-ready in shape (HTTP
POST, token in, PeerEntry out, session credential returned) but
the exact token model (one-time vs. refresh, single-use vs.
multi-use, rotation) and the endpoint path need a dedicated ADR
before the hub crate stabilizes.
Supervision
The hub provides a supervise_worker method that wraps
dial_worker_connection (or a transport-specific helper) in a
reconnect loop with configurable backoff:
impl Hub {
/// Supervise an outbound worker: dial, discover, attach. On
/// disconnect, detach and retry with backoff. Runs until the Hub
/// is dropped (the returned JoinHandle can be aborted).
///
/// `dial` is a closure that produces a channels `Connection` —
/// the hub does not bake a transport into the supervision loop.
/// The caller provides e.g. `|| async { Ok(Connection::from_quinn(quinn_endpoint.connect(addr, "alknet/channels")?.await?)) }`
/// for QUIC, or `|| async { Ok(Connection::from_bidi(tls_connector.connect(host, TcpStream::connect(addr).await?).await?, b"alknet/channels".to_vec(), Some(addr))) }`
/// for TCP+TLS. The supervision loop is transport-agnostic.
pub fn supervise_worker<F, Fut>(
self: &Arc<Self>,
dial: F,
config: FromCallConfig,
backoff: BackoffConfig,
) -> tokio::task::JoinHandle<()>
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Connection, HubError>> + Send + 'static;
}
The supervision loop takes a dial closure rather than a SocketAddr
CallCredentialspair. This keeps the loop transport-agnostic — the caller decides the transport by what the closure does. The backoff and re-discovery logic is the same regardless of transport.
Disconnect is detected via the OQ-52 interim — the loop polls
connection().accept_bi() until ConnectionClosed — pending a
CallConnection::closed() method (OQ-52 target resolution). On
disconnect, the peer is detached from the aggregated env and the
loop retries with backoff.
Backoff configuration
pub struct BackoffConfig {
pub initial: Duration,
pub max: Duration,
pub multiplier: f64,
}
impl BackoffConfig {
pub fn delay_for(&self, retries: u32) -> Duration {
let delay = self.initial.as_millis() as f64
* self.multiplier.powi(retries as i32);
let delay_ms = delay.min(self.max.as_millis() as f64) as u64;
Duration::from_millis(delay_ms)
}
}
impl Default for BackoffConfig {
fn default() -> Self {
Self {
initial: Duration::from_secs(1),
max: Duration::from_secs(60),
multiplier: 2.0,
}
}
}
Channel relay (ADR-079)
When a browser (or any non-peer client) connects to the hub and opens
a channel to a spoke, the hub translates channel/open on channel 0
(terminate on both legs, re-issue with forwarded_for — ADR-032) and
byte-forwards data channels with channel_id rewrite. channel/control
operations on channel 0 carry channel_id in their JSON payload; the
hub's CallAdapter translates these too, rewriting channel_id to
the other leg's id (ADR-079). The hub does not run protocol-specific
handlers (alknet/tty, alknet/ssh, alknet/tunnel) — it runs
alknet/channels (the relay) and alknet/call (for its own ops +
translation). The full relay contract is in ADR-079; the relay
implementation lives in alknet-hub.
Service discovery
The hub registers the built-in service discovery operations
(services/list, services/schema, services/list-peers)
automatically. The services/list-peers handler returns each
connected worker's operation list via
PeerCompositeEnv::peer_operations (ADR-068).
HubError
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HubError {
#[error("worker connection has no resolved peer identity")]
NoPeerIdentity,
#[error("from_call discovery failed: {0}")]
Discovery(#[from] AdapterError),
#[error("call client error: {0}")]
Client(#[from] ClientError),
#[error("channel error (client or adapter path): {0}")]
Channel(#[from] ChannelError),
#[error("registration failed: {0}")]
Registration(#[from] RegistrationError),
}
RegistrationError lives in alknet-hub (the registration endpoint is
a hub-crate surface). Its variants are defined alongside the OQ-58
resolution — the likely shape is InvalidToken, ExpiredToken,
AlreadyEnrolled, Store(StoreError), but the exact set is not
fixed until the enrollment-token model is decided.
What the hub does NOT do
- Worker authentication policy. The hub resolves the worker's
identity via
IdentityProvider(the existing mechanism). Whether a given identity is allowed to connect as a worker is anAccessControldecision on the assembly layer's curated ops — the hub does not add a separate worker-auth layer. - Worker-specific routing policy.
PeerRef::Anyuses insertion-order first-match (ADR-029 §2). A richerRoutingPolicy(round-robin, least-loaded) is a future extension behind the samePeerRefenum. - Multi-hop federation. The hub is one-hop: workers connect to the hub, the hub composes their ops. Worker A does not transitively see worker B's ops through the hub unless the hub explicitly re-exports them (ADR-029 Assumption 5).
- Worker provisioning. The hub does not spawn workers, configure them, or manage their lifecycle beyond connection supervision. The registration endpoint enrolls a key; it does not provision the instance. Worker provisioning (docker, vast.ai, runpod) is an assembly-layer concern that calls the hub's registration endpoint after provisioning.
Crate dependencies
alknet-hub
├── alknet-channels-call (ChannelClient, ChannelsAdapter, ChannelManager,
│ ChannelBidiStreamSource)
├── alknet-call (CallAdapter, Dispatcher, PeerCompositeEnv,
│ from_call, FromCallConfig, AdapterError, ClientError)
├── alknet-http (HttpAdapter — for the registration endpoint and browser access)
├── alknet-core (IdentityProvider, Connection, OperationRegistry,
│ HandlerRegistry, AuthContext)
├── tokio (spawn, time::sleep)
└── tracing (logging)
alknet-hub depends on alknet-channels-call, alknet-call, and
alknet-http, all of which depend on alknet-core. The hub is a
consumer of the channels substrate and the call protocol, not a new
protocol handler. The alknet-http dependency is for the
registration endpoint and browser HTTP access — the hub wires
HttpAdapter into the same HandlerRegistry as
ChannelsAdapter.
Assembly layer integration
A downstream hub (alkapi) uses alknet-hub like this:
// 1. Build the curated registry (Layer 0)
let registry = OperationRegistryBuilder::new()
.with_local(agent_chat_spec(), agent_chat_handler, ...)
.with_local(services_list_spec(), services_list_handler, ...)
// ... other curated ops
.build();
let registry = Arc::new(registry);
// 2. Create the hub
let hub = Arc::new(Hub::new(
Arc::clone(®istry),
Arc::clone(&identity_provider),
).with_ownership_provider(ownership_provider));
// 3. Register the ChannelsAdapter on alknet/channels. The endpoint
// dispatches alknet/channels connections to this adapter — whether
// they arrived over quinn, iroh, or TCP+TLS.
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(/* ... */)
.with_worker_connected_callback(callback);
registry.register(b"alknet/channels", Arc::new(channels_adapter));
// 4. Register the HttpAdapter on h2/http1.1. The endpoint dispatches
// h2/http1.1 connections (arriving over TCP+TLS) to this adapter
// (registration endpoint, browser access, stealth decoy).
let http_adapter = HttpAdapter::new(/* ... */);
registry.register(b"h2", Arc::new(http_adapter.clone()));
registry.register(b"http/1.1", Arc::new(http_adapter));
// 5. Build the quinn endpoint (raw-key config for native clients)
let quinn_endpoint = raw_key_tls.for_quinn()?.into_endpoint(listen_addr)?;
// 6. Build the TCP+TLS listener (X.509/ACME config for HTTPS)
let tcp_listener = TcpListener::bind(registration_addr).await?;
let tls_acceptor = x509_tls.for_tcp_tls();
// 7. Construct the endpoint with all owned transports, then run.
// TCP+TLS is a first-class owned transport (ADR-083) — the endpoint
// runs its accept loop inside run() alongside quinn/iroh. No external
// sibling loop; shutdown() stops them all.
let endpoint = Arc::new(
AlknetEndpoint::new(registry, dynamic, identity_provider, drain_timeout)
.with_quinn(quinn_endpoint)
.with_tcp_tls(tcp_listener, tls_acceptor),
);
endpoint.clone().run().await;
// 7. Dial outbound workers (hub dials workers). The closure produces a
// channels Connection — the hub's supervise_worker calls
// dial_worker_connection internally. Both transports are shown; a
// real deployment picks one per worker.
// QUIC dial:
hub.supervise_worker(
|| async move {
// Dial QUIC, wrap the quinn connection as a channels Connection.
let quinn_conn = quinn_endpoint
.connect(worker_addr, "alknet/channels")?
.await?;
Ok(Connection::from_quinn(quinn_conn))
},
FromCallConfig::new(),
BackoffConfig::default(),
);
// TCP+TLS dial (e.g., for a worker that can't reach the hub over QUIC):
hub.supervise_worker(
|| async move {
let tcp = TcpStream::connect(worker_addr).await?;
let tls = tls_connector.connect("worker.example.com", tcp).await?;
// from_bidi wraps the TlsStream as a channels Connection
// (ADR-065). The ALPN must be alknet/channels.
Ok(Connection::from_bidi(tls, b"alknet/channels".to_vec(), Some(worker_addr)))
},
FromCallConfig::new(),
BackoffConfig::default(),
);
The hub's aggregated_env() accessor returns the shared
Arc<RwLock<PeerCompositeEnv>> so the assembly layer can wire it
into CallAdapter::with_aggregated_env.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Hub relay — translate, not transparently forward | ADR-079 | Translate channel/open on channel 0 with forwarded_for; byte-forward data channels with channel_id rewrite |
| Aggregated peer-env wiring | ADR-067 | Dispatcher::with_aggregated_env hook; compose_root_env reads shared env |
| PeerCompositeEnv::peer_operations | ADR-068 | list_operation_names trait method; PeerCompositeEnv override |
| from_call is manual | ADR-069 | from_call is a free function; the hub calls it after connect |
| Peer-graph routing model | ADR-029 | Peer-keyed overlays, PeerRef routing, AccessControl-based peer auth |
| PeerEntry and Identity.id | ADR-030 | PeerId = Identity.id = PeerEntry.peer_id (stable) |
| Three peer roles | ADR-034 | Hub = role-3 PeerEntry (mixed fingerprints); browsers not peers; bearer-token identity over TCP/WebTransport |
| ChannelClient — transport-agnostic | ADR-080 | from_connection primary, connect_quic convenience; the dial path the hub uses |
| Channels transport-agnostic | ADR-071 | Substrate modes; Connection::from_stream/from_bidi (ADR-065) — the substrate the hub relays |
| TCP+TLS as first-class owned transport | ADR-083 | with_tcp_tls(listener, acceptor) — TCP+TLS is owned by the endpoint, not a sibling loop; supersedes ADR-010 Am. 1 |
| Channel 0 pre-negotiated | ADR-072 | Channel 0 = alknet/call; the CallAdapter runs here |
| Channel lifecycle operations | ADR-073 | channel/open/close/control/resources/subscribe — what the hub translates |
Open Questions
See open-questions.md for full details.
- OQ-58 (open): Worker registration flow — the enrollment-token
model, the HTTP registration endpoint shape, and the
register_workerAPI. Decision-ready in shape (HTTP POST, token in,PeerEntrycreated, session credential returned); the exact token model (one-time vs. refresh, rotation) and endpoint path need a dedicated ADR before the hub crate stabilizes. - OQ-52 (open):
CallConnection::wait_for_close()— the supervision loop needs a way to await connection close. The committed interim is pollingconnection().accept_bi()untilConnectionClosed. Aclosed()method onCallConnectionis the target resolution. - OQ-53 (open):
BackoffConfigdefaults — the committed policy is 1s initial, 60s max, 2x multiplier. OQ-53 tracks whether operational experience warrants a change before the first release. - OQ-54 (resolved): Inbound worker hook placement — the callback
fires inside
ChannelsAdapter::handle()between identity resolution and dispatch start.handle()blocks until disconnect, so there is no "after handle accepts" point for the assembly layer to hook into. TheWorkerConnectedCallbackis the committed design.
References
- channel-client.md —
ChannelClient(from_connection/connect_quic— the dial path) - channels-adapter.md —
ChannelsAdapter,ChannelManager, the accept path - channel-operations.md —
channel/open/close/control, the hub relay contract - client-and-adapters.md —
CallClient,from_call,OperationAdapter - call-protocol.md —
CallAdapter,Dispatcher,CallConnection - operation-registry.md —
OperationRegistry,OperationRegistryBuilder,OperationEnv - http-server.md —
HttpAdapter(the registration endpoint and browser HTTP access) - auth.md —
resolve_from_token,resolve_from_fingerprint(the identity paths over transports) - ADR-029: Peer-Graph Routing Model
- ADR-034: Three Peer Roles (hub = role-3, bearer-token identity)
- ADR-065:
Connection::from_stream/from_bidi(TCP+TLS path) - ADR-067: Aggregated Peer-Environment Wiring
- ADR-068: PeerCompositeEnv::peer_operations Override
- ADR-069: from_call Is a Manual Free Function
- ADR-079: Hub Relay — Translate, Not Transparently Forward
- ADR-080: ChannelClient (transport-agnostic
from_connection) - ADR-082: alknet-tls extraction (
TlsServerConfig— shared across quinn + TCP+TLS) - ADR-083: Endpoint as multi-transport accept-loop runner (
with_tcp_tls— TCP+TLS owned by the endpoint; the hub composes transports and handlers) - alkapi hub.md — the first hub consumer, the concrete use case that informed this crate
- alkapi ADR-011 — the downstream aggregation decision