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

218 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: reviewed
last_updated: 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](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)