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.
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:
- alkcall 0.4.0 —
OperationSpec.input_schemais 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 aspublish_schema/ CF-003). Violations returnINVALID_INPUTbefore the handler runs. - alkcall 0.4.1 — the demux parks early-arrival chunks for a
not-yet-adopted channel (bounded FIFO, 64 payloads per channel) and
adopt_channeldrains 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'sadopt_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 partialNegotiateRequestshape (required:carriage,backend,cmd).drive_session_pre_negotiatedis public API (a new producer entry point alongsidedrive_session);validate_and_allocateis 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
NegotiateRequestJSON 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_channelsfails fast on malformed params before a channel is allocated. open_via_channelsandfrom_bidi_stream_viaare covered end-to-end against the real producer path (review #001 L3): 5 session tests + a pre-negotiated adapter test + the sharedcrate::testingharness (moved out ofchannels.rs::testsso the session tests share it).
Negative:
- A second producer entry point.
drive_session(direct) anddrive_session_pre_negotiated(channels) must stay in sync on validation semantics; the sharedvalidate_and_allocatekeeps the validated logic in one place, but the scope-gate policy differs by path (enforce_scopeflag) 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_allocateand the backend'sallocate. Duplicating fullNegotiateRequestschemas in the spec would create a second definition to drift againstnegotiation.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.rs—drive_session,drive_session_pre_negotiated,validate_and_allocatesrc/channels.rs—make_tty_open_handler,tty_open_specsrc/session.rs—open_via_channels,from_halves_rawsrc/testing.rs— the shared producer/consumer channels harness