Files
alkcall/docs/architecture/decisions/012-connectioncredentials-decouple-dial-from-call.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
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).
2026-08-12 07:06:57 +00:00

21 KiB
Raw Blame History

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):

  1. tls_identity: Option<TlsIdentity> — the local node's key/cert
  2. auth_token: Option<AuthToken> — a call-protocol-level bearer token
  3. remote_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 IdentityProvider resolves 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_identityTlsClientConfig (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_identityEd25519SecretKey → 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_identityTlsClientConfig::new (client cert); creds.remote_identityClientVerifierContext (verifier selection).
  • iroh dial (dial_iroh): creds.local_identityEd25519SecretKeyiroh::SecretKey::from_bytes; creds.remote_identity.fingerprintNodeId (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 is from_call(connection: &CallConnection, config: FromCallConfig) — no CallCredentials parameter. FromCallConfig has no credential field.
  • The auth_token the from_call forwarding handlers can set on payloads is sourced from OpSummary.credentials_auth_token, an Option<String> that is hardcoded to None at every construction site (from_call.rs:185, 748, 757). It is not read from CallCredentials.auth_token, and it is a different type (Option<String> vs Option<AuthToken>). The two were never connected, even in intent.
  • The consuming side — Dispatcher::resolve_identity (dispatch.rs:119) — reads payload.get("auth_token").as_str() from the per-request call payload. It does not read CallCredentials.

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_token directly in the call.requested JSON payload (websocket/mod.rs:202206); the WS layer (upgrade.rs:178181) passes envelope.payload straight to dispatch_requested. The browser is the originator; the WS layer is a transparent passthrough.
  • HTTP gateway (bearer)gateway/dispatch.rs resolves the Authorization: Bearer header to an Identity at the HTTP boundary (resolve_bearer, line 58) and passes the Identity into build_root_context. The call protocol sees the resolved Identity, not the token. auth_token does 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):

  1. HTTP auth — the inbound case. The HTTP gateway resolves the bearer token to an Identity via IdentityProvider::resolve_from_token at the HTTP boundary. The call protocol receives the Identity, not the token.
  2. Registration (alknet/register native ALPN, /register HTTP endpoint) — a client not yet associated with a hub presents a one-time registration token; the hub creates a PeerEntry (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 involves CallCredentials.

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. ConnectionCredentials carries only transport-identity dimensions; alknet-client has no call-protocol coupling in its credential type. There is no call-protocol credential bundle — auth_token is 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. The node_id parameter on dial_iroh is derived from remote_identity, the same extraction pattern the rustls dials use for the verifier.
  • The auth_token spec inaccuracy is fixed. ADR-045 claimed the auth_token "travels with the Connection into the protocol take-over, where it is sent as the first call-protocol frame." This was aspirational — Connection carries no auth_token, and spawn_dispatch takes no credentials. With CallCredentials removed, the claim is not merely unneeded; the field it described was never read. auth_token is a per-request field on call.requested payloads, set by browsers (in the WS payload) or resolved by the HTTP gateway at its boundary (bearer → Identity).
  • dial_ssh fits the same shape when it arrives. The credential dimensions SSH needs (local key + expected host key) are exactly what ConnectionCredentials carries. 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). CallCredentials is removed (its auth_token field had no reader). from_call's credentials_auth_token is removed (always None, different type than CallCredentials.auth_token, never connected). Both were future hatches from the era before IdentityProvider::resolve_from_token solved the inbound identity problem; the hatches dissolved once it did. See the amended §"CallCredentials is removed" above for the trace.

Negative:

  • CallCredentials is removed (a public type). Callers that constructed CallCredentials (the integration test; any future assembly-layer code) switch to ConnectionCredentials for the dial. auth_token, where needed, is a per-request payload field (browsers send it in the WS payload; the HTTP gateway resolves bearer → Identity at its boundary). This is expected — connect() was CallCredentials'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 — ConnectionCredentials for the dial + a per-request auth_token), the assembly layer now builds ConnectionCredentials for the dial only. auth_token is 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 (not CallCredentials); this amendment removes CallCredentials entirely. What moves to alknet-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 removes CallCredentials and the from_call dead path, not just connect and 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 — AlknetClient native dial seam (§3 dial signatures amended — all take &ConnectionCredentials; §5 move amended — ConnectionCredentials/RemoteIdentity move to core, not CallCredentials; §5 further amended 2026-07-17 — CallCredentials removed, not retained in alknet-call)
  • ADR-087 — TlsClientConfig not blocked on dial (input framing amended — ClientVerifierContext derived from ConnectionCredentials.remote_identity, not CallCredentials)
  • ADR-034 — client-side verifier selection (the rule ConnectionCredentials.remote_identity drives — unchanged)
  • ADR-022 §7 — the three credential dimensions (the historical source of CallCredentials's three fields; the transport dimensions moved to ConnectionCredentials, the auth_token dimension is a per-request payload field, and CallCredentials itself is removed)
  • crates/alknet-call/src/protocol/dispatch.rsDispatcher::resolve_identity reads payload.get("auth_token") (per-request, not connection-level — the consumer of auth_token)
  • crates/alknet-call/src/client/from_call.rs — the credentials_auth_token field on OpSummary and the auth_token parameter on build_forwarded_payload (the removed dead path; always None, different type than CallCredentials.auth_token, never connected)
  • crates/alknet-http/src/gateway/dispatch.rsresolve_bearer (the HTTP path: bearer → Identity at the boundary; the call layer sees the identity, not the token)
  • crates/alknet-http/src/websocket/mod.rs — the WS path: auth_token in the browser's call payload, passed through to dispatch_requested unchanged
  • docs/research/references/ssh/russh/06-usage-patterns.md — the SSH client usage patterns (check_server_key + authenticate_publickey) validating the ConnectionCredentials shape for a future dial_ssh