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:
2026-08-17 10:52:26 +00:00
parent 712e7ae071
commit b3f50d1836
17 changed files with 5165 additions and 46 deletions
@@ -0,0 +1,219 @@
# ADR-004: Exit Code on a Control Chunk (the Last Chunk Before Stream Close)
## Status
Accepted (ported from alknet ADR-055 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 ADR referenced by alknet number (052, 053) is
not ported into alktty's ADR range as a separate ADR — it is ADR-001
and ADR-002 here. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative for any ADR not yet ported.)
## Context
The alknet-docker POC validated exit-code propagation for the JSON
carriage path: exec with an exit code rides on a final `call.responded`
frame `{ "exitCode": N }` before `call.completed`. That works because
the JSON carriage path is the call protocol — `call.responded` and
`call.completed` exist and carry the result.
The raw-carriage path (ADR-001) has no `call.responded` and no
`call.completed` — after the negotiation frame, the stream is raw
chunks. The exit code must ride on the chunk format itself. Two options:
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last control
chunk (`STREAM_CTRL_OUT`, stream_type 4) before stream close. Clean,
explicit, carries the code as structured data on the channel that
already exists for control metadata.
- **(b) Final data chunk with exit code**: a special stdout chunk with
an exit-code payload. Overloads the data channel for metadata — a
client parsing stdout chunks would have to special-case "this stdout
chunk is actually an exit code," conflating data and control.
The local-PTY POC validated option (a) end-to-end: the
`{"type":"exit","code":N}` chunk fires after the child is reaped (the
waiter thread's `oneshot::Receiver<i32>` resolves) and is the last
control chunk before the stream closes. The POC's `session.rs`
`pump_exit` task awaits `pty.exit_code`, serializes the result as
`ControlMessage::Exit { code }`, enqueues it as a control chunk, and
the drainer writes it to the client before the writer closes.
### Why this is a one-way door
Clients will depend on the **"exit chunk is last"** invariant: after
the exit control chunk, no more data chunks follow, and the stream
closes. This is the deterministic completion notification the docker
POC identified as the stopgap coordination property — a coordinator
spawns a process, streams its output, and gets a reliable "it exited
with code N" signal without polling or plugin state. Changing the
ordering after clients exist would break every consumer that reads
stdout until the exit chunk and then stops.
## Decision
### 1. The exit code rides on a control chunk, not a data chunk
The exit code is control metadata (the process's termination status),
not data (process output). It rides on the control channel
(`STREAM_CTRL_OUT`, stream_type 4, ADR-001) as:
```json
{"type":"exit","code":0}
```
The `code` is an `i32` (matches `std::process::ExitStatus::code()` and
Unix wait-status convention; negative values are signal-terminated,
e.g., `-9` for SIGKILL, matching `ExitStatus::code()`'s behavior on
Unix). The chunk is the last control chunk before stream close.
### 2. The "exit chunk is last" invariant
After the `{"type":"exit","code":N}` control chunk:
- The server sends no more data chunks (stdout/stderr) and no more
control chunks.
- The server closes the write half of the bidi stream.
A client reads stdout/stderr/control chunks until it sees the exit
chunk, records the exit code, and treats subsequent stream close as the
session end. The exit chunk is the deterministic completion signal.
### 3. The adapter owns the exit-chunk ordering, not the backend
Per ADR-002 assumption 3, the backend resolves `exit_code` (a
`BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it,
sends the exit control chunk, and closes the stream. The backend does
not write to the wire — it produces handles; the adapter pumps. This
keeps the wire-format logic (including the "exit is last" invariant) in
one place (the adapter's session driver) and the backend focused on its
allocation target.
The adapter's session driver (see `tty-adapter.md`) runs three
concurrent pumps:
1. **stdout → client**: backend stdout → stdout chunks (and stderr
chunks if `TtyHandle.stderr` is `Some`).
2. **client → backend**: stdin chunks → backend stdin; control chunks →
`TtyControl::resize`/`signal`/`eof`.
3. **exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
enqueue `{"type":"exit","code":N}` as a control chunk; after the
drainer writes it, close the write half.
The exit-chunk task coordinates with the stdout pump: the stdout pump
completes (backend stdout EOF) before or concurrently with the exit
resolve, and the exit chunk is enqueued only after the exit resolves.
The drainer writes chunks in arrival order; the exit chunk is last
because the exit is the last thing to resolve (the child must exit
before its stdout drains, but the exit chunk is sent only after
`exit_code` resolves, which is after `Child::wait()` returns — i.e.,
after the child is reaped).
### 4. Error exit codes
A backend `TtyError` during `allocate()` (the PTY couldn't be
allocated, the docker exec failed to start, the SSH channel request was
rejected) is handled before the raw-carriage phase begins — the adapter
sends a JSON error response to the negotiation frame and closes the
stream without entering raw mode. See `tty-adapter.md` §"Negotiation
errors".
A `TtyError` from the `exit_code` future (the child couldn't be reaped,
or the backend's wait path failed) is serialized as an exit code of
`-1` (`ControlMessage::Exit { code: -1 }`) and the stream closes. The
client treats `-1` as "the backend reported an exit error, not a real
exit code." This is a best-effort signal; a backend that cannot
determine the exit code still sends the exit chunk so the client gets
the completion notification.
## Consequences
**Positive:**
- The exit code is structured data on the control channel, not a hacky
overload of the data channel. Clients parse it as a
`ControlMessage::Exit`, not as a special-cased stdout chunk.
- The "exit chunk is last" invariant gives coordinators deterministic
completion notification — the same stopgap property the docker POC
validated for logs subscriptions. No polling, no plugin state; the
process exiting is the signal.
- The adapter owns the ordering, so the invariant is enforced in one
place; backends don't have to know the wire format's completion
semantics.
- The error-exit `-1` fallback keeps the completion notification
reliable even when the backend can't determine the real code — the
client still knows the session ended.
**Negative:**
- The "exit chunk is last" invariant is a one-way door — clients depend
on it. Reversing it (allowing data chunks after the exit chunk, or
moving the exit code to a data chunk) would break every consumer.
This is the intended commitment: the invariant is the value.
- A client that doesn't read until the exit chunk (e.g., a runner that
cancels mid-stream by closing the write half) won't see the exit
code. That's correct — a cancelled stream doesn't have a
deterministic exit; the client that cancels already knows it
cancelled. The exit chunk is for the client that reads to completion.
- The `-1` error-exit code conflates "the backend couldn't determine
the exit" with "the process exited with code -1" (which doesn't
happen on Unix — `ExitStatus::code()` returns `None` for signal
termination, not -1; the POC's waiter thread sends -1 only on
`wait()` failure, not on signal termination — signal termination
sends the negative signal number, e.g., -9 for SIGKILL). A client
that needs to distinguish "real exit -1" from "backend error" can't
from the code alone. This is a documented edge case; if it becomes
load-bearing, a future control message type
(`{"type":"exit_error","message":"..."}`) can carry the distinction
additively (the `type`-tagged enum is the extension seam per
ADR-001).
## Door type
**One-way.** The "exit chunk is last" invariant is what clients depend
on for deterministic completion. Changing it after clients exist breaks
every consumer. The `{"type":"exit","code":N}` shape is also one-way
(clients parse it as a `ControlMessage::Exit`), though the
`type`-tagged enum (ADR-001) makes adding *new* control message types
additive.
## Assumptions
1. **The child exits before its stdout fully drains, and the exit chunk
is sent after `exit_code` resolves.** On Unix, `Child::wait()`
blocks until the child is reaped, which happens after the child
exits and its stdout pipe/PTY buffer drains. The POC validated this
ordering: the reader thread sees EOF (buffer drained), the waiter
thread reaps (exit code available), and the exit chunk is enqueued
after the exit resolves. There is no race where stdout chunks arrive
after the exit chunk.
2. **`exit_code` resolving implies the stdout pump is done or will be
soon.** The adapter's session driver waits for both the stdout pump
to complete (backend stdout EOF) and the exit to resolve before
sending the exit chunk and closing. If a backend's stdout outlives
the exit resolve (a hypothetical backend where the process exits but
a buffer flush is still in flight), the adapter waits for the stdout
pump before the exit chunk. The `TtyHandle.stderr` (if `Some`) is
pumped concurrently with stdout and also drains before the exit
chunk.
## References
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format
(control channel, `STREAM_CTRL_OUT` stream_type 4) this ADR's exit
chunk rides on
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the
`TtyHandle.exit_code` field (the `Future` the adapter awaits) this
ADR's ordering consumes
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel path
that bypasses this ADR's happy-path ordering (no exit chunk is sent
on cancel — the stream is gone)
- [ADR-008](008-channels-pure-channel-multiplexing.md) — the
exit-chunk-is-last invariant generalizes to channels mode (the exit
chunk is the last `STREAM_CTRL_OUT` chunk before the channel closes)
- Spec: [tty-adapter.md](../tty-adapter.md) (the session driver that
enforces the ordering)
- Port origin: alknet ADR-055 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/055-exit-code-on-control-chunk.md`