ADR-047: the unifying decision that dissolves into per-ALPN ops (, ) with a marker on . Each openable ALPN registers its own ops with their own , , , and the marker. The field is replaced by (Sub/Pub). The generic ops (channel/close, channel/control, channel/resources/subscribe) stay, keyed by channel_id. Resolves Gaps A-G from the research findings (Gap B broker named out-of-scope for alkcall; Gap C relay wrapper is consumer concern; Gap D connection-owner allocates; Gap E extension trait; Gap F boolean marker on wire; Gap G ACL/ownership complementary). ADR-037 amended: dissolves; removed; generic ops stay; preview dropped from resources/subscribe. Spec docs updated: channel-operations.md (unified model, opener ledger, ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec), README.md (ADR-047), open-questions.md (OQ-31..38 resolved). Source changes: - spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec, with_channel_open builder, 3 tests - discovery.rs: spec_to_json emits channel_open boolean, operation_spec_schema includes channel_open, 2 tests - from_call.rs: rebuild_spec_for parses channel_open marker, derive_alpn_from_op_name helper, 6 tests Channels module (src/channels/, 10 files, ~2400 lines): - wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header, read_header/write_chunk/write_eof async helpers), 12 tests - reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver<Bytes> → AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>), REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests - mux.rs: MuxHandle (clone-able, register(channel_id)), MuxRunner (per-channel pump tasks, exits when handles drop), OpenerLedger (ADR-047 §7), 4 tests - manager.rs: ChannelManager (channel map, open_channel, install_channel_zero, route_payload, teardown_channel, clear_all), 11 tests - source.rs: ChannelBidiStreamSource (yield-once accept_bi), channel_source helper, 4 tests - adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels, demux loop, install_channel_zero hook), 1 test - operations.rs: ChannelOperations (registers channel/close, channel/control, channel/resources/subscribe), ChannelCore (check_open/on_close wrappers), 4 tests - policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy (default 256, per_identity_caps override), default_policy, 8 tests - env.rs: ChannelOperationEnv extension trait (ADR-047 §4), ChannelsSessionEnv impl, 2 tests - client.rs: ChannelClient (from_connection, call_open_op, take_call_connection), 1 test Verification: 432 tests pass (66 new channels + 10 marker + 356 existing), clippy clean, fmt clean, cargo doc generates. Cargo.toml: +bytes dependency.
22 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-08-12 |
channel-operations.md — Channel Lifecycle on the Call Protocol
Channel lifecycle is orchestrated by the call protocol on channel 0
(ADR-036). Under the unified model (ADR-047), channel/open dissolves
into per-ALPN ops (channels/<alpn>/sub, channels/<alpn>/pub),
each with its own access_control, input_schema, resource_id_path,
and a channel_open marker. The generic ops channel/close,
channel/control, channel/resources/subscribe stay (keyed by
channel_id). All go through the existing OperationContext /
AccessControl::check path — no new auth machinery, no new framing.
The open ops (per-ALPN, ADR-047)
Each openable ALPN registers its own ops on the call
OperationRegistry at assembly time, named channels/<alpn>/sub
and/or channels/<alpn>/pub:
| Op | OperationType |
Initiator | Responder | Stream direction |
|---|---|---|---|---|
channels/<alpn>/sub |
Sub |
consumer (subscribes) | producer (streams) | server → client |
channels/<alpn>/pub |
Pub |
producer (publishes) | consumer (receives) | client → server |
The channels/<alpn>/... path segment is the ALPN with the alknet/
prefix stripped (ADR-047 §"Negative"). An ALPN may register one or both
ops; separate op types → separate ACLs. The op spec carries the
channel_open: Option<ChannelOpenSpec> marker (ADR-047 §2) — the
dispatch hint that tells the channels layer "this op's stream is
binary, allocate a channel for it."
channels/<alpn>/sub — subscribe to a binary stream
Request (call.requested on channel 0):
{
"operation": "channels/tty/sub",
"input": { "backend": "docker", "cmd": ["bash"], "container": "abc123" }
}
The input is ALPN-specific params (the former params field, now the
op's input_schema). For alknet/tty this is the NegotiateRequest;
for alknet/tunnel this is the target resource. The channels layer does
not interpret input.
Response (call.responded):
{
"output": { "channel_id": 7 }
}
| field | type | meaning |
|---|---|---|
channel_id |
u32 | Allocated by the connection owner (ADR-047 §5). In the Sub case, the responder (producer). Both sides route chunks with this ID to the new channel. |
channel_id allocation (ADR-047 §5): connection-owner allocates.
The side that holds the ChannelManager for that connection allocates
the channel_id via a monotonic AtomicU32 (next_id.fetch_add(1, Relaxed)) and returns it in the response. In the Sub case the
responder owns the connection and allocates; in the Pub case the
initiator owns and allocates. 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.
Error codes (CallError.code strings):
| code | meaning | retryable |
|---|---|---|
channel:forbidden |
AccessControl::check denied the open |
false |
channel:allocation_failed |
Handler allocate failed (e.g., backend couldn't start) | true (often transient) |
channel:too_many_channels |
Per-connection or per-identity channel limit hit (ADR-040, ADR-041) | false |
channel:no_channels_session |
The op was invoked outside a channels session (no ChannelManager — ADR-047 §2) |
false |
channel:unknown_alpn and channel:invalid_params (ADR-037) are gone —
an unregistered ALPN is an ordinary NOT_FOUND (op not registered); a
bad input is ordinary schema rejection.
channels/<alpn>/pub — publish a binary stream
OperationType::Pub (ADR-046). The initiator publishes a stream of
call.published events to the responder; the responder's SinkHandler
consumes them and returns a single call.responded. The
channel_open marker on the op spec tells the channels layer the
stream is binary — the call.published chunks carry binary payloads
on a data channel (not JSON on channel 0).
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
worker publishes to the hub, the hub owns the resource, and consumers
subscribe from the hub. The broker (topic registry, Pub↔Sub
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
The generic ops (keyed by channel_id)
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-040 — monotonic IDs with wrap-around,
not a free-list). reason is free-form for observability — not
semantically required.
Per-identity quota decrement (ADR-047 §7). The decrement is keyed
by the opener (from the per-connection opener ledger), not the
closer. The decrement is called from every teardown path — close
received, close sent locally, handler exit, connection drop — not just
channel/close. The ledger entry is removed atomically with its
decrement.
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.
channel/resources/subscribe — live resource discovery
This is a Sub operation (ADR-021), 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"]
},
{
"alpn": "alknet/tunnel",
"targets": ["container:*", "service:postgres"]
}
]
}
}
| field | type | meaning |
|---|---|---|
alpn |
string | The ALPN this side accepts open ops for. |
backends / targets |
[string] |
ALPN-specific enumeration of what's available. The channels layer doesn't interpret these; they're for the initiator to know what input to send. |
The access preview (ADR-037) is dropped (ADR-047 §6) — it's on the
op spec, available via services/schema. Carrying a preview in a
different shape invites staleness; the spec is the authority.
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-020 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.
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-035,
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.
ACL flow (end-to-end)
A browser opening a TTY channel to a spoke through a hub (ADR-042):
- Browser's channel 0 → hub's channel 0:
channels/tty/sub{ backend: "docker", cmd: ["bash"], container: "abc123" }. The browser's identity is a bearer token (ADR-004). - Hub's
CallAdapterrunsAccessControl::checkonchannels/tty/subwith 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-026 §3). The spoke receiveschannels/tty/subwithcaller = hub,forwarded_for = browser. - Spoke's
CallAdapterrunsAccessControl::checkwith the hub as caller (the spoke authorizes the hub — ADR-011). The spoke's ownership store verifies the hub ownscontainer:abc123(ADR-050 §4c —forwarded_foris metadata, not authority; the spoke sees the hub as the owner). - Spoke's
ChannelCoreallocateschannel_idvianext_id.fetch_add(ADR-047 §5 — connection-owner allocates; the spoke is the responder forSub, so it allocates), spawnsTtyAdapteron the channel'sBiStreamwith the docker backend, records opener (hub) in the ledger (ADR-047 §7), returns{channel_id}. - Hub receives the spoke's
{channel_id}, opens a matching channel on the browser's side (hub is the responder for the browser leg), records thechannel_idmappingbrowser_id ↔ spoke_id, returns{channel_id: browser_id}to the consumer. - Hub byte-forwards between
browser_idandspoke_idwith 4-bytechannel_idrewrite (ADR-042 unchanged).
The hub ran zero protocol-specific auth. It ran channels/tty/sub's
AccessControl::check (call-protocol machinery) and forwarded. The
relay contract from ADR-042 holds unchanged in shape; only the op name
changed (from generic channel/open to per-ALPN channels/tty/sub),
and the channel_open marker (not prefix-matching) is how the hub
recognizes and translates channel-open ops.
Per-identity channel cap (ADR-041, ADR-047 §7)
A channel slot is a resource. The cap on how many channels an identity
may hold open is a quota check on that resource — parallel to
OwnershipProvider::owns (ADR-011) for spawned resources. Same
primitive, different resource. The cap is a peer concern, not a
hub-specific concern: any accepting peer (worker or hub) enforces the
cap on its inbound channels, just as it enforces AccessControl::check
on the open op. The cap is also symmetric — both sides of a
channels connection enforce their cap on the other's channels.
Why the cap is not in the channels layer
ChannelManager (ADR-039) is auth-blind by design — no auth state, no
identity, no scopes. That decision is load-bearing (it is what makes
the channels layer WASM-compatible, transport-agnostic, and
ALPN-blind). So the per-identity cap lives in channels-call, where
the identity is already on OperationContext (the same place
AccessControl::check runs). The channels layer (channels-core) is
unchanged. See ADR-041 §"Why the channels layer cannot hold the cap".
The channels-layer per-connection max_channels = 256 (ADR-040) is
a per-connection memory bound (limits one connection's
reassembly-buffer cost), not a DoS defense. A peer can open an
unbounded number of transport connections, so a per-connection cap is
not a per-peer DoS defense. The per-identity DoS defense is the cap
documented here; see ADR-041 for the corrected DoS-defense framing.
The ChannelLifecyclePolicy trait
/// Per-identity channel lifecycle policy. Consulted by the open-op
/// wrapper (after `AccessControl::check`, before allocation) and on
/// every teardown path (after deallocation). Both have the identity
/// via `OperationContext` (for open) or the opener ledger (for
/// teardown — ADR-047 §7).
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// when the identity is over its cap. The identity is the direct
/// caller (the peer that opened this channels connection);
/// `forwarded_for` is metadata and is NOT consulted (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called on every teardown path (close received, close sent
/// locally, handler exit, connection drop), keyed by the opener
/// from the per-connection ledger (ADR-047 §7) — not the closer.
fn on_close(&self, opener: &Identity);
}
Default: PerIdentityChannelPolicy::new(256)
The default constructor enforces 256 per identity out of the box — no
"NoOp default + wire it later." A channels-accepting peer that
constructs ChannelOperations::new(manager) with no policy argument
gets PerIdentityChannelPolicy::new(256). The default is secure;
opt-outs are explicit:
PerIdentityChannelPolicy::new(cap)— shared per-identity state (HashMap<PeerId, usize>+ cap), constructed once per accepting peer and shared (viaArc) across every channels connection that peer accepts. The sharing is what makes the cap per-identity, not per-connection.PerIdentityChannelPolicy::with_per_identity_caps(mapping)— per-peer-role variant:HashMap<PeerId, usize>overrides the default cap for specific peers. Used by a spoke that serves a high-fan-out hub (the hub peer's cap is set higher than a worker peer's cap — see "Relay consequence" below).NoCap— no cap. Explicit opt-out for tests, POCs, and trusted single-peer deployments. Not the default.
The policy is constructed once and passed to ChannelOperations at
registration time:
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager, policy);
channel_ops.register_on(&mut call_registry)?;
Enforcement point: between AccessControl::check and allocation
The open-op wrapper (ADR-047 §3 — the ChannelCore wrapper around the
ALPN's open handler) gains the policy check after ACL and before
next_id.fetch_add:
- ACL is already checked by
OperationRegistry::invoke(the existingAccessControl::checkpath — unchanged). - NEW:
policy.check_open(&op_ctx.identity)?— deny withchannel:too_many_channelsif over cap. - Allocate the
channel_idvianext_id.fetch_add(1, Relaxed)(ADR-047 §5 — connection-owner allocates). - Record the opener in the per-connection ledger (ADR-047 §7).
- Construct the
ChannelBidiStreamSource, spawn the handler, record theChannelState(unchanged). - Return the
channel_id.
Every teardown path (close received, close sent locally, handler exit,
connection drop) walks the ledger and calls policy.on_close(opener)
per open channel, removing the ledger entry atomically with its
decrement (ADR-047 §7).
Relay consequence: the spoke caps the hub, not the browser
When the hub relays a browser's channel to a spoke (ADR-042), the
spoke sees the hub as the direct caller. forwarded_for carries the
browser's identity as metadata (ADR-026 — forwarded_for is not
authority; AccessControl::check never reads it). The channel cap
follows the same shape: the spoke's ChannelLifecyclePolicy is
consulted with the hub's identity, not the browser's. The spoke
asks "does the hub have access to open another channel?" and the
hub's quota on the spoke reflects the aggregate of all relayed
channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
Deployment consequence: a spoke that serves a hub relaying for
many browsers must set the hub peer's cap higher than a worker peer's
cap, or the spoke denies legitimate relayed channels when the hub's
aggregate count exceeds a worker-sized cap. This is a per-peer-role
policy, set by the spoke via with_per_identity_caps. The
architecture provides the mechanism; the deployment sets the numbers.
This is not a flaw — it is the same shape as any per-peer ACL.
Recursive channels do not bypass the cap
A recursive alknet/channels-inside-alknet/channels channel runs a
new ChannelsAdapter with a new ChannelManager. If the same
ChannelLifecyclePolicy is wired into the inner ChannelOperations,
the inner channels are counted against the same identity. Recursion
is not a bypass; the 13-byte-per-chunk overhead is the documented
cost (ADR-035), and the cap behavior is unchanged. Recursive channels
are an edge case for edge cases and not specced further.
Hub relay contract (ADR-042 — summary)
The hub translates, not transparently forwards:
- Call-protocol layer (channel 0): translate. The hub terminates
channel 0 on both legs.
channels/<alpn>/subfrom the browser → hub'sAccessControl::check→ hub re-issueschannels/<alpn>/subon 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). The channel_open
marker (ADR-047 §2) is how the hub recognizes a channel-open op during
from_call discovery (ADR-047 §1, Gap C) — the from_call relay
wrapper wraps marked ops with relay machinery.
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 037 | Channel Lifecycle Operations | The generic ops; direction pinned (amended by ADR-047 — channel/open dissolves; direction removed) |
| 047 | Openable ALPNs Are Operations | Per-ALPN open ops; channel_open marker; ChannelCore wrapper; opener ledger |
| 036 | Channel 0 Pre-Negotiated | Channel 0 = alknet/call |
| 042 | Hub Relay | Translate channel 0, byte-forward data channels |
| 041 | Per-Identity Channel Cap | 256 per PeerId, enforced via ChannelLifecyclePolicy in channels-call (amended by ADR-047 §7 — opener ledger, every teardown path) |
| 035 | Pure Channel Multiplexing | No stream_types; handler owns sub-mux |
| 021 | StreamingHandler | The machinery channel/resources/subscribe uses |
| 046 | Pub Operation Type | The Pub/Sub primitives the per-ALPN open ops build on |
| 026 | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per forwarded_for) |
| 011 | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; resource_id_path works again under per-ALPN ops |
References
- ADR-047: openable ALPNs are operations (the unifying ADR — per-ALPN
open ops,
channel_openmarker,ChannelCorewrapper, opener ledger) - ADR-037: channel lifecycle operations (amended by ADR-047)
- ADR-041: per-identity channel cap (amended by ADR-047 §7 — opener ledger, every teardown path)
- ADR-042: hub relay (the translate contract)
- ADR-046: Pub operation type (the
Pub/Subprimitives) /workspace/@alkdev/alknet/docs/research/call-channels-unification/ findings.md— the research that surfaced the unification and the gaps