docs(arch): ADR-057 — alknet-tty does not depend on alknet-call (self-contained negotiation framing)

The earlier specs claimed alknet-tty reuses alknet-call's
FrameFramedReader/FrameFramedWriter 'framing utility' (ADR-052 §6,
ADR-003 Am. 1). A pre-implementation sanity check found this was
unsound: FrameFramedReader::read_frame() is hardcoded to deserialize
EventEnvelope — the length-prefix read and the type-specific deserialize
are one entangled call, not a separable utility. alknet-tty's
negotiation payload is a NegotiateRequest, not an EventEnvelope, so the
claimed reuse did not exist in a usable form.

ADR-057: alknet-tty implements its own ~30-line length-prefixed
framing directly on tokio AsyncRead/AsyncWrite. The format coincides
with alknet-call's framing by convention (both are length-prefixed
JSON); the implementations are independent. alknet-tty depends on
alknet-core only — no alknet-call dependency. The ~30 lines of framing
is an idiom, not a domain abstraction worth a cross-crate dependency.

Updates:
- ADR-003 Amendment 2: clarifies alknet-tty does not depend on
  alknet-call; the Am. 1 protocol-foundation exception remains for
  alknet-http/agent/napi (actual type reuse), not alknet-tty.
- ADR-052 §6: framing is self-contained in alknet-tty; format coincides
  by convention, not by code reuse.
- overview.md: dependency block drops alknet-call; new 'Why no
  alknet-call dependency' subsection explains the unsound-reuse finding.
- tty-wire.md, tty-backend.md, tty README, crates/tty/README.md,
  top-level README: ADR tables, Design Decisions tables, and dependency
  edges updated to reflect alknet-core-only dependency.
- ADR-053, ADR-054: References updated to ADR-003 Am. 2 + ADR-057.
This commit is contained in:
glm-5.2 committed 2026-07-07 11:15:52 +00:00
1 parent 25f0f55592
commit dbac972aaa
10 files changed
+297 -76

No files matched your search

+5 -3
View File
@@ -27,7 +27,7 @@ the PTY (docker, SSH, local process) via a `TtyBackend` trait.
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | `TtyAdapter` registers on `alknet/tty` |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | `TtyAdapter` implements `ProtocolHandler` |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-tty depends on alknet-core; backends depend on alknet-tty (Amendment 1: alknet-call as protocol-foundation, framing utility reuse) |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-tty depends on alknet-core only (no alknet-call — Am. 2); backends depend on alknet-tty for the trait |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | `alknet/tty` is the custom ALPN; one ALPN per connection; new ALPN for incompatible versions |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | `TtyAdapter` receives a `Connection`, accepts bidi streams, pumps per-session |
| [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Wire format is one-way; local backend placement is two-way (decided, not deferred) |
@@ -42,6 +42,7 @@ the PTY (docker, SSH, local process) via a `TtyBackend` trait.
| [054](../../decisions/054-local-tty-backend-sibling-crate.md) | Local TTY Backend as a Sibling Crate | `alknet-tty-local` behind a `local` feature re-export; PTY vs pipe per-session |
| [055](../../decisions/055-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk | Exit code on stream_type 3; "exit chunk is last" invariant; adapter owns the ordering |
| [056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel | Dropping the `exit_code` future (session cancel) MUST kill the session target; behavioral contract on the `TtyBackend` trait |
| [057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty Does Not Depend on alknet-call | Self-contained negotiation framing (~30 lines); format coincides with alknet-call's by convention, not by code reuse |
## Relevant Open Questions
@@ -87,8 +88,9 @@ the PTY (docker, SSH, local process) via a `TtyBackend` trait.
`TtyBackend`; the backend crates implement it. alknet-tty depends on
alknet-core; backends depend on alknet-tty for the trait; alknet-tty
does not depend on any backend. This preserves ADR-003's
no-handler-depends-on-another-handler rule (Amendment 1 for the
alknet-call framing utility reuse). See
no-handler-depends-on-another-handler rule. alknet-tty does not
depend on alknet-call either (the negotiation framing is
self-contained — ADR-057). See
[tty-backend.md](tty-backend.md) and
[ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md).
+36 -29
View File
@@ -73,9 +73,11 @@ A `alknet/tty` bidi stream has two phases (full detail in
1. **Negotiation (JSON carriage).** The client writes a single
length-prefixed JSON frame carrying the terminal parameters, backend
selector, command, and environment. The framing is byte-identical to
alknet-call's `FrameFramedReader`/`FrameFramedWriter` (the utility is
reused, not the `EventEnvelope` type — see Dependencies below).
selector, command, and environment. The framing is a 4-byte
big-endian length prefix + UTF-8 JSON body, self-contained in
alknet-tty (the format coincides with alknet-call's framing by
convention; alknet-tty does not depend on alknet-call — see
Dependencies below).
2. **Raw carriage.** After the negotiation frame, the stream switches to
the chunk format (`[stream_type: u8][length: u32 be][payload]`) for
@@ -99,34 +101,37 @@ the body, which is the part that is actually bytes. The full rationale
alknet-tty
├── alknet-core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
│ OwnershipProvider — ADR-050 for terminal sessions as resources)
├── alknet-call (FrameFramedReader/FrameFramedWriter — framing utility reuse only,
│ NOT EventEnvelope or the call protocol types; ADR-003 Amendment 1)
└── (no backend deps — portable_pty, bollard, russh are in the backend crates)
```
alknet-tty is dependency-light: alknet-core (the handler interface and
auth) and alknet-call's framing codec (the length-prefix utility for the
negotiation frame). The heavy backend dependencies (`portable_pty`,
`bollard`, `russh`) live in the backend crates, not here.
auth) only. The negotiation framing is a self-contained ~30-line module
in alknet-tty (4-byte BE length prefix + UTF-8 JSON body on tokio's
`AsyncRead`/`AsyncWrite`). The heavy backend dependencies (`portable_pty`,
`bollard`, `russh`) live in the backend crates, not here. alknet-tty
does **not** depend on alknet-call — see [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md).
### The `alknet-call` dependency (ADR-003 Amendment 1)
### Why no alknet-call dependency
alknet-tty depends on alknet-call for the `FrameFramedReader`/
`FrameFramedWriter` utility — the 4-byte length prefix + JSON body
framing the negotiation frame uses. This is a *framing utility* reuse,
not a dependency on the call protocol's type system: the negotiation
payload is a tty-specific struct (`NegotiateRequest`), not a
`call.requested` `EventEnvelope`. ADR-003's rule is "no handler crate
depends on another handler crate," but `alknet-call` is both a handler
(it implements `ProtocolHandler` on `alknet/call`) *and* the
protocol-foundation crate. alknet-tty depending on alknet-call is "tty
uses the call protocol's framing codec," not "tty depends on SSH." See
[ADR-003 Amendment 1](../../decisions/003-crate-decomposition.md).
An earlier draft had alknet-tty depending on alknet-call for the
`FrameFramedReader`/`FrameFramedWriter` "framing utility." A
pre-implementation check found this was unsound: `FrameFramedReader`'s
`read_frame()` is hardcoded to deserialize `EventEnvelope` — the
length-prefix read and the type-specific deserialize are one entangled
call, not a separable utility. alknet-tty's negotiation payload is a
`NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not
exist in a usable form. alknet-tty implements its own framing (the
format coincides with alknet-call's by convention; the implementations
are independent). The ~30 lines of length-prefix framing is an idiom,
not a domain abstraction worth a cross-crate dependency. See
[ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) for the
full decision and [ADR-003](../../decisions/003-crate-decomposition.md)
Amendment 2 for the dependency-edge clarification.
alknet-call stays lean — it has no `portable_pty`, no `bollard`, no
backend deps. The `TtyBackend` implementations are opaque
`Arc<dyn TtyBackend>` from the adapter's perspective: constructed by
the assembly layer at startup, stored in the adapter's backend map,
alknet-tty stays lean — it has no `portable_pty`, no `bollard`, no
`alknet-call`, no backend deps. The `TtyBackend` implementations are
opaque `Arc<dyn TtyBackend>` from the adapter's perspective: constructed
by the assembly layer at startup, stored in the adapter's backend map,
dispatched by the `backend` field of the negotiation frame.
## ALPN
@@ -164,7 +169,8 @@ alknet-tty (lean — no portable_pty, no bollard, no russh)
├── TtyParams, TerminalParams (the allocation request)
├── TtyAdapter (ProtocolHandler on alknet/tty — session lifecycle)
├── wire format (ChunkReader/ChunkWriter, ControlMessage — ADR-052)
└── negotiation framing (reuses alknet-call's FrameFramedReader/Writer)
└── negotiation framing (self-contained ~30-line module; format coincides
with alknet-call's by convention — ADR-057)
alknet-tty-local (sibling crate — ADR-054; behind alknet-tty's `local` feature re-export)
├── LocalTtyBackend (impl TtyBackend — portable_pty for PTY, std::process for pipe)
@@ -214,9 +220,9 @@ PTY allocation code. See [ADR-054](../../decisions/054-local-tty-backend-sibling
## Architecture (component pointers)
- **[tty-wire.md](tty-wire.md)** — the wire format: the negotiation
frame (JSON carriage, reusing alknet-call's framing), the raw chunk
codec (`[stream_type: u8][length: u32 be][payload]`), the four
stream types, the control channel (stream_type 3, JSON control
frame (JSON carriage, self-contained length-prefixed framing), the
raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the
four stream types, the control channel (stream_type 3, JSON control
messages), sentinels, and the fixed-channel-set rationale.
- **[tty-backend.md](tty-backend.md)** — the `TtyBackend` trait,
`TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between
@@ -248,7 +254,8 @@ PTY allocation code. See [ADR-054](../../decisions/054-local-tty-backend-sibling
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) kills the session target; the adapter triggers it by dropping the `TtyHandle` |
| ALPN-based protocol dispatch | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | `TtyAdapter` registers on `alknet/tty` |
| ProtocolHandler trait | [ADR-002](../../decisions/002-protocol-handler-trait.md) | `TtyAdapter` implements `ProtocolHandler` |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | alknet-tty depends on alknet-core + alknet-call (framing utility); backends depend on alknet-tty for the trait |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 2 | alknet-tty depends on alknet-core only (no alknet-call); backends depend on alknet-tty for the trait |
| No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse |
| ALPN string convention | [ADR-006](../../decisions/006-alpn-convention-and-connection-model.md) | `alknet/tty` is the custom ALPN; new ALPN for incompatible versions |
| BiStream type definition | [ADR-007](../../decisions/007-bistream-type-definition.md) | `TtyAdapter` receives a `Connection`, accepts bidi streams |
| Call protocol stream model (not used for body) | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | The raw carriage is *not* the call protocol's `EventEnvelope` streaming — by design |
+1 -1
View File
@@ -396,7 +396,7 @@ ALPN (`alknet/tty`), not hedged inside alknet-ssh.
| Wire format | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from these handles |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The adapter awaits `exit_code`, sends the exit chunk, closes |
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) MUST kill the session target; contract on the `TtyBackend` trait |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | alknet-tty depends on alknet-core; backends depend on alknet-tty for the trait |
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 2 | alknet-tty depends on alknet-core only (no alknet-call); backends depend on alknet-tty for the trait |
## Open Questions
+15 -10
View File
@@ -49,13 +49,15 @@ rather than extensible — is in
### Phase 1: Negotiation Frame (JSON Carriage)
The client opens a bidi stream (or the server accepts one) and writes a
single length-prefixed JSON frame. The framing is byte-identical to
alknet-call's `FrameFramedReader`/`FrameFramedWriter`
(`crates/alknet-call/src/protocol/wire.rs`): a 4-byte big-endian length
prefix + UTF-8 JSON body. alknet-tty reuses the framing *utility*
(`FrameFramedReader`/`FrameFramedWriter`), not the `EventEnvelope` type —
the negotiation payload is a tty-specific struct, not a `call.requested`
event. See ADR-052 §5 and ADR-003 Amendment 1.
single length-prefixed JSON frame. The framing is a 4-byte big-endian
length prefix + UTF-8 JSON body — a self-contained ~30-line module in
alknet-tty (read 4-byte length, bounds-check, read N bytes; write the
inverse) on tokio's `AsyncRead`/`AsyncWrite`. The format coincides with
alknet-call's `EventEnvelope` framing by convention (both are
length-prefixed JSON), not by code reuse — alknet-tty does not depend on
alknet-call. The negotiation payload is a tty-specific struct
(`NegotiateRequest`), not a `call.requested` event. See ADR-052 §6 and
ADR-057.
The payload shape:
@@ -319,7 +321,7 @@ a session — see [tty-adapter.md](tty-adapter.md).
| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
| `alknet-call` framing utility reuse | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | Negotiation frame uses `FrameFramedReader`/`FrameFramedWriter`, not `EventEnvelope` |
| No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on stream_type 3; "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk or zero-length stdin chunk; `eof` recommended |
@@ -339,8 +341,11 @@ See [open-questions.md](../../open-questions.md) for full details.
— the wire format decision
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
exit-chunk ordering the control channel carries
- [ADR-003](../../decisions/003-crate-decomposition.md) Amendment 1 —
the `alknet-call` framing utility reuse
- [ADR-003](../../decisions/003-crate-decomposition.md) Amendment 2 —
alknet-tty does not depend on alknet-call (self-contained framing)
- [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) — the
dependency-edge decision (negotiation framing is self-contained in
alknet-tty)
- `/workspace/alknet-tty-poc/src/raw.rs` — the chunk codec
(`ChunkReader`/`ChunkWriter`, stream_type 0-3) this spec commits
- `/workspace/alknet-tty-poc/src/control.rs` — the JSON control schema