Files
alktty/docs/plans/project-setup.md
T

12 KiB

Project Setup Plan

Status: draft Last updated: 2026-08-14

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.

Crate Structure

Single crate (alktty) with the local backend gated behind a local feature. This is a "tty-specific monorepo" — one crate, one concern.

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)
│       ├── mod.rs
│       ├── backend.rs      # LocalTtyBackend
│       ├── pty.rs          # PTY mode (portable_pty)
│       └── pipe.rs         # Pipe/runner mode (tokio::process::Command)
├── docs/
│   ├── architecture/
│   │   ├── README.md       # architecture index
│   │   ├── crates/
│   │   │   ├── overview.md
│   │   │   ├── tty-wire.md
│   │   │   ├── tty-backend.md
│   │   │   ├── tty-adapter.md
│   │   │   └── tty-local.md
│   │   └── 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 dep is optional. A docker-only deployment doesn't pull in PTY code.

2. Dependency: alknet-corealkcall

All types previously from alknet-core now come from alkcall::core:

  • ProtocolHandler, Connection, BiStream, BidiStreamSource
  • AuthContext, Identity, IdentityProvider
  • AccessControl, OwnershipProvider
  • HandlerError, StreamError

The TtyAdapter's impl ProtocolHandler changes which crate the trait comes from. The trait shape is identical.

3. Channels integration (the new part)

This is where alktty becomes a proper producer/consumer on alkcall.

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. This lives in src/channels.rs.

Consumer halfTtySession:

A typed client that wraps the wire protocol. Two constructors:

  • TtySession::connect_direct(connection) — for direct alknet/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 halfregister_*() 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
  • Channel lifecycle policy: ChannelLifecyclePolicy — per-identity channel caps, open/close hooks
  • Ownership: OwnershipProvider — runtime-spawned resource tracking (terminal sessions are resources per ADR-050)

The TtyAdapter's access control is reworked to use alkcall's primitives rather than the ad-hoc scope check. The channels path gets this for free via ChannelCore::register_openable which wires AccessControl into the operation spec.

Migration Strategy

Phase 1: Scaffold and core types (no new logic)

  1. Create Cargo.toml with alkcall dependency
  2. Port wire.rs — change alknet-core imports to alkcall::core, rename crate references in docs
  3. Port control.rs — same
  4. Port negotiation.rs — same
  5. Port backend.rs — same; BoxFuture type alias can use futures::future::BoxFuture since alkcall pulls in futures
  6. Port adapter.rs — change ProtocolHandler import, Connection import, AuthContext import, HandlerError import. The handle() method signature is identical.

Phase 2: Channels integration (new code)

  1. Create src/channels.rs:
    • register_openable helper — takes ChannelCore, backend map, OperationRegistry, registers the channels/tty/sub op
    • TtyOpenHandler — the OpenHandler that receives a channel Connection, calls accept_bi(), runs drive_session
  2. Create src/session.rs:
    • TtySession struct — typed consumer client
    • TtySession::open_via_channels(client, params) constructor
    • Methods: send_stdin, recv_stdout, resize, signal, wait

Phase 3: Local backend (behind local feature)

  1. Create src/local/mod.rs, src/local/backend.rs, src/local/pty.rs, src/local/pipe.rs
  2. Port from alknet-tty-local — change alknet-tty imports to crate
  3. LocalTtyBackend implements crate::backend::TtyBackend

Phase 4: Architecture docs

  1. Port the 5 spec docs from alknet/docs/architecture/crates/tty/
  2. Port relevant ADRs (052, 053, 054, 055, 056, 057, 077, 093) — renumber into alktty's ADR range, update cross-references
  3. Write docs/architecture/README.md index

Phase 5: Tests

  1. Port existing unit tests from alknet-tty (wire, negotiation, control, adapter)
  2. Port integration tests from alknet-tty-local (negotiation, pipe, pty)
  3. Add channels integration tests — end-to-end register_openable + ChannelClient::open_channel + drive_session round-trip

Open Questions

OQ-1: ALPN strings (RESOLVED)

Decision: Use alk/tty. The alknet/<name> convention is being shortened to alk/<name> across all crates. This is a wire-format change but alkcall is v0.1.0 and this is the first protocol crate built on it — the right time to make the change.

Upstream change needed: alkcall's test at channels/client.rs:767 uses ChannelOpenSpec::new("alknet/tty") — needs updating to "alk/tty". Also any docs/ADRs in alkcall that reference alknet/<name> as the ALPN convention.

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/<alpn>/sub for subscribe (consumer opens) and channels/<alpn>/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<Item = Bytes>). 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. alkcall already pulls in futures, so no new dependency.

Upstream Changes Needed in alkcall

These are changes we need to push to alkcall before or alongside this work:

  1. ALPN convention: alknet/<name>alk/<name> across docs, ADRs, and test code. The test at channels/client.rs:767 uses ChannelOpenSpec::new("alknet/tty") — needs "alk/tty".

  2. CHANNELS_ALPN constant: b"alknet/channels"b"alk/channels". This is in alkcall/src/channels/adapter.rs.

  3. ADR-004 (ALPN convention): Update from alknet/ prefix to alk/.

  4. Architecture README: The dependency layering diagram and pattern section reference alknet/tty and alknet/channels — update to alk/tty and alk/channels.

Risks and Concerns

Risk: alkcall is v0.1.0 — API stability

alkcall is published at 0.1.0. Breaking changes are expected. The integration surface we use (ChannelCore::register_openable, ChannelClient, ProtocolHandler, Connection) 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.

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.

References