Files
alktty/docs/architecture/decisions/009-channels-open-op-is-the-negotiation.md
T
glm-5.3-flash 37ae07a4d4 docs: add ADR-009 (open op's input is the negotiation); fix stale doc refs
Review of the 2026-09-05 session commits:

- the ADR-009 decision cited by 96692d3 and docs/reviews/001 was never
  written — added decisions/009-channels-open-op-is-the-negotiation.md
  (context: the L1 two-gate disagreement, alkcall 0.4.0/0.4.1
  prerequisites, producer/consumer design, consequences, door type)
- channels.rs module doc + register_openable doc still described the
  old wire-frame negotiation read (drive_session) — aligned with
  drive_session_pre_negotiated and the enforced input schema
- tty-adapter.md: session-driver section + ADR tables now reference
  ADR-009 and the pre-negotiated driver; overview.md ADR index row

Verification: cargo test 93 lib (default) / 136 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
2026-09-05 07:44:53 +00:00

8.5 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).

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) is a handler-side failure; the handler logs and returns without writing an error frame (the channel is torn down by the wrapper's teardown task).

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.

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