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,270 @@
|
||||
# ADR-007: TTY Inside Channels — Sub-Streams, Not Wire Format
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (**reversed 2026-07-18 by alknet ADR-093, ported here as
|
||||
[ADR-008](008-channels-pure-channel-multiplexing.md): TTY always uses
|
||||
its 5-byte format; the channels layer carries it transparently in the
|
||||
payload — see "Reversal (ADR-008, 2026-07-18)" below.** Ported from
|
||||
alknet ADR-077 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. The alknet ADRs
|
||||
referenced by alknet number (052, 053, 055, 057, 061, 071, 073, 074,
|
||||
092) are not ported into alktty's ADR range — they are alknet-core /
|
||||
alknet-channels ADRs. The alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
||||
authoritative.)
|
||||
|
||||
## Reversal (ADR-008, 2026-07-18)
|
||||
|
||||
The two-mode TTY design (direct vs inside-channels, with different
|
||||
sub-stream access paths) is **reversed**. TTY's 5-byte format
|
||||
(`[stream_type:u8][length:u32][payload]`, ADR-001) is TTY's internal
|
||||
format, used in **both** direct mode and inside-channels mode. The two
|
||||
modes differ only in *where the `BiStream` comes from* (a top-level
|
||||
`alk/tty` connection vs a `channel/open` with ALPN `alk/tty`), not in
|
||||
*how TTY parses it*. The same `wire.rs` code runs in both modes.
|
||||
|
||||
When TTY is inside channels, the channels layer strips its 8-byte header
|
||||
(alknet ADR-093 / ADR-008) and hands TTY the payload bytes. TTY parses
|
||||
its 5-byte header from the payload. The channels layer carries TTY's
|
||||
5-byte chunks transparently in its payload — no shared fields, no
|
||||
leaked abstraction, no double-chunking concern (the 13-byte total
|
||||
header is 8 channels + 5 TTY, not 8 + 9; the channels `length` is
|
||||
always `tty_len + 5`).
|
||||
|
||||
The `channels` feature on alktty (if such a feature were added) becomes
|
||||
"run TTY's sub-demux on a channels-backed `BiStream`" — the same code
|
||||
as direct mode, different `BiStream` source. The control channel split
|
||||
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
|
||||
channels layer doesn't know about it. alknet ADR-074's
|
||||
`into_sub_streams()` (the accessor this ADR's two-mode design relied
|
||||
on) is removed by alknet ADR-093 / ADR-008; TTY sub-demuxes its
|
||||
`BiStream` via its own 5-byte format instead.
|
||||
|
||||
The body below describes the **original** (two-mode) shape; the
|
||||
reversal above is the operative decision. The two-mode description is
|
||||
kept as the historical context for the reversal. See ADR-008 for the
|
||||
resolution rationale (the channels layer has no `stream_type` concept;
|
||||
the handler owns its sub-stream multiplexing) and the cross-ADR
|
||||
impacts.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-001 defines the alktty wire format: `[stream_type: u8][length: u32
|
||||
be][payload]`, a 5-byte chunk header for five sub-streams
|
||||
(stdin/stdout/stderr/ctrl-in/ctrl-out) within one bidi stream. This
|
||||
format is stable, implemented (`src/wire.rs`), and used for direct
|
||||
`alk/tty` connections.
|
||||
|
||||
The phase-0 research recommended that the TTY chunk format be "absorbed
|
||||
into channels" and that `alk/tty` remain as a "direct-connect
|
||||
shortcut." But the research did not pin what changes in the TTY crate
|
||||
when a TTY session runs *inside* a channels connection. This is a real
|
||||
integration question the research hand-waved.
|
||||
|
||||
The problem: the `TtyAdapter`'s current `handle()` loops `accept_bi()`,
|
||||
spawning a `drive_session` task per bidi stream that parses 5-byte TTY
|
||||
chunks off the stream. Inside a channels connection, the stream is
|
||||
*already de-chunked* by the channels layer's 9-byte format — the
|
||||
handler sees an `AsyncRead + AsyncWrite` pair, not a chunk-encoded
|
||||
stream. If the TTY adapter tries to parse 5-byte chunks off an
|
||||
already-de-chunked stream, it breaks.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two modes for TTY, one adapter
|
||||
|
||||
The `TtyAdapter` operates in two modes, determined by how it receives
|
||||
its `Connection`:
|
||||
|
||||
| Mode | When | Wire format | How the adapter gets sub-streams |
|
||||
|------|------|-------------|---------------------------------|
|
||||
| **Direct (`alk/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alk/tty` | TTY's 5-byte format (ADR-001, amended — see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
|
||||
| **Inside channels** | `channel/open` with ALPN `alk/tty` on a channels connection | Channels' 9-byte format (alknet ADR-071) — the channels layer de-chunks | `into_sub_streams()` (alknet ADR-074) → five named handles for stream_types 0-4 |
|
||||
|
||||
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
|
||||
(ADR-002). The backend allocates a PTY and returns a `TtyHandle`; the
|
||||
adapter pumps data between the handle and the sub-streams. The
|
||||
difference is only in how the adapter gets the sub-streams — 5-byte
|
||||
chunk parsing (direct) vs. `into_sub_streams()` (channels).
|
||||
|
||||
### The control channel is now properly bidirectional
|
||||
|
||||
The phase-0 findings flagged that the TTY control channel "isn't
|
||||
actually bidirectional… the adapter ignores Exit from the client." The
|
||||
root cause: `stream_type 3` was "bidirectional" — one stream both sides
|
||||
wrote to, which is not properly multiplexed.
|
||||
|
||||
alknet ADR-071's stream_type decomposition fixes this: **every
|
||||
stream_type is unidirectional.** Control is now two halves:
|
||||
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 3 | write (client→server) | control in: resize, signal, eof |
|
||||
| 4 | read (server→client) | control out: exit, keepalive response |
|
||||
|
||||
The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3)
|
||||
and reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its
|
||||
own reassembly buffer, its own flow control, its own EOF. The control
|
||||
channel is *actually* bidirectional — two unidirectional streams, not
|
||||
one shared stream both sides write to.
|
||||
|
||||
This amends ADR-001's stream_type assignments for direct mode too:
|
||||
direct `alk/tty` connections now use stream_types [0, 1, 2, 3, 4] (data
|
||||
in/out/err + control in/out), not [0, 1, 2, 3]. The 5-byte format's
|
||||
`stream_type` field gains value 4; the `ControlMessage` enum is
|
||||
unchanged (the JSON shape is the same; the stream_type it rides on
|
||||
splits from 3 into 3+4).
|
||||
|
||||
### What changes in alktty
|
||||
|
||||
1. **The adapter's session-driving code splits into two entry
|
||||
points:**
|
||||
- `drive_session_direct(send, recv, backends, ...)` — the existing
|
||||
path: parse 5-byte chunks, split into stream_types 0-4, pump. Used
|
||||
for direct `alk/tty` connections.
|
||||
- `drive_session_channels(sub_streams, backends, ...)` — the new
|
||||
path: receive `ChannelSubStreams` (five named handles: stdin=
|
||||
SendStream, stdout=RecvStream, stderr=Option<RecvStream>,
|
||||
ctrl_in=SendStream, ctrl_out=RecvStream), pump directly without
|
||||
chunk parsing. Used when the channel's `Connection` is backed by
|
||||
`ChannelBidiStreamSource`.
|
||||
|
||||
2. **The `TtyAdapter::handle()` branches on the `Connection`'s source
|
||||
type.** The channels crate's `ChannelBidiStreamSource` is a
|
||||
`BidiStreamSource` (alknet ADR-070); the `Connection` wraps it. The
|
||||
adapter detects whether the `Connection` is channels-backed (via a
|
||||
downcast or a channels-crate extension trait — exact ergonomics per
|
||||
alknet ADR-074's implementation detail) and calls
|
||||
`drive_session_channels` instead of `drive_session_direct`.
|
||||
|
||||
**This is the one place alktty knows about channels.** It is a
|
||||
branch on the connection source, not a dependency on channels' wire
|
||||
format. The branch can be feature-gated (`channels` feature on
|
||||
alktty) so the direct-only path has no channels dependency.
|
||||
|
||||
3. **The 5-byte wire format (ADR-001) is unchanged for direct
|
||||
connections.** ADR-001's scope is now "the wire format for direct
|
||||
`alk/tty` connections." The channels path does not use it. This
|
||||
amends ADR-001's scope — the format is not replaced, it's scoped.
|
||||
|
||||
4. **The control channel works the same in both modes, now properly
|
||||
bidirectional.** In direct mode, control-in JSON rides in 5-byte
|
||||
chunks with `stream_type=3` and control-out rides with
|
||||
`stream_type=4`. In channels mode, control-in rides in 9-byte chunks
|
||||
with `stream_type=3` (write) and control-out with `stream_type=4`
|
||||
(read) — but the channels layer de-chunks them, so the adapter
|
||||
reads raw JSON bytes from `ctrl_in`/`ctrl_out` in both cases. The
|
||||
`ControlMessage` enum (resize, signal, eof, exit) is unchanged —
|
||||
the JSON shape is the same; only the stream_type assignments change
|
||||
(3 splits into 3+4).
|
||||
|
||||
5. **The exit-chunk-is-last invariant (ADR-004) generalizes.** In
|
||||
direct mode, the exit chunk is the last 5-byte chunk on
|
||||
`stream_type=4` (read, server→client) before stream close (ADR-004,
|
||||
amended). In channels mode, the exit control message is the last
|
||||
data on `stream_type=4` before `channel/close` is sent on channel 0
|
||||
(alknet ADR-073 §channel/close). The ordering invariant is the same
|
||||
— exit before close — but the mechanism differs: 5-byte chunk
|
||||
ordering on stream_type 4 (direct) vs. `stream_type 4` ordering +
|
||||
`channel/close` after pump completion (channels).
|
||||
|
||||
### What does NOT change
|
||||
|
||||
- **`TtyBackend` trait, `TtyHandle`, `TtyControl`** (ADR-002) —
|
||||
unchanged. Backends don't know about channels or direct mode.
|
||||
- **`DockerTtyBackend`, `LocalTtyBackend`** — unchanged. They implement
|
||||
`TtyBackend::allocate()` and return a `TtyHandle`.
|
||||
- **`ControlMessage` enum** — unchanged. The JSON shape is the same in
|
||||
both modes.
|
||||
- **The `alk/tty` ALPN string** — unchanged. Direct connections use it;
|
||||
channels `channel/open` requests it.
|
||||
|
||||
### Crate dependency
|
||||
|
||||
`alktty` does **not** depend on `alknet-channels` unconditionally. The
|
||||
channels-integration code is behind a `channels` feature on `alktty`.
|
||||
When the feature is off, `TtyAdapter` only supports direct mode (the
|
||||
existing behavior). When the feature is on, the adapter branches into
|
||||
channels mode for channels-backed connections. This preserves alknet
|
||||
ADR-003's no-handler-depends-on-another-handler rule for the default
|
||||
build; the feature-gated dependency is opt-in, same as `alknet-docker`'s
|
||||
`tty` feature (alknet ADR-061).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- The TTY crate's direct mode is unchanged — existing `alk/tty`
|
||||
deployments (browser terminals over WebSocket, direct QUIC TTY) keep
|
||||
working with the 5-byte format.
|
||||
- The channels path uses the channels layer's de-chunking — no
|
||||
double-chunking (5-byte inside 9-byte). The TTY adapter sees clean
|
||||
sub-streams.
|
||||
- The `TtyBackend` trait is insulated — backends don't know which mode
|
||||
the adapter is in. Docker, SSH, and local backends work in both
|
||||
modes without changes.
|
||||
- The control channel and exit-chunk invariant carry forward cleanly —
|
||||
the `ControlMessage` enum and ordering semantics are
|
||||
mode-independent.
|
||||
|
||||
**Negative:**
|
||||
- `alktty` has two session-driving entry points
|
||||
(`drive_session_direct` vs `drive_session_channels`). This is the
|
||||
necessary cost of supporting both direct and channels modes without
|
||||
double-chunking. The alternative (always use channels format, even
|
||||
for direct) would break existing direct deployments and add 4 bytes
|
||||
of overhead per chunk for no benefit.
|
||||
- The `channels` feature on `alktty` adds a dependency edge (`alktty`
|
||||
→ `alknet-channels`, feature-gated). This is the same pattern as
|
||||
`alknet-docker`'s `tty` feature (alknet ADR-061) and is opt-in.
|
||||
- ADR-001's scope is amended (from "the TTY wire format" to "the TTY
|
||||
wire format for direct connections"). This is a scope clarification,
|
||||
not a format change — the 5-byte format itself is unchanged.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way (scope amendment) + two-way (feature gate).** ADR-001's scope
|
||||
amendment (direct-only) is one-way — once the channels path exists,
|
||||
re-merging the formats would require unifying 5-byte and 9-byte chunk
|
||||
handling, which is a rewrite. The `channels` feature gate is two-way —
|
||||
it can be removed if channels integration is no longer needed.
|
||||
|
||||
**Reversed by ADR-008 (2026-07-18):** the two-mode design is reversed —
|
||||
TTY always uses its 5-byte format, carried transparently in the
|
||||
channels payload. The one-way door is re-cast (the channels crate is
|
||||
not yet implemented, so this is the right time). See ADR-008 for the
|
||||
amended door-type discussion.
|
||||
|
||||
## References
|
||||
|
||||
- **[ADR-008](008-channels-pure-channel-multiplexing.md)**: channels
|
||||
pure channel multiplexing (reverses this ADR — TTY always uses its
|
||||
5-byte format; the channels layer carries it transparently; the
|
||||
two-mode design is preserved but differs only in `BiStream` source,
|
||||
not in parsing)
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
|
||||
(amended — scoped to direct connections by this ADR; **re-amended by
|
||||
ADR-008 — TTY always uses its 5-byte format, in both direct and
|
||||
inside-channels modes**)
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): TtyBackend trait
|
||||
and TtyHandle (unchanged by this ADR)
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md): exit-chunk-is-last
|
||||
(generalized by this ADR + alknet ADR-073)
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md): alktty does not
|
||||
depend on alkcall's internal wire types (preserved — the channels
|
||||
feature is on alknet-channels, not alkcall's internal wire types)
|
||||
- alknet ADR-071: channels wire format (the 9-byte format the channels
|
||||
path uses; **amended by alknet ADR-093 / ADR-008 — 8-byte format, no
|
||||
`stream_type`**)
|
||||
- alknet ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the
|
||||
accessor the channels path uses; **amended by alknet ADR-093 /
|
||||
ADR-008 — `into_sub_streams()` removed**)
|
||||
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
|
||||
decision that enables the reversal — `accept_bi` returns `BiStream`)
|
||||
- alknet ADR-061: DockerTtyBackend in alknet-docker (the feature-gated
|
||||
dependency pattern this ADR mirrors)
|
||||
- alknet channels phase-0 findings §DP-3, §OQ-CH-02, §Relationship to
|
||||
Existing Crates / alktty
|
||||
- Port origin: alknet ADR-077 at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md`
|
||||
Reference in New Issue
Block a user