Files
alktty/docs/architecture/decisions/009-channels-open-op-is-the-negotiation.md
T
glm-5.3-flash bc4a9b6024 docs: align remaining alloc-failure references with ADR-010 §2A (prepublish review for v0.3.0)
- README.md ADR index: ADR-010 status notes the §2A amendment
- ADR-009: amendment note records the §2A supersession (in-band path
  shrinks to nothing from registered producers)
- ADR-010 Consequences: dial_failed replaces the stale in-band
  allocate_failed retry-policy bullet
- AllocFailed doc comment + tty-backend.md: describe both failure
  surfaces (direct-path frame / channels-path dial_failed)
2026-09-07 10:04:44 +00:00

11 KiB

ADR-009: The Channels Open Op's input Is the Negotiation

Status

Accepted (2026-09-05). Resolves review #001 L1. Prerequisites: alkcall 0.4.0 (call-time input_schema enforcement) and alkcall 0.4.1 (early-arrival parking for un-adopted channels).

Amended 2026-09-05 (review #002 R4): a NegotiateRequest parse failure of the schema-validated input is now a client-visible malformed_negotiation error frame, not a silent teardown — see §"Parse-failure error frame (R4 amendment, 2026-09-05)".

Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006 E-01), the semantic-failure classes the R4 amendment reported in-band (malformed negotiation, unknown backend, ownership denial) move into the producer's establisher and resolve as channel:open_failed call errors — no phantom channel. The in-band error-frame path on this channel shrinks to one class: allocate_failed (the establisher cannot carry the allocated handle across — see ADR-010 §2). The R4 tests that asserted the in-band frames for those classes now assert the call-error shape.

Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0 — review 007 R-01): Establishment gained its plan payload, so allocate also moved into the establisher — the in-band error-frame path on this channel shrinks to nothing from a registered producer (the frames remain the defense-in-depth fallback for no-establisher registrations and the direct path). See ADR-010 §2A.

Context

Before this ADR, the channels path carried the negotiation twice. The consumer opened a channel via the channels/tty/sub open op, passing the NegotiateRequest params as the op's input; the producer-side open handler then ignored that input (let _ = input;) and read a second, full negotiation frame from the channel's data stream — the same frame the direct-ALPN path reads. The registry validated input against the op's schema, but the backend selection actually came from the unvalidated wire frame. Two independent gates (the registry's schema check on input and drive_session's wire-frame parse) could disagree: a client could pass schema-valid input and write a divergent frame.

Review #001 (L1) flagged this as a latent inconsistency and asked for a publisher decision: either drop the input parameter or pass it through as the authoritative negotiation. Investigation of alkcall found the review's premise wrong in an important way — alkcall (≤0.3.1) never enforced input_schema on any dispatch path at all: the schema was advertise-only (metadata in discovery). The registry gate this design leaned on did not exist.

Two upstream changes landed to make the design sound:

  1. alkcall 0.4.0OperationSpec.input_schema is enforced at call time by all three registry dispatch entry points (invoke, invoke_streaming, invoke_sink). The schema is compiled once at registration (fail-closed, the same rule as publish_schema / CF-003). Violations return INVALID_INPUT before the handler runs.
  2. alkcall 0.4.1 — the demux parks early-arrival chunks for a not-yet-adopted channel (bounded FIFO, 64 payloads per channel) and adopt_channel drains them into the new receiver. Before this, the open-op-response / producer's-first-write race silently dropped the first chunks of any push-first producer. This bug was found by the L3 end-to-end test (an immediately-resolving backend's stdout sentinel + exit chunk raced the consumer's adopt_channel; the session never resolved).

Decision

The open op's registry-validated input is the negotiation (design 2 of the L1 decision). The channels path carries no second negotiation frame on the channel's data stream — the stream starts directly in raw-chunk mode (TTY's 5-byte format, ADR-001/ADR-008).

Producer side

make_tty_open_handler parses the open op's input into a NegotiateRequest and drives the new drive_session_pre_negotiated (src/adapter.rs) — the same three-pump session driver as the direct path, minus the wire-frame negotiation phase. Validation is factored into validate_and_allocate, shared by both paths: carriage == "raw", non-empty cmd, backend lookup, the ADR-050 ownership check, and backend.allocate. On the channels path the tty:open scope gate is NOT re-checked in the handler — the registry's AccessControl enforced it before the handler ran (the identity the registry checked, resolved from the connection, is the authoritative one; the handler-side identity is only the ownership-check subject).

Post-open failures (unknown backend, allocate_failed, ownership denial) still go to the client as a 0x00-prefixed negotiation error frame on the channel stream — the same framing the direct path uses — so the consumer's ADR-001 §5 disambiguation read applies unchanged.

A NegotiateRequest parse failure of the schema-validated input (the schema is deliberately partial — carriage/backend/cmd required, tty/cwd/env/backend-params free-form so the opaque ADR-053 params pass through) was originally a handler-side failure logged and returned without an error frame. The R4 amendment below makes it a client-visible error frame like every other post-open failure.

Consumer side

TtySession::open_via_channels (src/session.rs) parses params as a NegotiateRequest locally first (fail-fast before a channel is allocated), opens the channel via ChannelClient::open_channel, builds the channel Connection, and starts raw-chunk mode directly (from_halves_raw — no negotiation frame is written). The 0x00-error-frame peek is retained: the first byte of the producer's response disambiguates a post-open negotiation error frame (length prefix starting 0x00) from a raw chunk (stream_type in {1, 2, 4}).

Scope of the change

  • tty_open_spec()'s input schema becomes the partial NegotiateRequest shape (required: carriage, backend, cmd).
  • drive_session_pre_negotiated is public API (a new producer entry point alongside drive_session); validate_and_allocate is private, shared by both drivers.
  • The direct-ALPN path is unchanged: it still reads the wire-frame negotiation (ADR-001) and enforces the scope gate itself.
  • The 5-byte chunk format, the error-frame layout, and the NegotiateRequest JSON shape are unchanged (ADR-001, ADR-006). This ADR removes a frame from the channels data stream; it does not change any frame that remains.

Parse-failure error frame (R4 amendment, 2026-09-05)

All three post-open failure classes on the channels path are now client-visible through the same 0x00 error-frame peek (from_halves_raw):

  1. Semantic validation failures (carriage != "raw", empty cmd, unknown backend, allocate_failed, ownership denial) — error frames from validate_and_allocate (unchanged).
  2. NegotiateRequest parse failure of schema-valid input — the open handler accepts the channel's BiStream and writes a malformed_negotiation frame ({"error":"malformed_negotiation","message":"..."}) via the shared crate::adapter::send_negotiation_error, then returns. Reachable despite the registry's schema check because the schema is deliberately partial: e.g. cwd typed as a number passes the schema (unknown-key/type fields pass through for the opaque ADR-053 params) but fails the typed parse.
  3. Schema-invalid input — rejected at dispatch by the registry (alkcall 0.4) before any handler runs: a CallError on the open op, no channel allocated, no error frame (unchanged).

This replaces the original behavior (log + return, channel teardown, consumer observes NoExitChunk — indistinguishable from a crashed producer). The error-frame layout is unchanged (ADR-001's wire-stable contract); no new frame type, no new stream type. Tests: the make_tty_open_handler seam test (hand-built input bypassing the schema, channels.rs::tests) and the real-registry end-to-end test (testing.rs); both assert the consumer-side 0x00 peek observes malformed_negotiation.

Consequences

Positive:

  • One negotiation per session. The registry's schema check and the semantic validation run on the same value — the two-gate disagreement is structurally impossible.
  • Fewer bytes and one less round trip on the channels path: no 4-byte length prefix + JSON frame on the data stream, and the consumer needs no negotiation write before its first read.
  • The consumer's open_via_channels fails fast on malformed params before a channel is allocated.
  • open_via_channels and from_bidi_stream_via are covered end-to-end against the real producer path (review #001 L3): 5 session tests + a pre-negotiated adapter test + the shared crate::testing harness (moved out of channels.rs::tests so the session tests share it).

Negative:

  • A second producer entry point. drive_session (direct) and drive_session_pre_negotiated (channels) must stay in sync on validation semantics; the shared validate_and_allocate keeps the validated logic in one place, but the scope-gate policy differs by path (enforce_scope flag) and is documented at both call sites.
  • The schema is deliberately partial. Raw JSON Schema is permissive on unknown keys, so the opaque backend params pass through unchecked by the registry; semantic validation stays in validate_and_allocate and the backend's allocate. Duplicating full NegotiateRequest schemas in the spec would create a second definition to drift against negotiation.rs.
  • Consumer/producer version skew. A consumer that writes the old second negotiation frame to a producer built on this ADR will have that frame parsed as a raw chunk (stream_type byte = first JSON byte, garbage length) — the session fails confusingly. Acceptable pre-1.0 (no released channels-path consumers); the direct path is unaffected.

Door type

Two-way for now, one-way once a released consumer exists. The channels-path negotiation location (open-op input vs. a wire frame) is reversible pre-1.0 — the NegotiateRequest shape, the 5-byte chunk format, and the error-frame layout (the wire-stable contracts) are untouched. Once a released consumer depends on "open op, then raw chunks," removing or re-adding the wire-frame negotiation on this path becomes a peer-breaking change. The drive_session_pre_negotiated public API is additive; drive_session is unchanged.

References

  • ADR-001 — the two-carriage model (JSON negotiation + raw chunks) and the §5 framing disambiguation this ADR reuses for the post-open error frame
  • ADR-002 — the backend selection and allocation this ADR's validation runs
  • ADR-006 — the negotiation frame layout (unchanged; the direct path still uses it)
  • ADR-008 — the channels layer strips its 8-byte header transparently; TTY owns its framing (this ADR decides what TTY's framing on the channels path contains)
  • docs/reviews/001-code-review.md §"Resolution (2026-09-05, L1 + L3)"
  • src/adapter.rsdrive_session, drive_session_pre_negotiated, validate_and_allocate
  • src/channels.rsmake_tty_open_handler, tty_open_spec
  • src/session.rsopen_via_channels, from_halves_raw
  • src/testing.rs — the shared producer/consumer channels harness