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.
15 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| w3-tty-session | Wave 3: session → alktty (translation bridge + exit derivation) | completed |
|
moderate | medium | phase | implementation |
Description
Land the session branch, the biggest single-row surface of the spine
(mapping.md's session rows): pty/shell/pipe-exec become a real
serving path through the published alktty seam, as a translation
bridge, not a byte pump (POC-3 §D.1). Everything POC-3 stage D
proved, re-landed as contract:
- The per-channel state + the store-and-take reuse: the channel
store
w3-git-mappinglands (channels.remove(&id)→into_stream()in the spawned serving task) gains the session rows' per-channel state, the PTY params apty_requestdelivered (consumed by the latershell_request/exec_requeston the same channel, sincessh -t host "cmd"sends pty-then-exec and must serve in PTY-mode; ashell_requestwithout a preceding pty serves pipe-modetty = None, and whether such a shell serves or declines is for the task to decide and record). - Negotiation, built locally, never wire-derived (OQ-08: the
serve-config is the whole truth):
pty_requestargs →TerminalParamsWire{term, cols, rows, pixel_w/h, modes}(SSH ttymodes → the reserved modes JSON, which is alktty OQ-44's reservation, deferred there);shell_request→NegotiateRequest{carriage: "raw", cmd: shell_argv (the serve-config shell, verbatim), cwd: session_cwd, env: session_env, backend: the seam selector}→drive_session_pre_negotiated(writer, reader, req, backends, ownership, identity)spawned;exec_request(pipe row) →cmd = wrapper ++ [cmd](the OQ-08 argv append with vanilla quoting semantics; never naively split),tty = Nonepipe mode. Unset-declines (OQ-08):shell_argvunset →shell_requestdeclines;exec_wrapper_argvunset → the pipe row declines (never a fallback). Decide the wire shape per row (capability decline, channel stays usable, vs the serving-path failure+close shape) and record. - The translation bridge, two small adapters around a duplex the
serving task drives (the POC-3 §D.1 shape): SSH
data/EOF →Chunk::stdin(decide the EOF translation: the zero-length stdin-chunk sentinel vs alktty'sControlMessage::EofCTRL_IN; verify what alktty's ChunkReader/backend actually consumes and record); stdout chunks → SSHdata; stderr chunks →extended_data(1)(PTY mode merges upstream, where the backend's stderr stream isNone, so the translation is universal with no mode-switch); the exit chunk (STREAM_CTRL_OUT) → the derivation contract. No unbounded buffering in either adapter (convention 9). - Control rows:
window_change_request→ControlMessage:: ResizeCTRL_IN;signal→ControlMessage::Signal(alktty's uppercase-name vocabulary, with the name accepted verbatim; decide the decline shape for names alktty's backend semantics reject and record). Mechanics: the requests arrive on the packet loop after the serving task owns the wire, so reach them into the spawned bridge through a per-channel control surface (a bounded mpsc; decide plus record; a silent unbounded proxy is a bounded-resources bug). - The exit derivation contract (mapping.md rules 1–6,
normative): the alktty exit chunk (
{"type":"exit","code":i32}, exit-chunk-is-last) is the sole fidelity input, never widened:code ≥ 0→exit-status(N); negative−N, N ≥ 2 →exit-signal(name)via the server-side Unix signum→name table (crate-local; not derived from alktty'ssignal_from_nameinverse unless that is public and sound; decide the table's form and record;core_dumpednever asserted, the bool stays false);−1→exit-status(255), never a signal claim (rule 3, the SIGHUP collision accepted symmetrically per the mapping.md rationale); non-Unix adds no other negatives (rule 4); the PTY-path rule-5 fidelity limit documented in the module docs (signal- terminated PTY sessions arriveexit-status(1), portable-pty's exit surface, upstream); unmapped negatives → 255 (rule 6). Sent before close, from the spawned task. env_requeststays declined (OQ-09, unchanged from wave 2);session_cwd/session_envrideNegotiateRequestonly.- Contract rows through the harness, per build, with the wave-2
stub rows (
pty_row_stub/shell_row_stub/pipe_row_stub) replaced; the decline matrix's unset-field rows (feature on + handle unset → the rows decline; featured+set serves through the alkssh-side TtyBackend shim double, since alktty'sMockBackend/testing.rsarepub(crate)+cfg(test)and so never importable; alkssh hand-rolls the shim, a backend that recordsTtyParams, emits scripted stdout/stderr/exit chunks and consumes stdin/resizes/signals, per theTtyHandlefield shapes). The identity-landing item (the parked wave-2 note) asserted through the row that observes the identity atdrive_session_pre_negotiated. - The real-tool smoke: one OpenSSH pty session through the
harness against the shim (or against a real PTY via a dev-dep
alkttylocalcomposition; decide which posture and record; the real bash/stty sizefidelity was POC stage D's, wave 5 re-lands it as e2e): the smoke owns the wire rows a plain rustsh-client row can't pin (echo paths,stty size-grade fidelity are wave-5's matrix; here the pty lifecycle rows only).
Acceptance Criteria
- pty/shell/pipe-exec rows serve through
drive_session_pre_negotiated(spawned, handler fast); the store-and-take + per-channel PTY state reused from the git task's handoff; pipe-less shell decided + recorded pty_request→TerminalParamsWire(ttymodes → the reserved JSON),shell_request→ shell argv, exec →wrapper ++ [cmd], cwd/env session-side; unset-declines on both OQ-08 rows with recorded wire shapes- The bridge translated per row (stdin chunks + EOF shape
verified against alktty; stdout→data; stderr→
extended_data(1); resize/signal CTRL_IN reach the serving path through a bounded per-channel surface) - The derivation contract rules 1–6 in code with the signum→name
table;
−1→255;core_dumpednever; PTY-path rule-5 documented; exit-status before close from the spawned task, pinned by rows - The decline matrix per build green through the harness (unset-
field rows included); identity ride asserted; the
env_requestdecline unchanged; skeleton stub rows migrated - The real-tool pty smoke row (posture decided + recorded)
cargo test(+ per-feature +--all-featuresrows), clippy ×2 (-D warnings), fmt,cargo doc --no-depspass
References
- docs/architecture/mapping.md (the
sessionchannel-type row, request-level rows, §Exit chunk → SSH exit reporting, rules 1–6) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4, spawn-and-return: status before close from the spawned task), docs/architecture/decisions/003-feature-graph.md
- docs/architecture/auth.md §Server direction step 4 (the identity ride), §Dispositions (OQ-03)
- docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape, control rows, the signal/exit fidelity findings, §D.3 mechanics)
- docs/research/reverse-runner.md (the pipe-mode pid-only signal caveat, the fidelity note)
- alktty 0.8.1:
adapter.rs:234-241(drive_session_pre_negotiated),negotiation.rs(NegotiateRequest/TerminalParamsWire),wire.rs(chunks, readers/writers),control.rs(ControlMessage),backend.rs(TtyHandle/TtyParams/TtyBackendshapes)
Notes
Decisions recorded (implementation, 2026-10-28)
-
The module (
src/tty_map.rs, featuresession): the session rows live in one module following thegit_mappattern, covering the bridge, the locally-built negotiation, the derivation contract, and the serving path.serde_json(the OQ-44 modes JSON + the exit chunk/error-frame reads) andbytes(the chunk plane) joined as session-gated optional deps at alktty's own pins. -
The negotiation (locally built, never wire-derived; OQ-08):
pty_requestargs →TerminalParamsWirewith SSH ttymodes → the reserved{}modes JSON (alktty OQ-44's reservation, deferred there; the map is honest and nothing is carried). Shell argv rides the serve-config verbatim; exec rideswrapper ++ [cmd](never naively split); cwd/env come fromsession_cwd/session_envonly.backend_paramsstays empty (no backend-specific selector fields on this door; the seam selector is thebackendstring). Pipe-less shell decided: ashell_requestwithout a preceding pty SERVES pipe-mode (tty = None), because vanilla sshd serves a shell without a terminal too (ssh -Tis legitimate vanilla). -
Unset-declines (OQ-08), wire shape decided + recorded: the serving-path shape (
channel_failurethen close, from a spawned task) forshell_argv-unset (shell-unconfigured),exec_wrapper_argv-unset (wrapper-unconfigured), and thesession_seam-unset rows (seam-unconfigured, the git row's recorded shape); the pty row checks the seam first (a doomed allocation is declined before its params are stored (decline first, alloc never). Field checks precede the seam check. The unvanilla guards keep the git row's shapes:channel-not-held→ inline request-level failure;no-identity→ the spawned serving-path decline. A routed request answerschannel_successinline (the POC-3 §A.5 shape). -
The translation bridge (the POC-3 §D.1 shape, two adapters around a duplex pair): SSH
data→Chunk::stdinthrough a bounded mpsc (bound 64) feeding a framing task; SSH channel EOF → the zero-length stdin-chunk sentinel, decided and verified against alktty 0.8.1'spump_client_to_backend: a zero-length stdin chunk shuts the backend's stdin, identical in effect toControlMessage::Eof, and rides the data path the SSH EOF already chose (no extra control frame). stdout chunks → SSHdata; stderr chunks →extended_data(1); the exit chunk → the derivation contract. All buffers bounded (convention 9): mpsc 64, duplexes 64 KiB, frames bounds-checked against alktty'sMAX_CHUNK_LEN. -
The negotiation-error frame is a serving-path decline class (decided + recorded): alktty re-runs validation/allocation/ownership inline even on the pre-negotiated path and answers failures with a length-prefixed error frame (first byte
0x00, which is alktty's ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte first-window disambiguation and maps it tonegotiation-failed+exit-status(255)+ close, the one place this door claims 255: the session never started, nothing ran, so 255 is the honest could-not-start semantic (contrast the git row, whose service runs and honestly reports1;255is never fabricated for a real outcome). A bridge read ending without an exit chunk reports nothing (sole-fidelity-input posture: absence is neither 0 nor 255), and simply closes. -
The control rows:
window_change_request→ControlMessage::Resize,signal→ControlMessage::Signal, forwarded through the per-channelBridgeSurface(handler state while the row serves): a bounded mpsc whosetry_sendis never awaited on the packet loop, so a full/closed surface drops the control event (fire-and-forget rows carry no SSH reply to decline with; bounded resources, convention 9). Signal names are accepted verbatim, no decline shape: the backend owns the rejection semantics, nothing on the wire can answer. A pre-serve resize updates the stored pty params instead (the final size wins, matching vanilla sshd's ordering). -
The derivation contract (mapping.md rules 1–6, in code + unit rows + wire rows):
derive_exit, wherecode ≥ 0→exit-status(N); negative−N, N ≥ 2 →exit-signal(name)via the crate-local [SIGNAL_NAMES] signum→name table (1–31, server-side work, deliberately not alktty'ssignal_from_nameinverse);−1→exit-status(255)(rule 3, the SIGHUP collision accepted symmetrically);core_dumpednever asserted (the bool stays false on every exit-signal row); unmapped negatives (andi32::MIN's checked-abs failure) → 255 (rule 6); the PTY-path rule-5 fidelity limit documented in the module docs. Report sent before close from the spawned task; names ride russh'sSig(known variants verbatim,Sig::Customfor the rest; RFC 4254 §6.10). -
Cancellation (bounded resources + alktty's kill-on-drop): the serving path watches two signals: the per-channel cancel (fired by
channel_closevia the surface's watch) and the connection teardown (fired by the handler'sDrop; the russh session ended however it ended). On either: the serving task aborts the alktty session task, dropping its not-yet-resolvedexit_codefuture, letting alktty's ADR-056 kill-on-drop contract clean up the cancelled session, then closes the channel and ends. Witnessed live by the shim's kill log row. -
The contract tests (
tests/tty_mapping.rs, session-gated;tests/common/tty_doubles.rs): the shim double (hand-rolled per theTtyHandlefield shapes, because alktty'sMockBackendispub(crate)and never importable) records TtyParams, consumes stdin/EOF/resizes/signals, emits scripted chunks and exit codes, and carries the kill-on-drop witness; the ownership witness double observes the identity at the seam. The wave-2 stub rows migrated:dispatch_core.rs's session-build rows now pin the unset-decline reasons (seam-unconfigured/wrapper-unconfigured, same serving-path wire shape);observe.rs's two session rows updated (and a one-line pre-existing clippy lint in its git-build cap row fixed).env_requestdecline unchanged (OQ-09). -
The identity ride (the parked wave-2 item, discharged): asserted through alktty's ADR-050 ownership check, with the shim's
resource_idguaranteeing the check evaluates, and the ownership double records the resolved identity; the deny shape also pins the negotiation-error wire shape. -
Real-tool smoke posture (recorded): one OpenSSH
-ttpty session (ssh -tt … smoke-rider@127.0.0.1with piped stdin) through a TCP-mounted adapter against a real PTY via a dev-dep alkttylocalcomposition, the decided posture: a shim row cannot show what vanilla OpenSSH really sends, and POC-3 stage D proved exactly this composition. The dev-depfeatures = ["local"]unification applies to the test graph only (the published lib never enables it;src/never names a local shape). Requires ssh + sh; absent tooling is skipped with a printed reason, never silently green. The smoke pins the pty lifecycle (serve, stdin ride, clean exit → exit-status(0) before close); thestty size-grade fidelity matrix is wave 5's. -
Verification:
cargo test(default),cargo test --features session(incl. the smoke + shim rows),cargo test --features git,cargo test --features tunnel,cargo test --all-features;cargo clippy --all-targets -- -D warnings×5 (default / session / git / tunnel / all-features);cargo fmt --check;cargo doc --no-deps(default + session + all-features); all green.