# ADR-001: alktty Wire Format and Two-Carriage Model ## Status Accepted (amended 2026-07-18 — Phase 7: the control channel is split into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` (server→client) halves; see §"Control channel split" below. Ported from alknet ADR-052 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 (001, 002, 003, 006, 007, 012, 044, 049, 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 The alknet-docker POC validated that interactive attach — bidirectional byte pumping over a framed bidi stream with a 1-byte stream-type multiplexer — is the same problem regardless of whether the backend is `bollard::attach_container()` or russh's `pty_request`. The POC's raw chunk format (`[stream_type: u8][length: u32 be][payload bytes]`, stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished version of SSH's channel multiplexer: fixed set of channel types, no negotiation, no open/close handshake, no windowing (QUIC provides flow control on the bidi stream). That impoverishment is the feature — a terminal session needs exactly those channels and no more. The alknet-tty POC (built 2026-07-05) extended that format with a 4th stream_type (3 = control) carrying JSON control messages (resize, signal, eof, exit) and validated the full round-trip against a real `portable_pty` PTY: negotiate → PTY alloc → bidirectional echo → mid-session resize → EOF → exit code, plus SIGINT forwarding to a child process group. The wire format this ADR commits is the POC's raw codec + control schema, generalized from one backend to the backend-agnostic crate. Three load-bearing questions are decided here: 1. **Separate ALPN with raw carriage, not call-protocol operations.** A terminal session could be modeled as a call-protocol `Subscription` operation (`tty/open`) streaming `call.responded` events. That is rejected: JSON-encoding every byte chunk is wasteful (base64 for binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams partial bytes with no message boundary that maps to a JSON object). The two-carriage model — a single JSON negotiation frame, then raw chunks — keeps the call protocol's JSON-RPC shape for the *request* and switches to bytes for the *body*, which is the part that is actually bytes. This is the pattern the docker POC validated and the SSH research independently arrived at for PTY. 2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with string-named types negotiated per channel. alktty multiplexes one service — a terminal session — with a fixed `u8` set and no negotiation. Adding a 6th channel type is a wire-format change (one-way door). The ALPN model handles extensibility at the protocol level: a genuinely new sideband (e.g., file transfer alongside the terminal) is a different ALPN, not a 6th tty channel type. A new ALPN is cheap; a wire-format change is not. 3. **Control messages as JSON, not binary.** A binary control format (`[control_type: u8][params...]`) would be faster but harder to extend and inconsistent with the negotiation layer. Control messages are rare (resize on window drag, signal on Ctrl-C, one eof, one exit per session) — serialization cost is negligible against the data chunks. If a hot control path appears, a binary `control_type` can be added without breaking the chunk format (additive within the control channel). ## Decision ### 1. alktty is a `ProtocolHandler` on ALPN `alk/tty` `alk/tty` is a custom ALPN per the alknet ADR-006 `alk/` convention (renamed from `alknet/` in alkcall 0.1.1). The `TtyAdapter` implements `ProtocolHandler` (alknet ADR-002, revised by alknet ADR-007 to receive a `Connection`). The handler owns the entire connection lifecycle and accepts one bidi stream per terminal session. This is a separate ALPN, not a set of operations in the call protocol's `OperationRegistry` — the raw-carriage byte pump is not a `StreamingHandler` (alknet ADR-049); it is its own wire format after a single JSON negotiation frame. ### 2. Two-carriage model: JSON negotiation, then raw chunks The bidi stream has two phases: - **Negotiation (JSON carriage).** The client opens a bidi stream and writes a single length-prefixed JSON frame — a 4-byte big-endian length prefix + UTF-8 JSON body. The frame carries the terminal parameters and backend selector the server needs to allocate the session: ```json { "carriage": "raw", "backend": "local", "tty": { "term": "xterm-256color", "cols": 80, "rows": 24, "pixel_width": 0, "pixel_height": 0, "modes": {} }, "cmd": ["/bin/bash"], "cwd": null, "env": {} } ``` The `carriage` field is `"raw"` for terminal sessions (the only carriage in v1). The `tty` block is `null` for the pipe/runner case (no PTY — see ADR-003). The `backend` field selects the `TtyBackend` (ADR-002); backend-specific fields (e.g., `container` for docker) ride alongside. - **Raw carriage.** After the negotiation frame, the stream switches to the chunk format below for the life of the session. There is no `call.responded`/`call.completed` — this is not the call protocol. ### 3. Chunk format ```text [stream_type: u8][length: u32 be][payload bytes] ``` - `stream_type` — fixed set, no negotiation: | stream_type | channel | direction | payload | |---|---|---|---| | 0 | data-in (stdin) | client→server | raw bytes | | 1 | data-out (stdout) | server→client | raw bytes | | 2 | data-err (stderr) | server→client | raw bytes | | 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) | | 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) | `stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7 amendment, §4a: the original `3 = control (bidirectional)` is split into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was `> 3`.) There is no extension escape hatch in the byte — a 6th channel is a wire-format change requiring a new ALPN (`alk/tty/v2` per alknet ADR-006), not a negotiated addition to this format. - `length` — payload length in bytes, u32 big-endian, max 16 MiB. A chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The 16 MiB bound accommodates large paste operations and large `env` blocks while bounding memory per chunk (a reader can allocate a bounded buffer up front); it mirrors the docker POC's limit. - Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF from the client; a zero-length stdout chunk is "drained" from the server (output stream ended). Control chunks are never zero-length (the JSON payload is at least `{}`). ### 4. Control channel (stream_type 3) carries JSON control messages Control chunks carry a small JSON payload, tagged by `type`: | Direction | Message | Shape | |---|---|---| | client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | | client→server | signal | `{"type":"signal","name":"INT"}` | | client→server | eof | `{"type":"eof"}` | | server→client | exit | `{"type":"exit","code":0}` | - **resize** — window-size change. Maps to SSH `window-change`, docker exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY. - **signal** — signal forwarding. `name` is an uppercase string (`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`, `"USR1"`, `"USR2"`). Unknown names fall back to the backend's default kill (see ADR-002 / `tty-local.md` REQ-TTY-02). - **eof** — client signals no more stdin. Maps to SSH channel EOF, docker stdin close, or `ChildStdin::drop` / PTY writer close. This is one of two canonical "stdin done" signals; the other is a zero-length stdin chunk (the docker POC's sentinel). Both are accepted as equivalent — `eof` is recommended for explicitness (it's a control message, not a data-length hack); the zero-length stdin chunk is kept for compatibility with the docker POC's pattern. See OQ-47. - **exit** — server signals process exit with code. This is the last control chunk before stream close (see ADR-004). The `type` tag is the extensibility seam: new control message types are added by extending the tagged enum. Unknown `type` values are ignored (not a protocol error) so that a newer client sending a control message an older server doesn't recognize degrades gracefully rather than tearing down the session. This is a two-way-door extension point within the one-way-door wire format — adding a control message type is additive; changing the chunk header is not. ### 4a. Control channel split (Phase 7 amendment, 2026-07-18) The single `stream_type 3 = control (bidirectional)` in §3 and §4 above is **split into two halves** so the control channel is genuinely bidirectional on the wire: | stream_type | channel | direction | payload | |-------------|-------------|----------------|------------------------------------------------------| | 3 | `STREAM_CTRL_IN` | client→server | JSON control message (`Resize`, `Signal`, `Eof`) | | 4 | `STREAM_CTRL_OUT` | server→client | JSON control message (`Exit`) | The `stream_type > 3` protocol-error bound becomes `stream_type > 4`. The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length). **Why the split.** The original §4 documented stream_type 3 as "bidirectional" and listed the four control messages with their directions. But the adapter had no way to distinguish the two directions on the same stream_type — `Exit` from the client was always ignored (the adapter's `pump_client_to_backend` matched `Exit` and logged "ignoring Exit control from client (server→client only)"). The spec said "bidirectional"; the code was half-duplex. The split makes the bidirectionality literal: each direction has its own stream_type, the adapter enforces the direction (an `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation; a `Resize` arriving on `STREAM_CTRL_OUT` is a protocol violation), and a client can route exit vs. control without parsing the JSON `type` tag first. **Door type.** One-way, same as the original §3 / §4. The stream_type set is bytes clients and servers parse. A client written against the old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as `InvalidStreamType (> 3)` and tear down the session — the split is a wire-format change, not an additive extension. The reversal path is the same as the original: a new ALPN (`alk/tty/v2`), which coexists rather than replaces. The trade is one new stream_type byte now vs. the half-duplex-in-disguise flaw forever. **What changes in the spec.** §3's stream_type table gains a 5th row (`4 = ctrl_out, server→client`); the bound becomes `> 4`. §4's direction table is unchanged in content (the four messages keep their directions and shapes) but the direction is now encoded in the stream_type, not just in the adapter's behavior. ADR-004's "exit chunk is last" invariant is unchanged — the exit chunk still rides the control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of the old single `STREAM_CONTROL` (stream_type 3). ### 5. Negotiation errors use the JSON framing, not the raw chunk format If the server cannot allocate the session (unknown backend, PTY allocation failed, the command is invalid), it sends a JSON error response in the same 4-byte length-prefixed framing as the negotiation frame and closes the stream without entering raw mode. The error response shape is `{"error":"","message":"..."}` (see [tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the codes). This is not `call.error` — this is not the call protocol; the error is a JSON response in the negotiation framing, and the stream closes after it. **Framing disambiguation (success vs error).** Both a successful allocation (raw chunks) and a failed allocation (JSON error frame) begin with bytes the client must read before knowing which framing applies. The disambiguation is by the first byte: a JSON error frame's 4-byte big-endian length prefix always starts with `0x00` (error frames MUST be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is a wire-format invariant, not an assumption), while a raw chunk's first byte is a `stream_type` in `{0, 1, 2, 3, 4}`. A stream_type of `0` (stdin from server) is invalid — the server never sends stdin chunks — so the client distinguishes: read the first byte; if it is `0x00`, interpret the next 4 bytes as a big-endian length prefix and read that many bytes as a JSON error frame; otherwise interpret it as a `stream_type` byte and continue reading the raw chunk header. This is a one-way-door wire-format invariant: error frames use the negotiation framing (length prefix) and MUST be under 16 MiB; success uses the raw chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs `0x00`-as-invalid-stream_type disambiguation is what makes the two distinguishable on the wire. (With the Phase 7 split, the server-sent set is `{1, 2, 4}` — stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is still unambiguous.) ### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency) The 4-byte length prefix + JSON body is implemented in alktty as a small, self-contained module (`src/negotiation.rs`, ~30 lines: read 4-byte BE length, bounds-check, read N bytes; write the inverse). The format coincides with alkcall's `EventEnvelope` framing by convention (both are length-prefixed JSON) — not by code reuse. alktty does not depend on alkcall's internal wire types: the negotiation payload is a tty-specific struct (`NegotiateRequest`), not a `call.requested` `EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is hardcoded to deserialize `EventEnvelope` (the length-prefix read and the type-specific deserialize are one entangled call), so it is not reusable for a different payload type. alktty implements its own framing on tokio's `AsyncRead`/`AsyncWrite`. See [ADR-006](006-negotiation-framing-self-contained.md) for the decision (and the three options considered: duplicate / promote to alkcall::core / use alkcall) and alknet ADR-003 Amendment 2 for the dependency-edge clarification. ## Consequences **Positive:** - The wire format is POC-validated twice (docker POC for stream_type 0/1/2 + bidirectional pump; tty POC for stream_type 3 + control messages + local PTY). No new wire-format invention in Phase 1. - The fixed channel set is a `match`, not a hash lookup — fast on the hot path where every chunk is data. - The two-carriage model keeps the call protocol's JSON-RPC shape for the structured request while letting the body be raw bytes, which is what a terminal actually is. No base64, no per-chunk `EventEnvelope` overhead. - Control messages as JSON are consistent with the negotiation layer and trivially extensible (tagged enum), at negligible cost for rare messages. - A separate ALPN composes with the ALPN dispatch model (alknet ADR-001/006): the endpoint routes `alk/tty` to the `TtyAdapter`; the call protocol is unaffected. Browser terminals (xterm.js over WebTransport, when WebTransport revives) connect to `alk/tty` directly without implementing SSH or the call protocol. **Negative:** - A 6th channel type is a wire-format change (one-way door). The justification is that the use cases are bounded — a terminal session has stdin, stdout, stderr, and control (now split into ctrl-in / ctrl-out). New sideband needs are different ALPNs, not 6th channels. If this proves wrong, the reversal is a new ALPN string (`alk/tty/v2`), which coexists with the old one rather than replacing it — but every client and server implementing the old format would need updating to speak the new one. - Control as JSON means a `serde_json` deserialize per control chunk. Control chunks are rare (one per resize, one per signal, one eof, one exit), so this is negligible. A hot control path would warrant a binary format — the `type`-tagged enum leaves that door open without a wire-format change. - The negotiation frame is a custom JSON shape, not a `call.requested` event, so a client library can't reuse its call-protocol client to open a tty session — it speaks the tty wire format directly. This is intentional (the tty session is not a call-protocol operation) but means the tty client is a separate small client, not a `CallClient` method. - **No application-level flow-control window (OQ-45 resolved).** The chunk format carries no window; backpressure relies on QUIC's per-stream flow control composing with the bounded channels in the adapter's pump and the OS pipe/PTY buffer. This is sufficient by construction (see §Assumptions 1). If a workload ever requires sub-QUIC-window backpressure signaling, the reversal is an additive `ControlMessage::WindowUpdate` (or similar) variant on the control channel — a two-way-door extension to the control channel, not a wire-format header change. The `type`-tagged enum's "unknown types ignored" rule means older peers degrade gracefully if such a variant is introduced. ## Door type **One-way.** The chunk header (5 bytes: 1 type + 4 length), the fixed stream_type set (0-4), and the two-carriage sequence (JSON frame → raw chunks) are bytes clients and servers parse. Changing any of them breaks every client and server implementing the format. The reversal path is a new ALPN (`alk/tty/v2`), which coexists rather than replaces — but the cost of migrating every consumer is the one-way-door cost. The control message `type` enum is a two-way-door extension point within the one-way wire format: adding a control message type is additive (unknown types are ignored), changing the meaning of an existing type is not. ## Assumptions 1. **No application-level windowing; QUIC per-stream flow control is the backpressure mechanism.** The chunk format carries no window field. This is decided (OQ-45 resolved): the backpressure chain from a slow client read all the way back to the child process's stdout write is complete by construction — QUIC flow control → bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer → process `write()` blocks. Every link awaits its producer; no unbounded buffer breaks the chain. The reversal path, if a workload ever surfaces a problem QUIC's defaults cannot handle, is an additive window-update `ControlMessage` variant (a two-way-door extension to the control channel, not a wire-format header change) — noted in §Consequences. See OQ-45. 2. **The negotiation frame fits in one chunk of the underlying stream's initial flow-control window.** The frame is small (terminal params + command + env, typically < 4 KiB). QUIC's default initial bidi-window (quinn defaults are tens of KiB) accommodates it without a flow-control round-trip. A pathological `env` block larger than the window would stall until the window opens; the 16 MiB chunk limit is the hard cap. 3. **Control messages are rare enough that JSON serialization cost is negligible.** Validated by the tty POC: resize on window drag, one signal per Ctrl-C, one eof, one exit per session. No measurable cost observed. ## References - alknet ADR-001 — ALPN-based dispatch - alknet ADR-002 — ProtocolHandler trait - alknet ADR-006 — `alk/` ALPN convention; one ALPN per connection; new ALPN for incompatible versions - alknet ADR-007 — handler receives a `Connection`, accepts bidi streams - alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call; self-contained negotiation framing - [ADR-006](006-negotiation-framing-self-contained.md) — the dependency-edge decision this ADR's §6 reflects - alknet ADR-012 — the call protocol's stream model (which tty is *not* using for the body, by design) - alknet ADR-049 — the `StreamingHandler` path tty explicitly does not use for the byte body - [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend trait the negotiation frame's `backend` field dispatches to - [ADR-003](003-local-backend-placement.md) — the local backend's module placement (the `tty: null` pipe-mode case) - [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk ordering this ADR's control channel carries - [ADR-008](008-channels-pure-channel-multiplexing.md) — TTY always uses its 5-byte format, including inside channels (the channels layer carries this wire format transparently in its payload) - Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md) - Port origin: alknet ADR-052 at `/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md`