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)
19 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft (ported from alknet 2026-08-17; alknet-tty → alktty, alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..008) | 2026-08-17 |
alktty — Wire Format
The wire protocol for alk/tty: the negotiation frame (JSON carriage),
the raw chunk codec, the control channel (split into STREAM_CTRL_IN /
STREAM_CTRL_OUT halves — Phase 7), and the sentinels. The
two-carriage model is decided in ADR-001;
this document specifies what an implementer builds.
What
A alk/tty bidi stream carries one terminal session. The stream has
two phases:
- Negotiation (JSON carriage). A single length-prefixed JSON frame from the client carrying the terminal parameters, backend selector, command, and environment.
- Raw carriage. After the negotiation frame, the stream switches to a chunk format for the life of the session: bidirectional byte pumping with a 1-byte stream-type multiplexer and a JSON control channel.
The format is the alknet-docker POC's raw chunk format (stream_type 0/1/2) extended with a 4th stream_type (3 = control) and a JSON control message schema, both validated by the alknet-tty POC. See ADR-001.
Why
A terminal session is a byte stream with a small control sideband. The
two-carriage model (JSON negotiation, then raw chunks) keeps the call
protocol's JSON-RPC shape for the structured request and switches to
bytes for the body, which is what a terminal actually is. The fixed
channel set (five stream types, no negotiation) is an impoverishment of
SSH's channel multiplexer that is the feature: alktty multiplexes one
service (a terminal session) with a fixed channel structure, not
arbitrary services, so the demux is a match, not a hash lookup. The
full rationale — why not JSON for everything, why fixed channel set
rather than extensible — is in
ADR-001 §Context.
Architecture
Phase 1: Negotiation Frame (JSON Carriage)
The client opens a bidi stream (or the server accepts one) and writes a
single length-prefixed JSON frame. The framing is a 4-byte big-endian
length prefix + UTF-8 JSON body — a self-contained ~30-line module in
alktty (read 4-byte length, bounds-check, read N bytes; write the
inverse) on tokio's AsyncRead/AsyncWrite. The format coincides with
alkcall's EventEnvelope framing by convention (both are
length-prefixed JSON), not by code reuse — alktty does not depend on
alkcall's internal wire types. The negotiation payload is a tty-specific
struct (NegotiateRequest), not a call.requested event. See ADR-001
§6 and ADR-006.
The payload shape:
{
"carriage": "raw",
"backend": "local",
"tty": {
"term": "xterm-256color",
"cols": 80,
"rows": 24,
"pixel_width": 0,
"pixel_height": 0,
"modes": {}
},
"cmd": ["/bin/bash"],
"cwd": null,
"env": {}
}
Fields:
carriage—"raw"for terminal sessions (the only carriage in v1). Selects the post-negotiation byte format. MUST be"raw"in v1; any other value (e.g.,"json", an unknown carriage, or the field absent) is amalformed_negotiationerror and the adapter closes the stream without entering raw mode. A future carriage (e.g., a structured JSON-only mode for a non-terminal use case) is a v2 addition; in v1 the field is required and must be the literal"raw".backend— the backend selector string ("local","docker","ssh"). The adapter dispatches to the registeredTtyBackendby this key (ADR-002 §5).tty— terminal parameters.nullfor the pipe/runner case (no PTY — ADR-003).Somefor the PTY case. Thettyblock maps directly to SSH'spty_requestparameters (term, cols, rows, pixel_width, pixel_height, modes) and to docker'sCreateExecOptions { tty: true }; a local backend passes it toportable_pty::PtySystem::openpty. Themodesfield is reserved (OQ-44 — default terminal modes suffice for the current scope).cmd— command vector (argv[0] + args). Non-empty.cwd— working directory (null= inherit/default).env— environment variables (empty = inherit).
The Rust struct the adapter parses the frame into:
#[derive(Deserialize)]
pub struct NegotiateRequest {
pub carriage: String, // "raw" in v1; any other value → malformed_negotiation
pub backend: String, // backend selector key ("local", "docker", "ssh")
pub tty: Option<TerminalParamsWire>, // None = pipe mode (ADR-003)
pub cmd: Vec<String>, // argv[0] + args; non-empty
#[serde(default)]
pub cwd: Option<PathBuf>, // None = inherit/default
#[serde(default)]
pub env: HashMap<String, String>, // empty = inherit
#[serde(default)]
pub backend_params: serde_json::Map<String, serde_json::Value>, // opaque; backend-deserialized
// plus backend-specific fields, captured into backend_params via serde(flatten)
}
#[derive(Deserialize)]
pub struct TerminalParamsWire {
pub term: Option<String>, // None = backend default
pub cols: u16,
pub rows: u16,
#[serde(default)]
pub pixel_width: u16,
#[serde(default)]
pub pixel_height: u16,
#[serde(default)]
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
}
Validation: carriage MUST be "raw" (else malformed_negotiation);
cmd MUST be non-empty (else malformed_negotiation); backend MUST
be a registered backend key (else unknown_backend). Backend-specific
params validation is the backend's job (in allocate()); the adapter
does not interpret backend_params. The struct's serde(flatten) for
backend-specific fields means the negotiation frame's top-level JSON
object carries both the shared fields (carriage, backend, tty,
cmd, cwd, env) and the backend-specific fields (e.g.,
"container": "abc123" for docker); the latter land in
backend_params.
Backend-specific selector fields ride alongside (e.g., "container": "abc123" for docker). The adapter parses the negotiation frame,
extracts the backend string, and passes the remaining backend-specific
fields to the selected backend's allocate() as an opaque
serde_json::Map (ADR-002) — the adapter does not interpret them; the
backend deserializes its own strongly-typed params struct.
After the negotiation frame, the stream switches to raw chunks. There is
no call.responded/call.completed — this is not the call protocol.
Phase 2: Raw Chunk Format
[stream_type: u8][length: u32 be][payload bytes]
-
stream_type(1 byte) — the channel:stream_type channel direction payload 0 data-in (stdin) client→server raw bytes 1 data-out (stdout) server→client raw bytes 2 data-err (stderr) server→client raw bytes 3 ctrl-in client→server JSON control message ( Resize,Signal,Eof)4 ctrl-out server→client JSON control message ( Exit)stream_type > 4is 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 (alk/tty/v2per alknet ADR-006), not a negotiated addition to this format. See ADR-001 §"Fixed channel set, not extensible."Bidirectional control channel (Phase 7). The control channel is split into two halves so it is genuinely bidirectional on the wire:
STREAM_CTRL_IN = 3carries client→server control (Resize,Signal,Eof);STREAM_CTRL_OUT = 4carries server→client control (Exit). The previous singleSTREAM_CONTROL = 3was documented as "bidirectional" but the adapter ignoredExitfrom the client because the two directions were indistinguishable on the same stream_type. The split makes the bidirectionality explicit: each direction has its own stream_type, and the adapter enforces the direction (anExitarriving onSTREAM_CTRL_INis a protocol violation and is ignored; aResizearriving onSTREAM_CTRL_OUTis likewise a protocol violation and is ignored). -
length(4 bytes, big-endian) — payload length in bytes. Max 16 MiB (MAX_CHUNK_LEN = 16 * 1024 * 1024). A chunk larger than 16 MiB is a protocol error (ChunkTooLarge). -
payload(lengthbytes) — the raw bytes (for data channels) or UTF-8 JSON (for the control channel).
The codec is ChunkReader/ChunkWriter in src/wire.rs:
ChunkReader::read_chunk() reads the 5-byte header, validates the
stream_type and length, reads the payload; ChunkWriter::write_chunk()
writes the header and payload. See ADR-001.
Sentinels
Zero-length data chunks are sentinels:
- Zero-length stdin chunk (stream_type 0, length 0) — EOF from the
client. The server closes the backend's stdin (
ChildStdin::drop/ PTY writer close). This is one of two canonical "stdin done" signals; the other is a{"type":"eof"}control chunk — see OQ-47. - Zero-length stdout chunk (stream_type 1, length 0) — "drained" from the server. The backend's stdout stream ended (process exited, container output stream ended, SSH channel closed). This is an implementation sentinel; the deterministic completion signal is the exit control chunk (ADR-004), not this sentinel — but the drained sentinel is emitted for symmetry with the docker POC's pattern.
Control chunks are never zero-length (the JSON payload is at least
{}).
Control Channel
The control channel is split into two halves (Phase 7):
STREAM_CTRL_IN(stream_type 3) — client→server control.STREAM_CTRL_OUT(stream_type 4) — server→client control.
Each half carries JSON payloads tagged by type. The schema is the
ControlMessage enum (src/control.rs):
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMessage {
Resize {
cols: u16,
rows: u16,
#[serde(default)]
pixel_width: u16,
#[serde(default)]
pixel_height: u16,
},
Signal { name: String },
Eof,
Exit { code: i32 },
}
| stream_type | direction | Message | Shape | Maps to |
|---|---|---|---|---|
| 3 (ctrl_in) | client→server | resize | {"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0} |
SSH window-change, docker exec resize, ioctl(TIOCSWINSZ) |
| 3 (ctrl_in) | client→server | signal | {"type":"signal","name":"INT"} |
SSH signal, docker exec signal, kill(-pgid, sig) (REQ-TTY-02) |
| 3 (ctrl_in) | client→server | eof | {"type":"eof"} |
SSH channel EOF, docker stdin close, ChildStdin::drop |
| 4 (ctrl_out) | server→client | exit | {"type":"exit","code":0} |
the terminal/completion signal (ADR-004) |
The adapter enforces the direction: an Exit arriving on
STREAM_CTRL_IN is a protocol violation (the adapter ignores it); a
Resize/Signal/Eof arriving on STREAM_CTRL_OUT is likewise a
protocol violation (the adapter ignores it). The split makes the
control channel genuinely bidirectional on the wire — the previous
single STREAM_CONTROL = 3 was documented as "bidirectional" but the
adapter had to ignore Exit from the client because the two directions
were indistinguishable on the same stream_type.
Signal names. name is an uppercase string. The supported set (per
signal_from_name in src/control.rs): HUP, INT, QUIT, TERM,
KILL, USR1, USR2, TSTP, CONT. Unknown names fall back to the
backend's default kill (see tty-local.md REQ-TTY-02 —
portable_pty's ChildKiller::kill sends SIGHUP).
Exit code. code is i32 (matches std::process::ExitStatus::code();
negative values are signal-terminated, e.g., -9 for SIGKILL on Unix). The
exit chunk is the last control chunk before stream close (ADR-004).
Extensibility. The type tag is the extension seam: new control
message types are added by extending the tagged enum. Unknown type
values are ignored (not a protocol error) so that a newer client
sending a control message an older server doesn't recognize degrades
gracefully rather than tearing down the session. This is a two-way-door
extension point within the one-way-door wire format (ADR-001) — adding a
control message type is additive; changing the meaning of an existing
type is not.
Stdin Closure
Two signals both close the client's stdin:
{"type":"eof"}control chunk (stream_type 3,STREAM_CTRL_IN) — explicit, recommended. Tells the server to close the backend's stdin (ChildStdin::drop/ PTY writer close). The client may still want to receive remaining stdout + the exit code, so the server does not tear down the session on eof — it just closes stdin and keeps pumping output.- Zero-length stdin chunk (stream_type 0, length 0) — the docker POC's sentinel. Accepted for compatibility with that pattern.
The spec recommends eof for explicitness (it's a control message, not
a data-length hack), but both are accepted. See OQ-47.
Connection vs Stream
A Connection (alknet ADR-007) can open/accept multiple bidi streams. One
alk/tty connection hosts multiple terminal sessions — one session per
bidi stream (DP-6, decided in the alknet research). This matches the call
protocol's model (one operation per stream, multiple operations per
connection) and is the natural fit for QUIC's stream multiplexing. A
coordinator opens one connection to an endpoint and launches multiple
sessions (one stream each) for parallel tasks. The TtyAdapter::handle
accepts the connection and loops accept_bi, dispatching each stream to
a session — see tty-adapter.md.
Constraints
- The wire format is one-way (ADR-001). The 5-byte header, the fixed
stream_type set (0-4), and the two-carriage sequence are bytes clients
and servers parse. A 6th channel type requires a new ALPN
(
alk/tty/v2per alknet ADR-006), not a negotiated addition. - The control channel is split into two halves (Phase 7).
STREAM_CTRL_IN = 3is client→server (Resize,Signal,Eof);STREAM_CTRL_OUT = 4is server→client (Exit). The adapter enforces the direction: anExitonSTREAM_CTRL_INis ignored; aResizeonSTREAM_CTRL_OUTis ignored. The split is what makes the control channel genuinely bidirectional on the wire — the previous singleSTREAM_CONTROL = 3was documented as "bidirectional" but the adapter had to ignoreExitfrom the client because the two directions were indistinguishable on the same stream_type. - No windowing. The chunk format has no flow-control window; QUIC's
per-stream flow control is the backpressure mechanism (OQ-45 resolved:
the backpressure chain is complete by construction — QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY
buffer → process
write()blocks; no unbounded buffer breaks the chain). The reversal path, if ever needed, is an additiveControlMessagevariant onSTREAM_CTRL_IN/STREAM_CTRL_OUT, not a wire-format header change. - No negotiation round-trip. The client writes the negotiation frame and starts sending chunks; the server reads the frame and starts pumping. There is no "the server acknowledges the negotiation before the client sends data" step — QUIC's stream reliability handles in-order delivery, and the negotiation frame is small (fits in the initial flow-control window — ADR-001 assumption 2).
- Negotiation errors are JSON, not chunks. If the server cannot
allocate the session (unknown backend, PTY allocation failed, the
command is invalid), it sends a JSON error response in the same
length-prefixed framing as the negotiation frame and closes the stream
without entering raw mode. The error response MUST be under 16 MiB
(
MAX_CHUNK_LEN) so the 4-byte big-endian length prefix's high byte is0x00— this is what makes the framing-disambiguation trick (first byte0x00= error frame, first byte1/2/4= raw chunk; the server never sends0(stdin, client→server) or3(STREAM_CTRL_IN, client→server), so0x00is unambiguous) sound; it is a wire-format invariant, not an empirical observation. See tty-adapter.md §"Negotiation errors".
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Wire format and two-carriage model | ADR-001 | alk/tty ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON |
| Bidirectional control channel split | Phase 7 (amendment inside ADR-001) | STREAM_CTRL_IN = 3 (client→server) and STREAM_CTRL_OUT = 4 (server→client) replace the single STREAM_CONTROL = 3; the adapter enforces the direction |
| Self-contained negotiation framing | ADR-006 | alktty implements its own length-prefixed framing; format coincides with alkcall's by convention, not by code reuse |
| Exit code on a control chunk | ADR-004 | {"type":"exit","code":N} on STREAM_CTRL_OUT (stream_type 4); "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either eof control chunk (STREAM_CTRL_IN) or zero-length stdin chunk; eof recommended |
Open Questions
- OQ-44 (deferred(scope)): Terminal modes.
- OQ-45 (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
- OQ-47 (resolved): Stdin closure canonical signal.
References
- ADR-001 — the wire format decision
- ADR-004 — the exit-chunk ordering the control channel carries
- ADR-006 — the dependency-edge decision (negotiation framing is self-contained in alktty)
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call
(self-contained framing); see the alknet originals at
/workspace/@alkdev/alknet/docs/architecture/decisions/ src/wire.rs— the chunk codec (ChunkReader/ChunkWriter, stream_type 0-4) this spec documentssrc/control.rs— the JSON control schema (ControlMessagetagged enum) this spec documents- tty-bast.md — the BAST (Binary Abstract Syntax Tree)
document for the binary framing layer of this wire format (the
5-byte chunk header and the negotiation frame's 4-byte length
prefix); a normative JSON spec downstream consumers can validate
against. The JSON payloads (
NegotiateRequest,ControlMessage,TerminalParams) are specified in this document and the Rust source, not in the BAST — BAST describes binary layouts, not JSON shapes - tty-adapter.md — the session lifecycle that consumes this wire format