The spec docs had not caught up with the stub-sweep fix commits (4eae18f..3317ea0), and the ported docs still carried alknet numbering that now collides with alkcall's own ADR range. Spec sync with the landed code: - call-protocol.md: register_imported/register_imported_all return Result and refuse marked specs; marked-spec registration refusal contract spelled out (ADR-047 am. 4 §4.8, ADR-022 am. 2026-10-13) - client-and-adapters.md: from_call flow gains the Pub sink-forwarding branch and the marked-op refusal (resolves OQ-37 — no from_call relay wrapper; sanctioned compositions are ChannelRelay::register_relay_openable / ChannelClient) - ADR-051: "channel/control is not implemented" stale sentence replaced with the ADR-052 per-leg control contract - OQ-37 resolved; OQ-22 split (primitive resolved; broker placement out of scope) with new OQ-42 for the downstream hub broker (deferred(scope), concretely blocked); OQ-24 resolved by the shipped add/strip surface; OQ-41 → deferred(scope) with blocking condition - README: OQ bullets renumbered to tracker IDs; alknet-collision note rewritten (range 001..053, alknet prefix convention); new principle 11 (implement-or-stop-advertising); AGENTS.md ADR range 001..053 Port-debt repair: - 149 broken decision links across 17 docs — ported with alknet numbers/paths, most now remapped to alkcall files by slug; the rest link the alknet repo per the README convention - stale OQ numbering rewritten to tracker IDs in call-protocol.md, call-README.md, operation-registry.md, client-and-adapters.md, ADR-025, channels-overview/README/wire, channel-client - unambiguous "alknet ADR-NNN" labels where an alknet number collides with an alkcall ADR (052 TTY vs channel/control, 033 storage vs rust-canonical, 025/026/027+020 vault vs PeerEntry/auth ADRs) Verified: cargo test (697 + guard), fmt, clippy --all-targets -D warnings; full markdown link scan clean.
582 lines
29 KiB
Markdown
582 lines
29 KiB
Markdown
---
|
|
status: current
|
|
last_updated: 2026-10-13
|
|
---
|
|
|
|
# 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 `alk/`
|
|
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):
|
|
|
|
```json
|
|
{
|
|
"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 `alk/tty` this is the `NegotiateRequest`;
|
|
for `alk/tunnel` this is the target resource. The channels layer does
|
|
not interpret `input`.
|
|
|
|
Response (`call.responded`):
|
|
|
|
```json
|
|
{
|
|
"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, as amended for the put path by
|
|
ADR-047 amendment 4): connection-owner allocates.**
|
|
The side that holds the `ChannelManager` for that connection allocates
|
|
the `channel_id` via a monotonic `AtomicU32` (odd/even split — connect
|
|
side starts at 1, accept at 2) and returns it in the response. In the
|
|
`Sub` case the responder owns the connection and allocates — one
|
|
round-trip before data flows (the same round-trip every call makes).
|
|
In the `Pub` case (the put contract above) the initiator owns,
|
|
allocates, and streams bytes **before** the open resolves — the
|
|
early-arrival parking covers the adopt race. 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:open_failed` | The establishment phase failed (ADR-049) — `details: { reason, message }`, reason ∈ `dial_failed` / `unknown_resource` / `resource_shortage` / `handler_error` / `timeout` | 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.
|
|
|
|
### The establishment phase (ADR-049)
|
|
|
|
An openable ALPN may register an **establisher** alongside its open
|
|
handler (`ChannelCore::register_openable_with_establisher`). The
|
|
establisher is the awaited preparation step ADR-047 §3 described
|
|
("validate params, consult ownership, prepare the backend, return a
|
|
channel plan"): the wrapper awaits it **bounded** (the dispatch
|
|
deadline when the op carries one, else the registration's override or
|
|
the 10s `ESTABLISHMENT_TIMEOUT` — the earlier of the two), **before
|
|
replying and before spawning the pump handler**. On establisher
|
|
failure (error or deadline) the wrapper tears down the just-allocated
|
|
channel (demux sender, opener-ledger entry, `policy.on_close`
|
|
un-increment — the same atomic ledger-take gate every teardown path
|
|
runs) and replies `channel:open_failed` with
|
|
`details: { reason, message }` (the table row above). The SSH
|
|
contract holds consumer-visibly: a failed open never returns a
|
|
`channel_id`.
|
|
|
|
Registrations without an establisher behave exactly as before (the
|
|
open op cannot fail post-allocation; establishment work inside the
|
|
handler is invisible to the open reply). The bound applies only to
|
|
the establisher — the spawned pump handler's lifetime is governed by
|
|
the existing teardown machinery, unchanged. Head-of-line safety: the
|
|
serving loop spawns Once invocations as independent tasks, so a slow
|
|
establisher on one open op does not block other calls on channel 0.
|
|
|
|
### `channels/<alpn>/pub` — publish a binary stream (the put contract, ADR-047 amendment 4)
|
|
|
|
A marked `Pub`-typed open op is the **put** shape: the data plane is
|
|
the adopted channel's `BiStream` — never the call plane. The contract,
|
|
pinned end-to-end (ADR-047 amendment 4; review 011's corrections are
|
|
the authoritative shape — a serving-loop `PublishStream` sketch was
|
|
considered and rejected, it is ADR-046's *unmarked* pub semantics):
|
|
|
|
1. **The initiator allocates and writes immediately.** The putter
|
|
allocates the channel id on its own side (connect side → odd, per
|
|
the ADR-047 §5 odd/even split), inserts the **reserved input key
|
|
`"channel_id"`** into the open op's input, and streams bytes on the
|
|
channel before the open resolves — the early-arrival parking
|
|
(ADR-051's tested pre-adopt buffer) covers the responder-adopts
|
|
race. Zero `call.published` on marked ops: chunks on the call plane
|
|
are rejected loudly with `channel:publish_chunks_rejected` (the
|
|
first chunk writes the rejection in the initiator's direction; the
|
|
handler is never fed). An unmarked `Pub` op is an ordinary ADR-046
|
|
sink (JSON chunks, `publish_schema`); it does not ride the channel
|
|
machinery.
|
|
2. **The responder adopts.** `ChannelCore::register_pub_openable` is
|
|
the only registration seam: the wrapper strips the reserved key
|
|
pre-validation (dispatch layer, before `input_schema`) and adopts
|
|
the given id (`adopt_channel` — the remote parity range enforced;
|
|
failure replies `channel:open_failed` with reason `adopt_failed`).
|
|
3. **The wrapper is the responder of record:** `check_open` → adopt →
|
|
bounded establisher (offer validation + ack fields +
|
|
`Establishment::with_control`, ADR-049 amendment 3's mechanism) →
|
|
the `PubOpenHandler` runs on the adopted channel's `Connection`,
|
|
**awaited inline** → the standard completion teardown → **one
|
|
response**, merge order pinned:
|
|
|
|
```
|
|
{ "channel_id": <adopted> } → ack fields → handler result
|
|
```
|
|
|
|
(the handler result wins on key collision — its computed result
|
|
postdates the ack; a handler `Err` IS the response, and the channel
|
|
tears down).
|
|
4. **The ack-timing sentence (protocol semantics):** a sink op gets
|
|
exactly one `call.responded`, produced after the handler completes
|
|
— a putter stream-waits nothing; the establishment ack is
|
|
informative-in-result, never a gating ack. The fetch/put asymmetry
|
|
is direction-inherent: fetch (`Sub`) acks at establishment (the
|
|
`{channel_id}` response precedes any byte); put (`Pub`) acks in the
|
|
result. A putter that waits for the ack deadlocks — this sentence
|
|
is the contract, not per-consumer folklore.
|
|
5. **The putter-facing client shape:**
|
|
`ChannelClient::open_pub_channel(operation_id, input, alpn)`
|
|
allocates the odd id, sends the open op in the background, and
|
|
returns `(id, send, recv, reply)` immediately; `reply.resolve()`
|
|
is the awaited merged response.
|
|
|
|
**Error codes** (the pub path):
|
|
|
|
| code | meaning | retryable |
|
|
|------|---------|-----------|
|
|
| `channel:open_failed` | establishment failure (ADR-049 §3 vocabulary) or adoption failure — new reason `adopt_failed` (collisions, cap, out-of-range reserved ids; ADR-047 amendment 4 §4.4) | false |
|
|
| `channel:publish_chunks_rejected` | `call.published` chunks on a marked op (the data plane is the channel, ADR-047 amendment 4 §4.1) | false |
|
|
| `INVALID_INPUT` | absent/non-u32 reserved `channel_id` key on a marked pub op (ADR-047 amendment 4 §4.3) | false |
|
|
|
|
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
|
|
worker publishes to the hub, the hub owns the resource, consumers
|
|
subscribe from the hub. Marked pub openables relay through the put
|
|
translation (ADR-047 amendment 4 §4.7): the hub allocates its own
|
|
connect-side id, opens the spoke op in the background (put
|
|
acks-in-result — an establisher awaiting the spoke's response would
|
|
deadlock the leg), pumps consumer bytes → spoke leg, and mirrors the
|
|
spoke's single response with its reserved key stripped.
|
|
|
|
Registration invariants (ADR-047 amendment 4 §4.8 — the stub-class
|
|
guard): a marked spec refuses the generic registration path and
|
|
refuses `publish_schema`; `register_openable*` refuse `Pub`-typed
|
|
specs; `register_pub_openable` refuses unmarked/non-`Pub` specs.
|
|
|
|
## The generic ops (keyed by `channel_id`)
|
|
|
|
### `channel/close` — tear down a channel
|
|
|
|
```json
|
|
{
|
|
"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 (implemented, ADR-052)
|
|
|
|
For control that doesn't need ordering relative to data (resize, signal,
|
|
keepalive):
|
|
|
|
```json
|
|
{
|
|
"operation": "channel/control",
|
|
"input": {
|
|
"channel_id": 7,
|
|
"message": { "type": "resize", "cols": 80, "rows": 24 }
|
|
}
|
|
}
|
|
```
|
|
|
|
The channels layer routes `message` to the channel's control callback
|
|
(ADR-052): the ALPN crate contributes it through its open-op
|
|
establisher (`Establishment::with_control`); the wrapper installs it
|
|
under the allocated/adopted id and every teardown path removes it. The
|
|
`message` JSON is ALPN-specific; the channels layer does not interpret
|
|
it. The reply is `{ "control": <callback output> }`, or:
|
|
|
|
| code | meaning |
|
|
|------|---------|
|
|
| `channel:unknown_channel` | no such channel (pre-existing class) |
|
|
| `channel:no_control_handle` | the channel's ALPN registered no control surface — a capability answer, not a failure (ADR-052 §3); data-ordered control rides the data channel (DP-4 below) |
|
|
| (callback's own code) | the callback's `CallError` verbatim |
|
|
|
|
Control that MUST be ordered relative to data rides the data channel's
|
|
`BiStream` (the control-message division below is unchanged).
|
|
|
|
### `channel/resources/subscribe` — live resource discovery (implemented, ADR-053)
|
|
|
|
**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.
|
|
|
|
```json
|
|
{
|
|
"operation": "channel/resources/subscribe",
|
|
"input": {}
|
|
}
|
|
```
|
|
|
|
Each openable ALPN crate provides a **resource enumerator**
|
|
(ADR-053 §1 — snapshot-then-changes: the first item is the current
|
|
payload, subsequent items are full replacements; the producing crate
|
|
owns its change detection) at assembly/install time:
|
|
`ResourceEnumerators::register(alpn, enumerator)`, one shared registry
|
|
per accepting peer. The handler aggregates the enumerators registered
|
|
at subscription start (the **scan-once** pin — a later registration is
|
|
invisible to an in-flight subscription; re-subscribe):
|
|
|
|
- **non-empty registry:** each enumerator item becomes one event,
|
|
`{ "output": { "resources": [ { "alpn": …, …payload } ] } }` — the
|
|
first events across the ALPNs are the initial snapshot;
|
|
- **empty registry:** exactly one `{ "resources": [] }` event, then
|
|
the stream stays open (subscribe, read the first event, cancel —
|
|
the poll use case).
|
|
|
|
```json
|
|
{
|
|
"output": {
|
|
"resources": [
|
|
{
|
|
"alpn": "alk/tty",
|
|
"backends": ["docker", "local"]
|
|
},
|
|
{
|
|
"alpn": "alk/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 (alknet 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):
|
|
|
|
1. 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).
|
|
2. Hub's `CallAdapter` runs `AccessControl::check` on
|
|
`channels/tty/sub` with the browser's identity. If denied →
|
|
`channel:forbidden`.
|
|
3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for`
|
|
handler constructs a `call.requested` with the hub as caller and the
|
|
browser as `forwarded_for` (ADR-026 §3). The spoke receives
|
|
`channels/tty/sub` with `caller = hub`, `forwarded_for = browser`.
|
|
4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as
|
|
caller (the spoke authorizes the hub — ADR-011). The spoke's
|
|
ownership store verifies the hub owns `container:abc123`
|
|
(ADR-050 §4c — `forwarded_for` is metadata, not authority; the spoke
|
|
sees the hub as the owner).
|
|
5. Spoke's `ChannelCore` allocates `channel_id` via `next_id.fetch_add`
|
|
(ADR-047 §5 — connection-owner allocates; the spoke is the responder
|
|
for `Sub`, so it allocates), spawns `TtyAdapter` on the channel's
|
|
`BiStream` with the docker backend, records opener (hub) in the
|
|
ledger (ADR-047 §7), returns `{channel_id}`.
|
|
6. 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 the `channel_id` mapping `browser_id ↔ spoke_id`, returns
|
|
`{channel_id: browser_id}` to the consumer.
|
|
7. Hub byte-forwards between `browser_id` and `spoke_id` with 4-byte
|
|
`channel_id` rewrite (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
|
|
|
|
```rust
|
|
/// 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 (via `Arc`) 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:
|
|
|
|
```rust
|
|
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`:
|
|
|
|
1. ACL is already checked by `OperationRegistry::invoke` (the existing
|
|
`AccessControl::check` path — unchanged).
|
|
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
|
|
`channel:too_many_channels` if over cap.
|
|
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)`
|
|
(ADR-047 §5 — connection-owner allocates).
|
|
4. Record the opener in the per-connection ledger (ADR-047 §7).
|
|
5. Construct the `ChannelBidiStreamSource`, spawn the handler, record
|
|
the `ChannelState` (unchanged).
|
|
6. 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 `alk/channels`-inside-`alk/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:
|
|
|
|
1. **Call-protocol layer (channel 0): translate.** The hub terminates
|
|
channel 0 on both legs. `channels/<alpn>/sub` from the browser →
|
|
hub's `AccessControl::check` → hub re-issues `channels/<alpn>/sub`
|
|
on the spoke leg with `forwarded_for` → spoke returns its
|
|
`channel_id` → hub maps browser-id ↔ spoke-id.
|
|
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** The
|
|
relay reads chunks for `browser_id`, rewrites the `channel_id` field
|
|
to `spoke_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 `alk/tty`, `alk/ssh`, or
|
|
`alk/tunnel`. It runs `alk/channels` (the relay) and `alk/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/](decisions/).
|
|
|
|
| ADR | Decision | Summary |
|
|
|-----|----------|---------|
|
|
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The generic ops; `direction` pinned (amended by ADR-047 — `channel/open` dissolves; `direction` removed) |
|
|
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | Per-ALPN open ops; `channel_open` marker; `ChannelCore` wrapper; opener ledger |
|
|
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = alk/call |
|
|
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
|
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per PeerId, enforced via ChannelLifecyclePolicy in channels-call (amended by ADR-047 §7 — opener ledger, every teardown path) |
|
|
| [035](decisions/035-channels-pure-channel-multiplexing.md) | Pure Channel Multiplexing | No stream_types; handler owns sub-mux |
|
|
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
|
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
|
|
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | The establisher hook; typed `channel:open_failed`; `Establishment.plan` (amendment 2); the `JoinHandle` lifetime contract |
|
|
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | The establisher-contributed `ChannelControl`; wrapper-install/teardown-removal; `channel:no_control_handle` |
|
|
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | The snapshot-then-changes enumerator contract; scan-once aggregation; the empty seed |
|
|
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | The data-plane helper openable-ALPN handlers await inline |
|
|
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
|
|
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | 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_open` marker, `ChannelCore` wrapper, opener ledger)
|
|
- ADR-049: channel-open establishment phase (the establisher hook,
|
|
`channel:open_failed`, the plan payload)
|
|
- ADR-050: `pump_bidi` (the two-pump helper)
|
|
- 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`/`Sub` primitives)
|
|
- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/
|
|
findings.md` — the research that surfaced the unification and the gaps |