- 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)
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:
- 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) 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 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.
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):
- Semantic validation failures (
carriage != "raw", emptycmd, unknown backend,allocate_failed, ownership denial) — error frames fromvalidate_and_allocate(unchanged). NegotiateRequestparse failure of schema-validinput— the open handler accepts the channel'sBiStreamand writes amalformed_negotiationframe ({"error":"malformed_negotiation","message":"..."}) via the sharedcrate::adapter::send_negotiation_error, then returns. Reachable despite the registry's schema check because the schema is deliberately partial: e.g.cwdtyped as a number passes the schema (unknown-key/type fields pass through for the opaque ADR-053 params) but fails the typed parse.- Schema-invalid
input— rejected at dispatch by the registry (alkcall 0.4) before any handler runs: aCallErroron 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_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