Review of the 2026-09-05 session commits:
- the ADR-009 decision cited by 96692d3 and docs/reviews/001 was never
written — added decisions/009-channels-open-op-is-the-negotiation.md
(context: the L1 two-gate disagreement, alkcall 0.4.0/0.4.1
prerequisites, producer/consumer design, consequences, door type)
- channels.rs module doc + register_openable doc still described the
old wire-frame negotiation read (drive_session) — aligned with
drive_session_pre_negotiated and the enforced input schema
- tty-adapter.md: session-driver section + ADR tables now reference
ADR-009 and the pre-negotiated driver; overview.md ADR index row
Verification: cargo test 93 lib (default) / 136 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
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):
-
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).
-
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 nocall.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 theTtyBackendtrait. 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 onalknet-docker/alknet-ssh(or their own backend crate) directly. The default crate compiles towasm32-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— enablesalktty::local::LocalTtyBackend, pulling inportable_pty(PTY mode) andtokio::process(pipe mode). A consumer that wants the local backend (terminal or runner) enables this feature. Inherently non-wasm —portable-pty+tokio::processneed 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 intoSTREAM_CTRL_IN/STREAM_CTRL_OUThalves, JSON control messages), sentinels, and the fixed-channel-set rationale. - tty-bast.md — the BAST (Binary Abstract Syntax
Tree) document for the binary framing layer of the
alk/ttywire format (the 5-byte chunk header and the negotiation frame's 4-byte length prefix); a normative JSON spec conforming to the BAST meta-schema athttps://alk.dev/bast/v1/schema, validatable by any JSON Schema Draft 2020-12 validator. The JSON payloads (NegotiateRequest,ControlMessage,TerminalParams) are specified intty-wire.mdand the Rust source, not in the BAST — BAST describes binary layouts, not JSON shapes. - tty-backend.md — the
TtyBackendtrait,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(ProtocolHandleronalk/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
localfeature module:LocalTtyBackendviaportable_pty(PTY mode) andtokio::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) |
The open op's input is the negotiation |
ADR-009 | The channels path carries no second negotiation frame; the open op's registry-validated input is the negotiation |
Open Questions
- OQ-43 (resolved):
TtyControlas aClonetrait 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)