Files
alktty/docs/architecture/decisions/001-wire-format-and-two-carriage.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

21 KiB

ADR-001: alktty Wire Format and Two-Carriage Model

Status

Accepted (amended 2026-07-18 — Phase 7: the control channel is split into STREAM_CTRL_IN = 3 (client→server) and STREAM_CTRL_OUT = 4 (server→client) halves; see §"Control channel split" below. Ported from alknet ADR-052 2026-08-17; cross-references renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012, 044, 049, 050) are not ported into alktty's ADR range because they are not tty-specific; the alknet originals at /workspace/@alkdev/alknet/docs/architecture/decisions/ remain authoritative.)

Context

The alknet-docker POC validated that interactive attach — bidirectional byte pumping over a framed bidi stream with a 1-byte stream-type multiplexer — is the same problem regardless of whether the backend is bollard::attach_container() or russh's pty_request. The POC's raw chunk format ([stream_type: u8][length: u32 be][payload bytes], stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished version of SSH's channel multiplexer: fixed set of channel types, no negotiation, no open/close handshake, no windowing (QUIC provides flow control on the bidi stream). That impoverishment is the feature — a terminal session needs exactly those channels and no more.

The alknet-tty POC (built 2026-07-05) extended that format with a 4th stream_type (3 = control) carrying JSON control messages (resize, signal, eof, exit) and validated the full round-trip against a real portable_pty PTY: negotiate → PTY alloc → bidirectional echo → mid-session resize → EOF → exit code, plus SIGINT forwarding to a child process group. The wire format this ADR commits is the POC's raw codec

  • control schema, generalized from one backend to the backend-agnostic crate.

Three load-bearing questions are decided here:

  1. Separate ALPN with raw carriage, not call-protocol operations. A terminal session could be modeled as a call-protocol Subscription operation (tty/open) streaming call.responded events. That is rejected: JSON-encoding every byte chunk is wasteful (base64 for binary, per-chunk EventEnvelope overhead) and lossy (a TTY streams partial bytes with no message boundary that maps to a JSON object). The two-carriage model — a single JSON negotiation frame, then raw chunks — keeps the call protocol's JSON-RPC shape for the request and switches to bytes for the body, which is the part that is actually bytes. This is the pattern the docker POC validated and the SSH research independently arrived at for PTY.

  2. Fixed channel set, not extensible. SSH multiplexes arbitrary services (forwarding, SFTP, agent, X11) over ChannelId(u32) with string-named types negotiated per channel. alktty multiplexes one service — a terminal session — with a fixed u8 set and no negotiation. Adding a 6th channel type is a wire-format change (one-way door). The ALPN model handles extensibility at the protocol level: a genuinely new sideband (e.g., file transfer alongside the terminal) is a different ALPN, not a 6th tty channel type. A new ALPN is cheap; a wire-format change is not.

  3. Control messages as JSON, not binary. A binary control format ([control_type: u8][params...]) would be faster but harder to extend and inconsistent with the negotiation layer. Control messages are rare (resize on window drag, signal on Ctrl-C, one eof, one exit per session) — serialization cost is negligible against the data chunks. If a hot control path appears, a binary control_type can be added without breaking the chunk format (additive within the control channel).

Decision

1. alktty is a ProtocolHandler on ALPN alk/tty

alk/tty is a custom ALPN per the alknet ADR-006 alk/<name> convention (renamed from alknet/<name> in alkcall 0.1.1). The TtyAdapter implements ProtocolHandler (alknet ADR-002, revised by alknet ADR-007 to receive a Connection). The handler owns the entire connection lifecycle and accepts one bidi stream per terminal session. This is a separate ALPN, not a set of operations in the call protocol's OperationRegistry — the raw-carriage byte pump is not a StreamingHandler (alknet ADR-049); it is its own wire format after a single JSON negotiation frame.

2. Two-carriage model: JSON negotiation, then raw chunks

The bidi stream has two phases:

  • Negotiation (JSON carriage). The client opens a bidi stream and writes a single length-prefixed JSON frame — a 4-byte big-endian length prefix + UTF-8 JSON body. The frame carries the terminal parameters and backend selector the server needs to allocate the session:

    {
      "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": {}
    }
    

    The carriage field is "raw" for terminal sessions (the only carriage in v1). The tty block is null for the pipe/runner case (no PTY — see ADR-003). The backend field selects the TtyBackend (ADR-002); backend-specific fields (e.g., container for docker) ride alongside.

  • Raw carriage. After the negotiation frame, the stream switches to the chunk format below for the life of the session. There is no call.responded/call.completed — this is not the call protocol.

3. Chunk format

[stream_type: u8][length: u32 be][payload bytes]
  • stream_type — fixed set, no negotiation:

    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 — see §4a)
    4 ctrl-out server→client JSON control message (Exit — see §4a)

    stream_type > 4 is a protocol error (InvalidStreamType). (Phase 7 amendment, §4a: the original 3 = control (bidirectional) is split into 3 = ctrl-in and 4 = ctrl-out; the original bound was > 3.) There is no extension escape hatch in the byte — a 6th channel is a wire-format change requiring a new ALPN (alk/tty/v2 per alknet ADR-006), not a negotiated addition to this format.

  • length — payload length in bytes, u32 big-endian, max 16 MiB. A chunk larger than 16 MiB is a protocol error (ChunkTooLarge). The 16 MiB bound accommodates large paste operations and large env blocks while bounding memory per chunk (a reader can allocate a bounded buffer up front); it mirrors the docker POC's limit.

  • Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF from the client; a zero-length stdout chunk is "drained" from the server (output stream ended). Control chunks are never zero-length (the JSON payload is at least {}).

4. Control channel (stream_type 3) carries JSON control messages

Control chunks carry a small JSON payload, tagged by type:

Direction Message Shape
client→server resize {"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}
client→server signal {"type":"signal","name":"INT"}
client→server eof {"type":"eof"}
server→client exit {"type":"exit","code":0}
  • resize — window-size change. Maps to SSH window-change, docker exec resize, or ioctl(TIOCSWINSZ) on a local PTY.
  • signal — signal forwarding. name is an uppercase string ("INT", "TERM", "HUP", "QUIT", "TSTP", "CONT", "KILL", "USR1", "USR2"). Unknown names fall back to the backend's default kill (see ADR-002 / tty-local.md REQ-TTY-02).
  • eof — client signals no more stdin. Maps to SSH channel EOF, docker stdin close, or ChildStdin::drop / PTY writer close. This is one of two canonical "stdin done" signals; the other is a zero-length stdin chunk (the docker POC's sentinel). Both are accepted as equivalent — eof is recommended for explicitness (it's a control message, not a data-length hack); the zero-length stdin chunk is kept for compatibility with the docker POC's pattern. See OQ-47.
  • exit — server signals process exit with code. This is the last control chunk before stream close (see ADR-004).

The type tag is the extensibility 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 — adding a control message type is additive; changing the chunk header is not.

4a. Control channel split (Phase 7 amendment, 2026-07-18)

The single stream_type 3 = control (bidirectional) in §3 and §4 above is split into two halves so the control channel is genuinely bidirectional on the wire:

stream_type channel direction payload
3 STREAM_CTRL_IN client→server JSON control message (Resize, Signal, Eof)
4 STREAM_CTRL_OUT server→client JSON control message (Exit)

The stream_type > 3 protocol-error bound becomes stream_type > 4. The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).

Why the split. The original §4 documented stream_type 3 as "bidirectional" and listed the four control messages with their directions. But the adapter had no way to distinguish the two directions on the same stream_type — Exit from the client was always ignored (the adapter's pump_client_to_backend matched Exit and logged "ignoring Exit control from client (server→client only)"). The spec said "bidirectional"; the code was half-duplex. The split makes the bidirectionality literal: each direction has its own stream_type, the adapter enforces the direction (an Exit arriving on STREAM_CTRL_IN is a protocol violation; a Resize arriving on STREAM_CTRL_OUT is a protocol violation), and a client can route exit vs. control without parsing the JSON type tag first.

Door type. One-way, same as the original §3 / §4. The stream_type set is bytes clients and servers parse. A client written against the old single-STREAM_CONTROL shape will misread STREAM_CTRL_OUT = 4 as InvalidStreamType (> 3) and tear down the session — the split is a wire-format change, not an additive extension. The reversal path is the same as the original: a new ALPN (alk/tty/v2), which coexists rather than replaces. The trade is one new stream_type byte now vs. the half-duplex-in-disguise flaw forever.

What changes in the spec. §3's stream_type table gains a 5th row (4 = ctrl_out, server→client); the bound becomes > 4. §4's direction table is unchanged in content (the four messages keep their directions and shapes) but the direction is now encoded in the stream_type, not just in the adapter's behavior. ADR-004's "exit chunk is last" invariant is unchanged — the exit chunk still rides the control channel, just on STREAM_CTRL_OUT (stream_type 4) instead of the old single STREAM_CONTROL (stream_type 3).

5. Negotiation errors use the JSON framing, not the raw chunk format

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 4-byte length-prefixed framing as the negotiation frame and closes the stream without entering raw mode. The error response shape is {"error":"<code>","message":"..."} (see tty-adapter.md §"Negotiation errors" for the codes). This is not call.error — this is not the call protocol; the error is a JSON response in the negotiation framing, and the stream closes after it.

Framing disambiguation (success vs error). Both a successful allocation (raw chunks) and a failed allocation (JSON error frame) begin with bytes the client must read before knowing which framing applies. The disambiguation is by the first byte: a JSON error frame's 4-byte big-endian length prefix always starts with 0x00 (error frames MUST be under 16 MiB — MAX_CHUNK_LEN — so the high byte is zero; this is a wire-format invariant, not an assumption), while a raw chunk's first byte is a stream_type in {0, 1, 2, 3, 4}. A stream_type of 0 (stdin from server) is invalid — the server never sends stdin chunks — so the client distinguishes: read the first byte; if it is 0x00, interpret the next 4 bytes as a big-endian length prefix and read that many bytes as a JSON error frame; otherwise interpret it as a stream_type byte and continue reading the raw chunk header. This is a one-way-door wire-format invariant: error frames use the negotiation framing (length prefix) and MUST be under 16 MiB; success uses the raw chunk framing (stream_type byte first); the 0x00-as-length-prefix vs 0x00-as-invalid-stream_type disambiguation is what makes the two distinguishable on the wire. (With the Phase 7 split, the server-sent set is {1, 2, 4} — stdout, stderr, STREAM_CTRL_OUT; 0x00 is still unambiguous.)

6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency)

The 4-byte length prefix + JSON body is implemented in alktty as a small, self-contained module (src/negotiation.rs, ~30 lines: read 4-byte BE length, bounds-check, read N bytes; write the inverse). 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 EventEnvelope, and alkcall's FrameFramedReader::read_frame() is hardcoded to deserialize EventEnvelope (the length-prefix read and the type-specific deserialize are one entangled call), so it is not reusable for a different payload type. alktty implements its own framing on tokio's AsyncRead/AsyncWrite. See ADR-006 for the decision (and the three options considered: duplicate / promote to alkcall::core / use alkcall) and alknet ADR-003 Amendment 2 for the dependency-edge clarification.

Consequences

Positive:

  • The wire format is POC-validated twice (docker POC for stream_type 0/1/2 + bidirectional pump; tty POC for stream_type 3 + control messages + local PTY). No new wire-format invention in Phase 1.
  • The fixed channel set is a match, not a hash lookup — fast on the hot path where every chunk is data.
  • The two-carriage model keeps the call protocol's JSON-RPC shape for the structured request while letting the body be raw bytes, which is what a terminal actually is. No base64, no per-chunk EventEnvelope overhead.
  • Control messages as JSON are consistent with the negotiation layer and trivially extensible (tagged enum), at negligible cost for rare messages.
  • A separate ALPN composes with the ALPN dispatch model (alknet ADR-001/006): the endpoint routes alk/tty to the TtyAdapter; the call protocol is unaffected. Browser terminals (xterm.js over WebTransport, when WebTransport revives) connect to alk/tty directly without implementing SSH or the call protocol.

Negative:

  • A 6th channel type is a wire-format change (one-way door). The justification is that the use cases are bounded — a terminal session has stdin, stdout, stderr, and control (now split into ctrl-in / ctrl-out). New sideband needs are different ALPNs, not 6th channels. If this proves wrong, the reversal is a new ALPN string (alk/tty/v2), which coexists with the old one rather than replacing it — but every client and server implementing the old format would need updating to speak the new one.
  • Control as JSON means a serde_json deserialize per control chunk. Control chunks are rare (one per resize, one per signal, one eof, one exit), so this is negligible. A hot control path would warrant a binary format — the type-tagged enum leaves that door open without a wire-format change.
  • The negotiation frame is a custom JSON shape, not a call.requested event, so a client library can't reuse its call-protocol client to open a tty session — it speaks the tty wire format directly. This is intentional (the tty session is not a call-protocol operation) but means the tty client is a separate small client, not a CallClient method.
  • No application-level flow-control window (OQ-45 resolved). The chunk format carries no window; backpressure relies on QUIC's per-stream flow control composing with the bounded channels in the adapter's pump and the OS pipe/PTY buffer. This is sufficient by construction (see §Assumptions 1). If a workload ever requires sub-QUIC-window backpressure signaling, the reversal is an additive ControlMessage::WindowUpdate (or similar) variant on the control channel — a two-way-door extension to the control channel, not a wire-format header change. The type-tagged enum's "unknown types ignored" rule means older peers degrade gracefully if such a variant is introduced.

Door type

One-way. The chunk header (5 bytes: 1 type + 4 length), the fixed stream_type set (0-4), and the two-carriage sequence (JSON frame → raw chunks) are bytes clients and servers parse. Changing any of them breaks every client and server implementing the format. The reversal path is a new ALPN (alk/tty/v2), which coexists rather than replaces — but the cost of migrating every consumer is the one-way-door cost.

The control message type enum is a two-way-door extension point within the one-way wire format: adding a control message type is additive (unknown types are ignored), changing the meaning of an existing type is not.

Assumptions

  1. No application-level windowing; QUIC per-stream flow control is the backpressure mechanism. The chunk format carries no window field. This is decided (OQ-45 resolved): the backpressure chain from a slow client read all the way back to the child process's stdout write is complete by construction — QUIC flow control → bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer → process write() blocks. Every link awaits its producer; no unbounded buffer breaks the chain. The reversal path, if a workload ever surfaces a problem QUIC's defaults cannot handle, is an additive window-update ControlMessage variant (a two-way-door extension to the control channel, not a wire-format header change) — noted in §Consequences. See OQ-45.

  2. The negotiation frame fits in one chunk of the underlying stream's initial flow-control window. The frame is small (terminal params + command + env, typically < 4 KiB). QUIC's default initial bidi-window (quinn defaults are tens of KiB) accommodates it without a flow-control round-trip. A pathological env block larger than the window would stall until the window opens; the 16 MiB chunk limit is the hard cap.

  3. Control messages are rare enough that JSON serialization cost is negligible. Validated by the tty POC: resize on window drag, one signal per Ctrl-C, one eof, one exit per session. No measurable cost observed.

References

  • alknet ADR-001 — ALPN-based dispatch
  • alknet ADR-002 — ProtocolHandler trait
  • alknet ADR-006 — alk/<name> ALPN convention; one ALPN per connection; new ALPN for incompatible versions
  • alknet ADR-007 — handler receives a Connection, accepts bidi streams
  • alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call; self-contained negotiation framing
  • ADR-006 — the dependency-edge decision this ADR's §6 reflects
  • alknet ADR-012 — the call protocol's stream model (which tty is not using for the body, by design)
  • alknet ADR-049 — the StreamingHandler path tty explicitly does not use for the byte body
  • ADR-002 — the backend trait the negotiation frame's backend field dispatches to
  • ADR-003 — the local backend's module placement (the tty: null pipe-mode case)
  • ADR-004 — the exit-chunk ordering this ADR's control channel carries
  • ADR-008 — TTY always uses its 5-byte format, including inside channels (the channels layer carries this wire format transparently in its payload)
  • Spec: tty-wire.md, tty-bast.md
  • Port origin: alknet ADR-052 at /workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md