docs(adr): 094 — per-identity channel cap as DoS defense

ADR-076 framed the per-connection max_channels=256 cap as the DoS
defense, but a peer can open an unbounded number of transport
connections, so a per-connection cap bounds a connection's
reassembly-buffer cost, not a peer's total channels. The only coherent
unit for a channel DoS defense is the identity.

ADR-094 records the corrected design: a ChannelLifecyclePolicy trait
in channels-call (where the identity is already on OperationContext),
consulted by the channel/open handler (after AccessControl::check,
before allocation) and the channel/close handler (after the drain
completes). Default is PerIdentityChannelPolicy::new(256) — 256 per
PeerId across all the peer's connections, shared via Arc across every
channels connection a peer accepts. The cap is a peer concern (not
hub-specific), symmetric (both sides enforce), and lives in
channels-call because the channels layer is auth-blind by design
(ADR-075) — that is what makes it WASM-compatible, transport-agnostic,
and ALPN-blind.

For the hub-relay path (ADR-079), the spoke sees the hub as the direct
caller (ADR-032 — forwarded_for is metadata, not authority, for the cap
as for AccessControl::check), so the spoke caps the hub, not the
browser. A spoke serving a high-fan-out hub sets the hub peer's cap
higher via with_per_identity_caps — the spoke's own policy, not the
hub's. Recursive channels do not bypass the cap (the same policy can
be wired into the inner ChannelOperations).

ADR-076 is amended: the per-connection max_channels is reframed as a
per-connection memory bound (still returns channel:too_many_channels
when hit), the "DoS defense summary" table is removed, and the
"per-connection, not per-peer" line (the channels layer confessing a
hole and hoping the layer above would fill it) is corrected.

Spec docs updated to reference ADR-094: channel-operations.md gains a
"Per-identity channel cap" section (trait, default, enforcement
point, relay consequence, recursion); channels-adapter.md adds the
policy check as step 3 of the channel/open handler and the decrement
in channel/close; hub README adds the channel_policy field on Hub,
the with_channel_policy builder, a dedicated subsection, and the
inbound-peer-vs-hub-as-caller distinction; channels/README.md adds
ADR-094 to the Applicable ADRs table and a 9th Key Design Principle;
docs/architecture/README.md adds a Current State note and the ADR
table row.
This commit is contained in:
glm-5.2 committed 2026-07-19 11:17:57 +00:00
1 parent 762d9c7bd2
commit 0fcd5bc322
7 files changed
+789 -51

No files matched your search

+15 -1
View File
@@ -37,7 +37,8 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-093 — `into_sub_streams` removed) |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-094) |
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
@@ -120,6 +121,19 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
within the 8-byte header). This preserves the auth model. See ADR-079.
9. **The channel cap is per-identity, not per-connection.** A channel
slot is a resource; the cap on how many an identity may hold open is
a quota check, parallel to `OwnershipProvider::owns` (ADR-050) for
spawned resources. The cap lives in `channels-call` (the channels
layer is auth-blind by ADR-075 — no identity, no scopes), consulted
by the `channel/open` and `channel/close` handlers after
`AccessControl::check`. The default is `PerIdentityChannelPolicy::
new(256)` — 256 per `PeerId` across all the peer's connections. The
per-connection `max_channels` (ADR-076) is a memory bound, not a
DoS defense. The cap is symmetric (both sides enforce); the spoke
caps the hub as direct caller, not the browser as `forwarded_for`
(metadata, not authority — ADR-032). See ADR-094.
## References
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
@@ -235,6 +235,150 @@ 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.
## Per-identity channel cap (ADR-094)
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-050) 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 `channel/open`. 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-075) 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-094 §"Why the channels layer cannot hold the cap".
The channels-layer per-connection `max_channels = 256` (ADR-076) 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-094 for the corrected DoS-defense framing.
### The `ChannelLifecyclePolicy` trait
```rust
/// Per-identity channel lifecycle policy. Consulted by the
/// `channel/open` handler (after `AccessControl::check`, before
/// allocation) and the `channel/close` handler (after deallocation).
/// Both handlers have the identity via `OperationContext`.
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-073) 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-032).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-076 §channel-id-reuse).
fn on_close(&self, identity: &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 `channel/open` handler (above) 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)`
(DP-1: server-assigned — unchanged).
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
the `ChannelState` (unchanged).
5. Return the `channel_id`.
The `channel/close` handler gains the decrement after the drain
completes (the same point ADR-076 marks the `channel_id` as eligible
for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-076
§channel-id-reuse).
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
per-identity count.
3. Return `{ "closed": true }` (unchanged).
### Relay consequence: the spoke caps the hub, not the browser
When the hub relays a browser's channel to a spoke (ADR-079), the
spoke sees the hub as the direct caller. `forwarded_for` carries the
browser's identity as metadata (ADR-032 — `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.
This is correct and consistent — the spoke authorizes the hub for
container access the same way it authorizes any peer, and the hub's
browser-relay ACL is the hub's own layer. The channel cap follows the
same pattern as any other resource ACL.
**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 (a
spoke may authorize one peer for 1000 containers and another for 10;
the channel cap is the same kind of per-peer policy).
### 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-093), and the cap behavior is unchanged. Recursive channels
are an edge case for edge cases and not specced further.
## Hub relay contract (ADR-079 — summary)
The hub **translates**, not transparently forwards:
@@ -267,14 +411,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing |
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
| [032](../../decisions/032-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`) |
| [050](../../decisions/050-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 |
## References
- ADR-073: channel lifecycle operations (the decision)
- ADR-094: per-identity channel cap (the cap, the trait, the relay
consequence)
- ADR-079: hub relay (the translate contract)
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
Negotiation, §ACL and Security Model
@@ -78,7 +78,14 @@ pub struct ChannelManager {
// call-protocol-blind.
next_id: AtomicU32, // monotonic; wraps at u32::MAX
buffer_cap: usize, // default 1 MiB (ADR-076)
max_channels: usize, // default 256 (ADR-076)
max_channels: usize, // default 256 (ADR-076) — per-connection
// memory bound, NOT a DoS defense. The
// per-identity DoS defense is the
// ChannelLifecyclePolicy consulted by the
// channel/open handler in channels-call
// (ADR-094). The auth-blindness that forces
// the cap out of this struct is ADR-075's
// "no auth state" rule.
}
struct ChannelState {
@@ -136,27 +143,55 @@ dependencies.
The `channel/open` (and `channel/close`, `channel/control`,
`channel/resources/subscribe`) operations are registered on the call
protocol's `OperationRegistry` at assembly time:
protocol's `OperationRegistry` at registration time. The
`ChannelOperations` constructor takes a `ChannelLifecyclePolicy`
(ADR-094) — the default is `PerIdentityChannelPolicy::new(256)` (a
real per-identity cap, not NoOp):
```rust
let channel_ops = ChannelOperations::new(manager.clone());
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager.clone(), policy);
channel_ops.register_on(&mut call_registry)?;
```
The same `Arc<PerIdentityChannelPolicy>` is shared across every
channels connection this peer accepts — that is what makes the cap
per-identity, not per-connection. A hub constructs one policy and
shares it across all worker and browser legs; a worker accepting
direct channels constructs one policy and shares it across whatever
connections it accepts. See ADR-094 for the policy trait and the
default/opt-out variants.
The `channel/open` handler (ADR-073):
1. ACL is already checked by `OperationRegistry::invoke` before this handler
runs.
2. Looks up the ALPN in `HandlerRegistry` → `channel:unknown_alpn` if
missing.
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned).
4. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
3. **Per-identity cap check (ADR-094):**
`policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if the identity is over its cap. The
identity is the direct caller (the peer on this channels
connection); `forwarded_for` is metadata and is NOT consulted
(ADR-032). For the hub-relay path, the spoke sees the hub as the
direct caller — the hub's quota on the spoke reflects the aggregate
of all relayed channels (ADR-094 §5).
4. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned). The per-connection `max_channels` (ADR-076) is
checked here too — the per-connection memory bound; if hit, the same
`channel:too_many_channels` error is returned (which cap fired first
is an implementation detail — ADR-094 §4).
5. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
ADR-093) — one reassembly buffer, yielding a `BiStream`.
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
6. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
Identical to what `TtyAdapter::handle` does today, but on a
channels-backed `Connection`.
6. Records the `ChannelState`.
7. Returns the `channel_id`.
7. Records the `ChannelState`.
8. Returns the `channel_id`.
The `channel/close` handler (ADR-073) gains a symmetric
`policy.on_close(&op_ctx.identity)` call after the drain completes
(the same point ADR-076 marks the `channel_id` as eligible for reuse)
— decrementing the per-identity count.
## Demux invariants (REQ-CH-02, 04)
@@ -258,7 +293,8 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|-----|----------|---------|
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel per-connection memory bound, monotonic IDs (DoS defense reframed by ADR-094) |
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
@@ -271,7 +307,11 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-074: ChannelBidiStreamSource (what the manager constructs per
channel, as amended by ADR-093)
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels` — the
per-connection memory bound)
- ADR-094: per-identity channel cap (the `ChannelLifecyclePolicy`
consulted by the `channel/open` handler; the relay consequence for
hub-relayed channels)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
(REQ-CH-01..04, the two-pump deadlock)
- `docs/research/stream-unification/findings.md` — the research that
+110 -6
View File
@@ -117,7 +117,21 @@ welded to a dial. See "Transport" below.
Lets a browser reach a spoke's channels through the hub without the
hub parsing any protocol-specific framing.
6. **Worker registration** (in scope of the hub) — the HTTP endpoint
6. **Per-identity channel cap** — the hub constructs one
`ChannelLifecyclePolicy` (ADR-094) and shares it across every
channels connection it accepts. This is the cap the hub enforces on
its **inbound** peers (workers and browsers connecting to the hub).
The cap is per-identity, not per-connection — a peer with N
transport connections to the hub is bounded by the cap once, not
N times. The default is 256 per `PeerId`; per-peer-role overrides
(e.g., a lower cap for browser peers) are set via
`with_channel_policy`. The hub-as-caller case (hub dialing a
downstream spoke) is the **spoke's** policy — the spoke constructs
its own policy with a high cap for the hub peer (ADR-094 §5). The
cap is symmetric — both sides of a channels connection enforce
their cap. See "Per-identity channel cap" below.
7. **Worker registration** (in scope of the hub) — the HTTP endpoint
that lets a freshly-provisioned worker enroll its key with a
one-time registration token. The registration flow is what makes
worker provisioning over TCP+TLS a hard requirement, not an
@@ -181,7 +195,8 @@ The hub's `CallClient`-direct dial path is replaced by
### Hub struct
The `Hub` owns the aggregated `PeerCompositeEnv`, the
`OperationRegistry`, and the `Dispatcher`:
`OperationRegistry`, the `Dispatcher`, and the per-identity channel
cap policy:
```rust
pub struct Hub {
@@ -189,6 +204,15 @@ pub struct Hub {
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
dispatcher: Dispatcher,
identity_provider: Arc<dyn IdentityProvider>,
/// The per-identity channel cap policy (ADR-094). Shared across
/// every channels connection the hub accepts — that is what makes
/// the cap per-identity, not per-connection. Constructed once at
/// Hub::new and passed to ChannelOperations::new for each
/// connection. The hub's browser-leg caps and worker-leg caps are
/// enforced by the same policy (the cap is symmetric — both
/// sides of a channels connection enforce their cap on the other's
/// channels).
channel_policy: Arc<dyn ChannelLifecyclePolicy>,
}
```
@@ -211,15 +235,43 @@ impl Hub {
aggregated_env,
dispatcher,
identity_provider,
channel_policy: Arc::new(PerIdentityChannelPolicy::new(256)),
}
}
/// The shared aggregated PeerCompositeEnv. The assembly layer wires
/// this into CallAdapter::with_aggregated_env so every call's
/// The shared aggregated PeerCompositeEnv. The deployment binary
/// wires this into CallAdapter::with_aggregated_env so every call's
/// compose_root_env sees all connected workers.
pub fn aggregated_env(&self) -> &Arc<RwLock<PeerCompositeEnv>> {
&self.aggregated_env
}
/// The shared per-identity channel cap policy (ADR-094). Wired into
/// `ChannelOperations::new` for every channels connection the hub
/// accepts — this is the cap the hub enforces on its **inbound**
/// peers (workers and browsers connecting to the hub). The policy
/// `Arc` is shared across all the hub's accepted connections, which
/// is what makes the cap per-identity (a peer with N transport
/// connections to the hub is bounded by the cap once, not N times).
/// The hub-as-caller case (hub dialing a downstream spoke) is
/// governed by the **spoke's** policy, not this one — the spoke
/// constructs its own `ChannelLifecyclePolicy` with a high cap for
/// the hub peer (ADR-094 §5). See "Per-identity channel cap" below.
pub fn channel_policy(&self) -> &Arc<dyn ChannelLifecyclePolicy> {
&self.channel_policy
}
/// Override the default per-identity channel cap policy. Builder
/// method for the deployment binary to set per-peer-role caps on
/// the hub's inbound peers (e.g., a worker peer gets 256, a
/// browser peer gets a lower cap). The hub-as-caller case on a
/// downstream spoke is the spoke's own policy, not set here.
pub fn with_channel_policy(mut self, policy: Arc<dyn ChannelLifecyclePolicy>)
-> Self
{
self.channel_policy = policy;
self
}
}
```
@@ -410,12 +462,16 @@ via a builder method. The `ChannelsAdapter::handle` flow becomes:
aggregated env.
The assembly layer constructs the callback and passes it to
`ChannelsAdapter`:
`ChannelsAdapter`, wiring the hub's per-identity channel cap policy
(ADR-094) into `ChannelOperations::new` so every channels connection
the hub accepts shares the same policy (the cap is per-identity, not
per-connection, because the policy `Arc` is shared):
```rust
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(Arc::clone(&registry), /* ... */)
.with_worker_connected_callback(callback);
.with_worker_connected_callback(callback)
.with_channel_policy(hub.channel_policy().clone());
// Register channels_adapter on alknet/channels in the HandlerRegistry.
// The endpoint dispatches alknet/channels connections to it — whether
// they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
@@ -594,6 +650,46 @@ handlers (`alknet/tty`, `alknet/ssh`, `alknet/tunnel`) — it runs
translation). The full relay contract is in ADR-079; the relay
implementation lives in `alknet-hub`.
### Per-identity channel cap (ADR-094)
A channel slot is a resource. The cap on how many channels a peer may
hold open against the hub is a quota check on that resource — parallel
to `OwnershipProvider::owns` (ADR-050) for spawned resources. The hub
constructs one `ChannelLifecyclePolicy` and shares it across every
channels connection it accepts (the policy `Arc` is shared, so the
cap is per-identity, not per-connection). This is the cap the hub
enforces on its **inbound** peers — workers and browsers connecting
to the hub. The default is `PerIdentityChannelPolicy::new(256)` — 256
per `PeerId` across all the peer's connections to the hub. The cap is
symmetric — both sides of a channels connection enforce their cap on
the other's channels.
The cap lives in `channels-call`, not `channels-core`, because the
channels layer is auth-blind by design (ADR-075 — that is what makes
it WASM-compatible, transport-agnostic, and ALPN-blind). The identity
is on `OperationContext`; the `channel/open` handler consults the
policy after `AccessControl::check` and before allocation; the
`channel/close` handler decrements after the drain completes.
**Relay consequence (ADR-094 §5):** when the hub relays a browser's
channel to a spoke, the spoke sees the hub as the direct caller
(ADR-032 — `forwarded_for` is metadata, not authority, for the cap as
for `AccessControl::check`). The spoke's cap applies to the hub, not
the browser. A spoke that serves a hub relaying for many browsers
must set the hub peer's cap higher than a worker peer's cap on the
**spoke's own** `ChannelLifecyclePolicy`, 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 on the spoke, not on
the hub — the hub's `channel_policy` governs the hub's inbound peers,
not the hub-as-caller case. The hub enforces per-browser caps on the
browser leg (the hub's own policy); the spoke enforces per-hub caps
on the spoke leg (the spoke's own policy). Same shape as any per-peer
ACL.
See [ADR-094](../../decisions/094-per-identity-channel-cap.md) for
the full decision, the trait, the default/opt-out variants, and the
recursive-channels edge case.
### Service discovery
The hub registers the built-in service discovery operations
@@ -798,6 +894,7 @@ into `CallAdapter::with_aggregated_env`.
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
| `TlsClientConfig` for outbound dials | [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `alknet-tls` provides client-side TLS config; hub-as-client is a first-class use case; not blocked on the dial-seam extraction (OQ-55) |
| `AlknetClient` native dial seam | [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) | New crate `alknet-client`; the hub's outbound worker dials use `AlknetClient` (via the `supervise_worker` closure or the `connect_quic_worker` convenience); resolves OQ-55 |
| Per-identity channel cap | [ADR-094](../../decisions/094-per-identity-channel-cap.md) | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; the hub's policy governs its inbound peers and is shared across all their connections; the hub-as-caller case on a downstream spoke is the spoke's own policy with a high cap for the hub peer (ADR-094 §5) |
## Open Questions
@@ -857,16 +954,23 @@ See [open-questions.md](../../open-questions.md) for full details.
`resolve_from_fingerprint` (the identity paths over transports)
- ADR-029: Peer-Graph Routing Model
- ADR-034: Three Peer Roles (hub = role-3, bearer-token identity)
- ADR-050: Dynamic Resource Ownership (the parallel for the channel cap —
a channel slot is a resource, the cap is a quota check)
- ADR-065: `Connection::from_stream`/`from_bidi` (TCP+TLS path)
- ADR-067: Aggregated Peer-Environment Wiring
- ADR-068: PeerCompositeEnv::peer_operations Override
- ADR-069: from_call Is a Manual Free Function
- ADR-075: ChannelsAdapter and ChannelManager (the auth-blindness that
forces the per-identity cap into `channels-call`, not `channels-core`)
- ADR-079: Hub Relay — Translate, Not Transparently Forward
- ADR-080: ChannelClient (transport-agnostic `from_connection`)
- ADR-082: alknet-tls extraction (`TlsServerConfig` — shared across quinn + TCP+TLS)
- ADR-083: Endpoint as multi-transport accept-loop runner (`with_tcp_tls` — TCP+TLS owned by the endpoint; the hub composes transports and handlers)
- ADR-086: Endpoint types and entry points (web/native/iroh; entry-point vs. endpoint; split ALPN lists per endpoint type)
- ADR-087: `TlsClientConfig` not blocked on dial seam (client-side TLS config; hub-as-client requirement)
- ADR-094: Per-Identity Channel Cap (the `ChannelLifecyclePolicy` the hub
constructs and shares across all its channels connections; the relay
consequence for hub-as-caller on downstream spokes)
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
the first hub consumer, the concrete use case that informed this
crate