# ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point ## Status Accepted (ported from alknet ADR-053 2026-08-17; cross-references renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017, 050) are not ported into alktty's ADR range because they are not tty-specific; the alknet originals at `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain authoritative.) ## Context alktty's wire format (ADR-001) is backend-agnostic — the chunk codec pumps bytes and JSON control messages without knowing whether the backend is a docker container, an SSH session channel, or a local process. The question this ADR answers: **what is the seam between the wire-format adapter and the backends?** The alknet-tty research identified the `TtyBackend` trait as the inversion point. The guiding insight: > A terminal session is not an SSH concern, or a Docker concern — it is > a terminal concern. SSH and Docker are just two backends that can > allocate a PTY. The trait is what makes that insight load-bearing: alktty defines the trait and the wire-format adapter; the backend crates (alknet-docker, alknet-ssh, alktty's `local` feature module) implement the trait. This preserves alknet ADR-003's no-handler-depends-on-another-handler rule: alktty depends on alkcall; backend crates depend on alktty for the trait; alktty does not depend on any backend (and, per ADR-006, does not depend on alkcall's internal wire types either — the negotiation framing is self-contained). ### What the local-PTY POC discovered about the trait shape The Phase 0 POC was built *before* this ADR specifically to discover constraints the trait sketch would have missed by reading docs alone. Two requirements fell out of it (recorded as REQ-TTY-01 and REQ-TTY-02 in the alknet findings doc): - **REQ-TTY-01: backends are not required to be natively async.** `portable_pty` is a blocking `std::io::{Read, Write}` API with a blocking `Child::wait()`. The POC bridges it to async via three dedicated std threads (reader, writer, waiter) feeding tokio mpsc/oneshot channels — the same pattern wezterm (portable_pty's primary consumer) uses. The trait's adapter-facing types (`AsyncWrite`, `Stream`, `BoxFuture`) are the *adapter's* contract; a backend may expose blocking handles internally and bridge them. The bridging pattern is a documented, supported implementation strategy, not a workaround. - **`exit_code` is a `Future` the adapter awaits, not a method on `TtyHandle`.** A `oneshot::Receiver` (or any `BoxFuture<'static, i32>`) lets the adapter `select` between exit and stream-close without coupling to the handle's other fields. The local backend's waiter thread produces exactly this shape for free. The POC's `LocalPty` struct is the reference implementation of what a backend produces: `stdout: mpsc::Receiver`, `stdin: mpsc::Sender`, `control: PtyControl` (Clone), `exit_code: oneshot::Receiver`. The trait shape below generalizes this. ### What the local-PTY POC did not resolve The POC used a separate cloneable `PtyControl` struct for resize/signal, not a trait object. The research noted this worked cleanly because the control-chunk dispatcher needs to be `Clone` to hand off to the spawned pump task. Phase 1 confirms the `control` field as a separate `TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct wrapping `Arc` (the trait is NOT `Clone`; `Clone` is not object-safe — see OQ-43). The newtype carries the `Clone`-ability; the trait stays object-safe. ## Decision ### 1. `TtyBackend` trait ```rust #[async_trait] pub trait TtyBackend: Send + Sync { /// Allocate a terminal/process session and return the handles the /// adapter pumps. The `backend` field of the negotiation frame /// (ADR-001) selects which registered backend's `allocate` is called. async fn allocate(&self, params: &TtyParams) -> Result; /// The pre-existing resource this session targets, for ownership /// checks (alknet ADR-050). `None` = no pre-existing resource (the session /// creates its own — local process, SSH channel). `Some((kind, id))` /// = the session targets an existing resource the caller must own /// (e.g., DockerTtyBackend returns `Some(("container", id))`). The /// adapter calls this at negotiation to gate access; the backend /// extracts the id from its own `backend_params`. Default `None` /// (most backends create their own resource). fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None } } ``` The adapter holds a `HashMap>` keyed by the negotiation frame's `backend` string (`"local"`, `"docker"`, `"ssh"`). The assembly layer registers backends at startup; the adapter dispatches by the `backend` field. A backend is the *thing that allocates a session*; the wire-format pump is backend-agnostic. ### 2. `TtyParams` — the allocation request ```rust pub struct TtyParams { /// Terminal parameters. `None` = pipe mode (no PTY — the runner case, /// ADR-003). `Some` = allocate a PTY with these dimensions. pub terminal: Option, /// Command vector (argv[0] + args). pub cmd: Vec, /// Working directory (backend-specific; None = inherit/default). pub cwd: Option, /// Environment variables (backend-specific; empty = inherit). pub env: HashMap, /// Backend-specific selector fields from the negotiation frame, /// unparsed. The adapter passes the JSON object through verbatim; the /// backend deserializes its own strongly-typed params struct from it. /// alktty has zero knowledge of any backend's params shape. See /// §"Backend params are opaque" below. pub backend_params: serde_json::Map, } pub struct TerminalParams { pub term: Option, // e.g., "xterm-256color"; None = backend default pub cols: u16, pub rows: u16, pub pixel_width: u16, pub pixel_height: u16, pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 } ``` `terminal: None` is the pipe/runner case — no PTY, separate stdout/stderr (ADR-003). `terminal: Some` is the PTY case — stdout/stderr merged into the single stdout stream (`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal delivery to process group). ### Backend params are opaque `backend_params` is a `serde_json::Map`, not a typed enum. The adapter passes the negotiation frame's backend-specific fields through verbatim; the backend deserializes its own strongly-typed params struct. alktty has zero knowledge of any backend's params shape — not docker's `container`, not an SSH host selector, not anything. Each backend defines its own params struct: ```rust // in alknet-docker #[derive(Deserialize)] struct DockerBackendParams { container: String } // in alknet-ssh #[derive(Deserialize)] struct SshBackendParams { /* host selector if multi-host; else empty */ } // in alktty's local feature module // no backend-specific params — backend_params is empty ``` And deserializes from `params.backend_params` inside `allocate()`: ```rust impl TtyBackend for DockerTtyBackend { async fn allocate(&self, params: &TtyParams) -> Result { let p: DockerBackendParams = serde_json::from_value( serde_json::Value::Object(params.backend_params.clone()) ).map_err(|e| TtyError::Backend { message: e.to_string() })?; // use p.container ... } } ``` This is a complete inversion: the *trait* is inverted (backends implement, alktty doesn't depend on them) and the *params* are inverted (backends define their own typed shape, alktty doesn't carry it). A new backend crate requires zero changes to alktty — no enum variant to add, no forward-reference type to place, no dependency edge. **Why not a typed enum.** The earlier draft of this ADR defined `BackendParams` as a `#[non_exhaustive]` enum with `Local`, `Docker { container }`, and `Ssh { channel: SshChannelRef }` variants. Three problems: 1. **Rust enums are closed.** `#[non_exhaustive]` prevents *consumers* from matching exhaustively, but only the *defining crate* (alktty) can add variants. A backend crate cannot add a variant; every new backend requires modifying alktty. The inversion is only partial. 2. **`SshChannelRef` was an output, not an input.** The SSH channel is what `allocate()` *opens* (`session.channel_open_session()` → `pty_request` → `shell_request`). It doesn't exist until the backend creates it; the client doesn't send one. 3. **Dependency contradiction.** `SshChannelRef` "wraps a russh `ChannelId` and session reference." If it lives in alknet-ssh, alktty depends on alknet-ssh (violates the inversion). If it lives in alktty, alktty pulls in russh types (same violation). Opaque params dissolve the contradiction — there is no `SshChannelRef` type in alktty at all. The earlier draft rejected `serde_json::Value` because "it loses type safety and forces the adapter to parse backend-specific JSON it shouldn't interpret." The first concern doesn't apply (each backend has its own strongly-typed struct via serde; type safety moves from alktty to the backend where it belongs). The second was already inconsistent with the adapter, which hardcoded extraction of docker's `container` field for the ownership check — the adapter *was* parsing backend-specific JSON. The opaque approach removes that: the adapter delegates the resource-id extraction to the backend via `resource_id()` below. ### 3. `TtyHandle` — what a backend produces ```rust pub struct TtyHandle { /// Stdin writer — bytes the adapter pumps from client stdin chunks. /// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io` /// one — they are incompatible traits; the tokio stack is the /// adapter's runtime). pub stdin: Box, /// Stdout stream — bytes the adapter pumps to client stdout chunks. /// Ends when the backend's stdout reaches EOF (process exited, /// container output stream ended, SSH channel closed). /// `futures_core::Stream` (re-exported by /// `tokio_stream::StreamExt` for extension methods). pub stdout: Pin + Send>>, /// Stderr stream — `None` for PTY backends (stdout/stderr merged /// into `stdout`). `Some` for pipe backends (separate streams). pub stderr: Option + Send>>>, /// Exit code — a `Future` the adapter awaits. Resolves when the /// process/container/SSH exec exits. The adapter sends the result /// as the `{"type":"exit","code":N}` control chunk (ADR-004) and /// closes the stream. This is `BoxFuture`, not a method on /// `TtyHandle`, so the adapter can `select` between exit and /// stream-close without coupling to the other fields. (REQ-TTY-01.) pub exit_code: BoxFuture<'static, Result>, /// Control handle (resize, signal) — `Clone` so the adapter can /// hand it to the spawned control-chunk dispatcher. `None` only /// when the backend genuinely has no control path (e.g., a pipe /// backend with no PTY — signal still works via `kill(pid, sig)`, /// but resize is a no-op). See OQ-43 for the `TtyControlHandle` /// newtype rationale. pub control: Option, } pub trait TtyControl: Send + Sync { /// Resize the terminal. Maps to SSH `window-change`, docker exec /// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe /// backends without a PTY (the adapter still calls it; the backend /// ignores). fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16); /// Forward a signal by name. Best-effort delivery to the foreground /// process group (see tty-local.md REQ-TTY-02). Unknown names fall /// back to the backend's default kill. fn signal(&self, name: &str); } /// The `Clone`-able handle to a backend's control path. The `TtyControl` /// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) -> /// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability /// lives on this concrete newtype, which holds the trait object behind an /// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned /// control-chunk dispatcher. See OQ-43. #[derive(Clone)] pub struct TtyControlHandle(Arc); ``` ### 4. Backends are not required to be natively async (REQ-TTY-01) The trait's adapter-facing types (`AsyncWrite`, `Stream`, `BoxFuture`, the `TtyControl` trait object) are the **adapter's contract**. A backend may expose blocking handles internally and bridge them to these async-facing types. The bridging pattern — blocking `std::io` on dedicated std threads or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels — is a **documented, supported implementation strategy**, not a workaround. The local backend (ADR-003) uses this pattern: `portable_pty` is a blocking API, and the backend's `allocate()` spawns reader/writer/waiter threads that feed `mpsc::Receiver` (stdout), `mpsc::Sender` (stdin wrapped as `AsyncWrite`), and `oneshot::Receiver` (exit). The adapter consumes the bridged async-facing types and is unaware of the threading. See `tty-local.md` for the bridge details. ### 5. Backend registration and the assembly layer The `TtyAdapter` does not know the set of backends at compile time — it holds a `HashMap>` populated at construction. The assembly layer (the CLI binary) constructs backends with their dependencies (a `DockerTtyBackend` wraps a `bollard::Docker` client; an `SshTtyBackend` wraps an SSH session; a `LocalTtyBackend` takes no deps) and registers them: ```rust let mut backends = HashMap::new(); backends.insert("local".into(), Arc::new(LocalTtyBackend::new()) as Arc); backends.insert("docker".into(), Arc::new(DockerTtyBackend::new(docker_client)) as _); backends.insert("ssh".into(), Arc::new(SshTtyBackend::new(ssh_session)) as _); let tty_adapter = TtyAdapter::new(Arc::new(backends)); ``` A deployment that doesn't want docker registers only `local`. A browser terminal endpoint that proxies to remote docker/ssh registers `docker` and/or `ssh` backends. The adapter is backend-agnostic; the assembly layer chooses what's available. ## Consequences **Positive:** - The wire-format adapter is backend-agnostic and testable with a mock backend (in-memory pipes). The build order (ADR-001 wire format + mock backend first, real backends last) follows directly. - alktty stays dependency-light: no bollard, no russh, no `portable_pty` in the default crate. The heavy deps live in the backend crates / the `local` feature. This is the same inversion as `OperationAdapter` (alknet ADR-017): the trait lives where the types live; the implementations live where their transport dependencies live. - Blocking-API backends (portable_pty) are first-class — the trait accommodates them by making the adapter-facing types the contract and the bridging pattern a documented strategy. No re-spec required when a future backend is also blocking. - `exit_code` as a `Future` (not a method on the handle) lets the adapter `select` between exit and stream-close — the load-bearing composition the session lifecycle needs (the exit chunk is sent after the child is reaped, then the stream closes — ADR-004). **Negative:** - **Backend params are opaque (`serde_json::Map`), not a typed enum.** Each backend deserializes its own params struct; the adapter passes the JSON through verbatim. The cost is one serde deserialize per `allocate()` call (negligible — allocation is once per session, not on the hot path). The benefit is a complete inversion: alktty has zero knowledge of any backend's params shape, and a new backend crate requires zero changes to alktty (no enum variant, no forward-reference type). See §"Backend params are opaque" for the full rationale and why the typed-enum alternative was rejected (Rust enums are closed; the earlier `SshChannelRef` variant was an output modeled as an input and created a dependency contradiction). - **`TtyControl` is not `Clone`; the `TtyControlHandle` newtype is.** `Clone` is not object-safe (`fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch), so `Box` does not compile. The design splits the concerns: the `TtyControl` trait stays object-safe (`Send + Sync`, no `Clone`); the `TtyControlHandle` newtype (a concrete struct holding `Arc`) implements `Clone` by cloning the `Arc`. This is the cost of the POC-discovered constraint that the control-chunk dispatcher needs to be `Clone` to hand off to the spawned pump task. See OQ-43 for the confirmation and the concrete newtype approach. - A backend that produces neither a PTY nor a process (a hypothetical "recorded session replay" backend) would have a no-op `TtyControl` and a synthetic `exit_code`. The trait accommodates it but the `TtyParams` shape (`cmd` is `Vec`, `terminal` is `Option`) assumes command-spawning. A non-command backend would supply an empty `cmd` and synthesize one internally. Not a current use case; the trait shape doesn't preclude it but doesn't optimize for it. ## Door type **One-way.** The `TtyBackend` trait method `allocate()`, the `TtyHandle` field set, and the `TtyControl` trait are the API surface every backend crate implements and the adapter consumes. Changing them after backends exist is a rewrite across crates. `backend_params` as an opaque `serde_json::Map` is part of the one-way `TtyParams` shape — the *carrier* is fixed (opaque JSON), but the *contents* are backend-defined and require no alktty change for new backends. The `resource_id()` default method is additive (a new method with a default impl doesn't break existing implementors); its return type `Option<(&'static str, String)>` is one-way. ## Assumptions 1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone` on it; the `TtyControlHandle` newtype holds the trait object behind an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used a concrete `PtyControl` struct (inherently `Clone` — it held `Arc>` fields). The newtype generalizes the POC's shape so a backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))` without the adapter knowing the concrete shape. `Clone` cannot live on the trait itself (it is not object-safe); the newtype is the seam. OQ-43 confirms. 2. **Backends produce a single session per `allocate()` call.** The adapter calls `allocate()` once per accepted bidi stream (one session per stream — ADR-001). A backend that multiplexed multiple sessions over one `allocate()` would not fit the trait; no such backend is contemplated. 3. **The adapter, not the backend, owns the exit-chunk ordering.** The backend resolves `exit_code`; the adapter awaits it, sends the exit control chunk, and closes the stream (ADR-004). The backend does not write to the wire — it produces handles; the adapter pumps. This keeps the wire-format logic in one place (the adapter) and the backend focused on its allocation target (docker, ssh, local process). ## References - alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another- handler; alktty depends on alkcall (no alkcall-internal-wire-types per Am. 2 / ADR-006); backends depend on alktty for the trait - [ADR-006](006-negotiation-framing-self-contained.md) — alktty does not depend on alkcall's internal wire types (self-contained negotiation framing) - alknet ADR-007 — `Connection`, `SendStream`, `RecvStream` (the adapter receives a `Connection`, accepts bidi streams, pumps per-session) - [ADR-001](001-wire-format-and-two-carriage.md) — the wire format this trait's backends feed - [ADR-003](003-local-backend-placement.md) — the local backend's module placement (folded into alktty behind a `local` feature) - [ADR-004](004-exit-code-on-control-chunk.md) — the exit chunk ordering this trait's `exit_code` field feeds into - [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel- cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel kills the session target) - alknet ADR-017 — the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows - alknet ADR-050 — the ownership model the `resource_id()` default declares against - OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed) - OQ-44 — terminal modes (deferred(scope): not needed for current scope) - Spec: [tty-backend.md](../tty-backend.md) - Port origin: alknet ADR-053 at `/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md`