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.
331 lines
18 KiB
Markdown
331 lines
18 KiB
Markdown
---
|
||
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<russh::Error>` 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. |