# ADR-052: The `channel/control` Control-Handle Surface ## Status Accepted — amends ADR-037 (`channel/control`'s contract) and ADR-047 (amended §1: the generic ops stay). Resolves review 010 S-02 (the `channel/control` stub shipped nine published versions; the stub was pinned by tests and tracked as OQ-39) by implementing the surface the ADR-037 text always promised. The stub's tracked design work (OQ-39) is promoted here verbatim-ish: ALPN-crate-registered per-channel control callbacks, a channels-layer callback registry keyed by `channel_id`. ## Context ADR-037 specified `channel/control` as the out-of-band control op on channel 0 (resize, signal, keepalive — anything that does not need ordering relative to data), with the channels layer routing the ALPN-specific `message` to a handler's control handle. The implementation shipped a registered, discoverable stub that checked `channel` existence and then returned `channel:control_not_implemented` on every invocation — which perversely returned *not found* for unknown channels and *not implemented* for real ones (review 010 S-02). The implementation was tracked as OQ-39 with the shape recorded; nothing blocked it except that no ALPN crate had asked yet. alktty's move of its control surface to the channels path (ADR-049 §5's migration list) and the general stub ruling (review 010) make that answer invalid. The design constraint from ADR-039: `ChannelManager` is auth-blind, ALPN-blind, transport-blind. The control-handle surface must not reintroduce ALPN interpretation into the manager. ## Decision ### 1. The control callback type and registry ```rust pub type ChannelControl = Arc< dyn Fn(Value) -> BoxFuture<'static, Result> + Send + Sync, >; ``` The callback receives the `message` value (ALPN-specific JSON; the channels layer does not interpret it) and returns the ALPN-specific reply (or a `CallError` it wants surfaced verbatim — e.g. `target_gone`). The live handle: `HashMap` in the `ChannelManager`'s internals (the same map discipline as `channels`; `parking_lot` mutex). The manager stays blind to what a callback does — it stores opaque closures, exactly as `ChannelState` already stores opaque `handler_task` handles. ### 2. Registration: the establisher contributes the control handle The ALPN crate registers a control callback by returning it from its open-op establisher: `Establishment::with_control(callback)` (builder, alongside `with_reply_field` — ADR-049 amendment 3). The **wrapper** installs it under the allocated/adopted id (`manager.set_control_handle(id, callback)`) and every teardown path removes it (`teardown_channel` drops the entry; `clear_all` drains them) — same closure-lifecycle discipline as the opener ledger. Why the establisher, not the handler: the establisher already returns typed payloads the wrapper routes (`Establishment`), it runs where the ALPN crate has prepared its backend (the resize target is the backend the establisher dialled/planned), and adding a registration path through the `OpenHandler` would require handing the handler the manager and the id — a signature break across every published consumer for zero benefit. The wrapper-owned installation also makes the callback's lifetime exactly the channel's lifetime by construction. ### 3. The op: routing and error surface `make_control_handler` (the ADR-037 op, registered by `ChannelOps::register_on`) becomes: validate `channel_id` (unchanged) → `channel` exists? (unchanged; `channel:unknown_channel`) → callback registered for the id? → | case | reply | |------|-------| | unknown channel | `channel:unknown_channel` (existing class) | | no callback registered | `channel:no_control_handle` — typed, retryable false, message: the channel's ALPN registered no control surface (`details: { alpn }`) | | callback ok | `{ "control": }` | | callback err | the callback's `CallError` verbatim | `channel:no_control_handle` is a real capability answer, not a stub: a consumer that calls `channel/control` on a channel whose ALPN chose not to expose control learns exactly that. The `message` body is ALPN-specific JSON (unchanged; the channels layer does not read it). **The callback receives the message only** — no identity, no `OperationContext`. The authorization home is the op's own `access_control` (checked by the registry before the handler, per ADR-010/017 — the same op-level ACL every other surface runs); a per-owner resize is expressed as ADR-011 `resource_id_path` on the `channel/control` spec for ALPNs that want it, not by widening the callback signature. Wire-visibility: `channel:no_control_handle` joins the stable error set (ADR-016 — consumers branch on it deliberately); the callback's reply shape is the producing crate's schema concern. ### 4. What this does not decide Ordering-sensitive control rides the data channel (unchanged — channel-operations.md's control-message division, DP-4 pinned). The manager keeps its blindness; the enumerators registry of ADR-053 is the analogous ALPN-level surface for discovery, not control. ## Consequences **Positive:** the advertised op serves; S-02 dies; ALPN crates get out-of-band control for free (resize/signal arrive as one routed call-op instead of per-crate in-band framing); teardown-bound callback lifetime is structural. **Negative:** the manager's internals gain one map (opaque closures — no blindness regression); a control callback racing teardown can observe a torn-down backend through its own error (`CallError`) — the same shape the handler EOF path already produces, not a new class. ## Door type **One-way (wire-visible).** `channel:no_control_handle` and the reply envelope shape join the stable surface once consumers branch on them. The callback type, the establisher carrier, and the map are two-way-door implementation details inside it (the `Establishment` carrier is `#[non_exhaustive]` — ADR-049 amendment 2's posture). ## Verification gates - `channel/control` routes to the establisher-contributed callback and the reply is the callback's output (e2e over a real channels connection); - `channel:no_control_handle` on a channel whose ALPN registered none; - `channel:unknown_channel` preserved (the pre-existing error); - teardown removes the handle (post-teardown control answers `channel:unknown_channel`, never a stale callback); - callback `Err` passes through verbatim. ## References - Review 010 S-02 (the stub; the implement ruling), review 011 §4 (downstream-safe; implement preferred) - OQ-39 (the promoted design note this ADR resolves) - ADR-037 (the op's contract), ADR-047 (openable ALPNs; the wrapper this registration rides), ADR-049 (the establisher carrier this registers through), ADR-039 (manager blindness), ADR-011 (`resource_id_path` — the per-owner control-gate lever)