docs(arch): ConnectionCredentials decouples dial from call protocol (ADR-091) + migration plan cleanups

ADR-091: ConnectionCredentials — decouple the dial credential bundle
from the call protocol. CallCredentials (call-protocol-level, carries
auth_token) was being used as the dial's credential type, coupling the
dial to the call protocol. The auth_token is a hub-layer identity
correlation mechanism (browsers, alknet/register), not a transport
credential. ConnectionCredentials (transport-level: local_identity +
remote_identity) is the dial's credential bundle; all three dial
signatures unify on &ConnectionCredentials; dial_iroh's node_id
parameter is derived from remote_identity.fingerprint. CallCredentials
stays in alknet-call with auth_token. The shape is validated by the
future dial_ssh pattern (russh's check_server_key + authenticate_publickey
consume the same two dimensions).

Amends ADR-089 §3 (dial signatures) and §5 (move consequence), ADR-087
(input framing). Updates client/tls/call/core crate specs and the
extraction plan's Phase 0/3/4/5.

Migration plan cleanups (findings.md):
- Remove duplicated ordering-rationale bullets (copy-paste artifact)
- TL;DR: six phases -> seven (Phase 0 promoted); four compilable
  intermediate states -> each phase leaves workspace compilable
- Remove inline 'Wait — that's 10, not 8' self-correction; fix
  Category B header to (10 tests)
- Replace contradictory line ranges in Net Phase 5 test impact with
  name-based references
- Decide quinn feature fate: removed (not no-op)
- Note webpki-roots always-present per ADR-088 §5 in Phase 1 dep list
This commit is contained in:
glm-5.2 committed 2026-07-17 05:56:47 +00:00
1 parent 16ae3cf6c9
commit 613bf680cd
9 files changed
+599 -154

No files matched your search

@@ -2,7 +2,10 @@
## Status
Accepted (resolves OQ-64)
Accepted (resolves OQ-64; input framing amended 2026-07-16 by ADR-091 —
`ClientVerifierContext` is derived from `ConnectionCredentials.remote_identity`,
not `CallCredentials.remote_identity`; the `auth_token` is not in the
dial's credential bundle)
## Context
@@ -122,6 +125,16 @@ in ADR-034; the struct is a bag of already-decided inputs). It is
sketched lightly here; the full variant-granularity of `TlsError` is
OQ-63 (the next session).
> **Amendment 2026-07-16 (ADR-091):** `ClientVerifierContext` is derived
> from `ConnectionCredentials.remote_identity` (the transport-level
> credential bundle), not `CallCredentials.remote_identity`. The dial
> (`AlknetClient::dial_quic` / `dial_tcp_tls`) extracts
> `creds.remote_identity` from `ConnectionCredentials` and builds
> `ClientVerifierContext` from it. `CallCredentials` is no longer in the
> dial's path — its `auth_token` dimension is a call-protocol concept,
> not a transport credential. See
> [ADR-091](091-connectioncredentials-decouple-dial-from-call.md).
This is **not** the dial. `TlsClientConfig` produces a
`rustls::ClientConfig`; the caller (the transport-specific dial helper
— now `AlknetClient::dial_quic` / `dial_tcp_tls` per ADR-089; the
@@ -2,7 +2,14 @@
## Status
Accepted (resolves OQ-55)
Accepted (resolves OQ-55; §3 and §5 amended 2026-07-16 by ADR-091 —
the dial credential bundle is `ConnectionCredentials` (transport-level),
not `CallCredentials` (call-protocol-level); all three dial signatures
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
derived from `remote_identity`; the `auth_token` stays in the
call-protocol layer, not the dial; `CallCredentials` stays in
`alknet-call`, only `ConnectionCredentials`/`RemoteIdentity` move to
`alknet-core`)
## Context
@@ -195,6 +202,9 @@ impl AlknetClient {
local_key: &alknet_core::config::Ed25519SecretKey,
) -> Result<Connection, ClientDialError>;
}
// NOTE: The signatures above are the ORIGINAL (pre-ADR-091) shapes.
// ADR-091 amends §3 — see the amendment note below.
```
The three dials share `CallCredentials` (the local identity + remote
@@ -205,6 +215,33 @@ server side's "iroh shares the key, not the config" (ADR-082, ADR-087
§3) — the consistency is in the rule (ADR-034 verifier selection), not
in the type.
> **Amendment 2026-07-16 (ADR-091):** The dial credential bundle is
> `ConnectionCredentials`, not `CallCredentials`. `CallCredentials`
> couples the dial to the call protocol (its `auth_token` field is a
> call-protocol / hub-layer concept — bearer-token identity correlation
> for browsers and `alknet/register`, not a transport credential). The
> dial uses only the transport-identity dimensions (`local_identity` +
> `remote_identity`); those move to `ConnectionCredentials` in
> `alknet-core`. All three dial signatures unify on
> `&ConnectionCredentials`:
>
> ```rust
> dial_quic(addr, server_name, alpn, creds: &ConnectionCredentials) -> Connection
> dial_tcp_tls(host, addr, alpn, creds: &ConnectionCredentials) -> Connection
> dial_iroh(alpn, creds: &ConnectionCredentials) -> Connection
> ```
>
> 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 extraction pattern
> the rustls dials use for the verifier. The consistency is now in both
> the rule (ADR-034) and the type. `CallCredentials` stays in
> `alknet-call` as the call-protocol credential bundle; the
> `auth_token` is a per-request field on `call.requested` payloads
> (set by the caller or the `from_call` forwarding handler, resolved by
> `Dispatcher::resolve_identity`), not a dial-level credential. See
> [ADR-091](091-connectioncredentials-decouple-dial-from-call.md).
### 4. The dial is transport-polymorphic across the native endpoint type
The native endpoint type (ADR-086) has QUIC + TCP+TLS (both
@@ -259,6 +296,18 @@ options the original ADR-089 draft called a "two-way-door
implementation detail"; it is not implementation detail — it determines
the dep graph, and the dep graph requires it.
> **Amendment 2026-07-16 (ADR-091):** This consequence is superseded.
> `CallCredentials` does **not** move to `alknet-core` — it stays in
> `alknet-call` (it is the call-protocol credential bundle; its
> `auth_token` field is a call-protocol / hub-layer concept, not a
> transport credential). What moves to `alknet-core` is
> `ConnectionCredentials` (a new type carrying only the
> transport-identity dimensions: `local_identity` + `remote_identity`)
> and `RemoteIdentity`. The dial consumes `ConnectionCredentials`, not
> `CallCredentials`. All three dial signatures unify on
> `&ConnectionCredentials`. See
> [ADR-091](091-connectioncredentials-decouple-dial-from-call.md).
**Consequence: `FingerprintPinVerifier` moves to `alknet-tls`.** With
`connect` removed and the verifier-selection logic centralized in
`TlsClientConfig::new` (ADR-087), `FingerprintPinVerifier` has no
@@ -0,0 +1,313 @@
# ADR-091: `ConnectionCredentials` — Decouple the Dial Credentials from the Call Protocol
## Status
Accepted (amends ADR-089 §3 and §5; amends ADR-087's `TlsClientConfig::new`
input framing)
## Context
ADR-089 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:
```rust
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-017 §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-089 §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:
```rust
/// 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`:
```rust
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` stays in `alknet-call`
`CallCredentials` remains the **call-protocol** credential bundle. Its
`auth_token` field stays — the call protocol uses it (the `from_call`
forwarding handler populates `auth_token` on outgoing `call.requested`
payloads; the hub's `Dispatcher::resolve_identity` resolves it via
`IdentityProvider::resolve_from_token`). The call protocol layer
assembles `CallCredentials` from `ConnectionCredentials` (the
transport-level dimensions) + the call-protocol `auth_token`, or the
caller provides the `auth_token` per-request via `call_with_payload`.
`CallCredentials` does **not** move to `alknet-core`. ADR-089 §5's move
of `CallCredentials` to core is superseded by this ADR: only
`ConnectionCredentials` and `RemoteIdentity` move to core. The call
protocol's own credential type stays in the call crate.
### `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-089'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-065)
`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.
The `auth_token` (a call-protocol / hub-layer concept) stays in
`alknet-call` where it belongs.
- **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-089 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 `auth_token` out of the
dial's credential bundle entirely, the claim is no longer needed. The
token is a per-request field on `call.requested` payloads, set by the
caller or the `from_call` forwarding handler.
- **`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.
- **`CallCredentials` stays in `alknet-call` — its home.** The call
protocol's own credential type is not dragged into core for the dial's
benefit. Core gets `ConnectionCredentials` (transport-level); the call
crate keeps `CallCredentials` (protocol-level).
**Negative:**
- **`CallCredentials` loses two fields.** `tls_identity` and
`remote_identity` move to `ConnectionCredentials` in core;
`CallCredentials` becomes `{auth_token: Option<AuthToken>}` (or is
restructured — the call protocol may assemble it from
`ConnectionCredentials` + `auth_token` at the take-over site, or the
caller provides `auth_token` per-request). The exact restructure is a
two-way-door implementation detail; the one-way decision is that the
transport dimensions leave `CallCredentials`.
- **The assembly layer assembles two credential bundles, not one.** Where
ADR-089 had the assembly layer build one `CallCredentials`, it now
builds `ConnectionCredentials` (for the dial) and, separately,
provides the `auth_token` to the call-protocol layer (for the
per-request payload). This is the correct layering — the dial and the
protocol consume different dimensions — but it is one more type at the
assembly site.
- **ADR-089 §5's "CallCredentials moves to core" is superseded.** The
move target changes from `CallCredentials` to `ConnectionCredentials`
+ `RemoteIdentity`. `CallCredentials` stays in `alknet-call`. This
affects the extraction plan's Phase 0 (the additive credentials move).
## 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, `CallCredentials` in call) determines
the dep graph (`alknet-client` depends on `alknet-core` for
`ConnectionCredentials`, not on `alknet-call` for `CallCredentials`).
Reversing would mean re-coupling the dial to the call protocol's
credential type and re-asymmetrizing the iroh dial. The crate is
greenfield (Phase 3 of the extraction plan), so the door is still open
now — this ADR records the decision before implementation.
## References
- ADR-089 — `AlknetClient` native dial seam (§3 dial signatures amended
— all take `&ConnectionCredentials`; §5 move amended —
`ConnectionCredentials`/`RemoteIdentity` move to core, not
`CallCredentials`)
- 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-017 §7 — the three credential dimensions (the source of
`CallCredentials`'s three fields; this ADR splits the transport
dimensions from the protocol dimension)
- `crates/alknet-call/src/protocol/dispatch.rs` —
`Dispatcher::resolve_identity` reads `payload.get("auth_token")`
(per-request, not connection-level)
- `crates/alknet-call/src/client/from_call.rs` —
`build_forwarded_payload` sets `auth_token` on outgoing payloads
(per-request, the hub's own token)
- `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`