docs(arch): workspace scope correction (ADR-085) + tls spec review fixes

ADR-085 records the actual workspace scope: the mono-repo is the core
networking toolkit (substrate: core, tls, call, channels; deployment
shapes: hub, worker; foundational handlers: tty, http, ssh, tunnel,
socks5, fs, sftp; vault). Consumer repos (docker, agent) are separate
repos depending on the published core crates. The overview's crate
graph had been describing the wrong scope since ADR-003 — a flat
~12-crate workspace including DNS/messaging/NAPI while omitting
channels, hub, worker, and tls. This stale scope was a causal factor
in the 'assembly layer' hedging pattern: when the overview implies
everything lives in one repo but the architecture needs a hub/worker
composition layer not in the graph, the gap gets filled with
'assembly layer' as an escape hatch. The overview is rewritten to
match the real boundary.

TLS spec review fixes (from architecture review):
- C3: hub/worker/hub-worker terminology pointers (tls README + endpoint.md)
- W1: server-only statement + OQ-64 (client-side TLS helper, deferred)
- W2: ACME task lifecycle semantics (returns immediately, no first-cert await)
- W3: remove stale EndpointError::TlsConfig variant
- W4: update stale ALPN section for two-config hub
- W5: add alknet-tls to hub dep graph (assembly-layer dep)
- W6: trim inline rationale -> point to ADR-084
- W7: ADR-084 status dependency note

New open questions:
- OQ-62: ALPN list sharing for two-config hub (open, high)
- OQ-63: TlsError shape (open, high)
- OQ-64: client-side TLS helper (deferred, blocked on OQ-55)
This commit is contained in:
glm-5.2 committed 2026-07-14 12:46:16 +00:00
1 parent 34729c7846
commit 7610ec1f31
11 files changed
+761 -82

No files matched your search

+19 -3
View File
@@ -1,12 +1,27 @@
---
status: draft
last_updated: 2026-07-14
last_updated: 2026-07-15
---
# Alknet Architecture
## Current State
**Workspace scope corrected (ADR-085, 2026-07-15).** The overview's
crate graph had been describing the wrong scope since ADR-003 — a flat
~12-crate workspace including DNS, messaging, and NAPI, while omitting
channels, hub, worker, and tls. [ADR-085](decisions/085-workspace-scope-core-vs-consumer-repos.md)
records the actual scope: the mono-repo is the **core networking
toolkit** (substrate: core, tls, call, channels; deployment shapes: hub,
worker; foundational handlers: tty, http, ssh, tunnel, socks5, fs, sftp;
vault). Crates that build on top of a hub or worker (docker, agent) are
**consumer repos** — separate repos depending on the published core
crates. This corrects the root cause of the "assembly layer" hedging
pattern: the overview now reflects the real boundary, so the
"assembly layer" has a bounded home (hub/worker), not an escape hatch.
The [overview.md](overview.md) crate graph and ALPN registry are
rewritten to match.
**alknet-channels specs drafted.** The alknet-channels crate (multiplexing
proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N
channels over transport stream(s), channel 0 pre-negotiated as
@@ -138,7 +153,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Workspace-level overview, crate graph, 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/) |
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index |
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
@@ -269,10 +284,11 @@ adapter location map is now consistent: all HTTP-backed adapters
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Proposed (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 | Proposed (revised — TCP+TLS is an owned transport, not external) |
| [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 |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (55 OQs across 17 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (64 OQs across 18 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention).
## Document Lifecycle
+30 -3
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-14
last_updated: 2026-07-15
---
# Endpoint
@@ -46,6 +46,14 @@ A node can be reachable through different paths depending on its network context
These are not interchangeable transports — they are **complementary connectivity modes**. A node behind NAT that also has a public IP can use both simultaneously. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string.
> **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
@@ -94,7 +102,19 @@ Registration is static at startup (see [OQ-04](../../open-questions.md)). The CL
### ALPN strings in TLS ServerConfig and iroh endpoint
The quinn endpoint's `rustls::ServerConfig` ALPN list is set from `registry.alpn_strings()` at construction time. The iroh endpoint's ALPN list is similarly derived. Both connection sources advertise the same set of ALPNs.
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)). For a single-config
deployment (one identity, one set of ALPNs), all transports advertise
the same set. For a two-config hub (raw key + X.509/ACME — see OQ-62),
the assembly layer may pass different lists to each config; that split
is a hub-assembly-layer concern, not an endpoint concern.
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.
## Accept Loops
@@ -301,11 +321,18 @@ Fatal errors that prevent the endpoint from starting or continuing.
```rust
pub enum EndpointError {
BindFailed(io::Error),
TlsConfig(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.
+16 -1
View File
@@ -564,7 +564,7 @@ fixed until the enrollment-token model is decided.
## Crate dependencies
```
alknet-hub
alknet-hub (Hub struct deps)
├── alknet-channels-call (ChannelClient, ChannelsAdapter, ChannelManager,
│ ChannelBidiStreamSource)
├── alknet-call (CallAdapter, Dispatcher, PeerCompositeEnv,
@@ -574,6 +574,12 @@ alknet-hub
│ HandlerRegistry, AuthContext)
├── tokio (spawn, time::sleep)
└── tracing (logging)
(assembly-layer deps — used by the hub's composition code, not by
the Hub struct itself)
├── alknet-tls (TlsServerConfig — builds the raw-key + X.509/ACME
│ configs handed to the endpoint's transports)
└── alknet-core [tcp feature] (with_tcp_tls — the TCP+TLS accept loop)
```
`alknet-hub` depends on `alknet-channels-call`, `alknet-call`, and
@@ -584,6 +590,15 @@ registration endpoint and browser HTTP access — the hub wires
`HttpAdapter` into the same `HandlerRegistry` as
`ChannelsAdapter`.
`alknet-tls` is an **assembly-layer** dependency, not a `Hub` struct
dependency: the `Hub` struct holds no `TlsServerConfig`, but the hub's
composition code (the assembly-layer wiring shown in "Assembly layer
integration" below) builds the `TlsServerConfig`s and hands them to the
endpoint's transports via `for_quinn()` / `for_tcp_tls()`. This
distinction matters: a consumer that uses `Hub` with externally-built
transports does not pull `alknet-tls` through the `Hub` type, only
through the assembly wiring.
## Assembly layer integration
A downstream hub (alkapi) uses `alknet-hub` like this:
+77 -4
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-14
last_updated: 2026-07-15
---
# alknet-tls
@@ -150,6 +150,28 @@ The ALPN list is the set of ALPNs the deployment wants to advertise
`acme-tls/1` ALPN is appended automatically (for the TLS-ALPN-01
challenge, ADR-027 §7).
### `async fn new` — lifecycle semantics
`new` is `async` because the ACME path spawns a state-machine task
(`tokio::spawn`) before returning — the spawn itself is await-free, but
the function is async so the non-ACME paths share one signature.
**ACME path**: `new` spawns the `AcmeState` task, wires its `resolver()`
into the `rustls::ServerConfig`, and returns **immediately** — it does
**not** await the first certificate. The returned `TlsServerConfig` is
usable for `for_quinn()` / `for_tcp_tls()` right away; the resolver may
return no cert until the first ACME order completes, causing TLS
handshakes to fail transiently during that window. This matches the
current code's behavior (`TlsSetup::new_acme` spawns and returns).
**Non-ACME paths** (X509 / RawKey / SelfSigned): cert loading is
synchronous file I/O (`std::fs::read`) + in-memory construction; there
is no await point in the implementation. The `async` signature is for
API uniformity with the ACME path, not because the work is async. An
implementer who finds this objectionable may split a non-async
constructor — that is a two-way-door implementation detail, not an
architecture decision.
### Behavior-preservation invariants
The extraction must preserve these load-bearing TLS behaviors. They
@@ -163,9 +185,10 @@ changes TLS behavior:
RawKey, SelfSigned, ACME). Enables 0-RTT / early data. Omitting it
disables 0-RTT, silently breaking clients that use it.
- **`rustls::crypto::aws_lc_rs::default_provider()`** as the crypto
provider on all paths. Matches iroh's `tls-aws-lc-rs` feature. Do not
switch to `ring` or the process-default provider without an ADR —
different FIPS status, different platform support.
provider on all paths. Do not switch to `ring` or the process-default
provider without a new ADR — see
[ADR-084](../../decisions/084-aws-lc-rs-crypto-provider.md) for the
rationale (FIPS, platform matrix, iroh consistency).
- **`AcceptAnyCertVerifier`'s `supported_verify_schemes()`** returns
ED25519 + ECDSA P-256/P-384 + RSA PSS/PKCS1 (SHA256/384/512). This
list determines which client cert signature algorithms the server
@@ -264,6 +287,16 @@ endpoint struct and accept loops remain in core), `ed25519-dalek`
production and `rustls::sign` in the test helper `build_ed25519_spki_der`
— see OQ-59).
> **Terminology — hub, worker, hub-worker.** A *hub* is a node that
> accepts inbound connections from workers and browsers (the central
> node in a hub-and-spoke topology — see
> [`crates/hub/README.md`](../hub/README.md)). A *worker* is a node
> that dials out to a hub. A *hub-worker* is a node that does both
> (accepts inbound and dials out). A *pure worker* has no inbound
> endpoints. These terms come from the hub topology (ADR-029, ADR-034);
> "assembly layer" (ADR-014) is the deployment binary that wires crates
> — in practice, today, usually a hub or hub-worker.
### What `AlknetEndpoint` does after the refactor
`AlknetEndpoint::new()` currently builds `TlsSetup` internally. After
@@ -334,6 +367,34 @@ behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via
cert provider, not the accept loop. This keeps `alknet-tls` focused on
TLS setup and cert sharing, not transport accept logic.
### Server-only (for now) — client-side config is a separate question
`alknet-tls` as specified here is **server-side only**:
`TlsServerConfig` and its accessors (`for_quinn`, `for_tcp_tls`,
`rustls_config`) produce `rustls::ServerConfig`-shaped outputs for
inbound listeners. There is no `TlsClientConfig`, no `for_client()`,
no client-side cert/verifier helper in this crate.
The client side — the `rustls::ClientConfig` construction used by
`CallClient` / `ChannelClient` for outbound dials, including
[ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md)'s
verifier-selection rule (fingerprint pin for known peers, CA-verify for
unknown X.509, fail-closed for unknown raw-key) — currently lives in
`alknet-call`'s `FingerprintPinVerifier`. ADR-084 requires the client
side to use the same `aws_lc_rs` provider; today that is enforced by
**convention** (two crates independently constructing
`aws_lc_rs::default_provider()`), not by shared code.
Whether `alknet-tls` should grow a client-side helper (a
`TlsClientConfig` that centralizes verifier selection + provider
consistency, so `alknet-call` / a future `AlknetClient` don't each
rebuild it) is **deferred** — see OQ-64. The deferral blocks on the
`AlknetClient` dial-seam extraction (OQ-55): the client-side TLS helper
and the shared dial are the same seam, and extracting either without a
second transport's real client dial would bake QUIC in as the client
shape — the same welding ADR-065 unwound on the server side. The
provider-consistency convention (ADR-084) holds until then.
## Crate dependencies (in the dep graph)
```
@@ -391,6 +452,18 @@ See [open-questions.md](../../open-questions.md) for full details.
- **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.
- **OQ-62** (open): Does a hub pass the same ALPN list to both
`TlsServerConfig`s, or different (transport-appropriate) lists?
Decision-needed before the hub's assembly code is written.
- **OQ-63** (open): `TlsError` shape — the error type is referenced in
public signatures but not sketched. Needs a variant-granularity
decision (single enum vs thin wrapper) before implementation.
- **OQ-64** (deferred): Client-side TLS config helper. `alknet-tls` is
server-side only as specified; the client-side `ClientConfig` +
verifier selection lives in `alknet-call`. Blocked on the
`AlknetClient` dial-seam extraction (OQ-55) — extracting the client
helper without a second transport's dial would bake QUIC in as the
client shape.
## References
@@ -2,7 +2,11 @@
## Status
Accepted
Accepted (depends on ADR-082/083, which are still Proposed — this ADR
records a decision those ADRs list as an invariant. If ADR-082/083 are
revised in a way that changes the config-construction paths, the
provider decision here still stands; only the "where it is applied"
referent would update.)
## Context
@@ -0,0 +1,263 @@
# ADR-085: Workspace Scope — Core vs. Consumer Repos
## Status
Accepted
## Context
The original crate decomposition (ADR-003, 2026-06) listed a flat
workspace of ~12 crates: `alknet-core`, `alknet-vault`, `alknet-ssh`,
`alknet-call`, `alknet-agent`, `alknet-git`, `alknet-sftp`, `alknet-msg`,
`alknet-http`, `alknet-dns`, `alknet-napi`, and a CLI binary. This was
written before two things were clear:
1. **Channels** (ADR-071) — a multiplexing substrate that carries
`alknet/call` on channel 0 and any ALPN on data channels. Channels
did not exist when ADR-003 was written; it was created to solve a
pain point that surfaced writing the first consumers. Channels
changed the shape: a "hub" is a channels hub, a "worker" is a
channels worker, and most protocol handlers (TTY, tunnels, SFTP,
SOCKS5, git, SSH) ride inside channels as data-channel ALPNs, not as
top-level endpoint ALPNs.
2. **The hub/worker topology** (ADR-029, ADR-034, ADR-079) — the
deployment shape where a central hub accepts worker connections,
relays channels between legs, and aggregates operations. This is
the "vpn-like without being a vpn" target. A hub is one who
initiates an `AlknetEndpoint` serving many transports; a worker
dials out to a hub. A hub-worker does both.
Because ADR-003's flat list was never revised, the overview's crate
graph has been describing the wrong scope the entire time. It lists
crates that should be consumers in separate repos (`alknet-agent`,
`alknet-docker`) and omits crates that are core to the actual target
(`alknet-tls`, `alknet-channels`, `alknet-hub`, `alknet-worker`,
`alknet-tty`). It also lists crates that were never specced and are not
part of the current scope (`alknet-dns`, `alknet-msg`, `alknet-napi`).
This stale scope is a causal factor in the "assembly layer" hedging
pattern. When the overview implies everything lives in one repo, but
the actual architecture requires a hub/worker composition layer that
isn't in the graph, agents fill the gap with "assembly layer" as an
escape hatch — a path-of-least-resistance solution to an impossible
bind. The fix is not to suppress the hedging; it's to make the scope
clear so the bind doesn't arise.
### What "core" means now
The alknet mono-repo is the **core networking toolkit** — the crates
that a hub, a worker, or a hub-worker are built from, plus the protocol
handlers that are foundational to the "p2p-capable vpn-like without
being a vpn" target. Crates that build *on top of* a hub or worker
(docker operations, agent, future applications) are consumers in
their own repos — they depend on `alknet-call` / `alknet-channels` /
`alknet-hub` / `alknet-worker`, not on `alknet-core` directly.
The distinction is:
- **Core mono-repo**: the substrate (core, tls, call, channels), the
deployment shapes (hub, worker), and the foundational protocol
handlers that every hub/worker needs (tty, tunnels, fs, sftp, ssh,
http). Vault is core because it's foundational to ACL (key derivation,
identity).
- **Consumer repos**: crates that build on top of a hub or worker
(docker operations, agent, future applications). These are
independent repos that depend on the published core crates.
### The foundational handlers
The protocol handlers that are foundational to the vpn-like target
ride inside channels as data-channel ALPNs:
| ALPN (inside channels) | Crate | Status |
|------------------------|-------|--------|
| `alknet/tty` | `alknet-tty` | specced (ADR-052–057), implemented |
| `alknet/tunnel` | (in `alknet-channels` or a sibling) | POC-validated, not yet specced — minimal (the channels POC covers this use case) |
| `alknet/socks5` | (TBD) | not yet discussed — SOCKS5 proxy over channels |
| `alknet/fs` | (TBD) | not yet specced — filesystem access over channels |
| `alknet/sftp` | (TBD) | not yet specced — SFTP protocol core over channels |
Additionally, `alknet-http` (the HTTP edge case — registration endpoint,
browser access, MCP/OpenAPI adapters) and a future `alknet-ssh` (russh
server channels wrapper as an option for hubs, for git/sftp
compatibility) are core mono-repo concerns because they are part of the
hub's inbound surface.
### What leaves the mono-repo
| Crate | Destination | Why it's a consumer, not core |
|-------|-------------|-------------------------------|
| `alknet-docker` | own repo | Docker operations build on top of a hub/worker — a docker host is a worker, not a substrate concern. Depends on `alknet-call` + `alknet-tty`, not on core transport. |
| `alknet-agent` | own repo | The agent builds on `alknet-call` for tool dispatch — it's an application, not networking substrate. |
These specs are kept in `docs/architecture/crates/docker/` for
reference (the work is not lost), but the crates move to their own repos
as consumers. The overview's crate graph no longer lists them as
mono-repo members.
### What was never in scope (and is removed from the graph)
`alknet-dns`, `alknet-msg`, `alknet-napi` were listed in ADR-003's flat
decomposition but were never specced, never implemented, and are not
part of the current target. They are removed from the overview's crate
graph. If a DNS or messaging handler becomes needed, it will be a
consumer repo (a handler that rides inside channels or registers on the
endpoint), not a mono-repo member. NAPI projection, if needed, lives
with whatever consumer needs the Node.js bridge.
## Decision
### The alknet mono-repo scope is the core networking toolkit
The mono-repo contains the substrate, the deployment shapes, and the
foundational protocol handlers. Everything else is a consumer in its
own repo.
```
alknet mono-repo (the core networking toolkit)
│
├── Substrate
│ ├── alknet-core (ProtocolHandler, endpoint, auth, config, Connection)
│ ├── alknet-tls (shared TLS config — ADR-082)
│ ├── alknet-call (call protocol on alknet/call)
│ └── alknet-channels (multiplexing substrate on alknet/channels — ADR-071)
│ ├── alknet-channels-core (pure multiplexer — ADR-081)
│ └── alknet-channels-call (channel 0 pre-negotiation — ADR-081)
│
├── Deployment shapes
│ ├── alknet-hub (channels hub — accepts workers, relays, aggregates)
│ └── alknet-worker (channels worker — dials out to a hub)
│
├── Foundational handlers (inside channels as data-channel ALPNs, or on the endpoint)
│ ├── alknet-tty (alknet/tty — specced, implemented)
│ ├── alknet-http (h2/http1.1 + WebSocket — the HTTP edge case)
│ ├── alknet-tty-local (PTY/pipe backend — sibling crate)
│ ├── alknet-ssh (russh server channels wrapper — for git/sftp compat) [not yet specced]
│ ├── alknet-tunnel (alknet/tunnel — POC-validated, minimal spec needed) [not yet specced]
│ ├── alknet-socks5 (SOCKS5 proxy over channels) [not yet specced]
│ ├── alknet-fs (filesystem access over channels) [not yet specced]
│ └── alknet-sftp (SFTP over channels) [not yet specced]
│
└── alknet-vault (standalone — foundational to ACL: key derivation, identity)
```
### Dependency rules
- The substrate crates (`core`, `tls`, `call`, `channels`) depend on
each other in a clean DAG: `channels` → `call` → `core`; `tls` →
`core`. No cycles.
- `alknet-hub` and `alknet-worker` depend on the substrate (channels,
call, core) and on the handlers they wire. They are consumers of the
substrate, not part of it.
- Foundational handlers depend on `alknet-core` (for
`ProtocolHandler`, `Connection`) and/or `alknet-channels` (for
`ChannelBidiStreamSource`, `into_sub_streams`). No handler depends
on another handler — cross-handler communication goes through
`alknet/call` on channel 0.
- `alknet-vault` is standalone (zero alknet crate dependencies — ADR-018).
It is foundational to ACL: the hub/worker identity model
(`IdentityProvider`, `PeerEntry`, fingerprint resolution) derives
from vault-managed keys. Vault is accessed only at the assembly layer
(ADR-019); handlers receive derived credentials via capabilities
(ADR-014), never a vault reference.
- Consumer repos (docker, agent, future applications) depend on the
published core crates (`alknet-call`, `alknet-channels`,
`alknet-hub`, `alknet-worker`), not on `alknet-core` directly.
### The hub/worker model
A **hub** is a channels hub — it accepts inbound connections (over
quinn, iroh, TCP+TLS — ADR-083), runs `ChannelsAdapter` on
`alknet/channels`, relays data channels between legs (ADR-079),
aggregates workers' operations into a shared env, and serves the
discovery API. A hub may also serve HTTP (`h2`/`http/1.1` for
registration and browser access) and, optionally, an SSH server (russh
channels wrapper for git/sftp compatibility).
A **worker** is a channels worker — it dials out to a hub via
`ChannelClient`, runs `from_call` to discover the hub's (and other
workers') operations, and exposes its own operations on channel 0. A
worker has no inbound endpoints unless it is also a hub (hub-worker).
A **hub-worker** does both — accepts inbound and dials out. This is a
valid deployment shape; the topology is not strictly hierarchical.
The bidirectionality of call and channels means both sides can be both
hub and worker within a connection. A hub (A) that uses a client to
connect to another hub (B) is, from B's perspective, a worker. This
does not require a separate "hub-as-client" abstraction — the
`ChannelClient` / `CallClient` take-over APIs (`from_connection`,
`spawn_dispatch`) are transport-agnostic and work regardless of
whether the dialer is a hub, a worker, or a hub-worker.
### ADR-003 is amended
ADR-003's flat crate table is superseded for scoping purposes. The
"one crate per protocol handler, core provides shared infra" principle
survives; the specific crate list does not. The crate list is now this
ADR's scope table. ADR-003's amendments (the `alknet-call` as
protocol-foundation clarification, the `alknet-tty` no-`alknet-call`
clarification, the irpc removal) survive — they are about dependency
edges, not about which crates are in the mono-repo.
## Consequences
**Positive:**
- The overview's crate graph will match reality for the first time.
Future sessions start with an accurate scope, not a stale flat list
that implies the wrong boundary.
- The "assembly layer" escape hatch has a bounded home: the hub and
worker crates. "Assembly layer" = the deployment binary (hub, worker,
or hub-worker), not a dump for unknowns. This is the fix for the
hedging pattern's root cause.
- Consumer repos (docker, agent) are unblocked — they can be developed
independently against the published core crates, without waiting for
the mono-repo to accommodate their concerns.
- The scope is narrow enough to finish. Six substrate + deployment
crates (core, tls, call, channels, hub, worker) plus the foundational
handlers (tty, http, ssh, tunnel, socks5, fs, sftp) plus vault is a
bounded surface. The previous scope (~12 flat crates including DNS,
messaging, NAPI) was never the target.
- The foundational handlers that ride inside channels (tunnel, socks5,
fs, sftp) are correctly scoped as channels data-channel ALPNs, not
top-level endpoint handlers. This is the "p2p-capable vpn-like"
shape — these services are available over channels, with the ACL and
bidirectionality that channels + call provide.
**Negative:**
- `alknet-docker` and `alknet-agent` leave the mono-repo. Their specs
stay in `docs/architecture/crates/docker/` (and a future
`crates/agent/` if specced) for reference, but the crate code moves to
consumer repos. This is a repository boundary change, not a loss of
work — the specs and ADRs (058–063 for docker) remain valid as the
consumer's architecture.
- Several foundational handlers (ssh, tunnel, socks5, fs, sftp) are
named in the scope but not yet specced. The scope table makes this
visible — it is a backlog, not a hidden gap.
- `alknet-worker` has no spec yet. The worker pattern is described
inside the hub README (as the inverse of hub), but a dedicated
`crates/worker/README.md` or a combined hub/worker doc is needed.
## Door type
**One-way.** The repo boundary (core mono-repo vs. consumer repos) is
structural — once docker and agent are in their own repos with their
own release cycles, reversing means merging them back and breaking
downstream consumers that depend on the published crates. The scope
table (which crates are core) is one-way for the same reason: the
dep graph and the overview orient around it.
## References
- ADR-003: Crate Decomposition (amended — the flat crate list is
superseded; the decomposition principle survives)
- ADR-029: Peer-Graph Routing Model (the hub/worker topology)
- ADR-034: Three Peer Roles (hub = role-3, worker = role-1/2)
- ADR-071: alknet-channels Wire Format (the multiplexing substrate)
- ADR-079: Hub Relay (translate, not transparently forward)
- ADR-080: ChannelClient (the worker's dial path)
- ADR-081: channels sub-crate decomposition (channels-core / channels-call)
- ADR-082: alknet-tls extraction
- ADR-083: Endpoint as multi-transport accept-loop runner
- `docs/architecture/crates/hub/README.md` (the hub pattern — the
deployment shape this scope is built around)
+25 -1
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-14
last_updated: 2026-07-15
---
# Open Questions
@@ -178,6 +178,14 @@ Door type is separate from whether a decision is made. A two-way door is a decis
| [OQ-56](questions/056-full-channel-level-flow-control-windowing.md) | Full Channel-Level Flow-Control Windowing | deferred(scope) | two | low |
| [OQ-57](questions/057-two-pump-helper-extraction.md) | Two-Pump Helper Extraction to alknet-core | deferred(scope) | two | low |
### alknet-tls
| OQ | Title | Status | Door | Pri |
|----|-------|--------|------|-----|
| [OQ-62](questions/062-alpn-list-sharing-two-config-hub.md) | Does a Hub Pass the Same ALPN List to Both `TlsServerConfig`s? | open | one | high |
| [OQ-63](questions/063-tlserror-shape.md) | `TlsError` Shape | open | one | high |
| [OQ-64](questions/064-client-side-tls-helper.md) | Should `alknet-tls` Provide a Client-Side TLS Config Helper? | deferred(scope) | two | med |
## Deferred / Blocked
The safe-exit visibility surface. These questions are parked because the
@@ -282,3 +290,19 @@ filtering the tables above.
- **Priority**: low
- **Full file**: [OQ-57](questions/057-two-pump-helper-extraction.md)
### OQ-64: Should `alknet-tls` Provide a Client-Side TLS Config Helper?
- **Blocked on**: the `AlknetClient` dial-seam extraction (OQ-55). The
client-side TLS helper and the shared dial are the same seam — both
answer "how does an outbound connection build its
`rustls::ClientConfig` + select a verifier (ADR-034) + dial." The
blocking condition is the same as OQ-55: a second transport's real
client dial existing (TCP+TLS, SSH raw-TCP, HTTP-wrapped call), so the
transport-polymorphic client+TLS seam is extractable from two
*different* transport implementations, not one QUIC shape. Until then,
`alknet-tls` is server-side only; the client side lives in
`alknet-call`'s `FingerprintPinVerifier`, with provider consistency
(ADR-084) enforced by convention.
- **Priority**: medium
- **Full file**: [OQ-64](questions/064-client-side-tls-helper.md)
+165 -69
View File
@@ -1,15 +1,35 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-15
---
# Alknet Overview
## What Alknet Is
Alknet is a self-hostable networking toolkit built on QUIC+TLS with ALPN-based protocol dispatch. A single endpoint accepts connections on one port, and the ALPN string negotiated during the TLS handshake routes each connection to the correct protocol handler. Every service — SSH, SFTP, Git, HTTP, DNS, messaging, RPC — is an ALPN on a shared endpoint.
Alknet is a **core networking toolkit** for building self-hostable,
p2p-capable, "vpn-like without being a vpn" systems. It is built on
QUIC+TLS with ALPN-based protocol dispatch, plus TCP+TLS for the
hub/HTTP path. A single endpoint accepts connections on one port, and
the ALPN string negotiated during the TLS handshake routes each
connection to the correct protocol handler. Every service — call,
channels, HTTP, TTY, tunnels, SFTP — is an ALPN on a shared endpoint
or a data-channel ALPN inside channels.
This is the core insight: **a service IS an ALPN.** One endpoint, one port, many protocols — dispatched by the TLS handshake, not by application-level peeking or separate listeners.
This is the core insight: **a service IS an ALPN.** One endpoint, one
port, many protocols — dispatched by the TLS handshake, not by
application-level peeking or separate listeners.
### Scope: core mono-repo vs. consumer repos
The mono-repo is the **core networking toolkit** — the substrate
(core, tls, call, channels), the deployment shapes (hub, worker), the
foundational protocol handlers (tty, http, ssh, tunnel, socks5, fs,
sftp), and vault (foundational to ACL). Crates that build *on top of*
a hub or worker (docker operations, agent, future applications) are
**consumer repos** — they depend on the published core crates, not on
`alknet-core` directly. See [ADR-085](decisions/085-workspace-scope-core-vs-consumer-repos.md)
for the full scope decision.
## Why ALPN Dispatch
@@ -22,52 +42,80 @@ The previous architecture used a three-layer model (StreamInterface/MessageInter
See [ADR-001](decisions/001-alpn-protocol-dispatch.md) for the full rationale.
## The Hub/Worker Model
Alknet's deployment shape is hub-and-spoke. A **hub** is a channels
hub — it accepts inbound connections (over quinn, iroh, TCP+TLS),
runs `ChannelsAdapter` on `alknet/channels`, relays data channels
between legs (ADR-079), aggregates workers' operations, and serves
discovery. A **worker** is a channels worker — it dials out to a hub
via `ChannelClient`, discovers operations via `from_call`, and exposes
its own operations on channel 0. A **hub-worker** does both.
The bidirectionality of call and channels means both sides can be both
hub and worker within a connection. A hub (A) that dials another hub
(B) is, from B's perspective, a worker. This does not require a
separate "hub-as-client" abstraction — `ChannelClient` /
`CallClient` take-over APIs (`from_connection`, `spawn_dispatch`) are
transport-agnostic and work regardless of whether the dialer is a hub,
a worker, or a hub-worker.
See [ADR-029](decisions/029-peer-graph-routing-model.md),
[ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md),
[ADR-079](decisions/079-hub-relay-translate-not-forward.md), and
[crates/hub/README.md](crates/hub/README.md) for the full topology.
## Crate Graph
The mono-repo contains the substrate, the deployment shapes, and the
foundational handlers. Consumer repos (docker, agent) are not in this
graph — they depend on the published core crates from their own repos
(ADR-085).
```
alknet-vault (standalone, no alknet-core dependency)
alknet-vault (standalone — foundational to ACL: key derivation, identity)
│
alknet-core
│ ├── ProtocolHandler trait
│ ├── ALPN router / endpoint
│ ├── BiStream trait, Connection type
│ ├── AuthContext, IdentityProvider
│ └── StaticConfig, DynamicConfig (ArcSwap)
├── Substrate
│ alknet-core ProtocolHandler, endpoint (multi-transport accept loop),
│ │ Connection, BidiStreamSource, AuthContext, IdentityProvider,
│ │ StaticConfig, DynamicConfig
│ ├── alknet-tls TlsServerConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082)
│ ├── alknet-call CallAdapter on alknet/call, CallClient, OperationRegistry, adapters
│ └── alknet-channels
│ ├── alknet-channels-core pure multiplexer (wire format, demux/mux) — ADR-081
│ └── alknet-channels-call channel 0 pre-negotiation + lifecycle ops — ADR-081
│
├── alknet-ssh (depends on alknet-core, russh)
├── alknet-call (depends on alknet-core)
│ ├── CallAdapter (server: ProtocolHandler for alknet/call)
│ ├── Call client (send/receive over QUIC)
│ ├── OperationSpec, OperationRegistry, AccessControl
│ └── Adapter traits (from_*, to_*)
├── Deployment shapes
│ ├── alknet-hub channels hub — accepts workers, relays, aggregates (ADR-079)
│ └── alknet-worker channels worker — dials out to a hub [not yet specced]
│
├── alknet-agent (depends on alknet-call)
│ ├── LLM execution loop (forked aisdk, simplified)
│ ├── Tool dispatch via call protocol
│ └── Provider credentials via capabilities (no env vars, no vault on the wire)
├── Foundational handlers (inside channels as data-channel ALPNs, or on the endpoint)
│ ├── alknet-tty alknet/tty — specced (ADR-052–057), implemented
│ ├── alknet-tty-local PTY/pipe backend — sibling crate (ADR-054)
│ ├── alknet-http h2/http1.1 + WebSocket — the HTTP edge case (registration, browser, MCP)
│ ├── alknet-ssh russh server channels wrapper — for git/sftp compat [not yet specced]
│ ├── alknet-tunnel alknet/tunnel — POC-validated, minimal spec needed [not yet specced]
│ ├── alknet-socks5 SOCKS5 proxy over channels [not yet specced]
│ ├── alknet-fs filesystem access over channels [not yet specced]
│ └── alknet-sftp SFTP over channels [not yet specced]
│
├── alknet-git (depends on alknet-core, gix)
├── alknet-sftp (depends on alknet-core, russh-sftp)
├── alknet-msg (depends on alknet-core)
├── alknet-http (depends on alknet-core, alknet-call, axum, reqwest, wtransport, rmcp)
├── alknet-dns (depends on alknet-core, hickory-proto)
│
├── alknet-napi (depends on alknet-call, napi-rs)
│ └── Thin NAPI projection of call protocol client to Node.js
│
└── alknet (CLI binary, depends on all handler crates + alknet-vault)
└── Consumer repos (separate repos, depend on the published core crates)
alknet-docker docker operations — a docker host is a worker
alknet-agent LLM agent — builds on alknet-call for tool dispatch
```
Dependency rules:
- No handler crate depends on another handler crate
- All handler crates depend on alknet-core
- alknet-vault has zero alknet crate dependencies
- alknet-agent depends on alknet-call (not alknet-core) — it uses the call protocol client for tool dispatch
- alknet-napi depends only on alknet-call — thin NAPI projection, no business logic
- alknet (CLI) is the only crate that depends on all handler crates and alknet-vault
- Rust is the canonical implementation language — TypeScript is a reference/browser adaptation, not a parallel implementation (see ADR-013)
- The substrate crates form a clean DAG: `channels` → `call` → `core`; `tls` → `core`. No cycles.
- `alknet-hub` and `alknet-worker` depend on the substrate (channels, call, core) and on the handlers they wire. They are consumers of the substrate, not part of it.
- No handler crate depends on another handler crate — cross-handler communication goes through `alknet/call` on channel 0.
- `alknet-call` is a protocol-foundation crate (ADR-003 Am. 1): `alknet-http` depends on it for `OperationSpec`/`Handler`/`OperationAdapter` types, not as a peer-handler dep.
- `alknet-vault` has zero alknet crate dependencies (ADR-018). It is foundational to ACL: the hub/worker identity model derives from vault-managed keys. Vault is accessed only at the assembly layer (ADR-019); handlers receive derived credentials via capabilities (ADR-014).
- Consumer repos (docker, agent) depend on the published core crates, not on `alknet-core` directly.
- Rust is the canonical implementation language (ADR-013).
See [ADR-003](decisions/003-crate-decomposition.md) for the full decomposition rationale.
See [ADR-003](decisions/003-crate-decomposition.md) (as amended by
[ADR-085](decisions/085-workspace-scope-core-vs-consumer-repos.md)) for
the decomposition rationale.
## ProtocolHandler Trait
@@ -92,21 +140,56 @@ See [ADR-002](decisions/002-protocol-handler-trait.md) and [ADR-007](decisions/0
## ALPN Registry
ALPNs are split into two layers: **endpoint ALPNs** (negotiated in the
TLS handshake, dispatched by the endpoint) and **channels data-channel
ALPNs** (negotiated via `channel/open` inside a channels connection,
dispatched by the channels substrate). See ADR-071 and ADR-073.
### Endpoint ALPNs
| ALPN | Handler | Description |
|------|---------|-------------|
| `alknet/ssh` | SshAdapter | SSH-2 handshake, channel multiplexing, SOCKS5, port forwarding |
| `alknet/call` | CallAdapter | JSON-RPC via hand-rolled EventEnvelope framing: operations, streaming, pub/sub |
| `alknet/git` | GitAdapter | Git smart protocol over QUIC (gix, pkt-line) |
| `alknet/sftp` | SftpAdapter | SFTP protocol (russh-sftp core) |
| `alknet/msg` | MessageAdapter | E2E encrypted messaging, mixnet |
| `alknet/http` | HttpAdapter | axum REST API, dashboard, MCP endpoint |
| `alknet/dns` | DnsAdapter | DNS over QUIC/TLS, pkrr service discovery |
| `h3` | HttpAdapter (HTTP/3 + WebTransport) | Browser-compatible WebTransport + HTTP/3 (first-class, ADR-038) |
| `h2` / `http/1.1` | HttpAdapter | Standard HTTP for browsers, curl |
| `alknet/call` | `CallAdapter` | Call protocol: operations, streaming, pub/sub (hand-rolled EventEnvelope — ADR-064) |
| `alknet/channels` | `ChannelsAdapter` | Multiplexing substrate: N channels over one transport stream (ADR-071); channel 0 = `alknet/call` (ADR-072) |
| `h2` / `http/1.1` | `HttpAdapter` | Standard HTTP for browsers, curl, registration endpoint (WebSocket for bidirectional — ADR-048) |
> **Note**: `alknet/agent` is not in the ALPN registry. The agent service is a future consumer that builds on top of `alknet-call` (it depends on `alknet-call`, not `alknet-core` directly — see ADR-003). It uses the call protocol for tool dispatch and exposes agent operations (e.g., `/agent/chat`) as call-protocol operations in the `OperationRegistry`, not as a separate ALPN. The agent is a mental model that informed the core architecture (capabilities, scoped env, abort cascade) but is not specced yet — its design will change as it's built out against the implemented core crates.
### Channels data-channel ALPNs
> **Note**: `alknet/vault` is not in the ALPN registry. alknet-vault is a standalone local key vault with no alknet-core dependency and no remote dispatch capability (ADR-025). The CLI binary embeds it and accesses it at the assembly layer — unlocking the vault at startup, deriving and decrypting credentials, and injecting them into handler capabilities. The vault is not exposed over the call protocol. No vault operations are registered in the operation registry. See ADR-008, ADR-014, and ADR-025.
These ride inside a `alknet/channels` connection as data channels,
opened via `channel/open` (ADR-073). They get the ACL and
bidirectionality of channels + call for free.
| ALPN | Handler | Status |
|------|---------|--------|
| `alknet/tty` | `TtyAdapter` | specced (ADR-052–057), implemented |
| `alknet/tunnel` | (tunnel handler) | POC-validated, minimal spec needed [not yet specced] |
| `alknet/socks5` | (SOCKS5 handler) | not yet specced |
| `alknet/fs` | (fs handler) | not yet specced |
| `alknet/sftp` | (sftp handler) | not yet specced |
| (future) | any ALPN a consumer registers | channels supports any ALPN — ADR-071 |
### Notes
> **`alknet/vault`** is not in the ALPN registry. alknet-vault is a
> standalone local key vault with no alknet-core dependency and no
> remote dispatch capability (ADR-025). The assembly layer (hub or
> worker binary) embeds it, unlocks it at startup, derives/decrypts
> credentials, and injects them into handler capabilities (ADR-014).
> The vault is foundational to ACL — the hub/worker identity model
> (`IdentityProvider`, `PeerEntry`, fingerprint resolution) derives
> from vault-managed keys. See ADR-008, ADR-014, ADR-018, ADR-019.
> **`alknet/http`** is the HTTP edge case. It is an endpoint ALPN
> (`h2`/`http/1.1`), not a channels data-channel ALPN — it wraps the
> call protocol for browser/curl access (registration, MCP/OpenAPI
> adapters, WebSocket bidirectional path). See
> [crates/http/README.md](crates/http/README.md).
> **Consumer-repo ALPNs** (e.g., docker operations) are not listed
> here. A consumer that builds on top of a hub or worker registers its
> operations on the call protocol (channel 0), not as a separate ALPN.
> Docker, for example, registers its operations as call-protocol ops
> (ADR-058), not as `alknet/docker`.
## Authentication
@@ -178,13 +261,21 @@ The following types live in alknet-core and are used across handler crates:
Not all decisions carry the same reversal cost. One-way door decisions (BiStream type, crate independence, secret material flow) require ADRs and possibly POCs before commitment. Two-way door decisions (single vs multi-transport) can be decided during implementation — start simple, add complexity when needed. The static-vs-dynamic registration question is now resolved: the `HandlerRegistry` (ALPN-level) is static at startup (ADR-010, OQ-04), while the `OperationRegistry` (call-protocol-level) is layered — curated ops static, session/imported ops dynamic at their trust-boundary scopes (ADR-024). WASM compatibility is a design constraint within this framework, not a separate principle: decisions that would permanently close the WASM door require explicit justification. See [ADR-009](decisions/009-one-way-door-decision-framework.md).
### One ALPN, One Connection, One Handler
### One ALPN, One Connection, One Handler (endpoint layer)
Each ALPN gets its own QUIC connection. The handler owns the entire connection lifecycle. Handlers that need multiple streams (SSH, call) call `connection.accept_bi()` or `connection.open_bi()` as needed. There is no multiplexing layer between connections.
Each endpoint ALPN gets its own connection. The handler owns the
entire connection lifecycle. Handlers that need multiple streams (call,
channels) open/accept streams as needed. At the channels layer, the
model extends: one `alknet/channels` connection carries many
data-channel ALPNs, each dispatched via `channel/open` (ADR-073) — a
multiplexing power QUIC's per-connection ALPN doesn't provide natively.
### Handler Independence
No handler crate depends on another handler crate. Cross-handler communication goes through the call protocol (`alknet/call`) or through alknet-core's endpoint. The only crate that depends on all handlers is the CLI binary.
No handler crate depends on another handler crate. Cross-handler
communication goes through the call protocol (`alknet/call` on channel
0) or through the channels substrate. The assembly layer (hub or
worker binary) is the only place that depends on all handlers.
## Design Decisions
@@ -194,7 +285,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|-----|----------|---------|
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | Single endpoint, ALPN negotiation routes to handlers |
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | One trait replaces StreamInterface/MessageInterface |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | One crate per protocol handler, core provides shared infra |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | One crate per protocol handler, core provides shared infra (crate list superseded by [ADR-085](decisions/085-workspace-scope-core-vs-consumer-repos.md) — core mono-repo vs. consumer repos) |
| [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core | IdentityProvider in core, handlers extract credentials |
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | ~~Accepted~~ → **Superseded** by [ADR-064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated) |
| [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | `alknet/` prefix, one ALPN per connection |
@@ -219,6 +310,13 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc from vault; direct method calls; local-only by construction |
| [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | HD derivation from BIP39 seed; `74'` coin type; SLIP-0010/Ed25519 default; AES-256-GCM for credentials |
| [027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign — ACME + RawKey Decoupling | `TlsIdentity::Acme` variant + two-phase server config; `RawKey` uses `ed25519-dalek` (not `iroh::SecretKey`); `acme` feature gate |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections — unblocks TCP+TLS, SSH channels, WebTransport, wasm |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | Open `Connection` for extension — downstream crates add connection shapes without editing core |
| [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 |
| [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 |
| [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) |
## Open Questions
@@ -245,21 +343,19 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi
| Config reload fails | `ArcSwap<DynamicConfig>` keeps the previous valid config. Error is logged. No service interruption |
| BiStream read/write error | QUIC stream-level error. The handler detects this as an I/O error and returns from `handle()`. The connection itself may remain open for other streams — but since each handler owns a full `Connection` (one ALPN per connection, ADR-006), a stream error typically causes the handler to return, closing the connection |
## What Stays from the Previous Implementation
## Reference Implementation
The reference implementation at `/workspace/@alkdev/alknet-main/` contains working code that carries forward, adapted to the new model:
The reference implementation at `/workspace/@alkdev/alknet-main/` contains
working code that informed the new architecture. It is reference, not
constraint — understand what it did and why, then implement against
the new `ProtocolHandler` trait, ALPN router, and channels substrate.
| Module | Lines | Destination | Notes |
|--------|-------|-------------|-------|
| `src/auth/*` | ~1450 | alknet-core | Identity, IdentityProvider, keys — simplified per ADR-004 |
| `src/config/*` | ~950 | alknet-core | StaticConfig, DynamicConfig, ArcSwap — adapted for ALPN handler config |
| `src/transport/*` | ~1500 | alknet-core | Transport trait, TCP/TLS/iroh — becomes endpoint connection acceptors |
| `src/call/*` | ~1200 | alknet-call | EventEnvelope, registry, framing — becomes ProtocolHandler on alknet/call |
| `src/interface/ssh.rs` | 982 | alknet-ssh | SSH channel handling |
| `src/server/handler.rs` | 974 | alknet-ssh | SSH server handler |
| `src/server/channel_proxy.rs` | 555 | alknet-ssh | Channel proxy |
| `src/server/serve.rs` | 1526 | alknet-core (reference) | Accept loop pattern informs ALPN router, but gets rewritten |
| `src/client/*` | ~1900 | alknet-ssh | SOCKS5 client, connect logic |
| `src/socks5/*` | ~800 | alknet-ssh | SOCKS5 protocol |
The old code is reference, not constraint. Understand what it did and why, then implement against the new ProtocolHandler trait and ALPN router.
| Module | Destination | Notes |
|--------|-------------|-------|
| `src/auth/*` | alknet-core | Identity, IdentityProvider, keys — simplified per ADR-004 |
| `src/config/*` | alknet-core | StaticConfig, DynamicConfig, ArcSwap |
| `src/transport/*` | alknet-core + alknet-tls | Transport construction → alknet-tls (ADR-082); accept loops → alknet-core (ADR-083) |
| `src/call/*` | alknet-call | EventEnvelope, registry, framing — becomes `ProtocolHandler` on `alknet/call` |
| `src/server/serve.rs` | alknet-core (reference) | Accept loop pattern informs the ALPN router; rewritten as multi-transport accept-loop runner (ADR-083) |
| `src/interface/ssh.rs`, `src/server/*` | alknet-ssh [not yet specced] | SSH channel handling — future russh server channels wrapper for git/sftp compat |
| `src/socks5/*`, `src/client/*` | alknet-socks5 [not yet specced] | SOCKS5 protocol — future channels data-channel ALPN |
@@ -0,0 +1,54 @@
# OQ-62: Does a Hub Pass the Same ALPN List to Both `TlsServerConfig`s?
- **Origin**: `docs/architecture/crates/tls/README.md` (the
"What `AlknetEndpoint` does after the refactor" section describes a
hub holding two `TlsServerConfig`s — raw key + X.509/ACME — but does
not state whether each receives the same ALPN list or different
lists); `docs/architecture/crates/core/endpoint.md` (the ALPN section
previously stated "both connection sources advertise the same set of
ALPNs," which is stale under the two-config hub model).
- **Status**: open
- **Door type**: one-way (the ALPN list each `TlsServerConfig` advertises
is baked into the `rustls::ServerConfig` at construction; changing it
after the hub is deployed is a config+restart, but the *pattern* —
same-list vs split-list — sets the assembly-layer wiring shape that
downstream consumers copy)
- **Priority**: high (the hub is the first two-config consumer; its
wiring sets the pattern, and an implementer cannot write the hub's
assembly code without this decided)
- **Resolution**: Not yet decided. The two plausible options:
**Option A — same list (union) to both configs.** Both
`TlsServerConfig`s receive `registry.alpn_strings()` verbatim. The
raw-key QUIC listener advertises `h2`/`http/1.1` (browsers can't
connect to a raw-key listener anyway, so the advertisement is
harmless dead negotiation). The X.509 TCP+TLS listener advertises
`alknet/call` (a native client connecting over TCP+TLS with an X.509
client cert can use it). Simplest wiring; no split logic; every
transport can serve every ALPN.
**Option B — split list, transport-appropriate.** The raw-key config
gets the native ALPNs (`alknet/call`, `alknet/channels`,
`alknet/tty`); the X.509/ACME config gets the union including
`h2`/`http/1.1` (browser ALPNs that only make sense over TCP+TLS with
a domain cert). The assembly layer filters `registry.alpn_strings()`
by which transports can serve each ALPN. More logic; cleaner
advertisement (no browser ALPNs on a raw-key listener).
The question is whether the "harmless dead negotiation" in Option A
is acceptable or whether the cleaner advertisement in Option B is
worth the split logic. This needs a decision before the hub's
assembly code is written — it is not guessable from the existing
specs, and guessing produces a wiring shape that downstream consumers
copy.
Note: this is distinct from the *iroh* path. Iroh takes its ALPN list
from `iroh::Endpoint::builder().alpns()` at construction, set by the
assembly layer from `registry.alpn_strings()`. Iroh uses raw keys
only, so it gets the native ALPN set regardless of which option is
chosen for the quinn/TCP+TLS pair.
- **Cross-references**: ADR-082 (`TlsServerConfig::new` takes
`alpns: &[Vec<u8>]` — the caller decides), ADR-083 (the assembly
layer builds transports; ALPN-list construction is its job),
OQ-64 (client-side TLS helper — related but orthogonal; this OQ is
server-side advertisement)
@@ -0,0 +1,52 @@
# OQ-63: `TlsError` Shape
- **Origin**: `docs/architecture/crates/tls/README.md`
(`TlsError` is referenced as the `Result` error type in
`TlsServerConfig::new`, `for_quinn()`, and the crate's public
signatures, but is never sketched or defined); ADR-082 (same —
`TlsError` in signatures, no shape).
- **Status**: open
- **Door type**: one-way (the error type is the public API surface of
`alknet-tls`; changing it after consumers exist is a breaking change
to every assembly-layer call site)
- **Priority**: high (an implementer cannot write the crate without
deciding this; guessing produces divergent shapes — one thin
`rustls::Error` wrapper vs a 10-variant enum with per-path context)
- **Resolution**: Not yet decided. The shape needs to cover the failure
modes across all four identity paths:
- **Cert/key loading** (`X509`: file read + PEM parse;
`SelfSigned`: rcgen generation) — currently `io::Error`-wrapped in
the core code.
- **rustls config construction** (`builder_with_provider` /
`with_safe_default_protocol_versions` /
`with_single_cert` / `with_cert_resolver`) — currently
`rustls::Error`-wrapped.
- **quinn wrap** (`QuicServerConfig::try_from(rustls::ServerConfig)`)
— the one path where `for_quinn()` can fail; currently
`io::Error::other(e)`-wrapped.
- **ACME** — `rustls-acme` has its own error types; the ACME task
runs in the background and surfaces errors via events (logged, not
returned from `new`), so `new`'s ACME path may only need to cover
"ACME feature not enabled but `TlsIdentity::Acme` configured"
(currently an `io::ErrorKind::Unsupported`).
The open question is the granularity: a single `TlsError` enum with
variants per failure category (cert-load, rustls-build, quinn-wrap,
acme-disabled) vs a thin wrapper around `rustls::Error` /
`io::Error`. The single-enum shape gives callers matchable context
(the assembly layer can distinguish "cert file missing" from "quinn
rejected the rustls config"); the thin wrapper is less code but
loses the distinction. This is an API-surface decision — it needs an
ADR or at minimum a sketch in the TLS README before implementation.
Subsidiary question: does `TlsError` live in `alknet-tls` (owned by
the crate that produces it) or is it re-exported from `alknet-core`?
Likely `alknet-tls` (it's the crate's own error), but worth
confirming so `alknet-core`'s `EndpointError` (which no longer has a
`TlsConfig` variant after ADR-083) doesn't need to know about it.
- **Cross-references**: ADR-082 (the extraction that introduces
`TlsError`), ADR-083 (the endpoint refactor that removes
`EndpointError::TlsConfig`, making `TlsError` the sole TLS error
surface), `crates/alknet-core/src/endpoint.rs` (the current
`io::Error`-wrapping pattern the new type replaces)
@@ -0,0 +1,55 @@
# OQ-64: Should `alknet-tls` Provide a Client-Side TLS Config Helper?
- **Origin**: `docs/architecture/crates/tls/README.md` (the "Server-only
for now" section flags this as deferred); ADR-084 (requires the
client-side `rustls::ClientConfig` to use the same `aws_lc_rs`
provider — currently enforced by convention, not shared code).
- **Status**: deferred(scope)
- **Door type**: two-way (adding a client helper to `alknet-tls` is
additive; the risk is not the addition but *extracting it
prematurely* and baking in a QUIC-shaped client — see Blocking on)
- **Priority**: medium
- **Blocked on**: the `AlknetClient` dial-seam extraction (OQ-55). The
client-side TLS helper and the shared dial are the same seam: both
answer "how does an outbound connection build its
`rustls::ClientConfig` + select a verifier (ADR-034) + dial the
transport." Extracting the TLS helper without a second transport's
real client dial existing would bake the QUIC client's shape into a
shared helper — the same welding ADR-065 unwound on the server side.
The blocking condition is the same as OQ-55: a second transport's
real dial (TCP+TLS, SSH raw-TCP, HTTP-wrapped call) existing, so the
transport-polymorphic client+TLS seam is extractable from two
*different* transport implementations.
- **Resolution**: Not yet decidable. `alknet-tls` is server-side only
as specified. The client side — `rustls::ClientConfig` construction +
ADR-034 verifier selection (fingerprint pin for known peers, CA-verify
for unknown X.509, fail-closed for unknown raw-key) — lives in
`alknet-call`'s `FingerprintPinVerifier` today. The
provider-consistency requirement (ADR-084: `aws_lc_rs` on all paths)
is enforced by convention (two crates independently constructing
`aws_lc_rs::default_provider()`) until this OQ is resolved.
What does NOT block on this: each client (`CallClient`,
`ChannelClient`) building its own `ClientConfig` standalone with the
matching provider. The friction is duplicated boilerplate (each
client rebuilds verifier selection + provider wiring), not a missing
capability and not a QUIC-welded client API. The
transport-agnostic take-over (`CallClient::spawn_dispatch`,
`ChannelClient::from_connection` — ADR-080) is decided and is not
the thing being deferred; only the shared *client TLS config helper*
is.
Note on the hub-as-client case: a hub (A) that dials another hub (B)
uses a client to do so — from B's perspective A is a worker. The
bidirectionality of the call and channels protocols means both sides
can be both hub and worker within a connection. This does not change
the blocking condition: the shared client TLS helper is still about
the *dial*, regardless of whether the dialer is a hub, a worker, or a
hub-worker. The hub-as-client case is a use case that the resolved
helper must cover, not a reason to resolve it now.
- **Cross-references**: OQ-55 (the `AlknetClient` dial-seam extraction
— this OQ's blocking condition), ADR-034 (verifier selection — the
rule the helper would centralize), ADR-084 (provider consistency —
the convention that holds until this is resolved), ADR-065 (the
server-side transport generalization this OQ's deferral avoids
preempting on the client side)