Files
alknet/docs/research/alknet-endpoint-refactor/findings.md
T
glm-5.2 9823c6e4ab docs(research): TCP+TLS first-class dispatch, not sibling afterthought
The endpoint refactor findings doc relegated TCP+TLS to a 'sibling
accept loop' that duplicated the endpoint's dispatch logic at the
assembly layer. That was ADR-010 Amendment 1's workaround for the
endpoint being welded to quinn — not a deliberate design.

The endpoint now exposes a public dispatch() method so every transport
(quinn, iroh, TCP+TLS, future SSH, future WebTransport) calls the same
dispatch path: handler lookup, build_auth_context, spawn. The
transport-specific parts (ALPN extraction, fingerprint extraction,
Connection construction, no-handler close) happen before dispatch is
called. No duplicated logic at the assembly layer.

Also: the acme-tls/1 guard is quinn-specific (ACME challenges arrive
over QUIC); a TCP+TLS listener serving HTTPS does not advertise
acme-tls/1. build_auth_context becomes a private helper called by
dispatch, not a standalone function.
2026-07-13 13:54:57 +00:00

27 KiB

alknet-endpoint-refactor — Endpoint as Pure ALPN Dispatcher

Status: Findings complete. The endpoint refactor and the TLS extraction are interlocked; this document captures the full picture so neither gets done in isolation and drops half the context. Date: 2026-07-13 Scope: AlknetEndpoint becomes a pure accept-loop runner (no transport construction); alknet-tls extraction (TlsServerConfig) provides the shared cert config the assembly layer needs to build transports. Both changes are enabled by the pre-implementation window — nobody is using this code yet, and the issues surfaced from trying to write the first real consumers (hub, workers, HTTP).


TL;DR

  1. AlknetEndpoint conflates two concerns: transport construction (building quinn::Endpoint, iroh::Endpoint, wiring TLS) and dispatch (take a connection, extract ALPN, look up handler, build AuthContext, spawn). The TLS extraction forces a separation because the TLS config has to be built once and shared across transports — the endpoint can't build it internally anymore.

  2. The endpoint becomes a pure accept-loop runner with a public dispatch method. It owns the transport endpoints it runs accept loops for (quinn, iroh), and it exposes dispatch() for transport accept loops it doesn't own (TCP+TLS, future SSH, future WebTransport). Every transport calls the same dispatch path — no duplicated build_auth_context or handler-lookup logic at the assembly layer. The StaticConfig parameter, tls_identity reading, and transport construction all move to the assembly layer.

  3. TCP+TLS is a first-class transport, not a sibling afterthought. ADR-010 Amendment 1 made TCP+TLS a "sibling" because the endpoint's dispatch logic was crate-private and couldn't be shared. With a public dispatch() method, the TCP+TLS accept loop calls into the endpoint — same path as quinn and iroh. The hub needs TCP+TLS as a day-1 requirement (HTTPS for browsers, raw-key fallback for native clients when UDP is blocked); it is the most common transport after raw-key QUIC, not an edge case.

  4. A hub holds one or two TlsServerConfigs, not one. A hub serving both browsers and native workers holds a raw-key config (QUIC + TCP+TLS fallback for native clients) and an X.509/ACME config (TCP+TLS for HTTPS, QUIC for WebTransport when it revives). The cert-reuse value is within each identity: one config per identity, shared across that identity's transports. Iroh and SSH share the key with the raw-key TLS path, not the TlsServerConfig.

  5. The TCP fallback for native clients doesn't need X.509. The raw-key TlsServerConfig serves both QUIC and TCP+TLS. X.509 is only for the browser/HTTPS path. Raw-key and X.509 clients can connect to either server type — the server cert type and client cert type are independent in TLS. AcceptAnyCertVerifier already accepts both.

  6. This is a one-way door (endpoint struct shape) done during the pre-implementation window. The cost of doing it now is the same refactor that has to happen anyway. The cost of not doing it is a hybrid model (quinn TLS injected, iroh key read internally, TCP+TLS outside with duplicated dispatch) that will confuse every future reader and block the hub crate (the first multi-transport consumer).


1. The transport landscape

1.1 The full transport matrix

Transport TLS identity Serves Who connects Status
QUIC (raw key) RFC 7250 Ed25519 alknet/channels, alknet/call Native clients (workers, spokes) Day 1
TCP+TLS (raw key fallback) RFC 7250 Ed25519 Same as above Native clients (UDP blocked) Day 1
TCP+TLS (X.509 / ACME) X.509 h2/http/1.1 (HTTPS) Browsers, HTTP clients Day 1
QUIC (X.509 / ACME) X.509 WebTransport Browsers Deferred (ADR-044; revives with channels)
iroh Ed25519 (own TLS) alknet/channels, alknet/call P2P native clients Day 1
TCP (SSH) Ed25519 key (SSH) alknet/ssh channels Legacy SSH clients Future

1.2 Two cert identities, shared within each

A hub serving both browsers and native workers holds two TlsServerConfigs:

  • Raw key config — built from TlsIdentity::RawKey(Ed25519SecretKey). Shared across:

    • QUIC listener (for_quinn()) — native clients with UDP
    • TCP+TLS listener (for_tcp_tls()) — native clients when UDP blocked
    • The same Ed25519SecretKey also feeds iroh and (future) SSH
  • X.509 config — built from TlsIdentity::X509 or TlsIdentity::Acme. Shared across:

    • TCP+TLS listener (for_tcp_tls()) — HTTPS for browsers
    • QUIC listener (for_quinn()) — WebTransport when it revives

The cert-reuse value is within each identity, not across them. A hub doesn't share one cert for everything — it shares one cert per identity across that identity's transports. This is why AlknetEndpoint::new taking a single Arc<TlsServerConfig> (as ADR-082 currently specifies) is wrong for the hub case: the hub has two configs. The endpoint shouldn't take any TLS config — the assembly layer builds the transports and hands the endpoint pre-built quinn/iroh endpoints.

1.3 Iroh and SSH share the key, not the TLS config

Iroh has its own TLS built into the Endpoint (RFC 7250 raw keys). It does not consume a rustls::ServerConfig. SSH uses the Ed25519 key for its own handshake. Both share the Ed25519SecretKey with the raw-key TlsServerConfig, but neither receives a TlsServerConfig.

The assembly layer reads Ed25519SecretKey from StaticConfig and hands it to:

  • TlsServerConfig::new(TlsIdentity::RawKey(key), ...) — for quinn/TCP+TLS
  • iroh::SecretKey::from_bytes(key.as_bytes()) — for iroh
  • (future) the SSH handler's key configuration

1.4 Raw-key / X.509 mixing in the TLS handshake

A raw-key client can connect to an X.509 server and vice versa. In TLS, the server cert type and client cert type are independent. A server presenting an X.509 cert can accept a client presenting a raw key (RFC 7250), as long as both sides have RFC 7250 support in their rustls config. AcceptAnyCertVerifier already accepts both, and its supported_verify_schemes() includes ED25519.

The mixing cases:

  • Raw-key client → X.509 server: client verifies server via CA (ADR-034 public X.509 case) or fingerprint pin (known hub); server extracts client's ed25519:<hex> fingerprint.
  • X.509 client → raw-key server: client verifies server via fingerprint pin (raw-key remotes are always known peers); server extracts client's SHA256:<hex> fingerprint.

This means the TCP fallback for native clients doesn't need X.509 — the raw-key config serves both QUIC and TCP+TLS. X.509 is only for the browser/HTTPS path.

1.5 What's required vs. what's built now

All transports in the matrix above are requirements — not "nice to have." But we do not have to build all of them right now. The abstractions (the endpoint refactor, the TlsServerConfig extraction, Connection::from_stream/from_bidi) are what make the full matrix tractable without blocking any of the future transports. The point is to get the abstractions right so that each transport can be added without changing the core.

By volume, the most common use case is raw-key QUIC (native P2P, workers). But we have to support the HTTP crate and X.509 from day one. The TCP fallback for native clients (raw key) is important too since UDP is blocked on some networks. A worker has to register with the hub via HTTP POST in many cases, then connect via QUIC if available and fall back to TCP if not.


2. The endpoint refactor

2.1 Current state: the endpoint conflates construction and orchestration

AlknetEndpoint::new currently does three things:

  1. Builds the quinn endpoint — reads static_config.tls_identity, constructs TLS internally (TlsSetup::new, build_rustls_server_config), wraps it into quinn::ServerConfig, binds quinn::Endpoint::server().
  2. Builds the iroh endpoint — reads static_config.tls_identity again (for the Ed25519 key), calls build_iroh_endpoint().
  3. Runs accept loops for whichever of those it built, dispatching by ALPN through the HandlerRegistry.

TCP+TLS is not in this picture at all. ADR-010 Amendment 1 made it a "sibling accept loop" that runs outside the endpoint — the assembly layer builds it and dispatches through the same HandlerRegistry. But the dispatch logic (build_auth_context, fingerprint extraction, the acme-tls/1 guard, handler lookup + spawn) is crate-private (dispatch_quinn / dispatch_iroh in endpoint.rs), so the assembly layer can't call it. A TCP+TLS accept loop has to duplicate it or call HandlerRegistry::get and build_auth_context directly (the latter is also crate-private). The "sibling" framing was a workaround for the endpoint being welded to quinn — not a deliberate design.

The result is a three-tier asymmetry: quinn is built and dispatched internally, iroh is built internally and dispatched internally, TCP+TLS is built outside and dispatched outside (with duplicated logic). The TLS extraction surfaces this because it pulls quinn's TLS construction out, leaving iroh's key reading and TCP+TLS's second-class status as visible inconsistencies.

2.2 The dispatch logic is the endpoint's real value

Stripped of transport construction, what the endpoint provides is dispatch: take a connection, extract its ALPN, look up the handler, build the AuthContext, spawn the handler. This is the same for every transport. The transport-specific parts are:

  • ALPN extraction — quinn: handshake_data().protocol; iroh: connecting.alpn().await; TCP+TLS: tls_stream.alpn() (from the tokio-rustls handshake result).
  • Fingerprint extraction — quinn: peer_identity() → CertificateDer; iroh: remote_id() → ed25519:<hex>; TCP+TLS: tls_stream.peer_certificates() → CertificateDer.
  • Connection construction — quinn: from_quinn_with_alpn; iroh: from_iroh; TCP+TLS: from_bidi(tls_stream, alpn, remote_addr).
  • No-handler close — quinn: connection.close(); iroh: connection.close(); TCP+TLS: drop the stream.

Everything between those transport-specific bookends — the ACME ALPN guard, the handler lookup, the build_auth_context call, the tokio::spawn — is identical. If the endpoint exposes a public dispatch method that takes the already-extracted ALPN, fingerprint, and Connection, every transport's accept loop calls the same dispatch path. No duplication.

2.3 The new endpoint shape

The endpoint is a pure accept-loop runner + a public dispatch method. It owns the transport endpoints it runs accept loops for (quinn, iroh), and it exposes dispatch for transport accept loops that it doesn't own (TCP+TLS, future SSH, future WebTransport).

pub struct AlknetEndpoint {
    quinn: Option<quinn::Endpoint>,
    iroh: Option<iroh::Endpoint>,
    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;

    /// Dispatch a pre-established connection by ALPN. Called by the
    /// endpoint's own accept loops (quinn, iroh) after transport-
    /// specific extraction, and by external accept loops (TCP+TLS,
    /// future SSH, future WebTransport) after their own extraction.
    /// The caller supplies the ALPN, the fingerprint (if available),
    /// the remote address (if available), and the `Connection`.
    pub fn dispatch(
        &self,
        connection: Connection,
        alpn: Vec<u8>,
        fingerprint: Option<String>,
        remote_addr: Option<SocketAddr>,
    );

    /// Run the endpoint's accept loops. Returns when shutdown is
    /// signaled. The caller drives shutdown via `shutdown_sender()`.
    pub async fn run(self: Arc<Self>);
}

The StaticConfig parameter disappears from new(). The assembly layer reads StaticConfig, builds the transports, and hands the quinn/iroh endpoints to AlknetEndpoint via builder methods. build_iroh_endpoint and build_quinn_server_config_from_rustls move out of core — the assembly layer (or alknet-tls as a convenience) builds the transport endpoints.

TCP+TLS accept loops call endpoint.dispatch(connection, alpn, fingerprint, remote_addr) — the same dispatch path quinn and iroh use. No duplicated build_auth_context or handler-lookup logic at the assembly layer. The hub's TCP+TLS accept loop is still a sibling (it owns its TcpListener and TlsAcceptor), but its dispatch is first-class — it calls into the endpoint, not around it.

This resolves the ADR-010 Amendment 1 workaround. Amendment 1 made TCP+TLS a sibling because the endpoint couldn't share its dispatch logic. Now it can — dispatch is public. The sibling framing (the accept loop lives outside the endpoint struct) survives; the second-class dispatch (duplicated logic) doesn't.

2.4 What the assembly layer does

For a hub serving native clients (raw key) and browsers (X.509):

  1. Read StaticConfig.
  2. Build the raw-key TlsServerConfig from TlsIdentity::RawKey(key).
  3. Build the X.509 TlsServerConfig from TlsIdentity::Acme { ... } (or X509).
  4. Build the quinn endpoint for native clients: raw_key_tls.for_quinn() → quinn::Endpoint::server().
  5. Build the iroh endpoint: iroh::Endpoint::builder().secret_key(...).
  6. Construct AlknetEndpoint::new(registry, ...) with .with_quinn() and .with_iroh().
  7. Build the TCP+TLS accept loop for native fallback: raw_key_tls.for_tcp_tls() → TcpListener + TlsAcceptor → endpoint.dispatch(from_bidi(tls_stream, alpn, addr), alpn, fp, addr).
  8. Build the TCP+TLS accept loop for HTTPS: x509_tls.for_tcp_tls() → TcpListener + TlsAcceptor → endpoint.dispatch(...).
  9. (Future) Build the quinn endpoint for WebTransport: x509_tls.for_quinn() → quinn::Endpoint::server().
  10. Spawn the TCP+TLS accept loops (they call endpoint.dispatch).
  11. Spawn endpoint.run() — runs the quinn/iroh accept loops.

For a bare-bones P2P node (no public IP, no ACME, no browsers): just step 1, step 5, step 6 (with only .with_iroh()), step 11. No TlsServerConfig needed at all.

2.5 What this resolves

  • C5 (iroh path ownership): the endpoint doesn't build iroh; the assembly layer does. No ambiguity.
  • TCP+TLS second-class dispatch: dispatch is public; TCP+TLS accept loops call the same path as quinn/iroh. No duplicated build_auth_context or handler-lookup logic. ADR-010 Amendment 1's workaround is retired.
  • The single-Arc<TlsServerConfig> problem: ADR-082 currently specifies AlknetEndpoint::new taking Arc<TlsServerConfig>, but a hub has two configs (raw key + X.509). With the endpoint taking no TLS config, this disappears.
  • The static_config.tls_identity reading: the endpoint no longer reads it. The assembly layer reads it and builds the transports.
  • The acme_state_handle on the endpoint: the ACME task handle lives on TlsServerConfig (the assembly layer owns it), not on the endpoint.

3. Interaction with alknet-tls extraction (ADR-082)

3.1 The TLS extraction is the trigger, not the whole refactor

The TLS extraction (alknet-tls crate with TlsServerConfig) is what surfaced the endpoint's conflation. But the endpoint refactor is a separate, larger change that ADR-082 should reference but not contain.

3.2 ADR-082 changes

With the endpoint refactor, ADR-082's AlknetEndpoint::new signature changes. Instead of:

pub async fn new(
    static_config: &StaticConfig,
    tls_config: Arc<TlsServerConfig>,  // ← was the new param
    handlers: HandlerRegistry,
    dynamic: Arc<ArcSwap<DynamicConfig>>,
    identity_provider: Arc<dyn IdentityProvider>,
) -> Result<Self, EndpointError>;

The endpoint takes no TLS config at all (see §2.3). ADR-082 should reference the endpoint refactor ADR (to be written) for the new signature, and focus on what alknet-tls provides: TlsServerConfig and its accessors.

3.3 What moves out of alknet-core/endpoint.rs

Code Destination
build_rustls_server_config() alknet-tls
TlsSetup / ACME state machine alknet-tls (TlsServerConfig::new ACME path)
RawKeyCertResolver alknet-tls
Ed25519SigningKey alknet-tls
AcceptAnyCertVerifier alknet-tls
SelfSignedCert / generate_self_signed_cert() alknet-tls
load_cert_chain() / load_private_key() alknet-tls
build_quinn_server_config_from_rustls() alknet-tls (for_quinn())
build_iroh_endpoint() Assembly layer (or a helper; construction decision is assembly-layer)

What stays in alknet-core/endpoint.rs:

  • AlknetEndpoint struct (accept-loop runner + public dispatch)
  • HandlerRegistry
  • dispatch (public — takes ALPN, fingerprint, Connection; calls build_auth_context, looks up handler, spawns)
  • dispatch_quinn / dispatch_iroh (private — do transport-specific extraction, then call dispatch)
  • run_quinn_accept_loop / run_iroh_accept_loop
  • extract_quinn_alpn / extract_quinn_client_fingerprint / extract_iroh_client_fingerprint
  • The acme-tls/1 early-return guard (inside dispatch_quinn; the guard is quinn-specific — ACME challenges arrive over QUIC. TCP+TLS has no ACME ALPN guard because acme-tls/1 is only negotiated over QUIC for the TLS-ALPN-01 challenge. A TCP+TLS listener serving HTTPS does not advertise acme-tls/1.)

build_auth_context becomes a private helper called by dispatch, not a standalone function — it's the shared core of the dispatch path and should not be called directly by the assembly layer. The assembly layer calls dispatch, which calls build_auth_context internally.

What stays in alknet-core/config.rs:

  • TlsIdentity enum (config type)
  • Ed25519SecretKey (config type)
  • AcmeDirectory (config type)
  • StaticConfig (config type)

What stays in alknet-core/fingerprint.rs (pending OQ-59):

  • fingerprint_from_cert_der, extract_ed25519_raw_key_from_spki

3.4 Ordering question: tls first or core refactor first?

This is deferred to the task-decomposition phase. Both are interlocked: the TLS extraction removes code from endpoint.rs, and the endpoint refactor changes what stays. The natural ordering might be:

  1. Endpoint refactor first — change AlknetEndpoint to a pure accept-loop runner, move build_iroh_endpoint and build_quinn_server_config_from_rustls out of core. At this point, the TLS setup code still exists in core but is called by the assembly layer instead of the endpoint.
  2. TLS extraction second — move the TLS setup code into alknet-tls, build TlsServerConfig, replace the assembly layer's direct calls with TlsServerConfig::new / for_quinn() / for_tcp_tls().

But this is a task-decomposition decision, not an architecture decision. The architecture is: both changes happen, the endpoint becomes a pure accept-loop runner, and alknet-tls provides the shareable cert config.


4. The remaining review items (from the tls spec review)

These are unaffected by the endpoint refactor. They're about the TLS crate's API surface and the OQ/ADR status.

4.1 C2: Define TlsError type

The TlsError type is used in every public signature but never defined. Needs a variant list. The current code uses EndpointError::TlsConfig(io::Error). The new type should cover:

  • IO errors (file read failures for cert/key loading)
  • rustls config errors (protocol version, cipher suite setup)
  • Quinn config errors (QuicServerConfig::try_from failure)
  • ACME feature-not-enabled (when TlsIdentity::Acme is configured without the acme feature)

4.2 C3 + C6: ACME task lifecycle and cert renewal

The ACME state machine task is spawned by TlsServerConfig::new (ACME path) and its JoinHandle is held on TlsServerConfig. The spec currently says "dropping this handle does NOT stop the ACME state machine" but never says what does stop it.

Cert renewal behavior (confirmed from the reverse-proxy reference):

Identity Renewal behavior Drain needed?
ACME Live — ResolvesServerCertAcme swaps cert in place; all listeners sharing the Arc<rustls::ServerConfig> pick up the renewed cert on the next TLS handshake No
X.509 (manual) Loaded once at startup via with_single_cert; no live renewal Yes (restart to load new cert)
RawKey No expiry — Ed25519 keys don't expire N/A
SelfSigned Dev only — restart to regenerate N/A

The ACME renewal mechanism: TlsServerConfig::new (ACME path) calls state.resolver() to get an Arc<ResolvesServerCertAcme>, wires it into the rustls::ServerConfig via with_cert_resolver(resolver), and spawns the AcmeState event loop. When the event loop processes a renewed cert (EventOk::DeployedNewCert), it updates the resolver's internal cert in place. Since for_quinn() and for_tcp_tls() clone the rustls::ServerConfig (which shares the Arc<ResolvesServerCertAcme>), both the quinn endpoint and the TCP+TLS acceptor see the renewed cert on the next handshake. No drain, no restart, no listener replacement.

The shutdown path: the ACME task's lifetime is the renewal lifetime. Shutting it down stops renewal (but existing listeners keep serving the last-known cert until it expires). Restarting the process re-spawns the state machine, which deploys the cached cert (DeployedCachedCert) and resumes renewal. The TlsServerConfig needs a shutdown() method (or a Drop impl that aborts the handle) so the assembly layer can cleanly stop the ACME task on process shutdown.

4.3 C4: OQ-59 (fingerprint.rs location)

OQ-59 is open but the spec treats fingerprint.rs staying in core as decided. The "Likely resolution" in OQ-59 is Option A (stay in core), with solid rationale: alknet-call's client-side FingerprintPinVerifier uses fingerprint functions and shouldn't have to depend on alknet-tls (which would pull rustls + tokio + optionally quinn/tokio-rustls/rustls-acme into every client-only deployment). Recommendation: resolve OQ-59 to Option A (stay in core) — it's a two-way door (moving a module later is a refactor, not a wire-format change), and the rationale is clear.

4.4 W3: Feature-gate edge cases

  • TlsIdentity::Acme configured without the acme feature — should return a runtime error (matching the current code's behavior).
  • default = [] with no features — new() and rustls_config() work but for_quinn/for_tcp_tls don't exist. This is a supported config for iroh-only deployments (no TlsServerConfig needed at all — the assembly layer just uses the Ed25519SecretKey directly).
  • acme without quinn or tcp — ACME task spawns, rustls_config() available, but no transport accessor. Valid for a pure cert-provider use case (not currently expected, but not blocked).

4.5 W4: aws_lc_rs crypto provider ADR

The aws_lc_rs::default_provider() choice is a load-bearing decision (FIPS status, platform support) with no ADR. Either add a clause to ADR-082 (or ADR-027) recording the choice, or write a small standalone ADR. The invariant "do not switch to ring or the process-default provider without an ADR" is already in the spec; the decision record should exist.

4.6 W5: ADR-082 status

ADR-082 is "Proposed." If implementation is about to begin (and the endpoint refactor is part of the same work), the ADR should move to "Accepted" — with OQ-59 noted as a non-blocking open question that can be resolved alongside implementation. "Proposed" for an actively-implemented extraction is a stale status.


5. The reverse-proxy as reference

The reverse-proxy (/workspace/@alkdev/reverse-proxy/) is a useful reference for the TLS setup, with the caveat that it is X.509-only (manual + ACME). The relevant code:

  • src/tls/acme.rs — AcmeTlsConfig::setup() constructs the AcmeState and gets the resolver(). spawn_acme_state() runs the event loop with a tokio::select! against a shutdown signal (so the ACME task can be cleanly stopped, not just aborted).
  • src/tls/acceptor.rs — TlsMode enum (Manual vs Acme) holds the Arc<ServerConfig> and the Arc<ResolvesServerCertAcme>. setup_tls() builds the TlsAcceptor from the server config.
  • src/tls/config.rs — crypto_provider() constructs the aws_lc_rs provider with restricted cipher suites. Manual cert loading via rustls-pemfile.

What the reverse-proxy does that alknet-tls should learn from:

  • The ACME task is spawned with a shutdown signal (tokio::select! against shutdown_rx.changed()), not just a bare JoinHandle. This is cleaner than abort — the state machine gets to finish its current operation and exit gracefully. (Relevant to C3.)
  • The TlsMode enum separates the static config from the runtime objects — Manual(Arc<ServerConfig>) vs Acme { default_config, resolver }. This mirrors the TlsServerConfig / TlsIdentity split.
  • The ResolvesServerCertAcme is the live renewal mechanism — the TlsAcceptor built from default_config (which holds an Arc to the resolver) picks up renewed certs on the next handshake. No drain, no restart. (Relevant to C6.)

What the reverse-proxy doesn't cover (because it's X.509-only):

  • RFC 7250 raw keys (the RawKeyCertResolver / Ed25519SigningKey path)
  • Multiple transport sharing (it's TCP-only; no quinn, no iroh)
  • The endpoint-as-accept-loop-runner pattern (it has its own serve_https_listener that's simpler than AlknetEndpoint)

6. Open questions for the next session

  1. C2 (TlsError variants): what error types should TlsError wrap? The current EndpointError::TlsConfig(io::Error) is too narrow — it doesn't cover rustls::Error or ACME-specific errors. Need a variant list.

  2. C3 (ACME shutdown): should TlsServerConfig have a shutdown() method (clean, explicit) or a Drop impl (automatic, but surprising)? The reverse-proxy uses a shutdown signal (tokio::select! against shutdown_rx.changed()) rather than abort(). Should alknet-tls do the same?

  3. C4 (OQ-59): resolve to Option A (stay in core) or keep open? Recommendation: resolve to Option A.

  4. Endpoint refactor ADR: draft ADR-083 (amending ADR-010) for the endpoint-as-accept-loop-runner shape, or fold it into ADR-082? Recommendation: standalone ADR — it changes ADR-010's endpoint design, which is a broader change than the TLS extraction.

  5. Ordering: does the endpoint refactor land before the TLS extraction, or after? Deferred to task decomposition.