Files
alktty/docs/architecture/tty-bast.md
T
glm-5.2 b3f50d1836 phase 4: architecture docs + BAST schema + renumbered ADRs
Port the alknet-tty architecture docs into alktty and add the BAST
document for the alk/tty wire format. Docs-only; no Rust source
changes.

Spec docs (docs/architecture/, flat layout — single-crate repo):
- overview.md — crate purpose, two-carriage model, deps, ALPN,
  backend location map, feature gates
- tty-wire.md — 5-byte chunk codec, control channel split
  (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels
- tty-backend.md — TtyBackend trait, TtyHandle, TtyControl,
  REQ-TTY-01 (backends need not be natively async)
- tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk
  ordering (ADR-004), cancel cleanup (ADR-005), access control
- tty-local.md — LocalTtyBackend (local feature module), PTY +
  pipe modes, REQ-TTY-02 (signal forwarding to process group)
- README.md — architecture index

ADRs (docs/architecture/decisions/, renumbered 001..008 from
alknet 052,053,054,055,056,057,077,093 in order):
- 001 wire format + two-carriage model (incl. Phase 7 control-
  channel split amendment)
- 002 TtyBackend trait + TtyHandle
- 003 local backend placement (records both the alknet sibling-
  crate decision and the alktty single-crate consolidation behind
  a local feature)
- 004 exit code on a control chunk
- 005 backend cleanup on session cancel
- 006 self-contained negotiation framing
- 007 tty inside channels (reversed by 008; kept for historical
  context with reversal notice)
- 008 channels pure channel multiplexing (reverses 007; TTY
  always uses its 5-byte format)

BAST document (docs/architecture/tty-bast.md):
- Normative JSON spec for the alk/tty wire format, conforming to
  the BAST meta-schema at https://alk.dev/bast/v1/schema
- 5-byte chunk header (struct, big-endian: stream_type uint8,
  length uint32) + StreamType enum (Stdin=0..CtrlOut=4)
- ControlMessage union (field-name discriminator on type:
  resize/signal/eof/exit) with documented deviation that on-wire
  control payloads are UTF-8 JSON, not BAST's binary union
  encoding
- NegotiationFrame (4-byte BE length + UTF-8 JSON body) +
  NegotiateRequest / TerminalParams JSON shapes
- StreamType enum deviation noted: on-wire uint8, not BAST's
  standard u32 enum index (chunk header is 5 bytes, not 8)
- alktty does not depend on alktype; the hand-rolled wire.rs is
  the runtime codec, the BAST is the human-readable contract

AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8
mapping (the previous table substituted ADR-050 for 054, relabeled
056 as control-message split, dropped 077, and added a new
control-split ADR at 006 — inconsistent with both the plan and the
prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet-
core ADR, not tty-specific, and is not ported; the Phase 7 control
split stays as an amendment inside ADR-001, mirroring alknet.

Verification (all pass, no Rust source changed):
- cargo test (80 passed)
- cargo test --all-features (103 passed)
- cargo clippy --all-targets -- -D warnings (clean)
- cargo fmt --check (clean)
- cargo check --target wasm32-unknown-unknown (clean)
- cargo clippy --target wasm32-unknown-unknown -- -D warnings
  (clean)
- cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in
  src/session.rs and src/channels.rs (untouched by this commit;
  not introduced here)
- BAST JSON parses; StreamType indices match wire.rs constants
  (0=Stdin..4=CtrlOut)
- all markdown cross-reference links resolve
2026-08-17 10:52:26 +00:00

18 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 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 and ADR-006.

Scope

The BAST below describes the two wire formats that live in this crate (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 (4-byte BE length prefix + UTF-8 JSON NegotiateRequest body) — self-contained per ADR-006, 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

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

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.
  • ttynull for pipe/runner mode (no PTY — ADR-003); 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 §"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 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)
Backend params are opaque ADR-002 NegotiateRequest.backend_params is an opaque JSON object; the adapter does not interpret it
Exit code on a control chunk ADR-004 The ExitMessage variant and the "exit chunk is last" invariant

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
  • 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 — 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)