Files
alktty/docs/architecture/tty-bast.md
T
glm-5.2 da0d395ab3 docs: fix tty-bast.md to cover only binary framing, not JSON payloads
The previous BAST document modeled the JSON payloads (NegotiateRequest,
ControlMessage and its resize/signal/eof/exit variants, TerminalParams)
as BAST struct/union definitions with uint16/int32 fields. That was a
category error: BAST describes binary data layouts, and per the BAST
format spec itself, "a BAST document cannot validate a JSON payload."
The control and negotiation payloads on the wire are UTF-8 JSON text
serialized via serde_json, not struct-encoded binary — the uint16/int32
field widths implied a binary encoding that does not exist on the wire
and would have misled any generated validator.

The rewrite keeps only the genuinely-binary framing layer:

- ChunkHeader (5-byte: stream_type u8 + length u32 BE)
- StreamType enum (name->index table; documented deviation: on-wire
  is uint8, not BAST's standard u32 enum index)
- Chunk (header + length-prefixed bytes payload)
- NegotiationFrame (4-byte BE length prefix + UTF-8 JSON body,
  modeled as bytes since the body's JSON interpretation is above the
  BAST layer)

The JSON shapes (NegotiateRequest, ControlMessage, TerminalParams)
remain specified in tty-wire.md and implemented by the Rust source
(src/negotiation.rs, src/control.rs), which are the source of truth
for those payloads. Cross-references in tty-wire.md, overview.md, and
README.md updated to reflect the simplified scope.

The drift-detection test (project plan "Risk: BAST schema drift")
still works unchanged — it asserts the StreamType enum values match
wire.rs's STREAM_* constants, and that enum is retained.

Docs-only change; no Rust source changes.

Verification:
- cargo test --all-features -> 122 tests pass (unchanged)
- cargo clippy --all-targets --all-features -- -D warnings -> clean
- cargo fmt --check -> clean
- cargo doc --no-deps -> no new warnings (9 pre-existing rustdoc link
  warnings in src/, unchanged)
- BAST JSON block parses as valid JSON (4 : ChunkHeader,
  StreamType, Chunk, NegotiationFrame)
2026-08-17 11:20:23 +00:00

12 KiB

status, last_updated
status last_updated
draft 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 and ADR-006.

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). This document therefore specifies only the binary framing; the JSON shapes are specified in prose in 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):

  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.

What this BAST deliberately does not cover:

  • NegotiateRequest JSON shape — a UTF-8 JSON object, not a binary layout. Specified in 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 §"Control Channel" and implemented by ControlMessage in src/control.rs.
  • TerminalParams JSON shape — the tty field of NegotiateRequest. Specified in 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

{
  "$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. 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).

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 §"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 §"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 §"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 §"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.

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 The 5-byte chunk header + negotiation frame this BAST describes
Self-contained negotiation framing ADR-006 The NegotiationFrame is self-contained in alktty (not reused from alkcall's EventEnvelope framing)
Exit code on a control chunk ADR-004 The exit JSON variant carried on CtrlOut (specified in tty-wire.md, not this BAST)

References

  • ADR-001 — the wire format decision this BAST specifies
  • ADR-006 — the negotiation framing is self-contained (the NegotiationFrame is not reused from alkcall)
  • 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.rsControlMessage (JSON shape, out of BAST scope)
  • alktype BAST format spec — 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)