# Project Setup Plan Status: draft (revised 2026-08-17 to reflect landed upstream changes; Phase 4 landed 2026-08-17 — architecture docs + BAST schema + renumbered ADRs) Last updated: 2026-08-17 ## Overview Extract `alknet-tty` + `alknet-tty-local` from the alknet mono-repo into a standalone `alktty` crate — a producer/consumer protocol crate on top of alkcall channels. This is the first protocol crate built on alkcall and establishes patterns that `alktunnels` and others will follow. The local backend (`alknet-tty-local`) is folded into `alktty` as a feature-gated `local` submodule rather than kept as a sibling crate. Both the original `alknet-tty` (the wire/adapter/backend-trait half) and `alknet-tty-local` (the `portable_pty` + `tokio::process` half) are ported together, so the single crate carries the full stack: the `alk/tty` protocol + the one backend implementation that ships with it. A docker or SSH backend would be a separate crate; the local backend is small and the cyclic-dependency workaround it required in the alknet mono-repo (see ADR-054) doesn't apply to a single crate with a feature flag. ## Upstream changes that landed since the first draft These were "open questions" or "upstream changes needed" in the 2026-08-14 draft and are now resolved. The plan reflects the as-built shape, not the original intent. ### alkcall 0.1.1 — ALPN rename done - `CHANNELS_ALPN`: `b"alknet/channels"` → `b"alk/channels"` (in `alkcall/src/channels/adapter.rs`). - Test at `channels/client.rs:767` uses `"alk/tty"` (was `"alknet/tty"`). - Semver showed no public API change → alkcall bumped 0.1.0 → 0.1.1. - `alkcall` `Cargo.toml` dependency needs updating from `0.1.0` to `0.1.1` (the scaffold still pins `0.1.0`). ### alkcall — `Identity.resources` shape The alknet-core `Identity` had `resources: HashMap` (a flat resource-type → resource-id map). alkcall's `Identity` (in `alkcall::core::auth`) has `resources: HashMap>` — one resource type can carry multiple resource ids. The ported adapter tests that build an `Identity` by hand need this shape; the production code only reads `identity.scopes` (the scope-gate at negotiation), so the change is test-side. ### alkcall — `OwnershipProvider` / `OwnershipStore` signature - `OwnershipProvider::owns(&self, identity, resource_type, resource_id, action: &str)` — added an `action` parameter (was 3 args, now 4). The adapter's call site (`provider.owns(id_ref, kind, &id, "tty")`) already passes an `"tty"` action string, which is the new arity. No change to the production adapter; the mock and test fixtures need the 4-arg shape. - `OwnershipStore::record(&self, identity, resource_type, resource_id)` — **dropped** the old `action` parameter that alknet-core's variant took. The adapter test's `store.record(&owner, "container", "c1").await` matches the new arity. (The scaffold's plan previously listed an `OwnershipStore` import that no longer needs an action arg.) ### alkcall — `AuthContext::anonymous(alpn)` alkcall added a convenience constructor `AuthContext::anonymous(alpn)` that builds an `AuthContext` with no identity, no fingerprint, no remote address — only the ALPN. Useful in adapter tests; not used by the production adapter (which receives the `AuthContext` from the dispatch path). ### alktype 0.2.0 — BAST format pivot alktype was reworked: the v0.1.0 `AlkType:*` custom-keyword JSON Schema backend was dropped in favor of a straightforward `kind`-based BAST (Binary Abstract Syntax Tree) format inspired by unist ASTs. A BAST document is a plain JSON file (conforming to a JSON Schema meta-schema) that describes binary layouts using `kind` strings (`"uint8"`, `"uint32"`, `"struct"`, etc.) and `$defs`/`$ref` for composition. `alktty` will **not** depend on alktype — the hand-rolled `ChunkReader`/`ChunkWriter` in `wire.rs` already works and is straightforward. The BAST format matters here only as a normative **schema document** for the `alk/tty` wire format: a JSON file under `docs/architecture/` that downstream consumers (and alktype, if desired) can consume to generate validators, layout maps, or readers in any language. The schema is a documentation artifact, not a runtime dependency. ## Crate Structure Single crate (`alktty`) with the local backend gated behind a `local` feature. This is a "tty-specific monorepo" — one crate, one concern, both halves of the original alknet split (`alknet-tty` + `alknet-tty-local`) folded in. ``` alktty/ ├── src/ │ ├── lib.rs # crate root, re-exports │ ├── wire.rs # ChunkReader/ChunkWriter (5-byte format) │ ├── negotiation.rs # NegotiateRequest, framing reader/writer │ ├── control.rs # ControlMessage enum, signal_from_name │ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError │ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session │ ├── session.rs # TtySession — typed consumer client │ ├── channels.rs # OpenHandler factory, register_openable helper │ └── local/ # (behind `local` feature — the folded alknet-tty-local) │ ├── mod.rs # LocalTtyBackend re-export │ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe) │ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)]) │ └── pipe.rs # Pipe/runner mode (tokio::process::Command, cross-platform) ├── docs/ │ ├── architecture/ │ │ ├── README.md # architecture index │ │ ├── tty-wire.md # wire format spec (5-byte chunk + 4-byte neg frame) │ │ ├── tty-bast.md # BAST JSON document for the alk/tty wire format │ │ ├── tty-backend.md # TtyBackend trait + LocalTtyBackend │ │ ├── tty-adapter.md # TtyAdapter + drive_session │ │ ├── tty-local.md # PTY + pipe modes, REQ-TTY-01/02 │ │ └── decisions/ # renumbered ADRs │ └── plans/ │ └── project-setup.md # this document ├── Cargo.toml ├── LICENSE-APACHE └── LICENSE-MIT ``` ## Key Design Decisions ### 1. Single crate, feature-gated local backend The old split (`alknet-tty` + `alknet-tty-local`) had a cyclic dependency problem: `alknet-tty-local` depends on `alknet-tty` for the trait, and ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a `local` feature — which cargo rejects. The assembly-layer workaround (consumer depends on both crates directly) works but is awkward. Single crate with `local` feature solves this cleanly. The `portable-pty` and `tokio-util` deps are optional. A docker-only deployment doesn't pull in PTY code. **Folded-in scope.** The single crate carries both halves of the original alknet split: the wire/adapter/backend-trait half (from `alknet-tty`) and the local backend (from `alknet-tty-local`). The local backend is small (~1.4k lines), tightly coupled to the trait, and the cyclic-dep workaround it required in the alknet mono-repo doesn't exist in a single crate. A docker or SSH backend would still be a separate crate — those have real external deps (`bollard`, `russh`) and their own resource models. **WASM target.** The default crate (no `local` feature) MUST compile to `wasm32-unknown-unknown`. alkcall and alktype both target wasm; keeping alktty wasm-clean is what makes the downstream TS/Python adapter story work — a wasm-compiled alktty is the protocol layer for a sandboxed adapter (browser terminal over WebTransport, a Python wheel that shells out to a wasm module, etc.). The `local` feature is inherently non-wasm (`portable-pty` needs a real OS, `tokio::process` needs `spawn`), so enabling `local` on wasm is a build error by design — the local-process backend runs on a real OS (Linux, macOS, Windows), never in a sandbox. Concretely, `Cargo.toml` uses `tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }` (the wasm-clean subset alkcall uses) and `local` adds `tokio/process` + `tokio/rt-multi-thread`. Do NOT use `features = ["full"]` — it pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The adapter's `tokio::spawn` for per-session pumps is fine on wasm (the wasm tokio runtime supports `spawn`); the `local` module's std threads and `tokio::process::Command` are the non-wasm parts, and they're feature-gated. `libc` stays under `cfg(unix)` (it's already there for `signal_from_name` and the pipe-mode `kill` path — neither runs on wasm because the only callers are in `local`). ### 2. Dependency: `alknet-core` → `alkcall::core` All types previously from `alknet-core` now come from `alkcall::core`: - `ProtocolHandler`, `Connection`, `BiStream`, `BidiStreamSource` - `AuthContext`, `Identity`, `IdentityProvider` - `AccessControl`, `OwnershipProvider`, `OwnershipStore`, `InMemoryOwnershipStore` - `HandlerError`, `StreamError` The `TtyAdapter`'s `impl ProtocolHandler` changes which crate the trait comes from. The trait shape is identical. The `Identity` struct's `resources` field is `HashMap>` in alkcall (was `HashMap` in alknet-core); the production adapter only reads `identity.scopes`, so the change is test-side. `Cargo.toml` pins `alkcall = "0.1.1"` (the scaffold's `0.1.0` pin is stale — 0.1.1 is the ALPN-rename release; same public API per semver). ### 3. Channels integration (the new part) This is where alktty becomes a proper producer/consumer on alkcall. **ALPN strings (settled).** `alk/tty` (direct) and `alk/channels` (multiplexed). The `alknet/` → `alk/` rename landed in alkcall 0.1.1 — `CHANNELS_ALPN` is `b"alk/channels"`, the channels client test uses `"alk/tty"`. No upstream work remains; we just use the new strings. **Producer half** — two paths to expose TTY: - **Direct ALPN** (`alk/tty`): `TtyAdapter` implements `ProtocolHandler`. Existing code, just import migration. Used for direct-connect scenarios (browser terminal over WebTransport, standalone TTY endpoint). - **Through channels** (`alk/channels`): Register an `OpenHandler` via `ChannelCore::register_openable()`. The handler receives a `Connection` (the channel's data plane with ALPN `alk/tty`), calls `accept_bi()`, and runs the same `drive_session` on the resulting `BiStream`. Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format. The channels layer strips its 8-byte header and hands TTY the payload transparently. The same `wire.rs` code runs in both modes — only the `BiStream` source differs. The channels integration code is small: a `register_openable` helper function that takes a `ChannelCore`, `OperationRegistry`, and backend map, and registers the per-ALPN open op (`channels/tty/sub` — consumer-opens). This lives in `src/channels.rs`. The op spec carries the `channel_open` marker (`ChannelOpenSpec::new("alk/tty")`) and the `AccessControl` for the scope-gate + ownership check; the channels wrapper does `check_open` → `open_channel` → spawn the `OpenHandler` → respond with `{ channel_id }`. **Consumer half** — `TtySession`: A typed client that wraps the wire protocol. Two constructors: - `TtySession::connect_direct(connection)` — for direct `alk/tty` - `TtySession::open_via_channels(client, params)` — opens a channel via `ChannelClient`, negotiates, returns a session handle The session handle exposes: - `send_stdin(bytes)` / `close_stdin()` - `recv_stdout()` / `recv_stderr()` — streams - `resize(cols, rows)`, `signal(name)` - `wait()` — awaits exit code ### 4. Negotiation framing — keep self-contained ADR-057 says the negotiation framing is self-contained (~30 lines, format coincides with alkcall's by convention). Even though alkcall is now a dependency, we keep the self-contained framing because: - The payload is `NegotiateRequest`, not `EventEnvelope` — alkcall's `FrameFramedReader` is hardcoded to deserialize `EventEnvelope` - The framing is trivial (4-byte BE length prefix + body) - Avoids coupling to alkcall's internal wire types for a TTY-specific payload ### 5. Producer/consumer framing The architecture README in alkcall describes protocol crates as having two halves: 1. **Producer half** — `register_*()` functions that take an `&mut OperationRegistry` and register ops with handlers. For channels, an `OpenHandler` factory. 2. **Consumer half** — a typed client wrapper around `CallConnection` or `ChannelClient` that exposes the crate's ops as async methods. We make this explicit in the module structure: - `src/adapter.rs` + `src/channels.rs` = producer half - `src/session.rs` = consumer half ### 6. Access control and auth The old code had minimal access control (a scope-gate at negotiation). With alkcall, we get: - **Operation-level ACL**: `AccessControl` on `OperationSpec` — scopes, resource ownership checks (`required_scopes`, `required_scopes_any`, `resource_type` + `resource_id_path`, `resource_action`) - **Channel lifecycle policy**: `ChannelLifecyclePolicy` — per-identity channel caps, open/close hooks (consulted by the `ChannelCore` wrapper, not by alktty directly) - **Ownership**: `OwnershipProvider` — runtime-spawned resource tracking (terminal sessions are resources per ADR-050). alkcall's `OwnershipProvider::owns` takes an `action: &str` arg (the adapter passes `"tty"`); `OwnershipStore::record` takes no action arg. ### 7. BAST schema as a documentation artifact The `alk/tty` wire format gets a BAST (Binary Abstract Syntax Tree) document under `docs/architecture/tty-bast.md`. BAST is alktype's JSON-based binary-layout vocabulary (alktype 0.2.0 dropped the old `AlkType:*` custom-keyword backend in favor of this `kind`-based format). The document is a normative spec: downstream consumers (and alktype, if desired) can consume it to generate validators, layout maps, or readers in any language with a JSON parser. **alktty does not depend on alktype.** The hand-rolled `ChunkReader`/`ChunkWriter` in `wire.rs` is the runtime codec; the BAST document is the human-readable contract that describes what those types round-trip. Keeping them as separate artifacts avoids pulling in alktype as a runtime dep for a codec that's already ~200 lines and works. If we later want runtime validation against the BAST (e.g., to reject malformed chunks at the framing boundary with a generated validator), alktype becomes an optional dep — the BAST document is already there to feed it. The `TtyAdapter`'s direct-ALPN path keeps the existing ad-hoc scope check (`has_scope(identity, TTY_OPEN_SCOPE)`) for the scope gate and optionally consults `OwnershipProvider` for the resource-ownership check. The channels path gets both for free via `ChannelCore::register_openable`, which wires `AccessControl` into the operation spec — the registry runs the ACL before the wrapper, so the `OpenHandler` only needs to validate params and spawn the protocol. ## Migration Strategy ### Phase 0: Scaffold hygiene (small fixes before porting) 1. Bump `alkcall` in `Cargo.toml` from `"0.1.0"` → `"0.1.1"` (ALPN rename release). *(Done in this revision.)* 2. Add `futures = "0.3"` to `[dependencies]` (the `BoxFuture` alias uses `futures::future::BoxFuture`; alkcall pulls it transitively but alktty should declare it directly to not rely on a transitive dep for a public type alias). *(Done.)* 3. **Split tokio features for the WASM target.** *(Done.* `Cargo.toml` *now uses `tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }` for the default wasm-clean build, and `local` adds `tokio/process` + `tokio/rt-multi-thread`.)* The scaffold's `features = ["full"]` pulled in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The dev-deps that need `tokio/process` (the pipe/pty integration tests) get it via the `local` feature on the crate itself, not by re-declaring `full`. 4. Confirm the `local` feature already wires `portable-pty` + `tokio-util` as optional deps (it does in the scaffold). The `local` module's `pty.rs` additionally needs `libc` under `cfg(unix)` — already in `[target.'cfg(unix)'.dependencies]`. ### Phase 1: Port core types from `alknet-tty` (no new logic) 1. Port `wire.rs` — no `alknet-core` imports; just `std::io`, `tokio::io`, `bytes`, `thiserror`. Rename crate references in doc comments (`alknet/tty` → `alk/tty`, `alknet-tty` → `alktty`). 2. Port `control.rs` — no `alknet-core` imports; `serde`, `bytes`, `libc` (under `cfg(unix)`). Rename `alknet/tty` → `alk/tty` in doc comments. 3. Port `negotiation.rs` — no `alknet-core` imports; `std::io`, `bytes`, `tokio::io`, `thiserror`, `serde_json`. Rename doc references. 4. Port `backend.rs` — no `alknet-core` imports; `std`, `async_trait`, `bytes`, `futures_core`, `tokio::sync`, `tokio_stream`. Change the `BoxFuture` type alias from `Pin>` to `futures::future::BoxFuture<'static, T>` (functionally identical; the alias matches the alkcall convention and lets us drop the `Pin`/`Future` imports). 5. Port `adapter.rs` — change imports: - `alknet_core::auth::{AuthContext, Identity}` → `alkcall::core::{AuthContext, Identity}` - `alknet_core::ownership::OwnershipProvider` → `alkcall::core::OwnershipProvider` - `alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError}` → `alkcall::core::{Connection, HandlerError, ProtocolHandler, StreamError}` - Change `b"alknet/tty"` → `b"alk/tty"` in `TtyAdapter::alpn()`. - The `handle()` method signature is identical (alkcall's `ProtocolHandler` shape matches alknet-core's). 6. Port the unit tests inside each module alongside the production code. The adapter tests need: - `Identity.resources: HashMap>` (was `HashMap` in alknet-core). - `OwnershipStore::record(&self, identity, resource_type, resource_id)` — 3 args, no `action` (alkcall dropped it). - `OwnershipProvider::owns(identity, rt, rid, action)` — 4 args (alkcall added `action`). ### Phase 2: Channels integration (new code) 1. Create `src/channels.rs`: - `register_openable` helper — takes `ChannelCore`, backend map, `OperationRegistry`, `AuthContext`; builds the `OperationSpec` for `channels/tty/sub` with `ChannelOpenSpec::new("alk/tty")`, `AccessControl` carrying `TTY_OPEN_SCOPE` + resource-type/ownership if a backend declares a `resource_id`; calls `ChannelCore::register_openable(spec, open_handler, registry, auth)`. - `TtyOpenHandler` — the `OpenHandler` (an `Arc JoinHandle<()>`) that receives a channel `Connection`, calls `accept_bi()`, runs `drive_session` on the resulting `BiStream`. 2. Create `src/session.rs`: - `TtySession` struct — typed consumer client - `TtySession::connect_direct(connection)` — direct `alk/tty` - `TtySession::open_via_channels(client, params)` — opens a channel via `ChannelClient::open_channel("channels/tty/sub", params, "alk/tty")`, negotiates, returns a session handle - Methods: `send_stdin`, `recv_stdout`, `recv_stderr`, `resize`, `signal`, `wait` ### Phase 3: Local backend (behind `local` feature) — folded `alknet-tty-local` 1. Create `src/local/mod.rs` (re-exports `LocalTtyBackend`), `src/local/backend.rs`, `src/local/pty.rs`, `src/local/pipe.rs`. 2. Port from `alknet-tty-local/src/`: - `backend.rs` — change `alknet_tty::backend::{...}` → `crate::backend::{...}`. `LocalTtyBackend::allocate` dispatches on `params.terminal`: `Some` → `pty::allocate_pty`, `None` → `pipe::allocate_pipe`. - `pty.rs` — `portable_pty` + 3 std threads (reader/writer/waiter) feeding tokio mpsc/oneshot. `#[cfg(unix)]`-only: PTY mode uses `libc::kill(-pgid, sig)` for process-group signal forwarding. Update `alknet_tty::backend::{BoxFuture, TtyControl, ...}` → `crate::backend::{...}` and `alknet_tty::control::signal_from_name` → `crate::control::signal_from_name`. - `pipe.rs` — `tokio::process::Command` + `tokio_util::io::ReaderStream` for stdout/stderr. Cross-platform: signal forwarding uses `libc::kill(pid, sig)` under `cfg(unix)`, falls back to `Child::kill()` on non-Unix. Update `alknet_tty::...` → `crate::...`. 3. `LocalTtyBackend` implements `crate::backend::TtyBackend`. Wire the `local` feature in `src/lib.rs`: `#[cfg(feature = "local")] pub mod local;` ### Phase 4: Architecture docs + BAST schema — landed 2026-08-17 1. Ported the 5 spec docs from `alknet/docs/architecture/crates/tty/` into `docs/architecture/` (flat layout, not the `crates/tty/` subpath — this is a single-crate repo now): `overview.md` (the crate overview; the alknet `README.md` index is ported as `docs/architecture/README.md`), `tty-wire.md`, `tty-backend.md`, `tty-adapter.md`, `tty-local.md`. Each is renamed `alknet-tty` → `alktty`, `alknet/tty` → `alk/tty`, `alknet-core` → `alkcall::core`, `alknet-call` → `alkcall`, `alknet-tty-local` → alktty's `local` feature module, and cross-references to alknet ADRs are renumbered to alktty's ADR range (001..008). 2. Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093) into `docs/architecture/decisions/` renumbered 001..008 in order: - [001](../architecture/decisions/001-wire-format-and-two-carriage.md) ← alknet ADR-052 — wire format + two-carriage model (incl. the Phase 7 control-channel split amendment) - [002](../architecture/decisions/002-ttybackend-trait-and-ttyhandle.md) ← alknet ADR-053 — `TtyBackend` trait + `TtyHandle` - [003](../architecture/decisions/003-local-backend-placement.md) ← alknet ADR-054 — local backend placement (records both the alknet sibling-crate decision and the alktty single-crate consolidation behind a `local` feature) - [004](../architecture/decisions/004-exit-code-on-control-chunk.md) ← alknet ADR-055 — exit code on a control chunk - [005](../architecture/decisions/005-backend-cleanup-on-session-cancel.md) ← alknet ADR-056 — backend cleanup on session cancel - [006](../architecture/decisions/006-negotiation-framing-self-contained.md) ← alknet ADR-057 — self-contained negotiation framing - [007](../architecture/decisions/007-tty-inside-channels.md) ← alknet ADR-077 — TTY inside channels (reversed by 008; kept for historical context with its reversal notice pointing to 008) - [008](../architecture/decisions/008-channels-pure-channel-multiplexing.md) ← alknet ADR-093 — channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) 3. Wrote [`docs/architecture/tty-bast.md`](../architecture/tty-bast.md) — the BAST (Binary Abstract Syntax Tree) document for the `alk/tty` wire format. Conforms to the BAST meta-schema at `https://alk.dev/bast/v1/schema`; validatable by any JSON Schema Draft 2020-12 validator. Covers: - The 5-byte chunk header (`struct` with `endian: "big"`: `stream_type: uint8`, `length: uint32`). - The five stream-type channels as an `enum` (`Stdin=0`, `Stdout=1`, `Stderr=2`, `CtrlIn=3`, `CtrlOut=4`). - The control-message `union` (field-name discriminator on `type`: `resize`, `signal`, `eof`, `exit`) with the documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding. - The negotiation frame as a separate `struct` (4-byte BE length prefix + UTF-8 JSON `NegotiateRequest` body) — annotated as out-of-band for the chunk codec but documented for completeness. - The `StreamType` enum's documented deviation: on-wire encoding is `uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes) — the chunk header is 5 bytes, not 8. 4. Wrote [`docs/architecture/README.md`](../architecture/README.md) — the architecture index: documents table, ADR table (with alknet origin numbers and port status), key design principles, open questions, and references. The ADR mapping follows the plan's 8-to-8 list (ADR-052→001, 053→002, 054→003, 055→004, 056→005, 057→006, 077→007, 093→008). ADR-050 (dynamic resource ownership) is an alkcall/alknet-core ADR, not tty-specific, and is not ported into alktty's ADR range; the access-control work that declares against the ADR-050 model is described in `tty-adapter.md` and the ADR-001/002 ported docs, which reference ADR-050 by its alknet number. The Phase 7 control-channel split (`STREAM_CTRL_IN` = 3, `STREAM_CTRL_OUT` = 4) is an amendment inside ADR-001, mirroring alknet (it was not a standalone ADR there either). `AGENTS.md` was updated to match this mapping. ### Phase 5: Tests 1. Port existing unit tests from `alknet-tty` (wire, negotiation, control, adapter) — these live inline in each `src/*.rs` module's `#[cfg(test)] mod tests`. 2. Port integration tests from `alknet-tty-local/tests/` (negotiation, pipe, pty) into `tests/` at the crate root. The pty test stays `#[cfg(unix)]`. 3. Add channels integration tests — end-to-end `register_openable` + `ChannelClient::open_channel` + `drive_session` round-trip. Use the in-memory `MockBackend` from `backend.rs` for the producer side so the test doesn't need a real PTY. ## Open Questions ### OQ-1: ALPN strings (RESOLVED — landed in alkcall 0.1.1) **Decision**: `alk/tty` (direct) and `alk/channels` (multiplexed). The `alknet/` → `alk/` rename landed in alkcall 0.1.1: `CHANNELS_ALPN` is `b"alk/channels"`, the channels client test uses `"alk/tty"`. Semver showed no public API change → 0.1.0 → 0.1.1. ### OQ-2: Channel open operation name (RESOLVED) The alkcall test uses `channels/tty/sub` as the operation name. The convention from ADR-047 is `channels//sub` for subscribe (consumer opens) and `channels//pub` for publish (producer opens). Since TTY is consumer-opens (the client requests a shell), `channels/tty/sub` is correct. **Decision**: `channels/tty/sub` for consumer-opens, `channels/tty/pub` reserved for future producer-opens use case. ### OQ-3: `TtySession` API — stream vs callback (RESOLVED) **Decision**: Stream-based (`Stream`). Consistent with the backend trait's `TtyHandle` shape. ### OQ-4: `drive_session` — keep the three-pump pattern? (RESOLVED) **Decision**: Keep it. It works, it's tested, and ADR-093 means the same code runs in both direct and channels modes. ### OQ-5: `BoxFuture` type alias (RESOLVED) **Decision**: Use `futures::future::BoxFuture<'static, T>`. alkcall already pulls in `futures`, so no new transitive dep; alktty declares `futures = "0.3"` directly so the public alias doesn't ride on a transitive dep. ### OQ-6: BAST schema scope (RESOLVED) **Decision**: Documentation artifact only — `docs/architecture/tty-bast.md`. alktty does not depend on alktype. The hand-rolled `wire.rs` codec is the runtime; the BAST document is the normative contract for downstream consumers. If runtime validation against the BAST becomes desirable later, alktype becomes an optional dep and the BAST document is already there to feed it. ## Upstream Changes Needed in alkcall All four landed in alkcall 0.1.1 (no public API change per semver). Nothing is blocking alktty; this section is kept as a historical record of what was pushed. 1. **ALPN convention**: `alknet/` → `alk/` across docs, ADRs, and test code. The test at `channels/client.rs:767` now uses `ChannelOpenSpec::new("alk/tty")` (was `"alknet/tty"`). 2. **`CHANNELS_ALPN` constant**: `b"alknet/channels"` → `b"alk/channels"` in `alkcall/src/channels/adapter.rs`. 3. **ADR-004** (ALPN convention): Updated from `alknet/` prefix to `alk/`. 4. **Architecture README**: The dependency layering diagram and pattern section reference `alk/tty` and `alk/channels`. ## Risks and Concerns ### Risk: alkcall is v0.1.x — API stability alkcall is published at 0.1.1. Breaking changes are expected at this major-zero stage. The integration surface we use (`ChannelCore::register_openable`, `ChannelClient`, `ProtocolHandler`, `Connection`, `Identity`, `OwnershipProvider`) is the core API and likely stable, but the channels module in particular may evolve. **Mitigation**: We own alkcall and can push changes as needed. This is the first real consumer, so we'll find and fix issues upstream rather than working around them. Pin `alkcall = "0.1.1"` and bump deliberately. ### Risk: `drive_session` complexity The three-pump driver is ~1500 lines with edge cases around exit ordering, cancel cleanup, and bidirectional control. Porting it is mechanical but needs careful review. **Mitigation**: The existing code has extensive tests. Port tests first or alongside. ### Risk: Local backend PTY bridge The PTY mode uses 3 std threads feeding tokio channels. This is a well-understood pattern (wezterm uses it) but is inherently platform- specific (Unix only for PTY, pipe mode works cross-platform). **Mitigation**: Gate PTY behind `#[cfg(unix)]` as the existing code does. Pipe mode works on all platforms. The whole `local` module is behind the `local` feature, which is non-wasm by design. ### Risk: WASM target regression The default crate MUST stay `wasm32-unknown-unknown`-clean. A future change that pulls in `tokio::process`, `std::thread`, `libc`, or any OS-specific dep into a non-`local` module silently breaks the downstream TS/Python adapter story (the wasm-compiled protocol layer is what makes those adapters cheap to build). **Mitigation**: Add a CI job that runs `cargo check --target wasm32-unknown-unknown` (no features) on every PR. Cheap, catches the regression at the boundary. The tokio feature split in `Cargo.toml` (the wasm-clean `["rt", "sync", "io-util", "macros"]` subset, with `local` adding `process`/`rt-multi-thread`) is the structural guard; the CI job is the enforcement. ### Risk: BAST schema drift The BAST document in `docs/architecture/tty-bast.md` is a documentation artifact; the runtime codec is the hand-rolled `wire.rs`. If the wire format evolves (new stream_type, control message variant, negotiation field) and the BAST doc isn't updated, downstream consumers generated from the BAST will be wrong. **Mitigation**: Add a test that parses the BAST document with `serde_json` and asserts the stream-type enum values match `wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants. Cheap; catches the common drift case. (Doesn't require alktype as a dep — just `serde_json`, which we already have.) ## References - [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md) - [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs) — `ChannelCore::register_openable`, `OpenHandler` - [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs) — `ChannelClient::open_channel`, reference integration pattern - [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md) — normative format spec for the `tty-bast.md` document - [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/) — port origin (wire, control, negotiation, backend, adapter) - [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/) — port origin (local backend, pty, pipe) - [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/) — port origin for the spec docs - [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md) — TTY always uses 5-byte format - [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — reversed, historical context