feat: allocation moves into the channels establisher (alkcall 0.6.0 / review 007 R-01) — bump to 0.3.0

- Bump alkcall 0.5.0 -> 0.6.0 (Establishment plan payload, ADR-049
  amendment 2); alktty 0.2.0 -> 0.3.0 (breaking channels-path
  allocation-failure shape)
- make_tty_establisher runs backend.allocate; failure maps to
  EstablishmentError::DialFailed (channel:open_failed reason
  dial_failed); the TtyHandle crosses to the pump handler via a
  private per-open AllocatedHandle one-shot slot (the handle is not
  Sync — it cannot be the ChannelPlan directly)
- make_tty_open_handler gains the Option<ChannelPlan> parameter; a
  plan drives the new drive_session_pre_allocated (pumps only), a
  None plan falls back to drive_session_pre_negotiated (inline
  validate-and-allocate — defense-in-depth for no-establisher
  registrations)
- tty_open_spec's channel:open_failed ErrorDefinition declares
  dial_failed (four reachable reasons)
- Pinned in-band allocate test flipped:
  allocate_failure_fails_open_as_dial_failed (end-to-end, no channel
  survives); establisher unit gate for the plan slot + failure
  mapping
- ADR-010 amended (§2A; §2 kept as historical record), tty-adapter.md,
  session/channels/adapter docs, CHANGELOG, AGENTS.md alkcall pin
  updated

Verification: cargo test (113) + --all-features (137), clippy
-D warnings (native + wasm32, default + all-features), fmt, doc,
wasm32 check — all clean
This commit is contained in:
2026-09-07 09:53:57 +00:00
parent 3352a02dd5
commit af9dbaedea
10 changed files with 434 additions and 147 deletions
@@ -10,6 +10,19 @@ 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`).
@@ -68,18 +81,26 @@ the vocabulary is alkcall's one-way wire surface):
| `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` |
`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).
`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:
@@ -114,6 +135,60 @@ 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(&params)`. 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
@@ -149,10 +224,10 @@ unchanged (`true` direct, `false` channels — the registry's
| 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 })` |
| **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** |
`TtySessionError::ChannelsOpen` now carries alkcall's typed
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened
@@ -170,7 +245,8 @@ on `establishment_reason()` without unwrapping strings.
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`.
no peeking at the data stream for anything but the (now
unreachable-from-registered-producers) error-frame formality.
**Negative:**
@@ -180,9 +256,11 @@ on `establishment_reason()` without unwrapping strings.
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.
- **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
@@ -205,12 +283,16 @@ ADR moves failure *reporting*, not the negotiation *location*.
- 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-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 and the channels `allocate_failed` frame)
path; the channels-path peek is now a formality)
- ADR-002 (backend selection/allocation ownership)
- `src/channels.rs``make_tty_establisher`, `make_tty_open_handler`,
`tty_open_spec`
- 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_failed`-stays-in-band test
- `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed`
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)
+11 -9
View File
@@ -177,17 +177,18 @@ entering raw mode. The error response shape:
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).
ownership denial) **and allocation** are rejected by the establisher
before the open reply and surface as `channel:open_failed` call
errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
`Establishment` plan payload — ADR-010 §2A) — the in-band frames on
that path are defense-in-depth arms only (reachable from
no-establisher registrations).
| Error | When | Shape |
|-------|------|------|
| `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":"..."}` |
| `allocate_failed` | `backend.allocate()` returned a `TtyError`the failure surface of the **direct path**. On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload — ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"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
@@ -369,7 +370,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 |
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures and allocation are rejected/run in the establisher (`channel:open_failed`, alkcall ADR-049; alkcall 0.6 plan payload carries the `TtyHandle`); no in-band frame from a registered producer |
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
## Open Questions
@@ -394,8 +395,9 @@ architectural commitment.
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`
(`channel:open_failed`); since alkcall 0.6 the establisher also
allocates (the `TtyHandle` crosses via the plan payload), so no
failure class arrives in-band from a registered producer
- alknet ADR-050 — the ownership model the adapter's access control
declares against
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-