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.
This commit is contained in:
@@ -19,7 +19,8 @@ Syntax Tree) document for the wire format, and the ADRs.
|
||||
## Applicable ADRs
|
||||
|
||||
Ported from the alknet mono-repo and renumbered into alktty's ADR
|
||||
range (001..009; ADR-009 is alktty-native). The alknet originals at
|
||||
range (001..009; ADR-009 and ADR-010 are alktty-native). The alknet
|
||||
originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
|
||||
authoritative source for any ADR not yet ported, and for the alknet
|
||||
ADRs referenced by alknet number in the docs below (which are not
|
||||
@@ -35,7 +36,8 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
| [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted |
|
||||
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) |
|
||||
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
|
||||
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame) |
|
||||
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) |
|
||||
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment — in-band frames shrink to `allocate_failed`) |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ of the schema-validated `input` is now a client-visible
|
||||
`malformed_negotiation` error frame, not a silent teardown — see
|
||||
§"Parse-failure error frame (R4 amendment, 2026-09-05)".
|
||||
|
||||
Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006
|
||||
E-01), the semantic-failure classes the R4 amendment reported in-band
|
||||
(malformed negotiation, unknown backend, ownership denial) move into
|
||||
the producer's establisher and resolve as `channel:open_failed` call
|
||||
errors — no phantom channel. The in-band error-frame path on this
|
||||
channel shrinks to one class: `allocate_failed` (the establisher
|
||||
cannot carry the allocated handle across — see ADR-010 §2). The R4
|
||||
tests that asserted the in-band frames for those classes now assert
|
||||
the call-error shape.
|
||||
|
||||
## Context
|
||||
|
||||
Before this ADR, the channels path carried the negotiation twice. The
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# 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 `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.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
|
||||
@@ -65,11 +65,13 @@ channels path reuses — per [ADR-008](decisions/008-channels-pure-channel-multi
|
||||
TTY always uses its 5-byte format, so the same driver runs in
|
||||
both direct `alk/tty` and channels `alk/channels` modes; only the
|
||||
`BiStream` source differs. The two paths differ in where the
|
||||
`NegotiateRequest` comes from: the direct path reads the wire-frame
|
||||
negotiation (ADR-001 §"Negotiation Frame"); the channels path parses
|
||||
the open op's registry-validated `input` and runs
|
||||
`drive_session_pre_negotiated` (ADR-009 — the channels path carries no
|
||||
second negotiation frame on the channel's data stream).
|
||||
`NegotiateRequest` comes from and where semantic failures go: the
|
||||
direct path reads the wire-frame negotiation (ADR-001 §"Negotiation
|
||||
Frame") and answers failures with in-band error frames; the channels
|
||||
path parses the open op's registry-validated `input` and runs
|
||||
`drive_session_pre_negotiated` (ADR-009 — no second negotiation
|
||||
frame), with the establisher rejecting the semantic-failure classes
|
||||
as `channel:open_failed` before the handler spawns (ADR-010).
|
||||
|
||||
## Why
|
||||
|
||||
@@ -172,11 +174,20 @@ entering raw mode. The error response shape:
|
||||
{ "error": "unknown_backend", "backend": "kubernetes" }
|
||||
```
|
||||
|
||||
On the **direct-ALPN path** this is the failure surface for every
|
||||
negotiation/allocation failure class (below). On the **channels path**
|
||||
the semantic classes (unknown backend, malformed negotiation,
|
||||
ownership denial) are rejected by the establisher before the open
|
||||
reply and surface as `channel:open_failed` call errors (alkcall 0.5.0
|
||||
ADR-049 / alktty ADR-010) — the in-band frames on that path are
|
||||
defense-in-depth arms plus the one non-migratable class
|
||||
(`allocate_failed`, ADR-010 §2).
|
||||
|
||||
| Error | When | Shape |
|
||||
|-------|------|------|
|
||||
| `unknown_backend` | the `backend` string is not in the adapter's backend map | `{"error":"unknown_backend","backend":"..."}` |
|
||||
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame, on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial) | `{"error":"malformed_negotiation","message":"..."}` |
|
||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` | `{"error":"allocate_failed","message":"..."}` |
|
||||
| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
|
||||
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
|
||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — on both paths: the establisher cannot carry the allocated handle across to the pump handler (ADR-010 §2), so allocation failure is the one in-band failure class on the channels path | `{"error":"allocate_failed","message":"..."}` |
|
||||
|
||||
After sending the error response, the adapter closes the write half of
|
||||
the bidi stream. The client reads the error frame and treats stream close
|
||||
@@ -358,6 +369,7 @@ architectural commitment.
|
||||
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
|
||||
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
|
||||
| Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) |
|
||||
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures are rejected by the establisher (`channel:open_failed`, alkcall ADR-049); `allocate_failed` stays in-band |
|
||||
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
|
||||
|
||||
## Open Questions
|
||||
@@ -379,6 +391,11 @@ architectural commitment.
|
||||
the same session driver runs in channels mode
|
||||
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the
|
||||
channels path carries no second negotiation frame (the open op's
|
||||
registry-validated `input` is the negotiation)
|
||||
- [ADR-010](decisions/010-channels-establisher-migration.md) — the
|
||||
channels path's semantic failures are establisher call errors
|
||||
(`channel:open_failed`); the in-band frames shrink to
|
||||
`allocate_failed`
|
||||
`input` is the negotiation)
|
||||
- alknet ADR-050 — the ownership model the adapter's access control
|
||||
declares against
|
||||
|
||||
Reference in New Issue
Block a user