Files
alktty/docs/architecture/decisions/006-negotiation-framing-self-contained.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

8.9 KiB

ADR-006: Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency)

Status

Accepted (ported from alknet ADR-057 2026-08-17; the dependency-edge decision carries over unchanged — alktty depends on alkcall (for the ProtocolHandler trait, auth, ownership types) but not on alkcall's internal wire types. The framing is self-contained in src/negotiation.rs. 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 (003) is not ported into alktty's ADR range — it is an alknet-core ADR. The alknet originals at /workspace/@alkdev/alknet/docs/architecture/decisions/ remain authoritative.)

Context

The alknet-tty specs (alknet ADR-052, 053, 054) and alknet ADR-003 Amendment 1 previously stated that alknet-tty depends on alknet-call for the FrameFramedReader/FrameFramedWriter "framing utility" — the 4-byte big-endian length prefix + UTF-8 JSON body framing the negotiation frame uses. The claim was "reuse the framing utility, not the EventEnvelope type" (alknet ADR-052 §6).

A pre-implementation sanity check surfaced that the framing utility is not actually reusable as the spec described. FrameFramedReader (in alkcall's protocol/wire module) is hardcoded to deserialize EventEnvelope:

pub async fn read_frame(&mut self) -> Result<EventEnvelope, FrameError> {
    // ... read 4-byte length prefix, read body ...
    let envelope: EventEnvelope = serde_json::from_slice(&body)?;
    Ok(envelope)
}

The framing logic (read 4 bytes → length, read N bytes) and the EventEnvelope deserialization are entangled in the same method. The "framing utility" the spec claimed to reuse does not exist as a separable thing — the length-prefix read and the type-specific deserialize are one call. alktty's negotiation frame is a NegotiateRequest, not an EventEnvelope, so read_frame() cannot return what alktty needs.

This left three options (see alknet ADR-003 Amendment 2 for the full comparison):

  1. Duplicate the ~30 lines of framing logic in alktty. The framing is a trivial length-prefix idiom (4-byte BE length + body); the two copies would share an idiom, not a domain abstraction.
  2. Promote a generic length-prefixed framing utility to alkcall::core. Makes the spec's claim true (a reusable utility exists), but accretes a framing module to the foundation crate for the sake of two consumers — a shared utility pays for itself at ≥2 consumers, but the framing is trivial enough that the cost of the shared abstraction (a new module, a new type, a refactor of alkcall) exceeds the cost of the duplication.
  3. Actually use alkcall (e.g., model the tty control channel as call-protocol operations). A different architecture, not a dependency-edge fix — the current ControlMessage tagged enum (ADR-001) is the two-way-door seam; replacing it with the call protocol is a v2 ALPN decision, not a v1 dependency choice.

Decision

1. alktty does not depend on alkcall's internal wire types

alktty implements its own length-prefixed framing for the negotiation frame directly on tokio's AsyncRead/AsyncWrite. The crate's dependency edge is:

alktty
└── alkcall   (ProtocolHandler, Connection, AuthContext, Identity,
               AccessControl, OwnershipProvider — alknet ADR-050)

No alkcall-internal-wire-types dependency. The crate depends on alkcall (which every handler crate depends on anyway for the ProtocolHandler trait) and nothing else for the protocol surface. portable_pty, bollard, russh remain in the backend crates / the local feature (ADR-003).

2. The framing format coincides with alkcall's by convention, not by code reuse

Both alktty's negotiation frame and alkcall's EventEnvelope frame use a 4-byte big-endian length prefix + UTF-8 JSON body. This is a shared format convention (length-prefixed JSON is a standard framing pattern), not a code dependency. The two implementations are independent: alktty's reader deserializes NegotiateRequest; alkcall's FrameFramedReader deserializes EventEnvelope. They share an idiom (length-prefix framing), not a module.

3. The framing logic lives in alktty as a small, self-contained module

alktty implements the negotiation framing as a small module (src/negotiation.rs, ~30 lines for the framing reader/writer: read 4-byte BE length, bounds-check, read N bytes; write the inverse). The module's types (NegotiationReader/NegotiationWriter) are private to the crate — they are not a reusable utility for other crates. If a future crate wants length-prefixed JSON framing, it implements its own (the idiom is trivial) or a future ADR promotes a generic utility to alkcall::core at that point (deferred — not needed for the current scope; two consumers is the threshold but the second consumer does not yet exist).

Consequences

Positive:

  • alktty's dependency surface is minimal and correct: alkcall only. No dependency on alkcall's internal wire types for a "framing utility" that wasn't reusable as specced. The "weird" dependency edge (a handler crate depending on another handler crate's internal wire module for 30 lines of glue) is gone.
  • The spec is honest: it describes what the code does (a self-contained framing module) rather than what a previous draft hoped for (a reusable utility in alkcall that doesn't exist in a separable form).
  • The framing logic is trivial and self-contained; bugs in it are local to alktty (no cross-crate coordination if alkcall's framing changes for call-protocol reasons).
  • alknet ADR-003's "no handler crate depends on another handler crate's internal wire types" rule is preserved without the Amendment 1 exception for alktty. (alknet ADR-003 Amendment 1's exception remains for alknet-http/agent/napi, which use alkcall's OperationSpec/Handler/OperationAdapter types — actual type reuse, not framing glue. alktty does not need that exception.)

Negative:

  • ~30 lines of framing logic are duplicated between alktty and alkcall. The duplication is an idiom (length-prefix framing), not a domain abstraction; the cost of the shared abstraction (a new module in alkcall::core + a refactor of alkcall) exceeds the cost of the duplication for two consumers. If a third consumer appears, this trade-off should be revisited (promote to alkcall::core).
  • A bug found in the length-prefix framing edge cases (e.g., a partial-read handling bug) would need fixing in two places. The framing is mature (a standard read_exact-based pattern); the edge cases are known and tested in both crates independently.

Door type

One-way. alktty not depending on alkcall's internal wire types is a dependency-edge commitment. Adding the dependency back later (if, e.g., a generic framing utility is promoted to alkcall::core) is a new ADR. The framing logic being self-contained in alktty is two-way (it could be refactored to a shared utility in alkcall::core later without a wire-format change), but the dependency edge is one-way.

Assumptions

  1. The framing logic is trivial enough that duplication is cheaper than a shared abstraction. Length-prefixed framing (4-byte BE length + body) is a ~30-line idiom. The cost of a shared utility in alkcall::core (a new module, a new type, a refactor of alkcall's wire module to extract the generic layer) is higher than the cost of two independent implementations for two consumers. If a third consumer appears, revisit (promote to alkcall::core).

  2. The format coincidence (both use 4-byte BE length + JSON body) is stable. Both crates use the same length-prefix convention. If alkcall's framing changes (e.g., a different max-frame-size, a different prefix width), alktty's is unaffected — they are independent implementations that happen to share a format today.

References

  • alknet ADR-003 Amendment 1 (protocol-foundation exception for alknet-http/agent/napi) and Amendment 2 (this ADR's effect on the Amendment 1 framing-reuse claim for alktty)
  • ADR-001 §2 (negotiation frame format), §6 (revised: format coincides by convention, not by code reuse)
  • ADR-002 — the crate decomposition this ADR's dependency edge affects
  • src/negotiation.rs — the self-contained framing reader/writer this ADR commits (NegotiationReader, NegotiationWriter, error_response_bytes)
  • alkcall's FrameFramedReader/FrameFramedWriter — the EventEnvelope-bound methods that are NOT reused (the entangled length-prefix-read + EventEnvelope-deserialize that motivated this ADR)
  • Spec: overview.md (dependency edge), tty-wire.md (negotiation framing)
  • Port origin: alknet ADR-057 at /workspace/@alkdev/alknet/docs/architecture/decisions/057-alknet-tty-no-alknet-call-dep.md