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
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):
- The 5-byte chunk header (
[stream_type: u8][length: u32 BE] [payload]) — the raw chunk codec insrc/wire.rs. Five stream types:STREAM_STDIN=0,STREAM_STDOUT=1,STREAM_STDERR=2,STREAM_CTRL_IN=3,STREAM_CTRL_OUT=4. - The negotiation frame (4-byte BE length prefix + UTF-8 JSON
NegotiateRequestbody) — self-contained per ADR-006, not reused from alkcall'sEventEnvelopeframing. TheNegotiateRequestJSON 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: 16bounds the accepted JSON string length.backend— the backend selector key ("local","docker","ssh");maxLength: 64.tty—nullfor pipe/runner mode (no PTY — ADR-003);Somefor PTY mode. The BAST uses a$reftoTerminalParamsfor the non-null case; the on-wire JSONnullis the pipe-mode sentinel.cmd— command vector (argv[0] + args); non-empty (checked by the adapter). The BAST usesarraywithcount: 0as a placeholder — BAST v1 requirescountfor arrays (D-BAST-004), and a variable-length command vector does not have a schema-known count. This is a third documented deviation: thecmdfield is a JSON array of strings of arbitrary length, not a fixed-count BAST array. A generated validator should treatcmdas a JSON array (variable length, non-empty), not a BAST fixed-count array.cwd— working directory (null= inherit/default).env— environment variables (empty = inherit); arecordfrom string to string.backend_params— backend-specific selector fields, opaque to alktty. The BAST usesrecordwith 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:
- 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 (correctkindstrings, required fields present,$reftargets 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). - Drift detection — a cheap test that parses the BAST document
with
serde_jsonand asserts theStreamTypeenum values matchwire.rs'sSTREAM_STDIN/STREAM_STDOUT/STREAM_STDERR/STREAM_CTRL_IN/STREAM_CTRL_OUTconstants (the index of each name invaluesis the integer constant). This catches the common drift case (a new stream_type added towire.rsbut not the BAST, or vice versa) without requiring alktype as a dep — justserde_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
NegotiationFrameis 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 describessrc/control.rs— theControlMessagetagged enum this BAST describessrc/negotiation.rs— theNegotiateRequest/NegotiationReader/NegotiationWriterthis 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)