Files
alktty/docs/architecture/decisions/003-local-backend-placement.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

13 KiB

ADR-003: Local TTY Backend Placement (Single Crate with local Feature)

Status

Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate consolidation recorded as the alktty resolution. 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. Alknet ADRs referenced by alknet number (003, 009, 017) are not ported into alktty's ADR range because they are not tty-specific; the alknet originals at /workspace/@alkdev/alknet/docs/architecture/decisions/ remain authoritative.)

Context

The alknet-tty research (DP-1) posed the placement question for the local-process backend (std::process::Command with piped stdio, or portable_pty for a real PTY):

  • (a) In alktty: the crate ships with the local backend built-in. Pro: zero-config runner, one crate gets a terminal/process-streaming endpoint. Con: alktty pulls in portable_pty even for deployments that only use docker/ssh backends.
  • (b) In a sibling crate (alknet-tty-local): alktty defines the trait (ADR-002); the local backend is a separate crate. Pro: alktty stays dependency-light; consumers opt into the local backend explicitly. Con: one extra crate for the common case.

The local backend is the simplest backend and the one that enables 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). It has no heavy dependencies in the pipe case — just std — but the PTY case pulls in portable_pty (a non-trivial native dependency that builds on Unix openpty/ioctl and Windows ConPTY).

The relevant constraint from ADR-002: alktty itself depends only on alkcall and the wire-format codec (ADR-001). The portable_pty dependency does not belong in the core tty crate — a docker-only deployment or an SSH-PTY-only deployment should not pull in PTY allocation code. This is the same inversion as OperationAdapter (alknet ADR-017): the trait lives where the types live; the implementations live where their transport dependencies live.

The alknet mono-repo decision (sibling crate + feature re-export)

In the alknet mono-repo, the research recommended (b) sibling crate behind a feature flag on alknet-tty for the common case (features = ["local"] → re-export from alknet-tty-local). This keeps alknet-tty's default dependency surface minimal while making the local backend a one-feature opt-in. The portable_pty dependency lives in alknet-tty-local; alknet-tty itself never depends on portable_pty.

The sibling-crate placement was motivated by a cyclic-dependency workaround: alknet-tty-local depends on alknet-tty for the trait, and alknet ADR-054 wanted alknet-tty to re-export LocalTtyBackend behind a local feature — which cargo rejects (a crate cannot re-export from a sibling crate it depends on via an optional dep AND have that sibling depend back on it). The assembly-layer workaround (consumer depends on both crates directly) worked but was awkward.

The alktty consolidation (single crate, local feature)

alktty is a standalone single crate (not a mono-repo). The cyclic-dep workaround that motivated the alknet sibling-crate decision does not apply: the local backend can live in the same crate as the trait, gated behind a local cargo feature. The feature gate still keeps portable_pty out of the default dependency tree (a docker-only or ssh-only deployment enables neither feature and never pulls in portable_pty); the only thing that changes is the crate boundary.

This ADR records both decisions: the original alknet sibling-crate decision (preserved as the historical context for why the local backend is a separable concern), and the alktty consolidation (the operative decision — the local backend is folded into alktty behind a local feature).

Decision

1. The local backend is folded into alktty behind a local feature

LocalTtyBackend lives in src/local/ (a feature-gated module), implements TtyBackend (ADR-002), and is re-exported as alktty::local::LocalTtyBackend. The local cargo feature pulls in the heavy deps:

# alktty Cargo.toml
[features]
default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]

[dependencies]
portable-pty = { version = "0.9", optional = true }
tokio-util = { version = "0.7", features = ["io"], optional = true }

A consumer that wants the local backend enables features = ["local"] on alktty and gets alktty::local::LocalTtyBackend. A consumer that only wants docker/ssh backends uses the default features and depends on alknet-docker / alknet-ssh (or their own backend crate) directly — no portable_pty in the dependency tree.

This is the same feature-re-export pattern the Rust ecosystem uses for optional heavy dependencies (e.g., tokio's full feature pulling in tokio-util, h2, etc.). The seam is the TtyBackend trait; the feature gate is the only thing keeping portable_pty out of the default crate. Folding the local backend in is cheaper than the alknet sibling-crate pattern (no cross-crate coordination, no cyclic-dep workaround) and the local backend is small (~1.4k lines) and tightly coupled to the trait.

2. The local backend's dependency tree

alktty (local feature enabled)
├── alktty (default)   (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alkcall::core      (via alktty's re-export; not direct)
├── portable_pty       (PTY allocation — the heavy dep, here not in alktty default)
├── libc               (signal forwarding — REQ-TTY-02, Unix only)
└── tokio              (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)

The local feature is inherently non-wasm (portable-pty + tokio::process need a real OS); enabling local on wasm32-unknown-unknown is a build error by design. The default crate (no features) stays wasm-clean — this is what makes the downstream TS/Python adapter story work (a wasm-compiled alktty is the protocol layer for a sandboxed adapter; the local-process backend runs on a real OS, never in a sandbox).

3. PTY vs pipe is a per-session choice, not a per-deployment choice

TtyParams.terminal: Option<TerminalParams> (ADR-002) selects the mode:

  • terminal: Some(TerminalParams { ... }) — allocate a real PTY via portable_pty. Terminal semantics: resize (via ioctl(TIOCSWINSZ)), signal delivery to the foreground process group (via libc::kill(-pgid, sig), REQ-TTY-02), escape-sequence handling (the kernel PTY's line discipline). stdout and stderr are merged (kernel PTY property — one output stream from the slave), so TtyHandle.stderr is None.
  • terminal: None — pipe mode, no PTY. tokio::process::Command with Stdio::piped() for stdin/stdout/stderr. No resize, no escape-sequence handling, but kill(pid, sig) still works for signal forwarding. stdout and stderr are separate streams, so TtyHandle.stderr is Some. This is the runner case — a command-streaming endpoint with no terminal semantics.

The same LocalTtyBackend serves both; the allocate() call branches on params.terminal. A deployment that only does terminals always sends Some; a deployment that only does runners always sends None; a deployment that does both (a hub that runs agents in PTYs and runs cargo test as a runner) sends the appropriate one per session.

4. The runner pattern is preserved, not specialized

The pipe mode (terminal: None) is the "runner" generalization the research identified: a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection. This is functionally identical to GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling:

  • A coordinator sends a negotiation frame with { "backend": "local", "tty": null, "cmd": ["cargo", "test"] }.
  • The endpoint runs cargo test with piped stdio, streams stdout/stderr chunks back, sends {"type":"exit","code":N} when it finishes (ADR-004).
  • The coordinator gets reliable completion notification (the exit control chunk + stream close) — no polling.

The runner-specific API surface (job management, log persistence, task graph integration) is out of scope for alktty. alktty provides the mechanism (a framed byte stream for a process); the runner policy is a downstream crate's job. This ADR commits to preserving the option (terminal: None → pipe mode) and not building runner policy into alktty. See OQ-46.

Consequences

Positive:

  • alktty's default dependency surface is minimal (alkcall + the wire-format codec). A docker-only or ssh-only deployment never pulls in portable_pty.
  • The local backend is a one-feature opt-in (features = ["local"]) for the common case — a consumer that wants a terminal/runner endpoint with no docker or SSH gets it with one feature flag, not a separate dependency.
  • PTY vs pipe is per-session, so one LocalTtyBackend serves terminals and runners. A hub that does both doesn't need two backends.
  • The runner pattern is preserved without baking runner policy into alktty. The mechanism is the framed byte stream; the policy is downstream.
  • The single-crate placement composes with alknet ADR-003's no-handler-depends-on-another-handler rule: the local backend is in the same crate as the trait, so there is no cross-crate dependency edge to worry about. The docker and SSH backends remain separate crates (real external deps — bollard, russh — and their own resource models).
  • The alktty consolidation resolves the alknet cyclic-dep workaround that motivated the original sibling-crate decision. No assembly-layer "depend on both crates directly" pattern; no cargo-rejected feature-re-export-from-an-optional-dep dance.

Negative:

  • A consumer that wants the local backend must enable a feature flag (features = ["local"]). Forgetting the flag results in the alktty::local module not existing — a compile error, not a silent miss. This is the standard Rust feature-flag trade and is self-documenting.
  • The local feature is non-wasm by design. A consumer that targets wasm32-unknown-unknown must not enable local — the build error is intentional (the local-process backend cannot run in a sandbox). The default crate stays wasm-clean so the downstream TS/Python adapter story works.
  • The runner-specific API surface (job management, log persistence) is not in alktty. A downstream crate that wants a full runner builds on the pipe mode + the wire format. This is the right layering (mechanism vs policy) but means a "runner crate" is a separate future deliverable, not part of alktty. See OQ-46.

Door type

Two-way. The single-crate-with-feature-gate placement is reversible: if the local backend ever grows large enough to warrant splitting out (e.g., a future docker or SSH backend shares enough portable-pty bridge code to motivate a shared "blocking-backend bridge" crate), extracting src/local/ into a sibling crate behind the same local feature is mechanical — the trait is the seam, and the feature gate already exists. The cost of reversal is low (the module becomes a crate; the feature gate switches from dep:portable-pty to dep:alktty-local), and no downstream consumer breaks (the alktty::local::LocalTtyBackend path stays valid if the sibling crate re-exports it).

This is a two-way door that is decided (single crate + local feature), not deferred. The decision is made now; the reversal is cheap if a future split warrants it. See alknet ADR-009 §"What this framework is NOT" — door type classifies reversal cost, not urgency.

References

  • alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the single-crate placement preserves it; the local backend is in the same crate as the trait, not a separate crate that depends back)
  • alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's two-way-door classification is reversal cost, not a deferral)
  • alknet ADR-017 — the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows (the local feature is where the deps live)
  • ADR-002 — the TtyBackend trait this module implements
  • ADR-001 — the wire format the adapter pumps to/from this backend
  • ADR-004 — the exit-chunk ordering the local backend's waiter thread feeds
  • ADR-005 — the cancel- cleanup contract the local backend's exit_code future's Drop implements
  • OQ-46 — runner API surface (deferred(scope): mechanism in alktty, policy is a downstream crate)
  • Spec: tty-local.md
  • Port origin: alknet ADR-054 at /workspace/@alkdev/alknet/docs/architecture/decisions/054-local-tty-backend-sibling-crate.md