# 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`). **Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2 — review 007 R-01):** `Establishment` gained its plan payload (`ChannelPlan = Arc`), removing §2's payloadless-`Establishment` blocker. Backend `allocate` moves into the establisher; the allocated `TtyHandle` crosses to the pump handler via the plan; `allocate_failed` joins the call-error surface as `details.reason == "dial_failed"` (the target refused or the backend lacked capacity). No failure class arrives in-band on the channels path from a registered producer; the in-band error-frame fallback stays for no-establisher registrations (defense-in-depth) and the direct-ALPN path. See §2A below; §2 is retained as historical record. 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` | | **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` | | establishment deadline | (wrapper) | `timeout` | `resource_shortage` has no TTY producer path (allocation-capacity failures map to `dial_failed` — the target refused or the backend lacked capacity; §2A). `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 > **Amended away (2026-09-07) — see §2A.** This section is the > historical record of the pre-0.3.0 shape; its blocker 1 > (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049 > amendment 2, review 007 R-01). Blocker 2 (double-`allocate` > unsoundness) is avoided by construction in the amended shape: the > establisher's handle *is* the handle the handler pumps — there is no > second allocate to be unsound about. 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. ### 2A. Amendment (alkcall 0.6.0): allocation moves into the establisher alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the reserved field: `Establishment { plan: Option }` with `ChannelPlan = Arc` — typed-opaque, wrapper threaded to the `OpenHandler`'s new second parameter. §2's blocker 1 is gone; the double-`allocate` hazard (blocker 2) is structurally absent — one allocate per open, whose result the handler consumes. The amended shape: 1. **The establisher allocates.** After the validation sequence (§1), it runs `backend.allocate(¶ms)`. Failure maps to `EstablishmentError::DialFailed` (`details.reason == "dial_failed"` — the target refused or the backend lacked capacity; one string message, no nuance — the same granularity the direct path's `allocate_failed` frame always had). The `channel:open_failed` `ErrorDefinition` on `tty_open_spec()` therefore declares four reachable reasons (`dial_failed`, `unknown_resource`, `handler_error`, `timeout`). 2. **The handle crosses via the plan.** `TtyHandle` is not `Sync` (boxed dyn streams), so it cannot be the `ChannelPlan` directly. The establisher wraps it in a private `AllocatedHandle` one-shot slot (`Mutex>`), created **per establisher invocation** — per open. The wrapper moves this open's plan to this open's handler (alkcall's concurrent-same-resource test pins that plans are never shared), the handler `take()`s it exactly once, and establisher-before-handler ordering is alkcall's wrapper guarantee (ADR-049 §1). The shared-slot race §2 described is unreachable by construction: there is no shared slot. 3. **The handler pumps the pre-allocated handle.** With a plan it calls `drive_session_pre_allocated` (new `pub(crate)` driver: the pumps directly, no validation pass). With `plan: None` — a no-establisher registration — it falls back to `drive_session_pre_negotiated`, whose inline validate-and-allocate keeps the full in-band error-frame vocabulary. That fallback is defense-in-depth for the assembly-layer freedom to register without an establisher, not a registered-producer path. 4. **Consumer surface.** `TtySession::open_via_channels` sees allocation failure as `ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })` — a typed call error, branchable and retryable like every other establishment class. The `0x00` peek on the channel stream becomes a formality that no registered producer ever exercises; the pinned test flipped with the contract (`allocate_failure_fails_open_as_dial_failed` in `testing.rs`, plus the establisher unit gate `establisher_allocates_and_maps_ failure_to_dial_failed` in `channels.rs`). The R-02 lifetime contract (the handler's `JoinHandle` tracks the data-plane lifetime) was already alktty's shape — the open handler awaits the driver inline in its spawned task — so the amendment adds no lifetime changes. ### 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 })` | | **allocation failure** (amended — §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** | | 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 })` | `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), `dial_failed` (capacity/target refused — maybe retry; amended §2A, was the in-band `allocate_failed`). - The consumer's `open_via_channels` failure is a typed call error — no peeking at the data stream for anything but the (now unreachable-from-registered-producers) error-frame formality. **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). - **Amendment (0.3.0):** allocation failure changed shape again — `NegotiationRejected{ "allocate_failed" }` (in-band) → `ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`. The direct path's `allocate_failed` frame is unchanged (two transports, two contracts). ## 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 a *second* establisher-side allocate would be unsound; avoided by construction in the §2A amendment — one allocate per open, consumed by the handler) - ADR-001 (the §5 `0x00` disambiguation — retained for the direct path; the channels-path peek is now a formality) - ADR-002 (backend selection/allocation ownership) - alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment` plan payload — the upstream capability this ADR's §2A adopts) - `src/channels.rs` — `make_tty_establisher`, `AllocatedHandle`, `make_tty_open_handler`, `tty_open_spec` - `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels` - `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed` test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)