- 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)
299 lines
15 KiB
Markdown
299 lines
15 KiB
Markdown
# 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<dyn Any + Send + Sync>`), 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<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` |
|
|
| **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<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.
|
|
|
|
### 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<ChannelPlan> }` with
|
|
`ChannelPlan = Arc<dyn Any + Send + Sync>` — 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<Option<TtyHandle>>`), 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`) |