# ADR-010: Channels-Path Establishment Failures Move into an Establisher ## Status Accepted (2026-09-06). Migrates the channels-path semantic-failure surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1) — the sequencing ADR-049 §5 names ("alktty migration — channels-path semantic failures move into an establisher; the direct-ALPN in-band error frame is retained — two transports, two contracts"). Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`, `channel:open_failed`, typed `ChannelOpenError`). Amends ADR-009's R4 amendment: the in-band error-frame path on the channels path shrinks to one failure class (`allocate_failed`). ## Context ADR-009 made the open op's `input` the negotiation and moved semantic validation into the producer-side handler. Because alkcall 0.4.x's open wrapper could not fail after allocation (review 006 E-01), the semantic-failure classes had nowhere to go but the channel stream: the handler wrote a `0x00`-prefixed negotiation error frame (`malformed_negotiation`, `unknown_backend`, `allocate_failed`, ownership-denial `forbidden`) on a channel the open op had already reported as succeeding. The consumer observed success-then-frame — a per-crate workaround ADR-049 explicitly retires: > alktty's per-crate in-band error vocabulary is retired on the > channels path; every future ALPN crate gets the establishment reply > for free. alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded establisher (`OpenEstablisher` — `Fn(Value, AuthContext) -> BoxFuture< Result>`) between channel allocation and the open reply; on rejection it tears the channel down and replies `channel:open_failed` with `details: { reason, message }` (reason ∈ `dial_failed` / `unknown_resource` / `resource_shortage` / `handler_error` / `timeout`). The SSH contract holds consumer-visibly: a failed open never returns a `channel_id`. `ChannelClient::open_channel` carries the `CallError` verbatim in a typed `ChannelOpenError` (review 006 N-1), so the reason is branchable end-to-end. ## Decision ### 1. The channels path registers an establisher `register_openable` (alktty's helper, signature unchanged) now calls `ChannelCore::register_openable_with_establisher` with `make_tty_establisher` — the establishment phase runs the semantic validation that used to be post-open error frames: - Full `NegotiateRequest` parse of the registry-schema-validated `input` (the schema stays deliberately partial — the opaque ADR-053 backend params pass through; the establisher is the typed gate). - `carriage == "raw"`, non-empty `cmd`. - Backend lookup against the adapter's backend map. - The ADR-050 ownership check (moved from the pump handler's `validate_and_allocate`; the scope gate stays the registry's — pre-allocation, unchanged). Rejections map into ADR-049's fixed reason vocabulary (no new codes — the vocabulary is alkcall's one-way wire surface): | TTY failure class | `EstablishmentError` variant | `details.reason` | |---|---|---| | `NegotiateRequest` parse failure | `HandlerError` | `handler_error` | | `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` | | unknown backend | `UnknownResource` | `unknown_resource` | | ownership denial | `HandlerError` | `handler_error` | | establishment deadline | (wrapper) | `timeout` | `dial_failed` and `resource_shortage` have no TTY producer path (TTY dials nothing at open time; `allocate` capacity failures are the in-band class below) — the spec's `ErrorDefinition` for `channel:open_failed` declares only the three reachable reasons. `HandlerError` covers both the malformed-negotiation semantics (the request shape is wrong) and the ACL-outcome ownership denial (an authorization failure, not a dial failure). ### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band The one failure class that cannot migrate: `backend.allocate`. Two reasons, both structural: 1. **`Establishment` is payloadless** ("reserved for a channel plan"). There is no sound way to hand the allocated `TtyHandle` from the establisher to the pump handler: the establisher and handler are separate closures registered once per connection (no per-open key is shared between them), and an `Arc>>` stash would need exactly that key to be race-free. 2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`** contract: re-allocating in the handler after an establisher-side allocate would spawn the session target twice (the first handle's kill-guard would fire on its drop — a session killed before it started), and leaking the establisher-side handle would leak the live target. So `backend.allocate` runs where it always did — inside `drive_session_pre_negotiated`'s `validate_and_allocate` — and `allocate_failed` remains a post-open `0x00`-prefixed negotiation error frame, surfaced consumer-side as [`TtySessionError::NegotiationRejected`] via the same peek the direct path uses. A pinned test (`allocate_failure_still_arrives_in_band_on_ channels_path`) guards this boundary. Revisit when alkcall gives `Establishment` a payload (a channel plan carrying the handle would let `allocate_failed` join the call-error surface). The pump handler's parse arm (`malformed_negotiation` frame) and `validate_and_allocate`'s other error frames are now defense-in-depth: the establisher rejects those classes first, so on the registered path only `allocate_failed` is reachable. The arms stay (a no-establisher registration still compiles against alkcall's API and must not silently EOF), and the R4 seam test pins the handler-side frame. ### 3. The direct-ALPN path is unchanged `TtyAdapter::handle` / `drive_session` keep the wire-frame negotiation (ADR-001) and the full in-band error-frame vocabulary (`unknown_backend`, `malformed_negotiation`, `allocate_failed`, `forbidden`). Two transports, two contracts — the direct path has no open op to fail, and its `0x00`-peek disambiguation is the ADR-001 wire-stable contract. The `enforce_scope` split from ADR-009 is unchanged (`true` direct, `false` channels — the registry's `AccessControl` is the channels scope gate). ### 4. Spec enrichment (review 006 E-02 + ADR-016) `tty_open_spec()` gains: - `description` — disclosed by `services/list` (the open op describes itself; the produced resource set stays OQ-40's deferred `channel/resources/subscribe` shape). - An `ErrorDefinition` for `channel:open_failed` with the `details.reason` enum — so `services/schema` discloses the establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN crates' open-op specs gain matching `ErrorDefinition` entries so `services/schema` discloses the failure contract"). ### 5. Consumer surface `TtySession::open_via_channels` failures after this ADR: | Failure | Where | Consumer-visible as | |---|---|---| | schema-invalid params | registry gate | `ChannelsOpen(CallFailed{ INVALID_INPUT })` | | unparseable params (local fail-fast) | pre-open local parse | `InvalidParams` | | malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` | | ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` | | ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` | | channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` | | **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** | `TtySessionError::ChannelsOpen` now carries alkcall's typed `ChannelOpenError` verbatim (`#[from]`) instead of a flattened `String` — the N-1 fix applied at alktty's layer, so consumers branch on `establishment_reason()` without unwrapping strings. ## Consequences **Positive:** - The phantom-channel workaround is retired: a semantically invalid open no longer allocates → succeeds → in-band-fails. Ledger, policy count, and manager state balance on every rejection. - Retry policy / UX can branch: `unknown_resource` (bad config — don't retry), `timeout` (maybe retry), `allocate_failed` (capacity — still distinguishable in-band). - The consumer's `open_via_channels` failure is a typed call error — no peeking at the data stream for anything but `allocate_failed`. **Negative:** - **Breaking (0.2.0):** channels-path semantic failures change shape (`NegotiationRejected{unknown_backend}` → `ChannelsOpen(CallFailed{ channel:open_failed})`), and `ChannelsOpen`'s payload changed. The direct path's failure surface is unchanged. - The `channel:open_failed` reason codes the establisher emits join the wire-stable error set (ADR-049's one-way door). - `allocate_failed` remains a second, in-band failure shape on the channels path — one residual asymmetry, pinned and documented until `Establishment` gains a payload. ## Door type **One-way (wire-visible).** The establisher mapping fixes `details.reason` values for TTY's channels path — consumers branch on `unknown_resource` / `handler_error` / `timeout`, so re-mapping a failure class between reasons is a peer-breaking change. The establisher's internal validation order is a two-way door (implementation detail behind the fixed surface). ADR-009's "two-way for now" negotiation-location decision is unaffected — this ADR moves failure *reporting*, not the negotiation *location*. ## References - alkcall ADR-049 (the establisher, `channel:open_failed`, the typed client error — the upstream capability this ADR adopts) - alkcall review 006 (E-01 the establishment gap; N-1 the typed client error; E-02 the discovery enrichment this ADR's spec enrichment adopts) - ADR-009 (the open op's `input` is the negotiation; the R4 amendment's in-band error frames — shrunk to `allocate_failed` by this ADR) - ADR-005 (kill-on-`Drop` — why establisher-side allocation is unsound) - ADR-001 (the §5 `0x00` disambiguation — retained for the direct path and the channels `allocate_failed` frame) - ADR-002 (backend selection/allocation ownership) - `src/channels.rs` — `make_tty_establisher`, `make_tty_open_handler`, `tty_open_spec` - `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels` - `src/testing.rs` — the pinned `allocate_failed`-stays-in-band test