Prune the channels spec to reflect the stream-unification resolution (docs/research/stream-unification/findings.md): the channels wire format goes from 9 bytes to 8 bytes, the channels layer no longer carries a stream_type concept, into_sub_streams() is removed, and TTY always uses its 5-byte format (carried transparently in the channels payload). ADR-093 is the umbrella decision (the channels-layer consequence of ADR-092's BiStream handler leaf): every channel is a BiStream, the handler owns its sub-stream multiplexing, the channels layer routes by channel_id only. Amends ADR-071 (8-byte header, no stream_type), ADR-074 (into_sub_streams removed, accept_bi yields BiStream), reverses ADR-077 (TTY always 5-byte), and the channels-facing clauses of ADR-072/073/075/076/080/081. Adds ADR-092 forward-reference note (into_sub_streams preservation subsequently reversed by ADR-093) and the missing ADR-092 cross-reference on ADR-070. Adds OQ-68 (add/strip API shape — built-in vs utility; the contract is decided in ADR-093, the function surface is open; two-way door, low priority, decision-ready when the channels crate's implementation begins). Rewrites the 7 channels spec docs (README, overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client) to describe the post-amendment shape as current, with the 8-byte header, the add/strip composition, single accept_bi accessor, BiStream per channel, and TTY-always-5-byte. Touch-up cross-references in hub README, client README, ADR-085, and the OQ-45/47/65 question files (TTY-internal stream_type 3 → STREAM_CTRL_IN; channels 9-byte → 8-byte).
14 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-18 |
channel-operations.md — Channel Lifecycle on the Call Protocol
Channel lifecycle is orchestrated by the call protocol on channel 0
(ADR-072). Four operations on channel 0's OperationRegistry (ADR-073)
handle open, close, control, and resource discovery. All four go through
the existing OperationContext / AccessControl::check path — no new auth
machinery, no new framing.
The four operations
channel/open — open a data channel
Request (on channel 0):
{
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder"
}
}
| field | type | meaning |
|---|---|---|
alpn |
string | The ALPN the channel will carry. Responder looks this up in its HandlerRegistry. |
params |
object | ALPN-specific parameters. For alknet/tty this is NegotiateRequest. For alknet/tunnel this is the target resource. The channels layer does not interpret params. |
direction |
string | initiator-to-responder or responder-to-initiator. See "Direction semantics" below. |
Response:
{
"output": {
"channel_id": 7
}
}
| field | type | meaning |
|---|---|---|
channel_id |
u32 | Server-assigned (DP-1). The responder allocates via monotonic AtomicU32. |
Channel ID allocation: server-assigned (DP-1). One round-trip before data flows — the same round-trip the call protocol makes for every operation. All current channel types (TTY, tunnel, SSH) already require a negotiation round-trip, so the open round-trip is not additive latency.
Amendment (ADR-093, 2026-07-18): the
stream_typesfield is removed fromchannel/open's input and output. The channels layer has nostream_typeconcept (ADR-093) — the handler owns its sub-stream multiplexing on theBiStreamit receives. The handler's sub-stream set is implicit in its ALPN's wire format (e.g., TTY's 5-byte format declares its ownstream_typeset internally; the channels layer carries the bytes transparently). Thechannel:stream_type_unavailableerror code is removed (the channels layer can't refuse astream_typeit doesn't know about).
Error codes (new CallError.code strings, not new framing):
| code | meaning | retryable |
|---|---|---|
channel:unknown_alpn |
ALPN not in responder's HandlerRegistry |
false |
channel:forbidden |
AccessControl::check denied the open |
false |
channel:allocation_failed |
Handler allocate failed | true (often transient) |
channel:invalid_params |
params JSON didn't satisfy the ALPN's expectations |
false |
channel:too_many_channels |
Per-connection channel limit hit (ADR-076) | false |
channel/close — tear down a channel
{
"operation": "channel/close",
"input": { "channel_id": 7, "reason": "exit" }
}
The responder (the side that didn't send the close) drains its reassembled
stream for channel_id, signals EOF to the handler, and returns
{ "closed": true }. The channel_id is eligible for reuse after the drain
completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
reason is free-form for observability — not semantically required.
REQ-CH-06: exit-chunk-before-close ordering. The channel's data chunks
MUST be written and flushed before the channel/close operation is sent on
channel 0. The side closing must observe the data-channel pump complete
before issuing the call operation. For TTY this is the exit-chunk-is-last
invariant (ADR-055) carried forward — the exit control message rides on
TTY's STREAM_CTRL_OUT (stream_type 4, inside TTY's 5-byte payload
format); for tunnels it is the last data byte before close. This invariant
crosses two channels (the data channel and channel 0), so the channels
layer owns the ordering guarantee.
channel/control — out-of-band control on channel 0
For control that doesn't need ordering relative to data (resize, signal, keepalive):
{
"operation": "channel/control",
"input": {
"channel_id": 7,
"message": { "type": "resize", "cols": 80, "rows": 24 }
}
}
The channels layer routes message to the handler's control handle for
channel_id. The message JSON is ALPN-specific; the channels layer does
not interpret it.
Amendment (ADR-093, 2026-07-18): the
stream_typefield is removed fromchannel/control's input. Under ADR-093, the channels layer has nostream_typeconcept — the control message is routed to the handler's control handle (an ALPN-specific concept the handler owns), not to a channels-layer(channel_id, stream_type)reassembly buffer. The handler decides what to do with the message.
channel/resources/subscribe — live resource discovery
This is a Subscription operation (ADR-049), not a polled Query. The
call protocol has StreamingHandler / invoke_streaming (implemented and
tested). The first consumer (the hub aggregating worker resources) needs
live updates when workers connect/disconnect or containers start/stop.
{
"operation": "channel/resources/subscribe",
"input": {}
}
The responder registers a StreamingHandler that emits a ResponseEnvelope
whenever the resource set changes. Each event:
{
"output": {
"resources": [
{
"alpn": "alknet/tty",
"backends": ["docker", "local"],
"access": { "required_scopes": ["tty:open"] }
},
{
"alpn": "alknet/tunnel",
"targets": ["container:*", "service:postgres"],
"access": { "required_scopes_any": ["tunnel:open", "admin"] }
}
]
}
}
| field | type | meaning |
|---|---|---|
alpn |
string | The ALPN this side accepts channel/open for. |
backends / targets |
[string] |
ALPN-specific enumeration of what's available. The channels layer doesn't interpret these. |
access |
object | A preview of the AccessControl that channel/open will check. Advisory — lets the initiator fail fast. The real check happens on channel/open. |
The stream emits an initial snapshot immediately, then subsequent events on any change. The stream is long-lived; the subscriber cancels by dropping the subscription (ADR-016 abort cascade applies).
A channel/resources (non-subscribe, Query) operation is NOT provided.
The subscription's initial snapshot serves the poll use case (subscribe,
read the first event, cancel). Providing both would be redundant and would
pressure consumers toward the stale-poll path.
Direction semantics (OQ-CH-09 — pinned)
Channel open is bidirectional — either side can initiate. The
direction field determines who is the ALPN-server (allocates the handler,
writes the negotiation response) vs the ALPN-client (writes the first
request).
direction |
Initiator role | Responder role | Who writes first |
|---|---|---|---|
initiator-to-responder |
ALPN-client | ALPN-server | Initiator writes first (the request data); responder's handler is the server side. The common case: "open me a TTY on your docker container." |
responder-to-initiator |
ALPN-server | ALPN-client | Responder writes first (the negotiation response); initiator's handler is the client side. The "worker exposes, hub consumes" case: the worker initiates the open to make itself available; the hub is the client. |
The channels layer does not enforce write order. Write order is
ALPN-specific, determined by which side is the ALPN-server. The channels
layer routes chunks; the handlers negotiate who writes first via their
ALPN's params contract.
channel_id allocation is always by the responder (DP-1), regardless of
direction. The responder is the side that receives the channel/open call
operation; it allocates the ID and returns it. In the responder-to- initiator case, the initiator (worker) sends the channel/open, so the
responder (hub) allocates the ID — even though the worker is the ALPN-server
for the channel's data. This keeps ID allocation in one place and avoids the
collision-prone client-assigned alternative.
Control-message division (DP-4 — pinned)
| Control path | When | Examples |
|---|---|---|
Call operations on channel 0 (channel/control, channel/close) |
Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
Data-ordered bytes on the data channel's BiStream (handler-internal framing) |
Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
example of data-ordered control — it rides on TTY's STREAM_CTRL_OUT
(stream_type 4, inside TTY's 5-byte payload format) because it must arrive
after the last data on TTY's stdout stream_type, guaranteed by TTY's
per-stream_type chunk ordering within its own 5-byte format, not by a
call-protocol round-trip. The channel/close operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
The control-message division is handler-internal. Under ADR-093, the
channels layer has no stream_type concept — it carries the handler's
framing transparently in the payload. TTY's STREAM_CTRL_IN (stream_type
3) and STREAM_CTRL_OUT (stream_type 4) are stream_types in TTY's 5-byte
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
channels layer routes by channel_id only; the handler owns its
sub-stream multiplexing on the BiStream it receives. The
"bidirectional control channel" property is a TTY-layer concern, fixed
at the TTY layer by Phase 7's split — the channels layer doesn't know
about it.
ACL flow (end-to-end)
A browser opening a TTY channel to a spoke through a hub (ADR-079):
- Browser's channel 0 → hub's channel 0:
channel/open{ alpn: "alknet/tty", params: { backend: "docker", cmd: ["bash"], container: "abc123" } }. The browser's identity is a bearer token (ADR-034). - Hub's
CallAdapterrunsAccessControl::checkonchannel/openwith the browser's identity. If denied →channel:forbidden. - Hub forwards to spoke via
from_call: the hub'sforwarded_forhandler constructs acall.requestedwith the hub as caller and the browser asforwarded_for(ADR-032 §3). The spoke receiveschannel/openwithcaller = hub,forwarded_for = browser. - Spoke's
CallAdapterrunsAccessControl::checkwith the hub as caller (the spoke authorizes the hub — ADR-050). The spoke's ownership store verifies the hub (or theforwarded_forbrowser, per policy) ownscontainer:abc123. - Spoke allocates the channel via
TtyAdapter/DockerTtyBackend, returnschannel_id. - Hub opens a matching channel on the browser's side and bridges them
(byte-forward with
channel_idrewrite — ADR-079).
The hub ran zero protocol-specific auth. It ran channel/open's
AccessControl::check (call-protocol machinery) and forwarded. The channels
layer inherited the auth model by being a call-protocol operation.
Hub relay contract (ADR-079 — summary)
The hub translates, not transparently forwards:
- Call-protocol layer (channel 0): translate. The hub terminates
channel 0 on both legs.
channel/openfrom the browser → hub'sAccessControl::check→ hub re-issueschannel/openon the spoke leg withforwarded_for→ spoke returns itschannel_id→ hub maps browser-id ↔ spoke-id. - Data-channel layer: byte-forward with
channel_idrewrite. The relay reads chunks forbrowser_id, rewrites thechannel_idfield tospoke_id, writes onto the spoke's channels connection — and vice versa. The relay does not parse the payload.
channel/control operations on channel 0 carry channel_id in their JSON
payload; the hub's CallAdapter translates these too (rewrites
channel_id in the payload). The relay does not touch channel/control —
it's a call operation, translated, not byte-forwarded.
The hub never runs a handler for alknet/tty, alknet/ssh, or
alknet/tunnel. It runs alknet/channels (the relay) and alknet/call
(for its own hub-level operations + translation).
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 073 | Channel Lifecycle Operations | The four ops; direction pinned; subscribe not poll |
| 072 | Channel 0 Pre-Negotiated | Channel 0 = alknet/call |
| 079 | Hub Relay | Translate channel 0, byte-forward data channels |
| 093 | channels Pure Channel Multiplexing | stream_types field removed from channel/open; stream_type removed from channel/control; handler owns sub-stream multiplexing |
| 049 | StreamingHandler | The machinery channel/resources/subscribe uses |
| 032 | Forwarded-For Identity | The auth chain for hub-relayed opens |
| 050 | Dynamic Resource Ownership | The ownership store the spoke queries |
References
- ADR-073: channel lifecycle operations (the decision)
- ADR-079: hub relay (the translate contract)
docs/research/alknet-channels/phase-0-findings.md§Channel Open Negotiation, §ACL and Security Model