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

+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