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
This commit is contained in:
2026-08-17 10:52:26 +00:00
parent 712e7ae071
commit b3f50d1836
17 changed files with 5165 additions and 46 deletions
+384
View File
@@ -0,0 +1,384 @@
---
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 `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](decisions/001-wire-format-and-two-carriage.md)
and [ADR-006](decisions/006-negotiation-framing-self-contained.md).
## Scope
The BAST below describes the two wire formats that live in this crate
(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** (4-byte BE length prefix + UTF-8 JSON
`NegotiateRequest` body) — self-contained per
[ADR-006](decisions/006-negotiation-framing-self-contained.md), 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
```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 }
]
},
"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](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`). 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](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](decisions/001-wire-format-and-two-carriage.md): 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](decisions/004-exit-code-on-control-chunk.md)); `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](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](decisions/006-negotiation-framing-self-contained.md).
### `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`.
- `tty` — `null` for pipe/runner mode (no PTY —
[ADR-003](decisions/003-local-backend-placement.md)); `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](decisions/002-ttybackend-trait-and-ttyhandle.md) §"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](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) |
| Backend params are opaque | [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) | `NegotiateRequest.backend_params` is an opaque JSON object; the adapter does not interpret it |
| Exit code on a control chunk | [ADR-004](decisions/004-exit-code-on-control-chunk.md) | The `ExitMessage` variant and the "exit chunk is last" invariant |
## 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
- `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](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`)