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:
@@ -0,0 +1,312 @@
|
||||
---
|
||||
status: 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)
|
||||
last_updated: 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](decisions/002-ttybackend-trait-and-ttyhandle.md).
|
||||
|
||||
## The Two-Carriage Model in Brief
|
||||
|
||||
A `alk/tty` bidi stream has two phases (full detail in
|
||||
[tty-wire.md](tty-wire.md), decided in
|
||||
[ADR-001](decisions/001-wire-format-and-two-carriage.md)):
|
||||
|
||||
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](decisions/001-wire-format-and-two-carriage.md) §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](decisions/006-negotiation-framing-self-contained.md).
|
||||
|
||||
### 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](decisions/006-negotiation-framing-self-contained.md) 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](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](decisions/008-channels-pure-channel-multiplexing.md)
|
||||
and [tty-adapter.md](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](decisions/003-local-backend-placement.md);
|
||||
the docker and SSH backends are future crates (out of scope for this
|
||||
spec set — see [tty-backend.md](tty-backend.md) §"Backend implementations"
|
||||
for where they live).
|
||||
|
||||
## Feature Gates
|
||||
|
||||
```toml
|
||||
# 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](decisions/003-local-backend-placement.md).
|
||||
|
||||
## Architecture (component pointers)
|
||||
|
||||
- **[tty-wire.md](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](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](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](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](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](decisions/001-wire-format-and-two-carriage.md) | `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](decisions/002-ttybackend-trait-and-ttyhandle.md) | The backend inversion point; `exit_code` as `Future`; backends need not be natively async (REQ-TTY-01) |
|
||||
| Local backend placement | [ADR-003](decisions/003-local-backend-placement.md) | 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](decisions/004-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT`; "exit chunk is last" invariant; adapter owns the ordering |
|
||||
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) MUST kill the session target; the adapter triggers it by dropping the `TtyHandle` |
|
||||
| Self-contained negotiation framing | [ADR-006](decisions/006-negotiation-framing-self-contained.md) | alktty implements its own length-prefixed framing; format coincides with alkcall's by convention, not by code reuse |
|
||||
| TTY inside channels (reversed) | [ADR-007](decisions/007-tty-inside-channels.md) | Historical: the two-mode TTY design (direct vs inside-channels); reversed by ADR-008/093 |
|
||||
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | 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)
|
||||
Reference in New Issue
Block a user