Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
13 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| w4-dial | Wave 4: the client dial surface (russh client stack + the connection handle) | completed |
|
moderate | medium | phase | implementation |
Description
Land the client direction's substrate, the door facing outward
(client.md §The dial surface): the russh client stack every wave-4
op row composes into, mirroring w2-server-mount's role for the
server direction. Everything later in the wave rides the authenticated
connection handle this task lands.
- The dial surface: a
connect-shaped entry taking the wave-1 [DialConfig](the address, the key source, the host-key policy, theLimits): the russh clientConfigbuilt from the bounded knobs (keepalive 15 s / 3 default posture per the reverse-runner evidence andclient.md§Correctness facts; inactivity; window/packet knobs peroverview.md's table), the dial target asDialConfig.addresscarries it. If the dial surface needs aDialConfigamendment, land + record it here (thew3-seam-configcourse-correction pattern), while the wave-1 shape is expected to hold; decide and record. - Key source decrypt-at-dial (ADR-005), with the [
KeySource] load happens at dial time; locked / absent / decryption-failure / unknown-key_versionare all no-dial outcomes with their distinctKeySourceErrorvariants surfaced (never env vars, never plaintext at rest; the adapter-sideZeroizingwrap per OQ-07 already landed in wave 1, consumed here). - Host-key policy at
check_server_key(OQ-05), the wave-1 [HostKeyPolicy] seam consumed at russh's client hook (the verdict type was pinned against this hook shape inw1-host-key-policy): the crate default ([RejectAll]) connects to nothing; case-1 pins hard-decline on mismatch; case-2 assembly policies decide. The crate is policy-blind, with no accept-all built-in. - The client Handler skeleton at fail-closed defaults: the
russh
client::Handlerimpl the dial constructs: server-initiated channel opens (server_channel_open_forwarded_tcpip/_forwarded_streamlocal/_unknown/ x11 / agent) declined cleanly, never russh's accept-and-drop default (ADR-003 §4, the client-side symmetry rule; the acceptor registry that serves them isw4-reverse-listen's surface, this task pins the default). - The authenticated connection handle, the type everything else
rides: the russh
client::Handlewrapped with the crate's surface (publickey-only authentication from the key source, the family contract; no password/keyboard-interactive paths), the policy and limits carried, the lifecycle primitives' raw material (full cancellation/clean-close shape isw4-lifecycle's). - Test vehicle, because the client direction dials vanilla
servers, so
the loopback harness (alkssh server) is not the vehicle: land an
in-process russh server double (a fake
sshd: a hand-rolledrussh::serverhandler intests/common) as the contract-test vehicle, decide + record its placement and shape. Contract rows: dial success (authenticated), each key-source failure posture (locked/absent/decryption), policy-decline rows (default-reject, pin-mismatch hard decline, case-2 accept), the bounded-config wiring observable, server-initiated open declined cleanly. - The smoke posture decided + recorded (an alkssh-client ↔
alkssh-server in-process loopback is the cheap real-stack smoke;
a real-
sshddial is wave-5 e2e territory, never silently dropped).
Acceptance Criteria
- The dial surface lands over
DialConfig(+ any amendment decided and recorded); the russh clientConfigcarries the bounded knobs (keepalive 15 s / 3 posture, inactivity, window) - Key source decrypt-at-dial; every
KeySourceErrorfailure posture is a no-dial outcome with its distinct variant surfaced check_server_keycalls the injected policy;RejectAllconnects to nothing; pin-mismatch hard-declines; no accept-all built-in- The client Handler skeleton declines server-initiated opens cleanly (fail-closed default pinned by wire rows)
- The authenticated connection handle lands; authentication is publickey-only from the key source
- The in-process server double lands in
tests/common; the contract rows above green; the smoke posture decided + recorded cargo test(+ per-feature +--all-features), clippy ×2 (-D warnings), fmt,cargo doc --no-depspass
References
- docs/architecture/client.md §The dial surface, §Correctness facts (the POC-recorded mechanics), §What the crate does not include
- docs/architecture/decisions/002-russh-pin.md (the client russh surface), docs/architecture/decisions/003-feature-graph.md (§4 the client-side fail-closed acceptor default this skeleton pins), docs/architecture/decisions/005-key-sources.md (decrypt-at-dial)
- docs/research/russh.md §Client surface (
client::Handlerinventory, wherecheck_server_keydefaults to refuse; theserver_channel_open_*hook set;client::Configknobs), docs/research/reverse-runner.md (lessons 3/5, with the keepalives load-bearing, the ephemeral-fleet policy posture) - docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing facts), docs/research/poc-2-vault-keys.md (the dial lifecycle)
- src/config.rs (
DialConfig), src/host_key.rs, src/key_source.rs (the wave-1 seams consumed)
Notes
What landed (2026-10-29)
src/client.rs: the dial surface,dial(DialConfig)over [DialConfig], the four-step sequence (key load → bounded russhConfig→ policy atcheck_server_key→ single publickey auth), [SshConnection] (the connection handle:limits()/address()/username()/is_closed()/close(), the OQ-12 raw material; the full lifecycle surface isw4-lifecycle's), the address parse (host:port+ the bracket form;DialError::Addresson refusal (dial-time, matching the family's no-construction-validation shape), and the doc-hiddenrussh_client_configmapping (the bounded-config observable).src/client_handler.rs: the client Handler skeleton (the mirror ofhandler.rs's server-direction handler; a separate module becausew4-op-rows/w4-reverse-listengrow it), withcheck_server_keywired to the injected policy (certificates fingerprint their inner key; the verdict passes through), everyserver_channel_open_*hook overridden explicit-over-inherited with aChannelOpenFailure::AdministrativelyProhibitedreject (never russh's accept-and-drop default),should_accept_unknown_server_channelthe explicitfalse, decline events on the crate vocabulary.src/error.rs: [DialError], the dial direction's closed taxonomy (KeySourceper-variant preserved /Address/HostKeyRejected/AuthRejected/Wire), with a manualFrom<russh::Error>that upgrades russh'sUnknownKey(the only producer of which is thecheck_server_keydecline path) intoHostKeyRejected; hence theWirevariant carries no#[from](one conversion, one place).src/config.rs: the DialConfig amendment (below) + doc updates;src/observe.rs: the client-side decline reasons (no-acceptor, the skeleton's decline-all default; the registry isw4-reverse-listen's) recorded in the vocabulary;src/lib.rs: the re-exports (dial/SshConnection/DialErrorpublic; the handler + config mapping#[doc(hidden)]test surface, theserve.rspattern).tests/common/sshd_double.rs: the fakesshd(below);tests/client_dial.rs: the contract rows incl. the smoke.
Decided + recorded
- The DialConfig amendment (
username), the task's sanctioned course-correction, the only one. The wave-1 shape carried everythingclient.md§The dial surface names, but the USERAUTH_REQUEST structurally requires a user string (authenticate_publickey(user, key)) and the remote end evaluates it, and no dial can exist without one. Decided with thew3-seam-configpattern:username: Stringis a requiredDialConfig::newparameter (no crate default, since a default account would be deployment policy smuggled into the crate; the empty string is accepted as data and is the remote end's to refuse). It is not identity, as the identity model stays fingerprint-only (ADR-004 decision 5, mirrored client-side), it is never logged (the event vocabulary carries no username), and it rides [SshConnection] as carried dial data. Wave-1 records amended (the config module + field docs). - The server double's placement/shape
(
tests/common/sshd_double.rs): a plain russh server (no alkssh in its serving path, hence vanilla-parity) on a real loopback TCP listener at127.0.0.1:0(a dial target is an address the surface resolves and connects to; the duplex-pair trick is the server-direction harness's, and a dial has no&mut Handleslot to attach one to). One dial per instance (doubles are cheap); the handler resolves fingerprints against an injected accept set (the vanilla-authorized_keysstand-in) with every other auth hook an explicit reject; theauth_succeededhook hands the row the per-connectionrussh::server::Handlethrough a one-shot carrier, which is the initiator for the server-initiated-open decline rows (the double callschannel_open_*at the client and observes each asErr(ChannelOpenFailure(AdministrativelyProhibited))); a per-session outcome channel is the clean-close rows' observable; an optional client-session acceptor stub exists for later rows (next_opened_session). - The smoke posture: an alkssh-client ↔ alkssh-server
in-process loopback over a real TCP socket, landed as the row
smoke_alkssh_client_dials_the_alkssh_server_direction(tests/client_dial.rs, with both russh stacks, the full auth bridge server-side, the full custody + policy chain client-side). A real-sshddial is wave-5 e2e territory (docs/plans/ implementation.md§Wave 5); nothing is silently dropped. - The decline-class mapping: russh surfaces a policy decline as
its
UnknownKeywire error; since the client'scheck_server_keyhook is the only producer of that class, the manualFrommakes it the semantic [DialError::HostKeyRejected], while every other russh error stays [DialError::Wire]. - The skeleton's unknown-type gate (
should_accept_unknown_ server_channel→ explicitfalse) is code-pinned, not row-pinned: russh's serverHandleexposes no initiator for an unknown channel type, and russh'sChannelIdis deliberately unconstructible outside the library, so no wire row can reach the hook. The six reachable hooks (session / direct-tcpip / forwarded-tcpip / forwarded-streamlocal / x11 / agent) are wire-row-pinned; the_unknownopen hook is a defensive reject behind the gate. - Auth posture: exactly one
authenticate_publickeyattempt (ed25519-only, ADR-002 decision 4,Nonebeing the whole hash-algorithm posture); a refusal closes the attempt cleanly (Disconnect::ByApplication) and surfaces [DialError::AuthRejected]. russh's other auth methods (password / kbi / cert / gss / signer-injection, with the signer seam dismissed by ADR-005 decision 8) are never reached from [SshConnection]. - Bounded posture: the knob mapping keeps the crate's documented
postures (
keepalive15 s / 3, the wall clock alwaysSome, window/packet/buffer verbatim) and leaves russh's own default postures untouched (rekeyLimits, preferred algorithms,client_id,nodelay. No client-side auth-attempt knob exists at this pin and the single-attempt contract makes none needed).
Verification
cargo test (default) and cargo test --all-features (and
per-feature --features {session,git,tunnel}) all green, with 14 test
binaries each, the new tests/client_dial.rs carrying 10 rows;
cargo clippy --all-features --all-targets and cargo clippy --all-targets both -D warnings clean; cargo fmt --check clean;
cargo doc --no-deps warning-free.
Summary
The client direction's substrate landed: dial(config) -> Result<SshConnection, DialError> runs the fail-closed four-step
sequence (decrypt-at-dial key load with every no-dial posture
distinct; the bounded russh client Config from Limits; the
injected host-key policy at check_server_key, where RejectAll
connects to nothing; a single publickey-only auth attempt) and
returns the authenticated connection handle everything later in the
wave rides. One sanctioned DialConfig amendment: the required
username field (the USERAUTH_REQUEST's user string; no crate
default, never identity, never logged). The fail-closed client
Handler skeleton declines every server-initiated open on the wire
(the fake-sshd double in tests/common pins six hook rows +
decline events), and the alkssh-client ↔ alkssh-server loopback
smoke is landed as a row. The w4-op-rows chain grows this
skeleton in place.