--- 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 binary portions of 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, 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 `alk/tty` wire format mixes a small binary framing layer with UTF-8 JSON payloads for the structured parts. Per the BAST format spec, a BAST document describes **binary data layouts** — it is not a format for validating JSON payloads ([`validate_bytes` vs `validate_json`](https://alk.dev/bast/v1/spec)). This document therefore specifies only the binary framing; the JSON shapes are specified in prose in [tty-wire.md](tty-wire.md) and implemented by the Rust source (`src/negotiation.rs`, `src/control.rs`), which are the source of truth for those payloads. The binary portions this BAST covers (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 length prefix** (4-byte BE `u32` followed by `length` bytes of UTF-8 JSON `NegotiateRequest` body) — self-contained per [ADR-006](decisions/006-negotiation-framing-self-contained.md). What this BAST deliberately does **not** cover: - **`NegotiateRequest` JSON shape** — a UTF-8 JSON object, not a binary layout. Specified in [tty-wire.md](tty-wire.md) §"Phase 1" and implemented by `NegotiateRequest` in `src/negotiation.rs`. - **`ControlMessage` JSON shape** — the UTF-8 JSON payloads of `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` chunks (the `resize`/`signal`/ `eof`/`exit` variants). Specified in [tty-wire.md](tty-wire.md) §"Control Channel" and implemented by `ControlMessage` in `src/control.rs`. - **`TerminalParams` JSON shape** — the `tty` field of `NegotiateRequest`. Specified in [tty-wire.md](tty-wire.md) §"Phase 1" and implemented by `TerminalParamsWire` in `src/negotiation.rs`. A previous revision of this document modeled the JSON payloads as BAST `struct`/`union` definitions with `uint16`/`int32` fields. That was a category error: BAST describes binary layouts, and the control and negotiation payloads on the wire are UTF-8 JSON text, not struct-encoded binary. The JSON field types (`u16`, `i32`, `String`) are JSON value types produced and consumed by `serde_json`, not binary field widths. Describing them as BAST structs implied a binary encoding that does not exist on the wire and would have misled any generated validator. The simpler model — BAST for the binary framing, prose + Rust source for the JSON payloads — matches what the wire format actually is. ## 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 } ] }, "NegotiationFrame": { "kind": "struct", "endian": "big", "fields": [ { "name": "length", "kind": "uint32" }, { "name": "body", "kind": "bytes", "encoding": "length-prefixed", "maxLength": 16777216 } ] } } } ``` ## Annotations ### `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`). The `payload`'s interpretation depends on `stream_type`: - `Stdin`/`Stdout`/`Stderr` (0/1/2): raw bytes. Zero-length payloads on these channels are sentinels (zero-length stdin = EOF from client; zero-length stdout = "drained" from server); see [tty-wire.md](tty-wire.md) §"Sentinels". - `CtrlIn`/`CtrlOut` (3/4): UTF-8 JSON text (a serialized `ControlMessage`). Control chunks are never zero-length (the JSON payload is at least `{}`). The JSON shape is **not** specified by this BAST — see [tty-wire.md](tty-wire.md) §"Control Channel" and `src/control.rs`. ### `NegotiationFrame` — binary, exact (out-of-band for the chunk codec) The negotiation frame is a 4-byte big-endian length prefix + a 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 byte string (`length` bytes of UTF-8 JSON). 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 `body` is modeled as `bytes` (not `string`) because the BAST describes the binary framing layer: the body is `length` bytes whose interpretation (UTF-8 JSON, parseable as `NegotiateRequest`) is a contract above this BAST layer, specified in [tty-wire.md](tty-wire.md) §"Phase 1" and implemented in `src/negotiation.rs`. The `NegotiateRequest` JSON shape is **not** specified by this BAST. 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). ## 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) | | Exit code on a control chunk | [ADR-004](decisions/004-exit-code-on-control-chunk.md) | The `exit` JSON variant carried on `CtrlOut` (specified in `tty-wire.md`, not this BAST) | ## 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 (covers both the binary framing and the JSON payloads) - `src/wire.rs` — the runtime chunk codec (`ChunkReader`/`ChunkWriter`, `STREAM_*` constants, `MAX_CHUNK_LEN`) this BAST describes - `src/negotiation.rs` — the `NegotiationReader`/`NegotiationWriter` for the `NegotiationFrame`, and `NegotiateRequest` (JSON shape, out of BAST scope) - `src/control.rs` — `ControlMessage` (JSON shape, out of BAST scope) - [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`)