# 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.0** — `OperationSpec.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](001-wire-format-and-two-carriage.md) — the two-carriage model (JSON negotiation + raw chunks) and the §5 framing disambiguation this ADR reuses for the post-open error frame - [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend selection and allocation this ADR's validation runs - [ADR-006](006-negotiation-framing-self-contained.md) — the negotiation frame layout (unchanged; the direct path still uses it) - [ADR-008](008-channels-pure-channel-multiplexing.md) — 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.rs` — `drive_session`, `drive_session_pre_negotiated`, `validate_and_allocate` - `src/channels.rs` — `make_tty_open_handler`, `tty_open_spec` - `src/session.rs` — `open_via_channels`, `from_halves_raw` - `src/testing.rs` — the shared producer/consumer channels harness