--- id: w4-op-rows name: "Wave 4: the client op rows (exec/pty, direct-tcpip, git exec, sftp decline)" status: completed depends_on: [w4-dial] scope: moderate risk: medium impact: phase level: implementation --- ## Description Land the op rows: family op → vanilla translation per `client.md`'s table, pointed upstream at a vanilla server (the reverse-runner shape: the hub drives every channel open). Type symmetry at the seam is the contract: family-protocol types in, vanilla wire ops out, no vanilla-concept leaks up. The POC-3 §D.3 mechanics are the body of knowledge this task lands in code: - **Session exec/pty rows** (under `session`): `channel_open_session` + the exec/pty-req/shell-req translations onto the alktty session types (pipe vs PTY; exit semantics): `Channel::exec()`, with **no `request_exec` at this pin**; Success/Failure arrive on the channel queue via `wait()`-drain, **not** as `Err` from the request call (drain until the expected message, `continue` past the rest); `window_change`/`signal` are fire-and-forget and must be sent **before** `into_stream()` (the Channel is consumed by conversion); **never consume the channel queue before `into_stream()`**, because a `wait()` on a fresh channel breaks it (POC-1 §6, both sides). The data pump + the exit consumption mirror the server direction's derivation contract (the pipe-mode pid-only signal caveat rides the alktty exit-chunk semantics, `mapping.md`'s contract, consumed not re-derived). - **`direct-tcpip` typed dial** (under `tunnel`), the row the `-L` leg and the alksocks `DialFn` compose over: `channel_open_direct_tcpip` resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6), giving the typed open with no `wait()` before `into_stream`; **the connection stays usable after a declined open** (pinned by row). The `direct-streamlocal` twin with the runtime-Unix gate (OQ-11 symmetry: universal compile, clean off-Unix decline). - **Git exec consumption** (under `git`), the same exec grammar the server side parses (`git-{upload,receive}-pack '/repo'`), **reused, not forked**, pointed upstream at a vanilla `sshd`: parse → exec → the pack duplex pumped → exit-status consumed. The per-length limits and service-only decline classes ride the shared grammar's verdicts. - **The sftp subsystem placeholder decline** (every build, OQ-13), the client-side half of the tested decline placeholder: a `subsystem "sftp"` open declines cleanly in every build (the feature exists, the route doesn't until alkfs lands). The server-side rows already landed in wave 2; this lands the client mirror so both directions are contract-tested. - **Contract rows through the server double, per build**, with the `w4-dial` fake-`sshd` vehicle grown with the op-row surfaces: featured + working rows (exec exit-status, pty window-change ordering, direct-tcpip pump, git exec round trip), decline rows (server-rejected opens, the sftp decline, off-Unix streamlocal), the connection-stays-usable row after each decline class. The real-tool smoke posture decided + recorded (a real `sshd` exec is cheap where tooling permits, check-gated like the wave-3 smokes; else named wave-5 e2e territory explicitly). ## Acceptance Criteria - [x] Exec/pty rows land under `session` with the POC-3 §D.3 mechanics pinned in code + rows (`exec()` not `request_exec`; `wait()`-drain Success/Failure; `window_change`/`signal` before `into_stream`; queue never consumed before conversion) - [x] `direct-tcpip` (+ streamlocal twin, runtime-Unix gate) lands under `tunnel`: typed open, reject → `Err(ChannelOpenFailure)`, the connection stays usable after a decline (row-pinned) - [x] Git exec consumption lands under `git` over the shared grammar (no forked parser); the pack duplex pumps; exit-status consumed - [x] The sftp subsystem placeholder declines in every build (the client-side OQ-13 mirror) - [x] Per-build contract rows green through the server double; the smoke posture decided + recorded - [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2 (`-D warnings`), fmt, `cargo doc --no-deps` pass ## References - docs/architecture/client.md §Op rows (the table), §Correctness facts, §What the crate does *not* include (job supervision is assembly work; the crate gives channels + exit semantics) - docs/architecture/mapping.md §The exit derivation contract (the mirror the client consumes), docs/architecture/decisions/003-feature-graph.md (the feature rows), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (exec-only git, both directions) - docs/research/poc-3-mapping-bakeoff.md §D.3 (the client mechanics, the body of knowledge), docs/research/poc-1-auth-bridge.md §6 (the open-call `Err` semantics), docs/research/reverse-runner.md (the per-connection surface table, the consumer evidence) - src/git_map.rs (the grammar reused), src/tty_map.rs (the exit-chunk semantics consumed), russh 0.65 `client::Handle` (`channel_open_session` / `channel_open_direct_tcpip` / `channel_open_direct_streamlocal`) ## Notes ### What landed (2026-10-29) - `src/client_ops.rs` (new, every build; rows feature-gated inside): the op rows, the family op → vanilla translation surfaces, all on `SshConnection` (`dial`'s handle; sibling-module method impls over the crate accessors `russh_handle()` / `budgets()`): - *session*: `exec` (pipe), `pty_exec` (`ssh -t`), `shell(Option< TerminalParamsWire>)` (pty with params; the pipe shell with `None`, mirroring the server direction's decided pty-less shell parity) → `RemoteSession`, the **family chunk plane** (§below). - *tunnel*: `tunnel_direct(host, port)` / `tunnel_direct_unix(path)` → `TunnelChannel` (a plain byte duplex, which the alksocks `DialFn` composes; the `-D` leg's composition needs no dependency, ADR-003 §1a). - *git*: `git_service(alkgit::Service, repo_id)` → `GitPackSession` (raw pack duplex + stderr duplex + the consumed-exit oneshot). - *every build*: `sftp()` → `Err(OpError::SftpNotServed)` (the `Ok` type is `Infallible`, so the feature ships but the route doesn't, OQ-13, now tested in both the featureless and the featured build). - `src/exit_code.rs` (new, extracted from `tty_map.rs`, the wave's structural edit): the derivation contract's machinery (`SIGNAL_NAMES` / `ExitReport` / `derive_exit` / `sig_from_name` / `sig_control_name`) is now **two-direction territory**; the module gained the client arm's `signal_code_from_name` (the wire `exit-signal` name → the family `−signum` code, `−1` for the unmapped/the sentinel collision) and the module docs carry the client arm's rules as the same contract's inverse. `tty_map.rs` consumes it (no fork anywhere); `handler.rs`'s signal row follows the moved path. - `src/error.rs`: [`OpError`], the op rows' closed taxonomy (`OpenDecline(OpenDeclineReason)`, the *family-mirrored* copy of the typed open's wire class, `Other{code,reason}` verbatim, nothing collapsed / `RequestRefused` (the `wait()`-drain's `Failure` reply, as POC-3 §D.3's not-an-`Err` fact surfaced as the op's decline class) / `ChannelEnded` (the drain saw the channel's end; nothing fabricated) / `CommandTooLong` (the exec-length budget's client-mirror verdict, pre-wire) / `InvalidRepoId` (the git validation gate mirrored, pre-wire) / `UnixUnavailable` (the OQ-11 gate, pre-wire) / `SftpNotServed` (the placeholder) / `Wire(russh::Error)` (transparent); the `From` maps the typed open's `ChannelOpenFailure` into the family mirror, with one conversion point, unit rows pin every class). - `src/dispatch.rs`: the shared grammar's **construction arm**, `build_git_exec_command(route, repo_id)` beside `parse_exec`, with unit rows proving the construction round-trips the parse for both services (the grammar's gate is the `Service` *type*: the construction arm can emit no other token, so the client rows are structurally unreachable by `unknown-service`/`service-only`, recorded). - `src/client.rs`: the crate accessors (`russh_handle`/`budgets`, feature-gated; no public-surface change); `src/lib.rs`: the `client_ops` module + the per-feature surface re-exports (`RemoteSession` / `TunnelChannel` / `GitPackSession`, `OpError`/`OpenDeclineReason` public). - `tests/common/sshd_double.rs`: the fake `sshd` grown with the op-row script, with the session requests' reply knob, the scripted program (stdout/stderr/exit in the `exit-then-close` vanilla discipline; a no-output/no-exit script launches *nothing*, keeping interactive sessions open), the direct opens' echo-or-reject behavior, a counted-refusal knob for the stays-usable-after-a-decline rows, and the observation surface (`SshdObserved`: recorded execs/ptys/windows/signals, the wire translations' witnesses); `tests/client_ops.rs`: the contract rows (below). ### Decided + recorded 1. **The family plane (the session rows) is the alktty chunk framing in both directions**, the exact plane the server direction's bridge speaks, consumed not re-derived: stdin chunks + `CTRL_IN` (sentinel/`Resize`/`Signal`/`Eof`) toward the wire; stdout/stderr chunks + the **in-band exit chunk, last** toward the hub. The exit ride is the family exit-currency (`ControlMessage::Exit`'s `i32`): wire `ExitStatus(N)` → `N`, wire `ExitSignal(name)` → `−signum` via the table's inverse read (unmapped names ride the `−1` sentinel, including a genuine SIGHUP, the same collision rule 3 consumes server-side). A session ending with no report delivers no exit chunk (the no-exit-chunk close family's mirror). 2. **The exec/pty/git rows never `into_stream()`; the tunnel rows never wait.** The mechanics facts pin the *shapes*: the conversion's reader swallows exactly the non-`Data` messages the exit reports ride on, so an exit-consuming row is structurally a **split-halves wait-pump** (`Channel::split()` plus a spawned drain of `wait()`, the same shape `w4-git-exit-close` pinned server-side); the write half stays alive, so the fire-and-forget `window_change`/`signal` rows ride mid-session; the ordering fact's constraint, that controls precede `into_stream()`, is respected by *construction* across both row kinds). The tunnel rows go typed-open → straight `into_stream()`, no queue consumption ever (POC-1 §6's fresh-`wait()` caveat, honored by construction); `client.md` §Correctness facts records the landed shape. 3. **The hub→door plane reads without the error-frame disambiguation** (`ChunkReader::read_chunk`, not `tty_map::read_bridge_frame`): that plane's chunk types are stdin `0` / `CTRL_IN` `3`; a negotiation-error frame never exists toward the door, and a `0x00`-leading read there is a legitimately valid zero-length stdin chunk, so `read_bridge_frame`'s `0x00`-disambiguation (whose home is the alktty→hub direction, where error frames live) would misparse it. The wave-3 harness's mistake became this module's first live-row bug, caught by the contract rows before landing; recorded so the disambiguation rule ("the server never sends 0 or 3", alktty ADR-052 §5) is understood as *directional*. 4. **The pumps' terminal/kill structure** (the drop-is-wire-clean path): every pump pair shares a kill watch the *output* pump's terminal paths fire (the exit chunk / the channel's end / a hub write failure); the *input* pump closes the wire channel on it. stdin-EOF (the sentinel, a `CTRL_IN` Eof, or the hub's input half dropping) sends the wire EOF *once* and parks the pump in the idle-for-kill state (the session keeps pumping; stdin closure never ends the session). Dropping the surface (both halves) closes the channel, wire-clean, nothing dangling outside the dial's own wall clock. 5. **The launch drains are message-capped** (`DRAIN_MESSAGE_CAP`, 1024): a peer answering only with non-reply messages cannot loop the drain unboundedly (bounded resources; the drain's idle waits are bounded by the dial's always-set inactivity timeout). A refusal closes the channel behind itself (`drain_launch_request`, which the row owned; never half-serves). 6. **The pty translation**: family `TerminalParamsWire` → wire `pty_request` verbatim (term/cols/rows/pixels, `term: None` → the empty-terminal-string value; **the modes JSON never rides the wire**, always the empty set per the OQ-44 reserved posture mirrored: the door never invents wire terminal modes); a refused pty request refuses the whole row (never a pty-less fall), the never-half-capability row. 7. **The git row's gates before any wire op** (the shared grammar's verdicts): `validate_repo_id` → `InvalidRepoId`; the built command's length → `CommandTooLong`. The other grammar decline classes are structurally unreachable client-side (the `Service`-typed input carries only the two serving services). stderr rides a split raw duplex (the hub may drop it); the pack duplex is the protocol's own byte plane, untouched. 8. **The tunnel rows' originator**: the door's label (`"alk-ssh"`, port 0): nothing invented, nothing leaked; peer logs name the door (recorded; a real originator would be the assembly's local-listener fact, and the `DialFn` shape carries the target only). 9. **No client-side in-crate channel cap admits ops**: the server direction's cap is a wire-battery defense; the client rows are hub-driven (the family app chose the dial). Bounded resources on this direction: the always-on wall clock/keepalives, the russh windows, the bounded duplexes/mpscs, the drain cap. 10. **No crate events on the client op rows**: the outcomes ride the typed surfaces (`Result`/channels), as the decline* event vocabulary takes a wire channel id, and a *client-side* declined open has none to name (the typed open carries no id back); the events remain the server-direction (and dial-skeleton) rows' surface. Recorded so the asymmetry is a decision, not an omission. 11. **The smoke posture** (decided + recorded, per the task's item): the session rows' cheap real-stack smoke is the alkssh-client-op-row ↔ alkssh-server loopback (the client exec row against the server direction's *real* session rows, with both chunk-bridge arms over one live wire), landed as the row `smoke_client_exec_row_against_the_server_directions_session_rows` (gated on `sh` like the wave-3 smokes, skipped-with-reason when absent). A **real-vanilla-`sshd`** dial smoke is wave-5 e2e territory explicitly, with nothing silently dropped. ### Test rows (`tests/client_ops.rs`, one per decline/contract class; per-build binaries) - *every build*: the sftp placeholder's decline (before any wire op, no channel opened at the peer) + the featured-build variant (under `sftp`), and the connection-stays-usable close on both. - *session* (10 rows + the smoke): the exec family-plane row (command rides the wire verbatim; stdout/stderr chunks distinct; the exit chunk in-band, last; the stdin sentinel = wire EOF), the refused-exec `RequestRefused` row, the pre-wire budget row (no channel opened), the pty-shell param-translation row (the family `TerminalParamsWire` verbatim, the reserved modes never riding), the pipe-shell parity row (no pty request), the live-pump control rows (CTRL_IN Resize → wire `window_change`; Signal → wire `signal`, name verbatim), the exit-signal mirror row (terminal `exit-signal("TERM")` → the family code `−15` in-band), the never-half-capability row (a refused pty refuses the whole row, so no exec behind it ever rode the wire), the server-rejected-open decline row (`OpenDeclined(Prohibited)`) with the stays-usable-after-the-decline launch on the same connection, and the smoke. - *tunnel*: the direct-tcpip pump row (raw byte duplex round-trips; `poll_shutdown` EOF rides), the reject-maps-the-class + stays-usable row (`ConnectFailed` mirror; the streamlocal twin then opens on the same connection on-Unix; `#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only, per the universal-compile/clean-decline gate mirrored from the server side), the twin's on-Unix pump served in the same row. - *git*: the grammar's construction riding the wire verbatim + the pack duplex pump + the `exit-status(0)` consumed into the family code, the pre-wire `InvalidRepoId` / budget rows (no channel opened), and the receive-pack-refused `RequestRefused` row. - The `PumpBuf`/drain caps and the exit-currency mirror are unit rows inside `client_ops.rs` / `exit_code.rs` / `error.rs` (taxonomy) / `dispatch.rs` (the construction arm's round-trip). ### Verification `cargo test` (default), per-feature (`--features {session, git, tunnel, sftp}`), pairs, and `--all-features`, all 15 test binaries green in the full builds (the client_ops binary: 18 rows under `--all-features`, the per-build sets: session 12 / git 5 / tunnel 3 / featureless 1); `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 op rows landed: family ops in (`alktty` session shapes / alkgit `Service`), vanilla wire ops out, no vanilla concepts riding up (`OpError`'s family-mirrored taxonomy; `OpenDeclineReason` as the typed-open decline's one-to-one mirror). The session rows give the hub the alktty chunk plane in both directions with the exit consumed in-band as the family exit chunk (the derivation contract's client arm, extracted into the shared two-direction `exit_code.rs` module; table and rules never forked there); the tunnel rows are the typed `direct-tcpip`/`direct-streamlocal` opens into raw byte duplexes (rejects mirror to `OpenDeclineReason`, the connection stays usable, row-pinned per decline class); the git row builds the vanilla exec command through the shared grammar's new construction arm and pumps the raw pack duplex with the exit consumed; the sftp placeholder declines in every build before any wire op. The mechanics facts are pinned in code (the `wait()`-drain shapes, the split-halves pump for exit-consuming rows, the typed-open-then-straight-conversion for the exit-less ones), the fake-`sshd` double grew the scripted op-row surfaces + observations, and 18 contract rows per-build + one smoke landed through it.