--- status: draft last_updated: 2026-08-17 --- # alktty — BAST Document for the `alk/tty` Wire Format This document is the **BAST (Binary Abstract Syntax Tree)** specification for the `alk/tty` wire format. It is a normative JSON document conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema` (a standard JSON Schema Draft 2020-12 document); any JSON Schema Draft 2020-12 validator can check whether the BAST below is well-formed. **alktty does not depend on alktype.** The hand-rolled `ChunkReader` / `ChunkWriter` in `src/wire.rs` is the runtime codec; this BAST document is the human-readable contract that describes what those types round-trip. If runtime validation against the BAST becomes desirable later (e.g., to reject malformed chunks at the framing boundary with a generated validator), alktype becomes an optional dep and this document is already there to feed it. See [ADR-001](decisions/001-wire-format-and-two-carriage.md) and [ADR-006](decisions/006-negotiation-framing-self-contained.md). ## Scope The BAST below describes the two wire formats that live in this crate (both one-way doors per [ADR-001](decisions/001-wire-format-and-two-carriage.md)): 1. **The 5-byte chunk header** (`[stream_type: u8][length: u32 BE] [payload]`) — the raw chunk codec in `src/wire.rs`. Five stream types: `STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. 2. **The negotiation frame** (4-byte BE length prefix + UTF-8 JSON `NegotiateRequest` body) — self-contained per [ADR-006](decisions/006-negotiation-framing-self-contained.md), not reused from alkcall's `EventEnvelope` framing. The `NegotiateRequest` JSON shape is wire-stable once consumers exist. The control-message `union` (field-name discriminator on `type`: `resize`, `signal`, `eof`, `exit`) describes the JSON shape of the control channel payloads; the on-wire encoding of a control chunk is a 5-byte chunk header with `stream_type ∈ {3, 4}` and a UTF-8 JSON payload (the `ControlMessage` serialized via `serde_json`). See "Annotations" below for where the JSON carriage does not map 1:1 to BAST's binary-native vocabulary. ## The BAST document ```json { "$schema": "https://alk.dev/bast/v1/schema", "$defs": { "ChunkHeader": { "kind": "struct", "endian": "big", "fields": [ { "name": "stream_type", "kind": "uint8" }, { "name": "length", "kind": "uint32" } ] }, "StreamType": { "kind": "enum", "values": ["Stdin", "Stdout", "Stderr", "CtrlIn", "CtrlOut"] }, "Chunk": { "kind": "struct", "endian": "big", "fields": [ { "name": "header", "kind": { "$ref": "#/$defs/ChunkHeader" } }, { "name": "payload", "kind": "bytes", "encoding": "length-prefixed", "maxLength": 16777216 } ] }, "ControlMessage": { "kind": "union", "discriminator": { "kind": "field", "name": "type" }, "fields": [ { "name": "type", "kind": "string" } ], "mapping": { "resize": { "$ref": "#/$defs/ResizeMessage" }, "signal": { "$ref": "#/$defs/SignalMessage" }, "eof": { "$ref": "#/$defs/EofMessage" }, "exit": { "$ref": "#/$defs/ExitMessage" } } }, "ResizeMessage": { "kind": "struct", "fields": [ { "name": "type", "kind": "string" }, { "name": "cols", "kind": "uint16" }, { "name": "rows", "kind": "uint16" }, { "name": "pixel_width", "kind": "uint16" }, { "name": "pixel_height", "kind": "uint16" } ] }, "SignalMessage": { "kind": "struct", "fields": [ { "name": "type", "kind": "string" }, { "name": "name", "kind": "string", "maxLength": 16 } ] }, "EofMessage": { "kind": "struct", "fields": [ { "name": "type", "kind": "string" } ] }, "ExitMessage": { "kind": "struct", "fields": [ { "name": "type", "kind": "string" }, { "name": "code", "kind": "int32" } ] }, "NegotiationFrame": { "kind": "struct", "endian": "big", "fields": [ { "name": "length", "kind": "uint32" }, { "name": "body", "kind": "string", "encoding": "length-prefixed", "maxLength": 16777216 } ] }, "NegotiateRequest": { "kind": "struct", "fields": [ { "name": "carriage", "kind": "string", "maxLength": 16 }, { "name": "backend", "kind": "string", "maxLength": 64 }, { "name": "tty", "kind": { "$ref": "#/$defs/TerminalParams" } }, { "name": "cmd", "kind": { "kind": "array", "element": "string", "count": 0 } }, { "name": "cwd", "kind": "string" }, { "name": "env", "kind": { "kind": "record", "values": "string" } }, { "name": "backend_params", "kind": { "kind": "record", "values": {} } } ] }, "TerminalParams": { "kind": "struct", "fields": [ { "name": "term", "kind": "string" }, { "name": "cols", "kind": "uint16" }, { "name": "rows", "kind": "uint16" }, { "name": "pixel_width", "kind": "uint16" }, { "name": "pixel_height", "kind": "uint16" }, { "name": "modes", "kind": {} } ] } } } ``` ## Annotations The BAST above is well-formed (it conforms to the BAST meta-schema). Some definitions map 1:1 to the on-wire bytes; others describe the logical shape of a JSON carriage whose on-wire encoding is UTF-8 text, not the BAST-native binary encoding. The annotations below record which is which and note the two deliberate deviations from BAST's binary-native vocabulary. A generated validator should consult these annotations alongside the BAST; the runtime codec (`src/wire.rs`) is the source of truth for the bytes. ### `ChunkHeader` — binary, exact Maps 1:1 to the on-wire bytes. 5 bytes: `stream_type` as `uint8` (1 byte), `length` as `uint32` big-endian (4 bytes). This is the 5-byte chunk header committed by [ADR-001](decisions/001-wire-format-and-two-carriage.md). The Rust types are `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` and `CHUNK_HEADER_LEN = 5` in `src/wire.rs`. ### `StreamType` — enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index The BAST meta-schema specifies that an `enum`'s binary representation is a `u32` index into `values` (0-based). The `alk/tty` wire format encodes `stream_type` as a **`uint8`** (1 byte), not a `u32` (4 bytes) — the chunk header is 5 bytes, not 8. This is a deliberate deviation: the chunk header needs a 1-byte discriminator, and 4 bytes of padding would double the per-chunk overhead. The `StreamType` enum is therefore **normative for the name→value mapping** (the index of a name in `values` is the integer that appears on the wire as the `uint8` `stream_type`), but a generated validator must NOT emit a `u32` read for `stream_type`. The on-wire encoding is the `uint8` declared in `ChunkHeader`; the enum provides the integer→name table for validation and diagnostics. | index | name | on-wire byte | direction | payload | |-------|-----------|--------------|----------------|---------------------| | 0 | `Stdin` | `0x00` | client→server | raw bytes | | 1 | `Stdout` | `0x01` | server→client | raw bytes | | 2 | `Stderr` | `0x02` | server→client | raw bytes | | 3 | `CtrlIn` | `0x03` | client→server | UTF-8 JSON control | | 4 | `CtrlOut` | `0x04` | server→client | UTF-8 JSON control | `stream_type > 4` is a protocol error (`InvalidStreamType`); there is no extension escape hatch in the byte (a 6th channel is a wire-format change requiring a new ALPN — [ADR-001](decisions/001-wire-format-and-two-carriage.md)). ### `Chunk` — binary, exact A chunk is the 5-byte `ChunkHeader` followed by `length` bytes of payload. The `payload` field is `bytes` with `encoding: "length-prefixed"` and `maxLength: 16777216` (16 MiB = `MAX_CHUNK_LEN` in `src/wire.rs`). Zero-length payloads are sentinels on the data channels (zero-length stdin = EOF from client; zero-length stdout = "drained" from server); control chunks are never zero-length (the JSON payload is at least `{}`). See [tty-wire.md](tty-wire.md) §"Sentinels". ### `ControlMessage` — union, documented deviation: on-wire encoding is UTF-8 JSON, not BAST's binary union encoding The BAST `union` with a field-name discriminator describes a binary layout whose discriminator is a length-prefixed string field and whose variant is a binary struct. The `alk/tty` control channel does **not** use that binary encoding: a control chunk's payload is **UTF-8 JSON text** (`serde_json`-serialized), and the `type` tag is a JSON string field, not a BAST length-prefixed string. The `ControlMessage` union here is **normative for the JSON variant shapes** (the `mapping` keys are the JSON `type` values; the variant structs are the JSON field sets a peer must accept/produce), but a generated validator must NOT emit a binary union reader for control chunks. The on-wire encoding is: read the `ChunkHeader`, read `length` bytes as UTF-8, parse the result as JSON, dispatch on the `type` field. The Rust type is `ControlMessage` in `src/control.rs` (`#[serde(tag = "type", rename_all = "snake_case")]`). The `type`-tagged enum is the extension seam per [ADR-001](decisions/001-wire-format-and-two-carriage.md): unknown `type` values are **ignored** (not a protocol error) so a newer client sending a control message an older server doesn't recognize degrades gracefully. Adding a control message type is additive (two-way-door within the one-way wire format); changing the meaning of an existing type is not. ### `ResizeMessage`, `SignalMessage`, `EofMessage`, `ExitMessage` — JSON shapes These are the four control message variants. Their field types (`uint16`, `int32`, `string`) describe the **JSON value types** a peer must accept/produce, not binary layouts — the on-wire encoding is UTF-8 JSON text inside a control chunk's payload (see `ControlMessage` above). The `maxLength` on `SignalMessage.name` is a validation constraint (signal names are short uppercase strings: `HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`, `USR2`, `TSTP`, `CONT`); it bounds the accepted JSON string length, not a binary reservation. | variant | direction | stream_type | JSON shape | |---------|----------------|-----------------|----------------------------------------------------------------------------------| | resize | client→server | `CtrlIn` (3) | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | | signal | client→server | `CtrlIn` (3) | `{"type":"signal","name":"INT"}` | | eof | client→server | `CtrlIn` (3) | `{"type":"eof"}` | | exit | server→client | `CtrlOut` (4) | `{"type":"exit","code":0}` | The `exit` chunk is the last control chunk before stream close ([ADR-004](decisions/004-exit-code-on-control-chunk.md)); `code` is `int32` (matches `std::process::ExitStatus::code()`; negative values are signal-terminated, e.g., `-9` for SIGKILL on Unix; `-1` is the "backend couldn't determine the exit code" sentinel). ### `NegotiationFrame` — binary, exact (out-of-band for the chunk codec) The negotiation frame is a 4-byte big-endian length prefix + UTF-8 JSON body. This maps 1:1 to the on-wire bytes: `length` as `uint32` big-endian (4 bytes), `body` as a length-prefixed UTF-8 string (`length` bytes). The `maxLength: 16777216` (16 MiB) constraint is the same as `MAX_CHUNK_LEN` — error frames MUST be under 16 MiB so the 4-byte length prefix's high byte is `0x00`, which is what makes the framing-disambiguation trick sound (first byte `0x00` = error/negotiation frame; first byte `1`/`2`/`4` = raw chunk; see [tty-wire.md](tty-wire.md) §"Constraints"). The negotiation frame is **out-of-band for the chunk codec**: it is read once at session start (Phase 1, JSON carriage), then the stream switches to raw chunks (Phase 2). The `ChunkReader`/`ChunkWriter` in `src/wire.rs` does not read or write negotiation frames; the `NegotiationReader`/`NegotiationWriter` in `src/negotiation.rs` does. See [ADR-006](decisions/006-negotiation-framing-self-contained.md). ### `NegotiateRequest` — JSON shape The `NegotiateRequest` struct describes the **JSON shape** of the `NegotiationFrame.body`, not a binary layout. The on-wire encoding is UTF-8 JSON text inside the negotiation frame's `body` field (see `NegotiationFrame` above). The Rust type is `NegotiateRequest` in `src/negotiation.rs` (`#[derive(Serialize, Deserialize)]` with `#[serde(flatten)]` on `backend_params`). Field notes: - `carriage` — `"raw"` in v1 (the only carriage); any other value → `malformed_negotiation`. `maxLength: 16` bounds the accepted JSON string length. - `backend` — the backend selector key (`"local"`, `"docker"`, `"ssh"`); `maxLength: 64`. - `tty` — `null` for pipe/runner mode (no PTY — [ADR-003](decisions/003-local-backend-placement.md)); `Some` for PTY mode. The BAST uses a `$ref` to `TerminalParams` for the non-null case; the on-wire JSON `null` is the pipe-mode sentinel. - `cmd` — command vector (argv[0] + args); non-empty (checked by the adapter). The BAST uses `array` with `count: 0` as a placeholder — **BAST v1 requires `count` for arrays** (D-BAST-004), and a variable-length command vector does not have a schema-known count. This is a third documented deviation: the `cmd` field is a JSON array of strings of arbitrary length, not a fixed-count BAST array. A generated validator should treat `cmd` as a JSON array (variable length, non-empty), not a BAST fixed-count array. - `cwd` — working directory (`null` = inherit/default). - `env` — environment variables (empty = inherit); a `record` from string to string. - `backend_params` — backend-specific selector fields, opaque to alktty. The BAST uses `record` with an empty value type (`{}`) as a placeholder for "arbitrary JSON value"; the adapter passes this map through verbatim and each backend deserializes its own strongly-typed params struct from it. See [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) §"Backend params are opaque." ### `TerminalParams` — JSON shape The terminal parameters carried in `NegotiateRequest.tty` when non-null. JSON shape, not binary layout. `modes` is reserved (OQ-44 — default terminal modes suffice for the current scope); backends MUST ignore its content in v1. The empty value type (`{}`) is a placeholder for "arbitrary JSON value." ## Validation The BAST document above is validatable in two ways: 1. **Structural validation** — run any JSON Schema Draft 2020-12 validator against the BAST meta-schema at `https://alk.dev/bast/v1/schema`. This checks whether the BAST is well-formed (correct `kind` strings, required fields present, `$ref` targets exist). It does NOT check whether a binary payload conforms to the layout — that is the BAST-native validator's job (see the alktype BAST format spec, `/workspace/@alkdev/alktype/docs/architecture/bast-format.md`). 2. **Drift detection** — a cheap test that parses the BAST document with `serde_json` and asserts the `StreamType` enum values match `wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants (the index of each name in `values` is the integer constant). This catches the common drift case (a new stream_type added to `wire.rs` but not the BAST, or vice versa) without requiring alktype as a dep — just `serde_json`, which alktty already has. See the project setup plan's "Risk: BAST schema drift" mitigation. ## Design Decisions | Decision | ADR | Summary | |----------|-----|---------| | Wire format and two-carriage model | [ADR-001](decisions/001-wire-format-and-two-carriage.md) | The 5-byte chunk header + negotiation frame this BAST describes | | Self-contained negotiation framing | [ADR-006](decisions/006-negotiation-framing-self-contained.md) | The `NegotiationFrame` is self-contained in alktty (not reused from alkcall's `EventEnvelope` framing) | | Backend params are opaque | [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) | `NegotiateRequest.backend_params` is an opaque JSON object; the adapter does not interpret it | | Exit code on a control chunk | [ADR-004](decisions/004-exit-code-on-control-chunk.md) | The `ExitMessage` variant and the "exit chunk is last" invariant | ## References - [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire format decision this BAST specifies - [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the negotiation framing is self-contained (the `NegotiationFrame` is not reused from alkcall) - [tty-wire.md](tty-wire.md) — the prose wire format spec this BAST formalizes - `src/wire.rs` — the runtime chunk codec (`ChunkReader`/`ChunkWriter`, `STREAM_*` constants, `MAX_CHUNK_LEN`) this BAST describes - `src/control.rs` — the `ControlMessage` tagged enum this BAST describes - `src/negotiation.rs` — the `NegotiateRequest` / `NegotiationReader` / `NegotiationWriter` this BAST describes - [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the normative format spec for BAST documents (the meta-schema this document conforms to); see also `/workspace/@alkdev/alktype/docs/architecture/bast-format.md` - Project setup plan, "Risk: BAST schema drift" — the drift-detection test mitigation (`docs/plans/project-setup.md`)