Files
alkcall/docs/architecture/decisions/041-per-identity-channel-cap.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

324 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-041: Per-Identity Channel Cap as DoS Defense
## Status
Accepted (amends ADR-040's DoS-defense framing — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense)
## Context
ADR-040 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-040 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-011) 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-039) 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-039, ADR-035). Putting per-identity tracking in the channels
layer would reverse ADR-039.
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-037) 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-037: `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-011) for
/// spawned resources. Same primitive, different resource.
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-037) when the identity is over its cap. The identity is
/// the direct caller (the peer that opened this channels
/// connection); `forwarded_for` is metadata and is NOT consulted
/// (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-040 §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-037) 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-037) gains the decrement after the
drain completes (the same point ADR-040 marks the `channel_id` as
eligible for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-040
§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-040) 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-042), the
spoke sees the hub as the direct caller. `forwarded_for` carries the
browser's identity as metadata (ADR-026 — `forwarded_for` is not
authority; `AccessControl::check` never reads it). The channel cap
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
consulted with the **hub's** identity, not the browser's. The spoke
asks "does the hub have access to open another channel?" and the
hub's quota on the spoke reflects the aggregate of all relayed
channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
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-035); 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-039'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-026), 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-026 explicitly forbids.
This is the correct trade-off: capping against `forwarded_for`
would reverse ADR-026'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-040'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-040**: 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-039**: ChannelsAdapter and ChannelManager (the auth-blindness
this ADR preserves — the cap lives in `channels-call`, not
`channels-core`)
- **ADR-037**: Channel Lifecycle Operations (the `channel/open` and
`channel/close` handlers that gain the policy check; the
`channel:too_many_channels` error code)
- **ADR-035**: 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-026**: 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-042**: Hub Relay — Translate, Not Transparently Forward (the
relay path where the spoke sees the hub as the direct caller)
- **ADR-011**: 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-025**: PeerEntry and Identity.id Decoupling (`PeerId` =
`Identity.id` — the stable key the per-identity cap counts against)