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

+39 -2
View File
@@ -1,12 +1,48 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-07-19
---
# Alknet Architecture
## Current State
**Per-identity channel cap added (ADR-094, 2026-07-19).** The
channels-layer `max_channels = 256` cap (ADR-076) was framed as the
per-connection DoS defense. On review this is not a DoS defense at
all — 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](decisions/094-per-identity-channel-cap.md)
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: `PerIdentityChannelPolicy::new(256)` — 256 per
`PeerId` across all the peer's connections (no "NoOp default + wire it
later"). The policy `Arc` is shared across every channels connection
a peer accepts, which is what makes the cap per-identity, not
per-connection. ADR-076 is amended — the per-connection `max_channels`
is reframed as a memory bound, the "DoS defense summary" table is
removed, and the "per-connection, not per-peer — a peer can open more
channels on a second connection" line (the channels layer confessing
a hole and hoping the layer above would fill it) is corrected. 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 cap is a
peer concern, not a hub-specific concern — any accepting peer (worker
or hub) enforces it, the same way it enforces `AccessControl::check`.
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 "assembly layer" hedging
pattern (putting the hard question off on a fictional later that
turns out to be exactly the same problem) is actively avoided — the
default is secure out of the box, and per-peer-role overrides are
explicit opt-ins. See [ADR-094](decisions/094-per-identity-channel-cap.md)
and the amended [ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md).
**Client-dial SOCKS5 proxy seam added (ADR-090, 2026-07-16).**
`AlknetClient` (ADR-089) gains an optional SOCKS5 proxy
(`with_socks5_proxy`) so a native client can hide its real IP from the
@@ -340,7 +376,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted (amended by ADR-093 — `stream_types` field removed from `channel/open`; `stream_type` field removed from `channel/control`) |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted (amended by ADR-093 — `into_sub_streams()` removed; `accept_bi` yields `BiStream`) |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted (amended by ADR-093 — 8-byte headers, one reassembly buffer per channel) |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`) |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`; amended by ADR-094 — per-connection `max_channels` reframed as a memory bound, not a DoS defense; per-identity DoS defense lives in `channels-call` via `ChannelLifecyclePolicy`) |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently) |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
@@ -358,6 +394,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial Credentials from Call Protocol | Accepted (amends ADR-089 §3/§5 and ADR-087 input framing; dial takes `ConnectionCredentials` not `CallCredentials`; all three dial signatures unified; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17) |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi` | Accepted (amends ADR-070's `accept_bi` return type; amends ADR-065's `from_stream`/`from_bidi` constructors; amends ADR-074's `ChannelBidiStreamSource::accept_bi` return type; `Connection::from_stream` removed; `from_bidi` is the only public stream constructor) |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | Accepted (amends ADR-071 — 8-byte header; ADR-074 — `into_sub_streams` removed; reverses ADR-077 — TTY always uses its 5-byte format; amends the channels-facing clauses of ADR-072/073/075/076/080/081) |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap as DoS Defense | Accepted (amends ADR-076 — per-connection `max_channels` reframed as a memory bound; 256 per `PeerId` enforced via `ChannelLifecyclePolicy` in `channels-call`; symmetric; spoke caps hub as direct caller) |
## Open Questions
+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
@@ -5,7 +5,54 @@
Accepted (amended 2026-07-18 by ADR-093 — backpressure is per-`channel_id`,
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-093,
2026-07-18)" below)
2026-07-18)" below; **amended 2026-07-19 by ADR-094 — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense; the per-identity DoS defense lives in `channels-call` via
`ChannelLifecyclePolicy` — see "Amendment (ADR-094, 2026-07-19)" below**)
## Amendment (ADR-094, 2026-07-19)
The per-connection `max_channels = 256` cap is **reframed as a
per-connection memory bound**, not a DoS defense. A single peer can
open an unbounded number of transport connections, so a per-connection
cap is not a per-peer DoS defense — it is a bound on one connection's
reassembly-buffer cost. The per-identity DoS defense (256 per
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
is documented in [ADR-094](094-per-identity-channel-cap.md).
What changes in this ADR:
1. **§"Maximum channels per connection: 256 default"** — the cap stays
at 256, but its role is reframed. It is a per-connection memory
bound (limits one connection's reassembly-buffer cost regardless of
policy), not the DoS defense against an authenticated peer. The
per-identity DoS defense is the `ChannelLifecyclePolicy`
consultation in the `channel/open` handler (ADR-094).
2. **§"DoS defense summary"** — the table is **removed**. It framed
the per-connection cap as the DoS defense, which it is not. ADR-094
§2 contains the corrected per-identity DoS defense summary.
3. **The "per-connection, not per-peer — a peer can open more channels
on a second connection" line** — this was the channels layer
confessing a hole and hoping the layer above it would fill it. The
line is **corrected** to state that the per-connection cap is a
memory bound, and that the per-identity cap is the DoS defense
(ADR-094). A peer that opens a second connection gets a second
per-connection memory bound; it does **not** get a second
per-identity quota — the `ChannelLifecyclePolicy` is shared across
connections.
What stays:
- The 256 default and the `max_channels` field on `ChannelManager`
(still returns `channel:too_many_channels` when hit — the
per-identity policy returns the same error code, so an over-cap
peer sees the same error either way).
- The bounded-buffer backpressure decision (DP-5) — unchanged.
- The channel-ID reuse decision (monotonic `next_id` with
wrap-around) — unchanged.
- The drain-before-reuse invariant — unchanged, and the
`channel/close` handler now also calls
`ChannelLifecyclePolicy::on_close` at this point (ADR-094 §3).
## Amendment (ADR-093, 2026-07-18)
@@ -88,36 +135,47 @@ default `max_channels` of 256, the `u32` space is effectively unlimited
which time old channels are long drained. **The "reuse" in OQ-CH-04 is
satisfied by the wrap-around, not by a free-list.**
### Maximum channels per connection: 256 default (OQ-CH-05/06)
### Maximum channels per connection: 256 default (OQ-CH-05/06 — memory bound)
The `channel_id` is `u32` — the wire format supports ~4 billion channels.
The practical limit is memory (reassembly buffers per channel) and the
transport's flow control.
**Default per-connection channel limit: 256** (`max_channels` field on
`ChannelManager`, configurable). This is the DoS defense (OQ-CH-06): an
authenticated peer that opens many channels and never reads from them is
bounded by `max_channels × buffer_cap` = 256 × 1 MiB = 256 MiB worst case.
Bounded buffers (DP-5) limit the damage per channel; the connection cap
limits the number of channels. Defense in depth.
`ChannelManager`, configurable). This is a **per-connection memory
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
= 256 MiB worst case per connection) regardless of policy. It composes
with the per-identity DoS defense (ADR-094) but is not itself a DoS
defense — a peer can open an unbounded number of transport connections,
so a per-connection cap cannot bound a peer's total channels. The
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
via `ChannelLifecyclePolicy`) is documented in
[ADR-094](094-per-identity-channel-cap.md).
Exceeding the limit returns `channel:too_many_channels` (ADR-073 error
codes). The limit is per-connection, not per-peer — a peer can open more
channels on a second connection.
Exceeding the per-connection limit returns `channel:too_many_channels`
(ADR-073 error codes) — the same error code the per-identity policy
returns when the per-identity cap is hit. An over-cap peer sees the
same error either way; which cap fired first is an implementation
detail. The limit is per-connection as a memory bound; the per-identity
cap (ADR-094) is what bounds a peer's total channels across all its
connections.
### DoS defense summary (OQ-CH-06)
| Layer | Mechanism | Default |
|-------|-----------|---------|
| Per-channel | Bounded reassembly buffer (stop reading when full) | 1 MiB per `(channel_id, stream_type)` |
| Per-connection | Channel count cap | 256 channels |
| Per-peer | Auth (`AccessControl::check` on `channel/open`) | Assembly-layer policy |
The DoS defense against an authenticated peer opening many channels is
the **per-identity cap** enforced in `channels-call` via
`ChannelLifecyclePolicy` — documented in
[ADR-094](094-per-identity-channel-cap.md). A per-connection cap
cannot be the DoS defense because a peer can open an unbounded number
of transport connections; the unit that must be bounded is the
identity, not the connection.
An authenticated peer that opens 256 channels and never reads from them
consumes at most 256 MiB of reassembly buffers — bounded, not unbounded.
The assembly layer's `AccessControl` policy can further restrict
`channel/open` (e.g., `required_scopes: ["channel:open:alknet/tty"]`) to
limit who can open channels at all.
The per-connection `max_channels = 256` (this ADR) is a **memory
bound** that limits one connection's reassembly-buffer cost. It
composes with the per-identity cap as defense-in-depth (the
`NoCap` policy path still has the per-connection memory bound), but
it is not the security boundary. See ADR-094 §2 for the corrected
DoS defense summary.
## Consequences
@@ -125,18 +183,21 @@ limit who can open channels at all.
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no
deadlock, no cross-channel blocking). The decision is made, not hedged.
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB
worst-case memory per connection — a clear DoS ceiling, not an open-ended
one.
worst-case memory per connection — a clear per-connection memory
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
`PeerId` across all the peer's connections) is documented in ADR-094.
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
complexity while still satisfying ID reuse (on wrap, after ~16.7M
channels).
**Negative:**
- The 256-channel default may be too low for a hub with many concurrent
browser sessions each opening multiple channels. The cap is configurable
per `ChannelManager`; the hub assembly layer may set it higher for
deployments with many concurrent sessions. This is a deployment-time
decision, not an architecture decision.
- The 256-channel per-connection cap may be too low for a hub with many
concurrent browser sessions each opening multiple channels. The cap
is configurable per `ChannelManager`; the hub deployment may set it
higher for deployments with many concurrent sessions. This is a
deployment-time decision, not an architecture decision. (The
per-identity cap in ADR-094 is the DoS-relevant bound; the
per-connection cap is a memory backstop.)
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
bounds the memory cost. A slow consumer still stalls its own channel's
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
@@ -157,8 +218,19 @@ doesn't change the wire format, so even that reversal is feasible.
by ADR-093)
- ADR-093: channels pure channel multiplexing (amends this ADR —
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
- ADR-073: channel lifecycle operations (`channel:too_many_channels` error)
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields)
- ADR-094: per-identity channel cap as DoS defense (amends this ADR —
the per-connection `max_channels = 256` is reframed as a per-connection
memory bound, not a DoS defense; the per-identity DoS defense lives in
`channels-call` via `ChannelLifecyclePolicy`)
- ADR-073: channel lifecycle operations (`channel:too_many_channels`
error; the `channel/open` and `channel/close` handlers that gain the
`ChannelLifecyclePolicy` consultation)
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
fields; the auth-blindness that forces the per-identity cap into
`channels-call`, not `channels-core`)
- ADR-032: forwarded-for identity (why the spoke caps the hub, not the
browser — `forwarded_for` is metadata, not authority, for the cap as
for `AccessControl::check`)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
validation), §POC Target 3 (1 MiB tunnel test)
- `docs/research/alknet-channels/phase-0-findings.md` §DP-5, §OQ-CH-03/04/
@@ -0,0 +1,324 @@
# ADR-094: Per-Identity Channel Cap as DoS Defense
## Status
Accepted (amends ADR-076's DoS-defense framing — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense)
## Context
ADR-076 set the channels-layer channel limit at 256 **per connection**
and framed that cap as the DoS defense against an authenticated peer
opening many channels and never reading from them ("DoS defense
summary" table, "Per-connection channel count cap → 256 channels"). On
review, the per-connection cap is not a DoS defense at all. A single
peer can open an unbounded number of transport connections, and across
those connections, across substrates, the peer gets 256 × N × (substrate
multiplier) channels:
| Substrate a peer can use | Channels per connection |
|--------------------------|--------------------------|
| In-line (TCP+TLS, WebTransport, SSH `direct-tcpip`) | 256 (one stream, header-demuxed) |
| Native (QUIC substreams) | 256 (per-connection demux; each substream carries one channel) |
| Multi-connection (N transport connections) | 256 × N |
A peer that opens 10 transport connections to the same accepting peer
gets 2,560 channels. A peer that opens 100 gets 25,600. There is no
bound on the number of transport connections a peer can open. The
"per-connection, not per-peer — a peer can open more channels on a
second connection" line in ADR-076 was, in retrospect, the channels
layer confessing a hole and hoping the layer above it would fill it.
That is not a DoS defense; it is a per-connection memory bound
(reassembly-buffer cost per connection) labeled as a DoS defense.
The only coherent unit for a channel DoS defense is the **identity**.
The peer, not the connection, is what an authenticated-DoS defense
must bound. This is the same primitive as any other resource ACL:
`OwnershipProvider` (ADR-050) checks "does identity X own resource
Y?"; the channel cap checks "has identity X exceeded their channel
quota?" Same shape, different resource.
### Why the channels layer cannot hold the cap
`ChannelManager` (ADR-075) is auth-blind by design: "No auth state.
Auth lives in the `OperationContext` that the call protocol passes to
`channel/open`." That decision is load-bearing — it is what makes the
channels layer WASM-compatible, transport-agnostic, and ALPN-blind
(ADR-075, ADR-093). Putting per-identity tracking in the channels
layer would reverse ADR-075.
So the per-identity cap lives **one layer up**, in `channels-call`,
where the identity is already on `OperationContext` (the same place
`AccessControl::check` runs). The `channel/open` and `channel/close`
handlers (ADR-073) are in `channels-call` already; they gain a policy
consultation. The channels layer (`channels-core`) is unchanged —
still auth-blind, still WASM-clean.
### This is not a hub-specific concern
The cap is a **channels-accepting-peer concern**. A worker accepting a
direct channels connection from a peer needs the cap just as much as a
hub does. The call protocol does not need a hub to enforce "does this
peer have access to this resource?" (ADR-073: `AccessControl::check` on
`channel/open`), and neither should channels. Framing the cap as
hub-specific would be the "assembly layer" hedging pattern — putting
the hard question off on a fictional "later" that, when it arrives,
turns out to be exactly the same problem. The cap is a peer concern;
the hub is one peer that happens to aggregate others.
The cap is also **symmetric**, like the call protocol. Peer A accepts
a channels connection from Peer B; A enforces its cap on B's channels;
B enforces its cap on A's channels. Both sides have the cap, both
sides check it, same as `AccessControl::check` on any operation.
## Decision
### 1. A `ChannelLifecyclePolicy` trait in `channels-call`
```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`.
///
/// A channel slot is a resource; the cap is a quota check on that
/// resource — parallel to `OwnershipProvider::owns` (ADR-050) for
/// spawned resources. Same primitive, different resource.
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);
}
```
### 2. Default: `PerIdentityChannelPolicy::new(256)`
The default constructor enforces 256 per identity out of the box — no
hedging, no "NoOp default + wire it in the assembly layer." 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. For a hub, that's one `Arc<PerIdentityChannelPolicy>`
on the `Hub`, shared across all worker and browser legs. For a
worker accepting direct channels, that's one `Arc` on the worker's
own state, shared across whatever connections it accepts. For tests
and POCs, the default constructor.
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` — a
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 (for tests, POCs, and trusted single-peer
deployments). Explicit opt-out, 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)?;
```
The same `Arc<PerIdentityChannelPolicy>` is shared across every
channels connection that peer accepts — that is what makes the cap
per-identity, not per-connection.
### 3. Enforcement point: between `AccessControl::check` and allocation
The `channel/open` handler (ADR-073) 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 (ADR-073) 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).
### 4. `ChannelManager.max_channels = 256` stays as a per-connection memory bound
The per-connection cap (ADR-076) stays, but is reframed. It is no
longer the DoS defense — it is a per-connection **memory bound** that
limits one connection's reassembly-buffer cost regardless of policy.
It composes with the per-identity cap but is not the security
boundary. It still returns `channel:too_many_channels` when hit; the
per-identity policy returns the same error when the per-identity cap
is hit. An over-cap peer sees the same error either way; which cap
fired first is an implementation detail.
Keeping the per-connection bound as a backstop covers deployments that
use `NoCap` (tests, trusted single-peer) and bounds the damage if a
custom policy is buggy. Removing it would leave the channels layer
unbounded in the no-policy case. The cost of keeping it is zero (the
cap is already implemented in the POC); the cost of removing it is a
real hole in the `NoCap` path.
### 5. 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 (`PerIdentityChannelPolicy::with_per_identity_caps`);
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).
### 6. 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. If a
different policy is wired, the inner channels are counted against
that policy's identity (which may be a different identity, if the
inner channels connection is authenticated separately). Either way,
the cap applies; recursion is not a bypass. The 13-byte-per-chunk
overhead of recursion is the documented cost (ADR-093); the cap
behavior is unchanged. Recursive channels are an edge case for edge
cases and not specced further.
## Consequences
**Positive:**
- A real per-identity DoS defense. A peer with N transport connections
to the same accepting peer is bounded by 256 (or the configured
per-identity cap), not 256 × N × (substrate multiplier). The cap
composes correctly across substrates because the unit is the
identity, not the connection.
- The cap is symmetric, like the call protocol. Both sides of a
channels connection enforce their cap; the cap is a peer concern,
not a hub-specific concern.
- The cap lives in `channels-call`, where the identity is already on
`OperationContext`. The channels layer (`channels-core`) is
unchanged — still auth-blind, still WASM-clean, still
transport-agnostic. ADR-075's auth-blindness is preserved.
- The default is secure. `PerIdentityChannelPolicy::new(256)` is the
out-of-the-box behavior; opt-outs (`NoCap`) are explicit. A
deployment that forgets to wire a policy still gets a per-identity
cap.
- The cap is the same primitive as any other resource ACL
(`OwnershipProvider` for spawned resources, `AccessControl::check`
for operations). The mental model is uniform: a channel slot is a
resource, the cap is a quota check on that resource.
**Negative:**
- One new trait (`ChannelLifecyclePolicy`) and one new constructor
argument on `ChannelOperations`. The `channel/open` and
`channel/close` handlers gain a policy call. Small implementation
cost; the policy is a single trait method per direction.
- Per-identity state is shared across connections
(`HashMap<PeerId, usize>` on the policy, guarded by a `Mutex`). The
state is touched on `channel/open` and `channel/close` only — not
on every chunk. The contention is per-identity, not per-chunk;
acceptable for the intended use cases.
- A spoke serving a high-fan-out hub must set the hub peer's cap
higher than the default, or legitimate relayed channels are denied.
This is a deployment-time policy decision, surfaced explicitly by
`with_per_identity_caps`. Not a flaw; the same shape as any
per-peer ACL.
- The cap is per direct-caller identity (ADR-032), not per
`forwarded_for` originator. A hub relaying for 100 browsers
consumes one channel slot per relayed channel against the hub's
quota on the spoke, not 100 slots against 100 browser quotas. A
spoke that wants per-browser capping would need to read
`forwarded_for` for authority, which ADR-032 explicitly forbids.
This is the correct trade-off: capping against `forwarded_for`
would reverse ADR-032's "forwarded_for is metadata, not authority"
and is a much bigger change. The hub enforces per-browser caps on
the browser leg; the spoke enforces per-hub caps on the spoke leg.
## Door type
**One-way.** The `ChannelLifecyclePolicy` trait surface
(`check_open(&Identity) -> Result<(), ChannelError>` and
`on_close(&Identity)`) is a one-way-door API commitment — the
`channels-call` `channel/open` and `channel/close` handlers depend on
it, and consumers (`Hub`, worker crates) construct implementations.
Removing the trait or changing the signatures after deployments exist
is a breaking change.
The **default cap value (256)** is a two-way-door implementation
detail within the one-way trait surface — changing the default is
additive (a new constructor or a default-override), not a wire-format
change.
The **reframing of ADR-076's per-connection cap** (from DoS defense
to memory bound) is two-way — it's a documentation change, not a
behavior change. The per-connection cap still exists, still returns
`channel:too_many_channels`, and still bounds one connection's
reassembly-buffer cost.
## References
- **ADR-076**: Backpressure, Channel Limits, and ID Reuse (amended by
this ADR — the per-connection `max_channels = 256` is reframed as a
per-connection memory bound, not a DoS defense; the "DoS defense
summary" table is removed; the "per-connection, not per-peer" line
is corrected)
- **ADR-075**: ChannelsAdapter and ChannelManager (the auth-blindness
this ADR preserves — the cap lives in `channels-call`, not
`channels-core`)
- **ADR-073**: Channel Lifecycle Operations (the `channel/open` and
`channel/close` handlers that gain the policy check; the
`channel:too_many_channels` error code)
- **ADR-093**: channels Pure Channel Multiplexing (the umbrella
decision; the channels layer has no `stream_type` concept, and no
identity concept either — both are above it)
- **ADR-032**: Forwarded-For Identity (Metadata, Not Authority) (why
the spoke caps the hub, not the browser — `forwarded_for` is
metadata; the direct caller's identity is the authority for the cap
just as it is for `AccessControl::check`)
- **ADR-079**: Hub Relay — Translate, Not Transparently Forward (the
relay path where the spoke sees the hub as the direct caller)
- **ADR-050**: Dynamic Resource Ownership for Runtime-Spawned
Resources (the parallel — a channel slot is a resource, the cap is
a quota check, same primitive as `OwnershipProvider::owns`)
- **ADR-030**: PeerEntry and Identity.id Decoupling (`PeerId` =
`Identity.id` — the stable key the per-identity cap counts against)