Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
21 KiB
ADR-012: ConnectionCredentials — Decouple the Dial Credentials from the Call Protocol
Status
Accepted (amends ADR-045 §3 and §5; amends ADR-087's TlsClientConfig::new
input framing; amended 2026-07-17 — CallCredentials is removed, not
retained in alknet-call; from_call's credentials_auth_token dead
path removed; auth_token is a per-request payload field, not a
call-protocol credential)
Context
ADR-045 extracted the dial into AlknetClient and moved CallCredentials
from alknet-call to alknet-core so the dial would not depend on the
call protocol. The three dial signatures were:
dial_quic(addr, server_name, alpn, credentials: &CallCredentials) -> Connection
dial_tcp_tls(host, addr, alpn, credentials: &CallCredentials) -> Connection
dial_iroh(node_id: iroh::NodeId, alpn, local_key: &Ed25519SecretKey) -> Connection
Two problems surfaced on review:
Problem 1: the iroh dial signature is asymmetric
dial_quic and dial_tcp_tls take &CallCredentials; dial_iroh takes
a bare &Ed25519SecretKey + a separate node_id: iroh::NodeId. The
asymmetry exists because iroh has its own TLS (it shares the key, not
the rustls config — ADR-087 §3), so the iroh dial bypasses
TlsClientConfig and reads the key directly. But the asymmetry forces
the caller to know which dimension of the credential bundle each
transport consumes, and it leaves no path for the iroh dial to receive
the same inputs as the rustls dials — even though all three consume the
same two things: a local identity (key/cert) and an expected remote
identity (fingerprint).
Problem 2: CallCredentials couples the dial to the call protocol
CallCredentials carries three dimensions (ADR-022 §7):
tls_identity: Option<TlsIdentity>— the local node's key/certauth_token: Option<AuthToken>— a call-protocol-level bearer tokenremote_identity: Option<RemoteIdentity>— the expected remote fingerprint
The dial uses only dimensions 1 and 3 (the transport-identity layer).
Dimension 2 (auth_token) is a call-protocol concept: it correlates
a token to an identity via IdentityProvider::resolve_from_token — a
mechanism that exists for two hub-dependent cases where TLS-fingerprint
identity is unavailable:
- Browsers — no raw-key support, no client cert the hub can
fingerprint; the browser authenticates via a bearer token over
HTTP/WebSocket, and the hub's
IdentityProviderresolves it. alknet/register— a native worker that hasn't been enrolled dials in with no prior peer relationship; a registration token (or open registration) establishes identity, not a TLS fingerprint.
Both depend on a hub running IdentityProvider with token-to-identity
mapping. A pure P2P connection (two nodes with raw-key identities) never
needs auth_token — the TLS fingerprint IS the identity.
auth_token is not a transport credential. It is a per-request field on
call.requested payloads (Dispatcher::resolve_identity reads
payload.get("auth_token"); the from_call forwarding handler sets it
via build_forwarded_payload). The dial never delivers it to the
protocol take-over — spawn_dispatch(&self, connection: Connection)
takes no credentials, and Connection (a Box<dyn BidiStreamSource>)
carries no auth_token field. The auth_token in CallCredentials is
unused by the dial and dropped after connect() in the current code.
By moving CallCredentials (with auth_token in it) to alknet-core
for the dial's benefit, ADR-045 §5 would drag a call-protocol concept
into the shared-types crate for the dial's benefit — when the dial
doesn't use it. The dial should consume a transport-level credential
bundle, not a call-protocol one.
The two identity models
Underneath the three transports, there are two identity-consumption models, both consuming the same two dimensions:
| Model | Transports | Consumes | What the transport does |
|---|---|---|---|
| rustls config | QUIC (quinn), TCP+TLS (tokio-rustls) | local_identity → TlsClientConfig (client cert); remote_identity → verifier (FingerprintPinVerifier / WebPkiServerVerifier) |
Builds rustls::ClientConfig, hands to transport connector |
| key-native | iroh, SSH (future — docs/research/references/ssh/russh/06-usage-patterns.md) |
local_identity → Ed25519SecretKey → transport's key type (iroh::SecretKey, russh key); remote_identity → fingerprint → transport's verifier (NodeId match, known_hosts) |
Reads the key directly; transport handles identity internally |
The difference is how each model consumes the dimensions, not what they are. A unified credential bundle carrying just those two dimensions lets every dial extract what its transport's identity layer needs, without call-protocol coupling.
Decision
ConnectionCredentials — the dial's credential bundle
A new type in alknet-core, carrying the two transport-identity
dimensions every dial consumes:
/// Transport-level credentials for an outbound dial. Consumed by
/// `AlknetClient`'s dial methods and (for the server side) by the
/// assembly layer when building transports. Carries only the dimensions
/// the transport's identity layer needs — the local identity (key/cert
/// presented to the transport) and the expected remote identity
/// (fingerprint, driving verifier selection per ADR-034).
///
/// This is NOT the call-protocol credential bundle. The call-protocol
/// `auth_token` (hub-correlated bearer for browsers / `alknet/register`)
/// is a per-request field on `call.requested` payloads, not a
/// transport credential. It stays in the call-protocol layer.
pub struct ConnectionCredentials {
/// The local node's identity (RFC 7250 raw key or X.509), presented
/// to the transport's identity layer. rustls dials → `TlsClientConfig`
/// (client cert via `RawKeyClientCertResolver`); iroh/SSH dials →
/// key directly (`iroh::SecretKey::from_bytes`, russh key).
pub local_identity: Option<TlsIdentity>,
/// Expected identity of the remote node. `Some(fingerprint)` → pin
/// (known peer); `None` → CA verification for X.509 remotes or
/// fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). `None`
/// is the public-X.509-endpoint state, not a missing field.
pub remote_identity: Option<RemoteIdentity>,
}
RemoteIdentity moves with ConnectionCredentials to alknet-core
(both are transport-level types; the dial and the server-side transport
construction both consume them).
Unified dial signatures
All three dials take &ConnectionCredentials:
impl AlknetClient {
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
}
The node_id: iroh::NodeId parameter on dial_iroh is removed — it is
derived from creds.remote_identity.fingerprint (ed25519:<hex> →
NodeId::from_bytes), the same way the rustls dials derive their
verifier from remote_identity. The consistency is now in both the rule
(ADR-034) and the type.
Each dial extracts what its transport's identity layer needs:
- rustls dials (
dial_quic,dial_tcp_tls):creds.local_identity→TlsClientConfig::new(client cert);creds.remote_identity→ClientVerifierContext(verifier selection). - iroh dial (
dial_iroh):creds.local_identity→Ed25519SecretKey→iroh::SecretKey::from_bytes;creds.remote_identity.fingerprint→NodeId(verifier).
CallCredentials is removed (amendment 2026-07-17)
This section supersedes the original "CallCredentials stays in
alknet-call" decision. The original rationale rested on a code path that does not exist. The trace below is the correction.
CallCredentials is removed, not retained. Once the transport
dimensions (local_identity, remote_identity) move to
ConnectionCredentials in alknet-core, CallCredentials would
reduce to a one-field struct { auth_token: Option<AuthToken> } — and
that field has no reader.
The trace (why the original rationale was wrong). The original
section claimed the call protocol uses CallCredentials.auth_token
because "the from_call forwarding handler populates auth_token on
outgoing call.requested payloads." That chain does not connect:
from_call's signature isfrom_call(connection: &CallConnection, config: FromCallConfig)— noCallCredentialsparameter.FromCallConfighas no credential field.- The
auth_tokenthefrom_callforwarding handlers can set on payloads is sourced fromOpSummary.credentials_auth_token, anOption<String>that is hardcoded toNoneat every construction site (from_call.rs:185, 748, 757). It is not read fromCallCredentials.auth_token, and it is a different type (Option<String>vsOption<AuthToken>). The two were never connected, even in intent. - The consuming side —
Dispatcher::resolve_identity(dispatch.rs:119) — readspayload.get("auth_token").as_str()from the per-request call payload. It does not readCallCredentials.
Where auth_token actually originates. It is a per-request payload
field, populated by two real paths, neither of which touches
CallCredentials:
- Browsers over WebSocket — the browser sends
auth_tokendirectly in thecall.requestedJSON payload (websocket/mod.rs:202–206); the WS layer (upgrade.rs:178–181) passesenvelope.payloadstraight todispatch_requested. The browser is the originator; the WS layer is a transparent passthrough. - HTTP gateway (bearer) —
gateway/dispatch.rsresolves theAuthorization: Bearerheader to anIdentityat the HTTP boundary (resolve_bearer, line 58) and passes theIdentityintobuild_root_context. The call protocol sees the resolvedIdentity, not the token.auth_tokendoes not enter the call payload on this path.
So CallCredentials.auth_token is a write-only field (it has a setter,
with_auth_token, and zero readers). connect() — CallCredentials's
only consumer — is removed in Phase 5 of the migration. With connect
gone, nothing constructs or reads CallCredentials except the tests.
auth_token's two real use cases (confirming no call-protocol
credential bundle is needed):
- HTTP auth — the inbound case. The HTTP gateway resolves the
bearer token to an
IdentityviaIdentityProvider::resolve_from_tokenat the HTTP boundary. The call protocol receives theIdentity, not the token. - Registration (
alknet/registernative ALPN,/registerHTTP endpoint) — a client not yet associated with a hub presents a one-time registration token; the hub creates aPeerEntry(a new identity based on the fingerprint). Outbound, the vault manages the token on the client side; inbound, the hub's registration handler consumes it. Neither path involvesCallCredentials.
A hub does not "forward with its own token" in the way the original
rationale assumed. Where the hub authenticates to an outside service
(another hub's HTTP interface, an external API), the vault manages that
outbound token — it is not a call-protocol credential. The
from_call credentials_auth_token path was a future hatch for a
use case that dissolved once IdentityProvider::resolve_from_token
solved the inbound identity problem: the hub authenticates as itself
(its Identity is on the connection), and the spoke authorizes the hub
as the direct caller. No per-forwarded-call token is needed.
from_call's credentials_auth_token is removed too. It is the
same family of dead code — an always-None field of a different type
than CallCredentials.auth_token, never connected to anything. The
credentials_auth_token field on OpSummary, the credentials_auth_token
parameters on make_forwarding_handler / make_streaming_forwarding_handler,
and the auth_token parameter on build_forwarded_payload are removed.
The forwarding handlers stop emitting auth_token in payloads (which
they never did in practice — the source was always None). The two
from_call tests asserting the Some path
(build_forwarded_payload_sets_auth_token_when_provided,
streaming_forwarding_handler_sets_auth_token_when_provided) are
removed — they test a code path never exercised in production. If a
future hub needs its own token on forwarded payloads, that is a fresh,
end-to-end-wired feature, not a vestigial path.
What does NOT move to alknet-core: ConnectionCredentials and
RemoteIdentity move (the original decision). CallCredentials does
not move — it is removed. ADR-045 §5's move of CallCredentials to
core is superseded twice over: first by the original ADR-012 (move
ConnectionCredentials instead), and now by this amendment (remove
CallCredentials entirely). There is no call-protocol credential
bundle; auth_token is a per-request payload field, full stop.
TlsClientConfig::new input framing
TlsClientConfig::new (ADR-087) takes a ClientVerifierContext derived
from the credential bundle's remote_identity. The rustls dials extract
creds.local_identity and creds.remote_identity from
ConnectionCredentials and build ClientVerifierContext from the latter
— the same extraction ADR-087 described, just from
ConnectionCredentials instead of CallCredentials. The auth_token
dimension is simply not present in ConnectionCredentials, so the
"stripped at the TLS boundary" framing (ADR-045's claim that the token
"travels with the Connection") is no longer needed — the token was never
in the dial's credential bundle to strip.
Future dial_ssh validates the shape
An SSH dial (docs/research/references/ssh/russh/06-usage-patterns.md)
consumes the same two dimensions:
check_server_key(&mut self, key: &ssh_key::PublicKey)— the verifier (fingerprint pin against known_hosts =remote_identity)authenticate_publickey("user", PrivateKeyWithHashAlg::new(...))— local identity (the Ed25519 key =local_identity)channel_open_session()→Connection::from_bidi(ADR-007)
dial_ssh(addr, alpn, creds: &ConnectionCredentials) fits the same
signature. The SSH host-key verification is fingerprint-pinning
(known_hosts), which is what remote_identity carries. The local SSH
key is the same Ed25519 key iroh and raw-key quinn use. The pattern is
general — ConnectionCredentials covers it without call-protocol
coupling. SSH itself is unspecced (not yet specced — comes after
channels, tunnels, TTY rework), but the russh usage patterns confirm
the credential dimensions.
Consequences
Positive:
- The dial is fully decoupled from the call protocol.
ConnectionCredentialscarries only transport-identity dimensions;alknet-clienthas no call-protocol coupling in its credential type. There is no call-protocol credential bundle —auth_tokenis a per-request payload field, not a credential. - All three dial signatures are unified. A caller no longer needs to
know that iroh takes a bare key while quinn/tcp take a credential
bundle — all take
&ConnectionCredentials. Thenode_idparameter ondial_irohis derived fromremote_identity, the same extraction pattern the rustls dials use for the verifier. - The
auth_tokenspec inaccuracy is fixed. ADR-045 claimed theauth_token"travels with theConnectioninto the protocol take-over, where it is sent as the first call-protocol frame." This was aspirational —Connectioncarries noauth_token, andspawn_dispatchtakes no credentials. WithCallCredentialsremoved, the claim is not merely unneeded; the field it described was never read.auth_tokenis a per-request field oncall.requestedpayloads, set by browsers (in the WS payload) or resolved by the HTTP gateway at its boundary (bearer →Identity). dial_sshfits the same shape when it arrives. The credential dimensions SSH needs (local key + expected host key) are exactly whatConnectionCredentialscarries. No future ADR needed for the SSH dial signature.- A dead credential type and a dead forwarding-token path are removed
(amendment 2026-07-17).
CallCredentialsis removed (itsauth_tokenfield had no reader).from_call'scredentials_auth_tokenis removed (alwaysNone, different type thanCallCredentials.auth_token, never connected). Both were future hatches from the era beforeIdentityProvider::resolve_from_tokensolved the inbound identity problem; the hatches dissolved once it did. See the amended §"CallCredentialsis removed" above for the trace.
Negative:
CallCredentialsis removed (a public type). Callers that constructedCallCredentials(the integration test; any future assembly-layer code) switch toConnectionCredentialsfor the dial.auth_token, where needed, is a per-request payload field (browsers send it in the WS payload; the HTTP gateway resolves bearer →Identityat its boundary). This is expected —connect()wasCallCredentials's only consumer and is removed in the same migration. There are no external consumers (develop branch is a total rewrite).- The assembly layer builds one credential bundle, not two. Where
ADR-045 had the assembly layer build one
CallCredentials(and the original ADR-012 reframed it as two —ConnectionCredentialsfor the dial + a per-requestauth_token), the assembly layer now buildsConnectionCredentialsfor the dial only.auth_tokenis not a credential the assembly layer constructs; it is a per-request payload field the browser (or the HTTP gateway's bearer resolution) supplies. This is fewer types at the assembly site, not more. - ADR-045 §5's "CallCredentials moves to core" is superseded twice.
The original ADR-012 reframed the move target as
ConnectionCredentials(notCallCredentials); this amendment removesCallCredentialsentirely. What moves toalknet-core:ConnectionCredentials+RemoteIdentity. What does not move:CallCredentials(removed, not relocated). This affects the extraction plan's Phase 0 (additive credentials move) and Phase 5 (the call prune now removesCallCredentialsand thefrom_calldead path, not justconnectand the TLS helpers).
Door type
One-way. The dial signatures (dial_quic / dial_tcp_tls /
dial_iroh all taking &ConnectionCredentials) are the public API
surface of alknet-client. The credential-type decoupling
(ConnectionCredentials in core, no call-protocol credential bundle)
determines the dep graph (alknet-client depends on alknet-core for
ConnectionCredentials, not on alknet-call). Reversing would mean
re-coupling the dial to the call protocol's credential type and
re-asymmetrizing the iroh dial. The CallCredentials removal
(amendment 2026-07-17) is the same door — removing a public type whose
only consumer (connect) is removed in the same migration. The crate
is greenfield (Phase 3 of the extraction plan), so the door is still
open now — this ADR records the decisions before implementation.
References
- ADR-045 —
AlknetClientnative dial seam (§3 dial signatures amended — all take&ConnectionCredentials; §5 move amended —ConnectionCredentials/RemoteIdentitymove to core, notCallCredentials; §5 further amended 2026-07-17 —CallCredentialsremoved, not retained inalknet-call) - ADR-087 —
TlsClientConfignot blocked on dial (input framing amended —ClientVerifierContextderived fromConnectionCredentials.remote_identity, notCallCredentials) - ADR-034 — client-side verifier selection (the rule
ConnectionCredentials.remote_identitydrives — unchanged) - ADR-022 §7 — the three credential dimensions (the historical source of
CallCredentials's three fields; the transport dimensions moved toConnectionCredentials, theauth_tokendimension is a per-request payload field, andCallCredentialsitself is removed) crates/alknet-call/src/protocol/dispatch.rs—Dispatcher::resolve_identityreadspayload.get("auth_token")(per-request, not connection-level — the consumer ofauth_token)crates/alknet-call/src/client/from_call.rs— thecredentials_auth_tokenfield onOpSummaryand theauth_tokenparameter onbuild_forwarded_payload(the removed dead path; alwaysNone, different type thanCallCredentials.auth_token, never connected)crates/alknet-http/src/gateway/dispatch.rs—resolve_bearer(the HTTP path: bearer →Identityat the boundary; the call layer sees the identity, not the token)crates/alknet-http/src/websocket/mod.rs— the WS path:auth_tokenin the browser's call payload, passed through todispatch_requestedunchangeddocs/research/references/ssh/russh/06-usage-patterns.md— the SSH client usage patterns (check_server_key + authenticate_publickey) validating theConnectionCredentialsshape for a futuredial_ssh