Files
alkcall/src/channels/policy.rs
T
glm-5.2 f305f8c0a5 feat: implement channels protocol + ADR-047 (openable ALPNs are operations)
ADR-047: the unifying decision that  dissolves into
per-ALPN ops (, ) with a
 marker on . Each openable ALPN registers
its own ops with their own , ,
, and the marker. The  field is replaced
by  (Sub/Pub). The generic ops (channel/close,
channel/control, channel/resources/subscribe) stay, keyed by
channel_id. Resolves Gaps A-G from the research findings (Gap B broker
named out-of-scope for alkcall; Gap C relay wrapper is consumer
concern; Gap D connection-owner allocates; Gap E extension trait;
Gap F boolean marker on wire; Gap G ACL/ownership complementary).

ADR-037 amended:  dissolves;  removed; generic
ops stay;  preview dropped from resources/subscribe.

Spec docs updated: channel-operations.md (unified model, opener ledger,
ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec),
README.md (ADR-047), open-questions.md (OQ-31..38 resolved).

Source changes:
- spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec,
  with_channel_open builder, 3 tests
- discovery.rs: spec_to_json emits channel_open boolean,
  operation_spec_schema includes channel_open, 2 tests
- from_call.rs: rebuild_spec_for parses channel_open marker,
  derive_alpn_from_op_name helper, 6 tests

Channels module (src/channels/, 10 files, ~2400 lines):
- wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header,
  read_header/write_chunk/write_eof async helpers), 12 tests
- reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver<Bytes> →
  AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>),
  REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests
- mux.rs: MuxHandle (clone-able, register(channel_id)),
  MuxRunner (per-channel pump tasks, exits when handles drop),
  OpenerLedger (ADR-047 §7), 4 tests
- manager.rs: ChannelManager (channel map, open_channel,
  install_channel_zero, route_payload, teardown_channel, clear_all),
  11 tests
- source.rs: ChannelBidiStreamSource (yield-once accept_bi),
  channel_source helper, 4 tests
- adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels,
  demux loop, install_channel_zero hook), 1 test
- operations.rs: ChannelOperations (registers channel/close,
  channel/control, channel/resources/subscribe), ChannelCore
  (check_open/on_close wrappers), 4 tests
- policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy
  (default 256, per_identity_caps override), default_policy, 8 tests
- env.rs: ChannelOperationEnv extension trait (ADR-047 §4),
  ChannelsSessionEnv impl, 2 tests
- client.rs: ChannelClient (from_connection, call_open_op,
  take_call_connection), 1 test

Verification: 432 tests pass (66 new channels + 10 marker + 356
existing), clippy clean, fmt clean, cargo doc generates.

Cargo.toml: +bytes dependency.
2026-08-12 12:13:53 +00:00

274 lines
9.4 KiB
Rust

//! `ChannelLifecyclePolicy` — per-identity channel cap (ADR-041,
//! amended by ADR-047 §7 — opener ledger, every teardown path).
//!
//! The cap is a **peer concern**: any accepting peer enforces the cap
//! on its inbound channels, just as it enforces `AccessControl::check`
//! on the open op. 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
//! `ChannelManager` is auth-blind by design (ADR-039). The identity is
//! on `OperationContext`; the policy is consulted after
//! `AccessControl::check` and before allocation, and on every teardown
//! path keyed by the opener (from the per-connection opener ledger).
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use crate::core::auth::Identity;
/// Errors raised by `ChannelLifecyclePolicy::check_open`.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum ChannelError {
#[error("too many channels for identity {identity}: {count} (cap {cap})")]
TooManyChannels {
identity: String,
count: usize,
cap: usize,
},
}
/// Per-identity channel lifecycle policy. Consulted by the open-op
/// wrapper (after `AccessControl::check`, before allocation) and on
/// every teardown path (after deallocation). Both have the identity
/// via `OperationContext` (for open) or the opener ledger (for
/// teardown — ADR-047 §7).
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// when the identity is over its cap. The identity is the direct
/// caller (the peer that opened this channels connection);
/// `forwarded_for` is metadata and is NOT consulted (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called on every teardown path (close received, close sent
/// locally, handler exit, connection drop), keyed by the opener
/// from the per-connection ledger (ADR-047 §7) — not the closer.
fn on_close(&self, opener: &Identity);
}
/// No-cap policy — explicit opt-out for tests, POCs, and trusted
/// single-peer deployments. Not the default (ADR-041).
#[derive(Default)]
pub struct NoCap;
impl ChannelLifecyclePolicy for NoCap {
fn check_open(&self, _identity: &Identity) -> Result<(), ChannelError> {
Ok(())
}
fn on_close(&self, _opener: &Identity) {}
}
/// Shared per-identity state for `PerIdentityChannelPolicy`. The
/// `HashMap<PeerId, usize>` + cap is 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.
#[derive(Default)]
struct SharedCounts {
counts: HashMap<String, usize>,
}
/// The default policy — 256 per identity (ADR-041). 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 (`NoCap`).
pub struct PerIdentityChannelPolicy {
cap: usize,
per_identity_caps: HashMap<String, usize>,
shared: Arc<Mutex<SharedCounts>>,
}
impl PerIdentityChannelPolicy {
/// Construct with a uniform cap. The cap is shared across every
/// channels connection this peer accepts (via the `Arc<Mutex<_>>`).
pub fn new(cap: usize) -> Self {
Self {
cap,
per_identity_caps: HashMap::new(),
shared: Arc::new(Mutex::new(SharedCounts::default())),
}
}
/// Construct with per-peer-role overrides. `mapping` is
/// `HashMap<PeerId, cap>` — a hub peer's cap may be set higher
/// than a worker peer's cap (the "Relay consequence" in
/// `channel-operations.md`).
pub fn with_per_identity_caps(cap: usize, mapping: HashMap<String, usize>) -> Self {
Self {
cap,
per_identity_caps: mapping,
shared: Arc::new(Mutex::new(SharedCounts::default())),
}
}
/// The current count for `identity` — for tests and observability.
pub fn count_for(&self, identity: &Identity) -> usize {
self.shared
.lock()
.counts
.get(&identity.id)
.copied()
.unwrap_or(0)
}
fn effective_cap(&self, identity: &Identity) -> usize {
self.per_identity_caps
.get(&identity.id)
.copied()
.unwrap_or(self.cap)
}
}
impl ChannelLifecyclePolicy for PerIdentityChannelPolicy {
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError> {
let cap = self.effective_cap(identity);
let mut shared = self.shared.lock();
let entry = shared.counts.entry(identity.id.clone()).or_insert(0);
if *entry >= cap {
return Err(ChannelError::TooManyChannels {
identity: identity.id.clone(),
count: *entry,
cap,
});
}
*entry += 1;
Ok(())
}
fn on_close(&self, opener: &Identity) {
let mut shared = self.shared.lock();
if let Some(entry) = shared.counts.get_mut(&opener.id) {
if *entry > 0 {
*entry -= 1;
}
if *entry == 0 {
shared.counts.remove(&opener.id);
}
}
}
}
/// The default per-identity cap (ADR-041).
pub const DEFAULT_CHANNEL_CAP: usize = 256;
/// Construct the default policy (`PerIdentityChannelPolicy::new(256)`).
/// A channels-accepting peer that doesn't pass an explicit policy gets
/// this — the default is secure.
pub fn default_policy() -> Arc<dyn ChannelLifecyclePolicy> {
Arc::new(PerIdentityChannelPolicy::new(DEFAULT_CHANNEL_CAP))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn identity(id: &str) -> Identity {
Identity {
id: id.to_string(),
scopes: vec![],
resources: HashMap::new(),
}
}
#[test]
fn no_cap_allows_unlimited() {
let policy = NoCap;
let id = identity("alice");
for _ in 0..1000 {
assert!(policy.check_open(&id).is_ok());
}
policy.on_close(&id);
}
#[test]
fn per_identity_allows_up_to_cap() {
let policy = PerIdentityChannelPolicy::new(3);
let id = identity("alice");
assert!(policy.check_open(&id).is_ok());
assert!(policy.check_open(&id).is_ok());
assert!(policy.check_open(&id).is_ok());
match policy.check_open(&id) {
Err(ChannelError::TooManyChannels { count, cap, .. }) => {
assert_eq!(count, 3);
assert_eq!(cap, 3);
}
other => panic!("expected TooManyChannels, got {other:?}"),
}
}
#[test]
fn per_identity_on_close_decrements() {
let policy = PerIdentityChannelPolicy::new(2);
let id = identity("alice");
assert!(policy.check_open(&id).is_ok());
assert!(policy.check_open(&id).is_ok());
policy.on_close(&id);
assert!(policy.check_open(&id).is_ok(), "decrement frees a slot");
}
#[test]
fn per_identity_counts_are_independent() {
let policy = PerIdentityChannelPolicy::new(2);
let alice = identity("alice");
let bob = identity("bob");
assert!(policy.check_open(&alice).is_ok());
assert!(policy.check_open(&alice).is_ok());
assert!(policy.check_open(&bob).is_ok());
assert!(policy.check_open(&bob).is_ok());
assert!(policy.check_open(&alice).is_err(), "alice at cap");
assert!(policy.check_open(&bob).is_err(), "bob at cap");
}
#[test]
fn per_identity_with_per_identity_caps_overrides_default() {
let mut overrides = HashMap::new();
overrides.insert("hub".to_string(), 10);
let policy = PerIdentityChannelPolicy::with_per_identity_caps(2, overrides);
let hub = identity("hub");
let worker = identity("worker");
for _ in 0..10 {
assert!(policy.check_open(&hub).is_ok(), "hub gets its own cap");
}
assert!(policy.check_open(&hub).is_err(), "hub at its own cap");
assert!(policy.check_open(&worker).is_ok());
assert!(policy.check_open(&worker).is_ok());
assert!(policy.check_open(&worker).is_err(), "worker at default cap");
}
#[test]
fn per_identity_on_close_not_in_map_is_noop() {
let policy = PerIdentityChannelPolicy::new(2);
let id = identity("ghost");
policy.on_close(&id);
assert_eq!(policy.count_for(&id), 0);
}
#[test]
fn default_policy_is_256_per_identity() {
let policy = default_policy();
let id = identity("alice");
for _ in 0..256 {
assert!(policy.check_open(&id).is_ok(), "up to 256");
}
assert!(policy.check_open(&id).is_err(), "257th denied");
}
#[test]
fn per_identity_on_close_removes_zero_count_entry() {
let policy = PerIdentityChannelPolicy::new(2);
let id = identity("alice");
policy.check_open(&id).expect("open");
policy.on_close(&id);
assert_eq!(policy.count_for(&id), 0);
assert!(
!policy.shared.lock().counts.contains_key("alice"),
"zero-count entry removed"
);
}
}