Files
alknet/tasks/tty/negotiation.md
T

8.6 KiB

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
tty/negotiation Implement negotiation frame (NegotiateRequest, length-prefixed framing, error response) completed
tty/wire-codec
narrow medium component implementation

Description

Implement the Phase 1 "JSON carriage" negotiation frame in src/negotiation.rs. The client opens a bidi stream and writes a single length-prefixed JSON frame carrying the terminal parameters, backend selector, command, and environment. After this frame, the stream switches to raw chunks (task tty/wire-codec). The framing is self-contained in alknet-tty (ADR-057) — a 4-byte big-endian length prefix + UTF-8 JSON body.

NegotiateRequest struct

#[derive(Deserialize)]
pub struct NegotiateRequest {
    pub carriage: String,          // "raw" in v1; any other value → malformed_negotiation
    pub backend: String,           // backend selector key ("local", "docker", "ssh")
    pub tty: Option<TerminalParamsWire>,  // None = pipe mode (ADR-054)
    pub cmd: Vec<String>,           // argv[0] + args; non-empty
    #[serde(default)]
    pub cwd: Option<PathBuf>,       // None = inherit/default
    #[serde(default)]
    pub env: HashMap<String, String>,  // empty = inherit
    #[serde(default)]
    pub backend_params: serde_json::Map<String, serde_json::Value>,  // opaque; backend-deserialized
    // plus backend-specific fields, captured into backend_params via serde(flatten)
}

#[derive(Deserialize)]
pub struct TerminalParamsWire {
    pub term: Option<String>,       // None = backend default
    pub cols: u16,
    pub rows: u16,
    #[serde(default)]
    pub pixel_width: u16,
    #[serde(default)]
    pub pixel_height: u16,
    #[serde(default)]
    pub modes: serde_json::Value,   // reserved — OQ-44; backends MUST ignore content in v1
}

The serde(flatten) for backend-specific fields means the negotiation frame's top-level JSON object carries both the shared fields (carriage, backend, tty, cmd, cwd, env) and the backend-specific fields (e.g., "container": "abc123" for docker); the latter land in backend_params.

Validation

  • carriage MUST be "raw" (else malformed_negotiation).
  • cmd MUST be non-empty (else malformed_negotiation).
  • backend MUST be a registered backend key (else unknown_backend) — this check happens in the adapter (task tty/adapter), not here; this module only parses.
  • Backend-specific params validation is the backend's job (in allocate()).

Length-prefixed framing

A self-contained ~30-line reader/writer on tokio's AsyncRead/AsyncWrite:

pub struct NegotiationReader<R: AsyncRead + Unpin> { /* ... */ }
impl<R: AsyncRead + Unpin> NegotiationReader<R> {
    pub fn new(reader: R) -> Self;
    pub fn into_inner(self) -> R;
    /// Read a 4-byte BE length prefix, bounds-check, read N bytes.
    pub async fn read_frame(&mut self) -> Result<Bytes, NegotiationError>;
}

pub struct NegotiationWriter<W: AsyncWrite + Unpin> { /* ... */ }
impl<W: AsyncWrite + Unpin> NegotiationWriter<W> {
    pub fn new(writer: W) -> Self;
    pub fn into_inner(self) -> W;
    /// Write a 4-byte BE length prefix + the JSON body.
    pub async fn write_frame(&mut self, body: &[u8]) -> Result<(), NegotiationError>;
}

The reader bounds-checks the length against MAX_CHUNK_LEN (from wire.rs) so a malformed length prefix can't trigger an oversized allocation. The POC used a 1 MiB cap; the crate uses MAX_CHUNK_LEN (16 MiB) to match the raw chunk limit and to make the framing-disambiguation trick sound (see below).

Error response shape

If the server cannot allocate the session, it sends a JSON error response in the same length-prefixed framing and closes the stream without entering raw mode:

{ "error": "unknown_backend", "backend": "kubernetes" }
Error When Shape
unknown_backend the backend string is not in the adapter's backend map {"error":"unknown_backend","backend":"..."}
malformed_negotiation the negotiation frame failed to parse or failed validation {"error":"malformed_negotiation","message":"..."}
allocate_failed backend.allocate() returned a TtyError {"error":"allocate_failed","message":"..."}

Provide a helper to serialize an error response:

pub fn error_response_bytes(error: &str, fields: &[(&str, &str)]) -> serde_json::Result<Vec<u8>>;

Framing disambiguation (success vs error)

Both a successful allocation (raw chunks) and a failed allocation (JSON error frame) begin with bytes the client must read before knowing which framing applies. The disambiguation is by the first byte:

  • A JSON error frame's 4-byte big-endian length prefix always starts with 0x00 (error frames MUST be under 16 MiB — MAX_CHUNK_LEN — so the high byte is zero; this is a wire-format invariant, not an assumption).
  • A raw chunk's first byte is a stream_type in {0, 1, 2, 3}. A stream_type of 0 (stdin from server) is invalid — the server never sends stdin chunks.

So the client distinguishes: read the first byte; if it is 0x00, interpret the next 4 bytes as a big-endian length prefix and read that many bytes as a JSON error frame; otherwise interpret it as a stream_type byte and continue reading the raw chunk header. This is a one-way-door wire-format invariant (ADR-052). The NegotiationWriter::write_frame for an error MUST ensure the body is under 16 MiB (the high byte of the length is 0x00).

NegotiationError

#[derive(Debug, thiserror::Error)]
pub enum NegotiationError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("connection closed")]
    ConnectionClosed,
    #[error("frame too large: {0}")]
    FrameTooLarge(u32),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
}

Tests

  • Round-trip: write a NegotiateRequest as JSON, read the frame, parse, assert fields match.
  • serde(flatten) test: a frame with {"carriage":"raw","backend":"local",...,"container":"abc"} parses container into backend_params.
  • Validation: carriage != "raw" → adapter rejects (this module parses; the adapter checks the value). Test that the struct deserializes regardless and the adapter-side check is a string comparison.
  • FrameTooLarge on length > MAX_CHUNK_LEN.
  • ConnectionClosed on truncated frame.
  • Error response serialization produces the expected JSON shape.
  • Framing disambiguation: an error frame's first byte is 0x00 (write a frame, read the first byte, assert 0x00).

Acceptance Criteria

  • NegotiateRequest struct with all fields, serde(flatten) for backend_params
  • TerminalParamsWire struct with term, cols, rows, pixel_width, pixel_height, modes
  • NegotiationReader::read_frame reads 4-byte BE length, bounds-checks against MAX_CHUNK_LEN, reads body
  • NegotiationWriter::write_frame writes 4-byte BE length + body
  • NegotiationError with Io, ConnectionClosed, FrameTooLarge, Json
  • error_response_bytes helper produces {"error":"...","field":"..."}
  • Error frames are under 16 MiB (high byte of length prefix is 0x00)
  • into_inner on reader/writer reclaims the underlying stream for raw-chunk use
  • Round-trip unit test for NegotiateRequest (all fields)
  • Unit test: serde(flatten) captures backend-specific fields into backend_params
  • Unit test: FrameTooLarge on length > MAX_CHUNK_LEN
  • Unit test: ConnectionClosed on truncated frame
  • Unit test: error response first byte is 0x00 (framing disambiguation)
  • cargo test -p alknet-tty succeeds
  • cargo clippy -p alknet-tty succeeds with no warnings

References

  • docs/architecture/crates/tty/tty-wire.md — §"Phase 1: Negotiation Frame", §"Constraints" (negotiation errors)
  • docs/architecture/crates/tty/tty-adapter.md — §"Negotiation Errors" (framing disambiguation)
  • docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md — ADR-052
  • docs/architecture/decisions/057-alknet-tty-no-alknet-call-dep.md — ADR-057 (self-contained framing)
  • /workspace/alknet-tty-poc/src/session.rs — NegotiationReader (the ~30-line reference)

Notes

The framing is self-contained (ADR-057) — do NOT depend on alknet-call's FrameFramedReader. The MAX_CHUNK_LEN constant from wire.rs is the bounds-check ceiling for both the negotiation frame and the raw chunks, which is what makes the 0x00-as-length-prefix vs 0x00-as-invalid-stream_type disambiguation sound. The POC's NegotiationReader used a 1 MiB cap; the crate uses MAX_CHUNK_LEN (16 MiB) per the spec.

Summary

To be filled on completion