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
190 lines
8.9 KiB
Markdown
190 lines
8.9 KiB
Markdown
# 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`:
|
|
|
|
```rust
|
|
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](001-wire-format-and-two-carriage.md) §2 (negotiation frame
|
|
format), §6 (revised: format coincides by convention, not by code
|
|
reuse)
|
|
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — 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](../overview.md) (dependency edge),
|
|
[tty-wire.md](../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` |