Files
alktty/docs/architecture/decisions/010-channels-establisher-migration.md
T
glm-5.3-flash e2fa32b3c7 feat: channels-path establisher migration (alkcall 0.5.0 / ADR-049) — bump to 0.2.0
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.
2026-09-06 20:25:39 +00:00

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 (OpenEstablisherFn(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 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<Mutex<Option<TtyHandle>>> 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.rsmake_tty_establisher, make_tty_open_handler, tty_open_spec
  • src/session.rsTtySessionError::ChannelsOpen, open_via_channels
  • src/testing.rs — the pinned allocate_failed-stays-in-band test