Files
alktty/docs/architecture/overview.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

17 KiB

status, last_updated
status last_updated
draft (ported from alknet 2026-08-17; alknet-tty → alktty, alknet-tty-local → alktty's `local` feature module, alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..008) 2026-08-17

alktty — Overview

The terminal session protocol crate: a ProtocolHandler on alk/tty that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON control channel (resize, signal, eof, exit) over a framed bidi stream, decoupled from the backend that allocates the PTY via a TtyBackend trait. This document covers the crate's purpose, the two-carriage model in brief, its dependency edges, the ALPN, and the backend location map. Component details are in the sibling documents.

What

alktty is the terminal session protocol crate for the ALPN-as-service architecture (alknet ADR-001). It registers the alk/tty ALPN on the shared endpoint and implements the ProtocolHandler trait (alknet ADR-002, alknet ADR-007). The TtyAdapter receives a Connection, accepts one bidi stream per terminal session, reads a single JSON negotiation frame, switches to a raw chunk format, and pumps bytes bidirectionally for the life of the session — backend-agnostic.

The guiding insight that shapes the crate:

A terminal session is not an SSH concern, or a Docker concern — it is a terminal concern. SSH and Docker are just two backends that can allocate a PTY.

The alknet-docker POC proved that the hard part of interactive attach — bidirectional byte pumping over a framed 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 is the seed of alktty's wire format. alktty extracts that pattern into its own crate and ALPN; the backends (Docker, SSH, local process) implement a TtyBackend trait; the alk/tty handler is backend-agnostic. This dissolves the PTY hedge in the alknet-ssh research (DP-5): PTY is not an SSH feature delegated to a separate crate, it's a tty feature that SSH happens to be able to provide.

Why

The crate's purpose is to be the terminal session library for downstream consumers. A hub that runs agent workspaces in containers wires DockerTtyBackend into the TtyAdapter and gets interactive terminal sessions over alk/tty. A coordinator that runs cargo test remotely wires LocalTtyBackend (pipe mode) and gets the runner pattern (a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection) — the same shape as GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling. A browser terminal (xterm.js over WebTransport, when WebTransport revives) connects to alk/tty directly and gets raw bytes without implementing SSH or the call protocol.

The key architectural insight: the wire format and the backends invert at the TtyBackend trait. alktty owns the wire format, the negotiation frame, the chunk codec, the control channel, and the session lifecycle; the backends own the PTY allocation (docker exec with tty: true, russh pty_request + shell_request, portable_pty::openpty). The adapter is backend-agnostic and testable with a mock backend (in-memory pipes). See ADR-002.

The Two-Carriage Model in Brief

A alk/tty bidi stream has two phases (full detail in tty-wire.md, decided in ADR-001):

  1. Negotiation (JSON carriage). The client writes a single length-prefixed JSON frame carrying the terminal parameters, backend selector, command, and environment. The framing is a 4-byte big-endian length prefix + UTF-8 JSON body, self-contained in alktty (the format coincides with alkcall's framing by convention; alktty does not depend on alkcall's internal wire types — see Dependencies below).

  2. Raw carriage. After the negotiation frame, the stream switches to the chunk format ([stream_type: u8][length: u32 be][payload]) for the life of the session. Five stream types: 0=stdin (client→server), 1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in (client→server, JSON control messages: resize, signal, eof), 4=ctrl-out (server→client, JSON control messages: exit). There is no call.responded/call.completed — this is not the call protocol; the raw-carriage byte pump is its own wire format after the single JSON negotiation frame.

This is the pattern the docker POC validated and the SSH research independently arrived at: JSON for the structured request, raw bytes for the body, which is the part that is actually bytes. The full rationale (why not JSON for everything; the two-carriage decision; the Phase 7 control-channel split) is in ADR-001 §Context.

Dependencies

alktty (default — wasm-clean)
├── alkcall::core   (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
│                    OwnershipProvider — alknet ADR-050 for terminal sessions as resources)
└── (no backend deps — portable_pty, bollard, russh are in the backend crates / `local` feature)

alktty (local feature) — non-wasm by design
└── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread

alktty is dependency-light: alkcall (the handler interface and auth) only. The negotiation framing is a self-contained ~30-line module in alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's AsyncRead/AsyncWrite). The heavy backend dependencies (portable_pty, bollard, russh) live in the backend crates, not here. alktty does not depend on alkcall's internal wire types — see ADR-006.

Why no alkcall-internal-wire-types dependency

An earlier draft had alktty depending on alkcall for the FrameFramedReader/FrameFramedWriter "framing utility." A pre-implementation check found this was unsound: FrameFramedReader's read_frame() is hardcoded to deserialize EventEnvelope — the length-prefix read and the type-specific deserialize are one entangled call, not a separable utility. alktty's negotiation payload is a NegotiateRequest, not an EventEnvelope, so the claimed reuse did not exist in a usable form. alktty implements its own framing (the format coincides with alkcall's by convention; the implementations are independent). The ~30 lines of length-prefix framing is an idiom, not a domain abstraction worth a cross-crate dependency. See ADR-006 for the full decision and alknet ADR-003 Amendment 2 for the dependency-edge clarification.

alktty stays lean — it has no portable_pty (default), no bollard, no russh, no alkcall-internal-wire-types. The TtyBackend implementations are opaque Arc<dyn TtyBackend> from the adapter's perspective: constructed by the assembly layer at startup, stored in the adapter's backend map, dispatched by the backend field of the negotiation frame.

ALPN

ALPN Handler Transport Browser?
alk/tty TtyAdapter QUIC bidi stream (direct) or alk/channels (multiplexed) Yes (when WebTransport revives — alknet ADR-040 parked)

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 registers for it; the endpoint's HandlerRegistry maps alk/tty to the adapter instance. One ALPN per connection (alknet ADR-006); within a connection, multiple bidi streams carry independent sessions (one session per stream — see tty-adapter.md).

The browser terminal case: a browser (xterm.js) connects via WebTransport to alk/tty and gets raw bytes. The browser doesn't need to implement SSH or the call protocol for the terminal use case — only if it wants SSH-specific features (port forwarding, SFTP). This is a cleaner browser story than "run a WASM SSH client." WebTransport is deferred per alknet ADR-044; when it revives, the alk/tty ALPN is reachable over WebTransport's ALPN-stream-proxy (alknet ADR-040, parked).

Channels mode (ADR-008)

The same alk/tty protocol also runs inside an alk/channels connection: a channel with ALPN alk/tty carries the TTY session, and the channels layer strips its 8-byte header before handing TTY the payload. The same wire.rs code runs in both modes; only the BiStream source differs. See ADR-008 and tty-adapter.md.

Backend Location Map

The decomposition principle: the trait lives where the types live (alktty); the implementations live where their transport dependencies live.

alktty (default — lean, no portable_pty, no bollard, no russh)
├── TtyBackend trait             (the contract — ADR-002)
├── TtyHandle, TtyControl        (the handle shape backends produce)
├── TtyParams, TerminalParams    (the allocation request)
├── TtyAdapter                   (ProtocolHandler on alk/tty — session lifecycle)
├── wire format                  (ChunkReader/ChunkWriter, ControlMessage — ADR-001)
└── negotiation framing          (self-contained ~30-line module; format coincides
                                  with alkcall's by convention — ADR-006)

alktty (local feature module — ADR-003; folded in from the old
                               alknet-tty-local sibling crate)
├── LocalTtyBackend              (impl TtyBackend — portable_pty for PTY, std::process for pipe)
├── portable_pty dependency      (PTY allocation — the heavy dep, here not in alktty default)
└── libc                         (signal forwarding — REQ-TTY-02, Unix only)

alknet-docker (or alktty-docker adapter — future crate, out of scope here)
└── DockerTtyBackend             (impl TtyBackend — wraps bollard::attach_container / exec with tty:true)

alknet-ssh (future crate — out of scope here)
└── SshTtyBackend                (impl TtyBackend — wraps russh pty_request + shell_request/exec_request)

alktty never sees portable_pty (default), bollard, or russh. The backend implementations are opaque Arc<dyn TtyBackend> from the adapter's perspective. alktty stays lean; the backend crates own their transport dependencies. The local backend's module placement (folded into alktty behind a local feature, resolving the alknet cyclic-dep workaround) is decided in ADR-003; the docker and SSH backends are future crates (out of scope for this spec set — see tty-backend.md §"Backend implementations" for where they live).

Feature Gates

# alktty Cargo.toml
[features]
default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
  • default — the wire format, TtyAdapter, and the TtyBackend trait. No backend implementations; the assembly layer registers backends from their own crates. A docker-only or ssh-only deployment uses the default features and depends on alknet-docker / alknet-ssh (or their own backend crate) directly. The default crate compiles to wasm32-unknown-unknown — this is what makes the downstream TS/Python adapter story work (a wasm-compiled alktty is the protocol layer for a sandboxed adapter).
  • local — enables alktty::local::LocalTtyBackend, pulling in portable_pty (PTY mode) and tokio::process (pipe mode). A consumer that wants the local backend (terminal or runner) enables this feature. Inherently non-wasm — portable-pty + tokio::process need a real OS.

The local backend's portable_pty dependency is the heavy dep that motivates the feature gate — a docker-only deployment should not pull in PTY allocation code. See ADR-003.

Architecture (component pointers)

  • tty-wire.md — the wire format: the negotiation frame (JSON carriage, self-contained length-prefixed framing), the raw chunk codec ([stream_type: u8][length: u32 be][payload]), the five stream types, the control channel (split into STREAM_CTRL_IN / STREAM_CTRL_OUT halves, JSON control messages), sentinels, and the fixed-channel-set rationale.
  • tty-bast.md — the BAST (Binary Abstract Syntax Tree) document for the alk/tty wire format; a normative JSON spec conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema, validatable by any JSON Schema Draft 2020-12 validator.
  • tty-backend.md — the TtyBackend trait, TtyParams, TtyHandle, TtyControl. The inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async; the bridging pattern is a documented strategy). Notes where the docker/SSH backend crates live (future, out of scope here).
  • tty-adapter.md — the TtyAdapter (ProtocolHandler on alk/tty): the session lifecycle, the three-pump bidirectional driver (stdout→client, client→backend, exit→exit-chunk), negotiation errors, the exit-chunk ordering (ADR-004), access control (terminal sessions as runtime-spawned resources per alknet ADR-050), session-cancel cleanup (ADR-005).
  • tty-local.md — the local feature module: LocalTtyBackend via portable_pty (PTY mode) and tokio::process::Command (pipe/runner mode). Carries REQ-TTY-02 (signal forwarding to the foreground process group). The blocking→async bridge pattern (the three std threads feeding tokio mpsc/oneshot) is the reference for any future blocking-API backend.

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; Phase 7 control-channel split
TtyBackend trait and TtyHandle ADR-002 The backend inversion point; exit_code as Future; backends need not be natively async (REQ-TTY-01)
Local backend placement ADR-003 alktty folds the local backend in behind a local feature (resolves the alknet cyclic-dep workaround); PTY vs pipe per-session
Exit code on a control chunk ADR-004 {"type":"exit","code":N} on STREAM_CTRL_OUT; "exit chunk is last" invariant; adapter owns the ordering
Backend cleanup on session cancel ADR-005 Dropping exit_code future (cancel) MUST kill the session target; the adapter triggers it by dropping the TtyHandle
Self-contained negotiation framing ADR-006 alktty implements its own length-prefixed framing; format coincides with alkcall's by convention, not by code reuse
TTY inside channels (reversed) ADR-007 Historical: the two-mode TTY design (direct vs inside-channels); reversed by ADR-008/093
Channels pure channel multiplexing ADR-008 TTY always uses its 5-byte format; the channels layer carries it transparently in the payload (reverses ADR-007)

Open Questions

  • OQ-43 (resolved): TtyControl as a Clone trait object.
  • OQ-44 (deferred(scope)): Terminal modes (TTY modes).
  • OQ-45 (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
  • OQ-46 (deferred(scope)): Runner API surface.
  • OQ-47 (resolved): Stdin closure canonical signal.

References

  • alknet ADR-001 — ALPN-based dispatch
  • alknet ADR-002 — ProtocolHandler trait
  • alknet ADR-003 + Amendments 1 & 2 — crate decomposition (no-handler-depends-on-another-handler; alktty depends on alkcall only; backends depend on alktty for the trait)
  • alknet ADR-006 — alk/<name> ALPN convention; one ALPN per connection; new ALPN for incompatible versions
  • alknet ADR-007 — Connection, accept_bi, the handler-receives- Connection pattern
  • alknet ADR-050 — dynamic resource ownership (terminal sessions as runtime-spawned resources; the adapter's access-control shape declares against this model)
  • /workspace/@alkdev/alknet/docs/architecture/decisions/ — the alknet originals of the ADRs ported here as 001..008, plus the alknet ADRs referenced by alknet number above (which are not ported into alktty's ADR range because they are not tty-specific)