Files
alktty/docs/plans/project-setup.md
T
glm-5.2 2086817a90 plan: revise for landed upstream changes and fold alknet-tty-local
Reflect what actually landed since the 2026-08-14 draft:
- alkcall 0.1.1: alk/tty + alk/channels ALPN rename (no public API change)
- alkcall Identity.resources now HashMap<String, Vec<String>>
- alkcall OwnershipProvider::owns gained action arg; OwnershipStore::record lost it
- alktype 0.2.0: kind-based BAST format (schema doc artifact, not a runtime dep)

Fold alknet-tty-local into the single crate as the feature-gated local
module (pty.rs + pipe.rs + backend.rs) — the cyclic-dep workaround it
required in the alknet mono-repo doesn't apply to a single crate.

Add Decision 7 (BAST schema as documentation artifact), Phase 0
(scaffold hygiene), Phase 4 BAST-schema step, OQ-6, and a BAST-drift
risk with a cheap test mitigation.

Cargo.toml: bump alkcall 0.1.0 -> 0.1.1; add futures = 0.3 as a direct
dep so the BoxFuture alias doesn't ride on a transitive.
2026-08-17 08:57:14 +00:00

26 KiB

Project Setup Plan

Status: draft (revised 2026-08-17 to reflect landed upstream changes) 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<String, String> (a flat resource-type → resource-id map). alkcall's Identity (in alkcall::core::auth) has resources: HashMap<String, Vec<String>> — 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.

2. Dependency: alknet-corealkcall::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<String, Vec<String>> in alkcall (was HashMap<String, String> 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/<name>alk/<name> 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_openopen_channel → spawn the OpenHandler → respond with { channel_id }.

Consumer halfTtySession:

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 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 (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).
  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).
  3. 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/ttyalk/tty, alknet-ttyalktty).
  2. Port control.rs — no alknet-core imports; serde, bytes, libc (under cfg(unix)). Rename alknet/ttyalk/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<Box<dyn Future + Send>> 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::OwnershipProvideralkcall::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<String, Vec<String>> (was HashMap<String, String> 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<dyn Fn(Value, Connection, AuthContext) -> 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: Somepty::allocate_pty, Nonepipe::allocate_pipe.
    • pty.rsportable_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_namecrate::control::signal_from_name.
    • pipe.rstokio::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

  1. Port 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): tty-wire.md, tty-backend.md, tty-adapter.md, tty-local.md, plus an overview.md index.
  2. Port relevant ADRs (052, 053, 054, 055, 056, 057, 077, 093) — renumber into alktty's ADR range (001..008), update cross-references (alknet/ttyalk/tty, alknet-tty-localalktty's local feature, alknet-corealkcall::core).
  3. Write docs/architecture/tty-bast.md — the BAST JSON document for the alk/tty wire format. Covers:
    • The 5-byte chunk header (struct with endian: "big": stream_type: uint8, length: uint32).
    • The four 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).
    • 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.
    • Conforms to the BAST meta-schema at https://alk.dev/bast/v1/schema; validatable by any JSON Schema Draft 2020-12 validator.
  4. Write docs/architecture/README.md index.

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/<name>alk/<name> 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/<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<'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/<name>alk/<name> 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.

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