The family op → vanilla translation surfaces on SshConnection, per client.md §op rows: - session: exec (pipe) / pty_exec / shell → RemoteSession — the alktty chunk plane both directions, the exit consumed in-band as the family exit chunk (exit-chunk-is-last); the POC-3 §D.3 mechanics pinned (exec() + wait()-drain, split-halves pump for exit-consuming rows, typed-open-then-straight-into_stream for the exit-less tunnel rows) - tunnel: tunnel_direct / tunnel_direct_unix → TunnelChannel — typed opens, rejections mirrored to OpError::OpenDeclined (OpenDeclineReason), the connection stays usable after every decline (row-pinned per class); the OQ-11 runtime-Unix gate mirrored - git: git_service(Service, repo_id) → GitPackSession — the shared grammar's construction arm in dispatch.rs (parse round-trips unit-pinned), the raw pack duplex pumped, the exit consumed - sftp: the every-build OQ-13 placeholder (OpError::SftpNotServed, Infallible Ok type), before any wire op - shared exit-currency module src/exit_code.rs (extracted from tty_map.rs): SIGNAL_NAMES + derive_exit + sig maps two-direction, new signal_code_from_name client arm - OpError taxonomy (closed, family-mirrored) in error.rs; the fake sshd double grown with the op-row script + observations; tests/client_ops.rs — 18 rows + the alkssh↔alkssh exec smoke (real-vanilla-sshd smoke named wave-5 e2e territory) Verification: cargo test (default + per-feature + all-features) all green; clippy ×2 -D warnings clean; fmt clean; cargo doc --no-deps warning-free.
18 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| w4-op-rows | Wave 4 — the client op rows (exec/pty, direct-tcpip, git exec, sftp decline) | completed |
|
moderate | medium | phase | 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()— norequest_execat this pin; Success/Failure arrive on the channel queue viawait()-drain, not asErrfrom the request call (drain until the expected message,continuepast the rest);window_change/signalare fire-and-forget and must be sent beforeinto_stream()(the Channel is consumed by conversion); never consume the channel queue beforeinto_stream()(await()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).
- the exec/pty-req/shell-req translations onto the alktty session
types (pipe vs PTY; exit semantics):
direct-tcpiptyped dial (undertunnel) — the row the-Lleg and the alksocksDialFncompose over:channel_open_direct_tcpipresolves toErr(ChannelOpenFailure)on server reject (POC-1 §6) — the typed open, nowait()beforeinto_stream; the connection stays usable after a declined open (pinned by row). Thedirect-streamlocaltwin 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 vanillasshd: 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 — the
w4-dialfake-sshdvehicle 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 realsshdexec is cheap where tooling permits — check-gated like the wave-3 smokes; else named wave-5 e2e territory explicitly).
Acceptance Criteria
- Exec/pty rows land under
sessionwith the POC-3 §D.3 mechanics pinned in code + rows (exec()notrequest_exec;wait()-drain Success/Failure;window_change/signalbeforeinto_stream; queue never consumed before conversion) direct-tcpip(+ streamlocal twin, runtime-Unix gate) lands undertunnel: typed open, reject →Err(ChannelOpenFailure), the connection stays usable after a decline (row-pinned)- Git exec consumption lands under
gitover the shared grammar (no forked parser); the pack duplex pumps; exit-status consumed - The sftp subsystem placeholder declines in every build (the client-side OQ-13 mirror)
- Per-build contract rows green through the server double; 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 §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
Errsemantics), 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 onSshConnection(dial's handle; sibling-module method impls over the crate accessorsrussh_handle()/budgets()):- session:
exec(pipe),pty_exec(ssh -t),shell(Option< TerminalParamsWire>)(pty with params; the pipe shell withNone, 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 — the alksocksDialFncomposes it; the-Dleg'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)(theOktype isInfallible— the feature ships but the route doesn't, OQ-13, now tested in both the featureless and the featured build).
- session:
src/exit_code.rs(new, extracted fromtty_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'ssignal_code_from_name(the wireexit-signalname → the family−signumcode,−1for the unmapped/the sentinel collision) and the module docs carry the client arm's rules as the same contract's inverse.tty_map.rsconsumes 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(thewait()-drain'sFailurereply — POC-3 §D.3's not-an-Errfact 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); theFrom<russh::Error>maps the typed open'sChannelOpenFailureinto the family mirror — one conversion point, unit rows pin every class).src/dispatch.rs: the shared grammar's construction arm —build_git_exec_command(route, repo_id)besideparse_exec, with unit rows proving the construction round-trips the parse for both services (the grammar's gate is theServicetype: the construction arm can emit no other token, so the client rows are structurally unreachable byunknown-service/service-only— recorded).src/client.rs: the crate accessors (russh_handle/budgets, feature-gated; no public-surface change);src/lib.rs: theclient_opsmodule + the per-feature surface re-exports (RemoteSession/TunnelChannel/GitPackSession,OpError/OpenDeclineReasonpublic).tests/common/sshd_double.rs: the fakesshdgrown with the op-row script — the session requests' reply knob, the scripted program (stdout/stderr/exit —exit-then-close, the 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
- 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'si32): wireExitStatus(N)→N, wireExitSignal(name)→−signumvia the table's inverse read (unmapped names ride the−1sentinel — 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). - 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-Datamessages the exit reports ride on, so an exit-consuming row is structurally a split-halves wait-pump (Channel::split()+ spawned drain ofwait()— the same shapew4-git-exit-closepinned server-side; the write half stays alive, so the fire-and-forgetwindow_change/signalrows ride mid-session — the ordering fact's constraint, that controls precedeinto_stream(), is respected by construction across both row kinds). The tunnel rows go typed-open → straightinto_stream(), no queue consumption ever (POC-1 §6's fresh-wait()caveat, honored by construction);client.md§Correctness facts records the landed shape. - The hub→door plane reads without the error-frame
disambiguation (
ChunkReader::read_chunk, nottty_map::read_bridge_frame): that plane's chunk types are stdin0/CTRL_IN3; a negotiation-error frame never exists toward the door — and a0x00-leading read there is a legitimately valid zero-length stdin chunk, soread_bridge_frame's0x00-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. - 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_INEof, 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. - 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— the row owned it; never half-serves). - The pty translation: family
TerminalParamsWire→ wirepty_requestverbatim (term/cols/rows/pixels,term: None→ the empty-terminal-string value; the modes JSON never rides the wire — always the empty set, 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. - 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 (theService-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. - 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 theDialFnshape carries the target only). - 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.
- No crate events on the client op rows: the outcomes ride the
typed surfaces (
Result/channels) — 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. - 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 — both
chunk-bridge arms over one live wire), landed as the row
smoke_client_exec_row_against_the_server_directions_session_rows(gated onshlike the wave-3 smokes, skipped-with-reason when absent). A real-vanilla-sshddial smoke is wave-5 e2e territory explicitly — 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) — 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
RequestRefusedrow, the pre-wire budget row (no channel opened), the pty-shell param-translation row (the familyTerminalParamsWireverbatim, the reserved modes never riding), the pipe-shell parity row (no pty request), the live-pump control rows (CTRL_IN Resize → wirewindow_change; Signal → wiresignal, name verbatim), the exit-signal mirror row (terminalexit-signal("TERM")→ the family code−15in-band), the never-half-capability row (a refused pty refuses the whole row — 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_shutdownEOF rides), the reject-maps-the-class + stays-usable row (ConnectFailedmirror; the streamlocal twin then opens on the same connection on-Unix;#[cfg(not(unix))]'s OQ-11 decline row compiles off-Unix only — 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-wireInvalidRepoId/ budget rows (no channel opened), and the receive-pack-refusedRequestRefusedrow. - The
PumpBuf/drain caps and the exit-currency mirror are unit rows insideclient_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); 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.