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)
249 lines
12 KiB
Markdown
249 lines
12 KiB
Markdown
---
|
|
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`) |