Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
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.
2026-11-04 20:28:00 +00:00

12 KiB
Raw Permalink Blame History

status, last_updated
status last_updated
reviewed 2026-10-29

Client (client direction): driving vanilla SSH servers as family ops

The door framing

The client direction is a door to vanilla servers: family in, vanilla out. The motivating consumer is the reverse-runner shape (docs/research/reverse-runner.md, alkminer extraction): a family hub dials vanilla sshd on rented/near-ephemeral hosts; the remote boxes expose plain sshd and hold nothing of ours; every op the hub drives maps family-side onto the same protocols the server direction serves. The asymmetry is honest (vision principle 2): the server direction serves family seam entries; the client direction consumes family types. Same features gate both (ADR-003 symmetry rule).

Type symmetry at the seam is the client contract: a family app drives alkssh in family-protocol types; alkssh translates to vanilla wire ops (russh client surface, ADR-002). No vanilla-concept leaks up into the family surface.

The dial surface

Every dial needs: address, key source (ADR-005, a vault-backed blob: decrypt-at-dial, fail-closed on locked/absent, never env vars, never plaintext at rest), host-key policy (the OQ-05 resolution, assembly-provided; crate default fail-closed reject-unknown, hard decline on mismatch; the OpenSSH "add to known_hosts?" moment is an assembly policy, e.g. learn-and-record / prompt / provider-metadata match, and the crate is policy-blind), and the bounded-resource config (keepalive_interval/keepalive_max, a 15 s / 3 default posture per the reverse-runner evidence; inactivity_timeout; window/packet knobs per overview.md).

The identity the dial authenticates as is a registered fingerprint at the remote end: for family-run peers (alkforge runner) that's the alkcall IdentityProvider; for vanilla fleets it's whatever the vanilla server's authorized_keys holds. Either way the key is ours from the vault and the host's key is checked by the policy.

Op rows (family op → vanilla translation)

Family-facing op Family protocol/types Vanilla-wire translation Feature
run a command / own a shell on the runner alktty session types (pipe vs PTY; exit semantics) channel_open_session + exec/pty-req/shell-req; data pump; drain wait() per the POC-3 §D.3 mechanics (Channel::exec(), not request_exec; Success/Failure arrive on the channel queue, not as Err from the request call) session
open a tunnel target alktunnels TunnelSession/target types channel_open_direct_tcpip per connection; the typed open resolves to Err(ChannelOpenFailure) on server reject (POC-1 §6); no wait() before into_stream tunnel
the -D behavior (local SOCKS door) alksocks' local door composed at the assembly, fed a DialFn backed by row 2's open the wire sees only direct-tcpip; alkssh does not depend on alksocks on this path (ADR-003 §1a) tunnel
listen + ingest (-R leg) alktunnels consumer/ForwardedHandler shape tcpip_forward(address, port) -> bound global request (no mpsc sink param) + accept the server-initiated forwarded-tcpip opens via server_channel_open_forwarded_tcpip, routing each to the family handler tunnel
upstream git consumption alkgit client/service types the same exec grammar the server side parses (git-(upload,receive)-pack '/repo' exec), pointed at an upstream sshd git
artifact/deploy transfer alkfs client half (future) subsystem "sftp" + the alkfs protocol sftp (decline placeholder, OQ-13 resolved)

The acceptor: fail-closed client-side

russh's client handler receives server-initiated channel opens; its default posture for a forwarded open is accept-and-drop, a half-capability, which the family forbids (ADR-003 symmetry + reverse-runner.md lesson 1). The client contract:

  • Server-initiated opens are served only when the assembly registered an acceptor for that type and under its feature (the real case is forwarded-tcpip under tunnel, the -R data path).
  • Unregistered/unfeatured accepted opens are declined cleanly, never silently dropped after accept.
  • Acceptor handlers may be installed across a live connection's lifetime (the alkminer install_forward_sink late-install dynamic: consumers turn capabilities on/off per connection; the shape is an assembly-mutable acceptor registry).

The landed acceptor shape (w4-reverse-listen, recorded at landing): the registry is per-type fixed slots, exactly the two -R data-leg types the client direction can serve (forwarded-tcpip, forwarded-streamlocal, both under tunnel; no map, no generic key), one acceptor trait per type; install is refuse-never-replace (the ListenRouter::register recorded posture, mirrored client-side); the slots are read at arrival time by the dial handler's hooks, so a pre-install/unfeatured open is declined at arrival, never buffered. The payload an accepted open hands the assembly: the channel duplex (family-typed, the -L rows' TunnelChannel byte duplex, one type both legs ride) + the open's echo/originator fields; the pump is the acceptor's from the handoff on (the family handler shape), and the crate keeps no accepted-open ledger. Bounded bookkeeping, the cap relation: the registry bounded by construction (two slots); the reverse-listen count and the accepted-open handoff count are two dedicated counters, each against the same Limits::max_concurrent_channels ceiling (the server-side listener/ListenerCap posture mirrored: independent counts, one ceiling; the listens live at the peer, and the counter exists so a dial cannot mint an unbounded listener set). The forward-request rows gate on the registered acceptor (the issuing-side disposition: a listen whose data leg cannot serve is never asked for; install-first, OpError::NoAcceptor pre-wire), plus OpError::ListenRefused (the peer's RequestDenied: denied listens and unknown-listener cancels, sshd raw-string parity) and OpError::ListenCapExceeded (the ceiling, pre-wire). The runtime-Unix gate rides the unix listen rows only (the bind is the platform-bound op; the forwarded-streamlocal ingestion is platform-neutral byte pumping). The tcpip_forward reply's mint rides only for port = 0 requests (russh/sshd parity: an explicit port's bound is the requested port, which the row surfaces).

Lifecycle primitives (OQ-12 resolution)

  • Forward listeners return the actually-bound port (russh tcpip_forward -> u32); every listener/handle is explicitly cancellable; disconnects are explicit (Disconnect::ByApplication).
  • No reconnect or restore policy is owned by the crate. Reconnect-and-restore (rebinding persisted tunnel ports, the alkminer flow) is assembly policy; the crate provides the primitives (bound ports, handles, cancellation, clean close) to make that possible.

The landed lifecycle surface (w4-lifecycle, recorded at landing): the -R listens return ReverseListener handles, with the ReverseEndpoint carried (the actually bound values: the wire address + the bound port / the socket path, the assembly's own record for a rebind policy it may compose) and the explicit cancel() as the wire-honest teardown (the confirmed cancel also releases the connection's listener count; a repeat rides the wire and hits the unknown-listener refusal). The stateless cancel forms (reverse_listen_cancel + the unix twin) ride beside the handles 1:1 (sshd's global-request cancels are stateless, so a cancel by the raw wire strings works from anywhere, including after the handle itself is gone: the handle shares the connection's internals through the arc, so its cancel() is the same wire op). No Drop hides a wire op on any of these handles: a cancellation/bundle of the wire ops is async and cannot run in a sync Drop, and at this russh pin dropping the last wire-stack handle ends the client runner, so the transport breaks (the peer observes an error-class session end, never the clean disconnect message). The primitives-not-policy posture is the module-docs contract: client.rs §Lifecycle posture (the primitives list + the abrupt-drop/clean-close boundary, row-pinned both ways) and client_accept's recorded decisions; the alkminer reconnect-and-restore flow stays assembly composition (reverse-runner fact 4).

What the crate does not include (client direction)

  • Job supervision: background-job lifecycle (the alkminer start/stop/logs job model, pid files, host-side ledgers) is assembly work composed over the exec rows; the crate gives the channels and the exit semantics (including the pipe-mode pid-only signal caveat from mapping.md's derivation contract).
  • Credential storage policy: assembly (the vault handle is injected; ADR-005 / OQ-06).
  • Trust decisions: the host-key policy is assembly-provided (OQ-05; the crate default rejects; a permissive case-2 policy for ephemeral fleets is the assembly's to choose, since the crate carries no trust model of its own).
  • sftp: the sftp feature is the tested decline placeholder on both directions (OQ-13 resolved); the alkfs-protocol mapping arrives with alkfs, as its own ADR.

Correctness facts to carry (POC-recorded)

The landed op-row shape (w4-op-rows, recorded at landing): the exit-consuming rows (session exec/pty, git exec) ride the split-halves wait-pump (Channel::split() + a spawned task draining wait()), never into_stream(): the conversion's reader side swallows exactly the non-Data messages the exit reports ride on, so a wait-pump is the only shape that can consume them (the same reason w4-git-exit-close pinned the split-halves shape server-side). Only the exit-less rows (the tunnel directs) use into_stream(), straight after the typed open; no queue consumption ever precedes a conversion, which is POC-1 §6's caveat respected by construction. The session rows' family plane is the alktty chunk framing both directions (stdin/CTRL_IN out, stdout/ stderr in, the family exit chunk in-band, last, per exit_code.rs's two-arm contract: the server arm derives wire reports from family codes, the client arm mirrors wire reports into family codes, −signum through the one table, the −1 sentinel never widened). The op surface carries no russh types: OpError mirrors the typed open's decline classes (OpenDeclineReason), the wait()-drain's Failure reply is RequestRefused, and the sftp placeholder declines in every build before any wire op (OQ-13).

  • Channel::exec() (no request_exec at this pin); pty/subsystem request failures surface as ChannelMsg::Failure via wait(), not Err from the call; the caller must drain until the expected message and continue past the rest.
  • window_change/signal are fire-and-forget and must be sent before into_stream() (the Channel is consumed by conversion); server-side they carry no SSH reply.
  • Do not consume the channel queue before into_stream(): a wait() on a fresh channel breaks it (POC-1 §6 caveat), both sides.
  • Keepalives on cloud fleets: 15 s / 3 (OpenSSH ServerAliveInterval=15/CountMax=3 parity), since with russh's default (none) dead NAT'd connections sit unnoticed until an exec health check fails (reverse-runner.md lesson 3).
  • A rejected channel open is an Err from the typed open call (not a wait()-match); the connection stays usable after declines.

Open Questions

None open or deferred; see open-questions.md (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-05 (the host-key policy, §The dial surface), OQ-12 (lifecycle primitives only, §Lifecycle primitives), OQ-13 (the sftp decline placeholder, §op rows and §not-include), OQ-07 (closed upstream: the vault returns a Zeroizing<String> from alkvault 0.2.0; ADR-005 decision 3 amended, the adapter-side wrap dropped).

References

  • ADR-002 (the client russh surface + config knobs), ADR-003 (§1a the alksocks composition; the symmetry rule), ADR-004 (fingerprint identity), ADR-005 (key sources), ADR-006 (route-1 bridge, both legs)
  • docs/research/reverse-runner.md (the consumer pattern + lessons), poc-3-mapping-bakeoff.md §D.3 (client mechanics), §C.4 (the fabric recipe), poc-1-auth-bridge.md §6/§7 (open-call Err semantics, plumbing), poc-2-vault-keys.md (dial lifecycle)
  • mapping.md (the server-direction mirror rows)