Adopt alkcall 0.5.0's channel-open establishment phase (ADR-049 — review 006 E-01 + N-1) and migrate the channels-path semantic failures per its §5 sequencing (alktty ADR-010). - `register_openable` registers `channels/tty/sub` with an establisher (`register_openable_with_establisher`): full `NegotiateRequest` parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership check run before the open reply; rejections are `channel:open_failed` with `details.reason` (`unknown_resource` / `handler_error` / `timeout`) — no phantom channel (the SSH contract holds consumer-visibly) - `backend.allocate` deliberately stays in the pump handler: `Establishment` is payloadless so the `TtyHandle` cannot cross the establisher→handler boundary, and re-allocating would violate ADR-005's kill-on-Drop contract — `allocate_failed` remains the one in-band failure class on the channels path (pinned by test) - `TtySessionError::ChannelsOpen` carries alkcall's typed `ChannelOpenError` (`#[from]`) instead of a flattened `String` — the N-1 fix at alktty's layer (breaking) - channels-path semantic failures change shape from `NegotiationRejected` in-band frames to `channel:open_failed` call errors (breaking); the direct-ALPN path is unchanged - `tty_open_spec()` gains a `description` (review 006 E-02) and an ErrorDefinition for `channel:open_failed` (ADR-016 — disclosed via services/schema) - alkcall = "0.5.0"; version 0.2.0; ADR-010 + ADR-009 amendment + tty-adapter.md + CHANGELOG Verification: cargo test (112 lib + integration), cargo test --all-features (136), clippy --all-targets -D warnings (host + wasm), fmt --check, cargo doc --no-deps clean; wasm32-unknown-unknown check confirms the default crate stays wasm-clean.
10 KiB
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<Establishment, EstablishmentError>>) 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
NegotiateRequestparse of the registry-schema-validatedinput(the schema stays deliberately partial — the opaque ADR-053 backend params pass through; the establisher is the typed gate). carriage == "raw", non-emptycmd.- 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:
Establishmentis payloadless ("reserved for a channel plan"). There is no sound way to hand the allocatedTtyHandlefrom 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 anArc<Mutex<Option<TtyHandle>>>stash would need exactly that key to be race-free.- Double-
allocateis unsound under ADR-005's kill-on-Dropcontract: 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 byservices/list(the open op describes itself; the produced resource set stays OQ-40's deferredchannel/resources/subscribeshape).- An
ErrorDefinitionforchannel:open_failedwith thedetails.reasonenum — soservices/schemadiscloses the establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN crates' open-op specs gain matchingErrorDefinitionentries soservices/schemadiscloses 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_channelsfailure is a typed call error — no peeking at the data stream for anything butallocate_failed.
Negative:
- Breaking (0.2.0): channels-path semantic failures change shape
(
NegotiationRejected{unknown_backend}→ChannelsOpen(CallFailed{ channel:open_failed})), andChannelsOpen's payload changed. The direct path's failure surface is unchanged. - The
channel:open_failedreason codes the establisher emits join the wire-stable error set (ADR-049's one-way door). allocate_failedremains a second, in-band failure shape on the channels path — one residual asymmetry, pinned and documented untilEstablishmentgains 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
inputis the negotiation; the R4 amendment's in-band error frames — shrunk toallocate_failedby this ADR) - ADR-005 (kill-on-
Drop— why establisher-side allocation is unsound) - ADR-001 (the §5
0x00disambiguation — retained for the direct path and the channelsallocate_failedframe) - ADR-002 (backend selection/allocation ownership)
src/channels.rs—make_tty_establisher,make_tty_open_handler,tty_open_specsrc/session.rs—TtySessionError::ChannelsOpen,open_via_channelssrc/testing.rs— the pinnedallocate_failed-stays-in-band test