From 5305cd55514c6154946be933c62fbd61a8aaa6f2 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:40:53 +0000 Subject: [PATCH 1/4] feat(net): RateKey + LimitPolicy/LimitDecl + total RateLimitConfig --- crates/adapter/net/http/api/src/lib.rs | 4 + crates/adapter/net/http/api/src/rate.rs | 139 ++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 crates/adapter/net/http/api/src/rate.rs diff --git a/crates/adapter/net/http/api/src/lib.rs b/crates/adapter/net/http/api/src/lib.rs index bc7e72f..d72243a 100644 --- a/crates/adapter/net/http/api/src/lib.rs +++ b/crates/adapter/net/http/api/src/lib.rs @@ -8,6 +8,8 @@ //! - [`client`] — the `HttpClient` dependency-inversion seam //! - [`body`] — `ResponseBody`, `BufferMode`, and the permit-carrying `Guarded` //! - [`auth`] — the `AuthSource` seam, `NoAuth`, and the `Auth`/`SetHeaders` layers +//! - [`rate`] — `RateKey`, the `LimitPolicy`/`LimitDecl` vocabulary, the total +//! `RateLimitConfig`, and the boot-time `validate_coverage` check //! //! The resilience layers, `stack`/`build` assembly, and backends land in later //! slices. No async runtime, `hyper`, `reqwest`, or `serde` here. @@ -17,10 +19,12 @@ pub mod auth; pub mod body; pub mod client; pub mod error; +pub mod rate; pub mod service; pub use auth::{Auth, AuthSource, NoAuth, SetHeaders}; pub use body::{BufferMode, Guarded, ResponseBody}; pub use client::HttpClient; pub use error::{BoxError, HttpError}; +pub use rate::{LimitDecl, LimitPolicy, RateKey, RateLimitConfig}; pub use service::Service; diff --git a/crates/adapter/net/http/api/src/rate.rs b/crates/adapter/net/http/api/src/rate.rs new file mode 100644 index 0000000..64acac0 --- /dev/null +++ b/crates/adapter/net/http/api/src/rate.rs @@ -0,0 +1,139 @@ +//! Boot-time pacing coverage (ADR-0034 §3). +//! +//! The `RateKey` universe, the `LimitPolicy`/`LimitDecl` classification +//! vocabulary, the total `RateLimitConfig` map, and the `validate_coverage` +//! construction-time check. +//! +//! A `RateLimitConfig` is **total**: every `K::all()` variant must be +//! explicitly classified — `LimitDecl::Policy` or `LimitDecl::GlobalOnly`, +//! never "absent". A missing or ill-configured bucket is caught at +//! construction ([`validate_coverage`]), so it is a boot failure rather than a +//! first-live-order 429 → 15-minute IBKR penalty box. This module is pure data +//! + one validator; the `RateLimit` layer that consumes it lands in Slice 1. + +use std::collections::HashMap; +use std::hash::Hash; + +/// An adapter's rate-limit key with a **finite universe** — the enumeration +/// that makes the boot-time coverage check possible (ADR-0034 §3). +/// +/// `Clone` is doubly-earned: `http::Extensions::insert` demands it, and `Retry` +/// clones the request per attempt (Slice 1), so a stamped key survives replay. +/// The universe is kept generic (not erased to `u32`/`&str`) precisely so +/// [`validate_coverage`] can iterate every variant. +pub trait RateKey: Hash + Eq + Clone + Send + Sync + 'static { + /// Every key in the universe. Its exhaustiveness is what the coverage check + /// trusts; an adapter keeps it drift-proof (`strum::VariantArray` or an + /// exhaustive-`match` test), keeping this trait dependency-free. + fn all() -> &'static [Self] + where + Self: Sized; +} + +/// A single pacing policy applied to one scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum LimitPolicy { + /// A refilling token bucket: `rate` tokens/second, up to `burst` in hand. + TokenBucket { + /// Steady-state tokens per second (must be `>= 1`). + rate: u32, + /// Maximum tokens available at once (must be `>= 1`). + burst: u32, + }, + /// A concurrency cap: at most `max` in-flight requests in this scope. + Concurrency { + /// Maximum concurrent requests (must be `>= 1`). + max: u32, + }, +} + +/// How one endpoint is paced — an **explicit** classification. There is no +/// "absent" arm: totality (every [`RateKey`] variant classified) is what the +/// boot check enforces. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum LimitDecl { + /// This endpoint has its own local policy (in addition to the global one). + Policy(LimitPolicy), + /// This endpoint is paced by the global policy only — declared on purpose. + GlobalOnly, +} + +/// A **total** pacing configuration: a required `global` policy plus a +/// per-endpoint classification for every key in the [`RateKey`] universe. +/// +/// [`validate_coverage`] rejects a `local` map that is not total over +/// `K::all()`, so forgetting to pace a new endpoint is a boot failure. +#[derive(Debug, Clone)] +pub struct RateLimitConfig { + /// The account-wide policy every request is subject to. + pub global: LimitPolicy, + /// The per-endpoint classification. Must be total over `K::all()`. + pub local: HashMap, +} + +#[cfg(test)] +mod tests { + use super::{LimitDecl, LimitPolicy, RateKey, RateLimitConfig}; + use std::collections::HashMap; + + /// A stand-in endpoint key for the tests — the shape an adapter provides. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + enum TestKey { + PlaceOrder, + Snapshot, + History, + } + + impl RateKey for TestKey { + fn all() -> &'static [Self] { + &[Self::PlaceOrder, Self::Snapshot, Self::History] + } + } + + #[test] + fn rate_key_all_is_drift_proof() { + // Exhaustive `match` with no wildcard arm: adding a `TestKey` variant + // fails to compile HERE, forcing whoever adds it to also list it in + // `all()`; the length assertion catches a variant added to the enum + // but dropped from `all()`. + fn is_listed(k: TestKey) -> bool { + match k { + TestKey::PlaceOrder | TestKey::Snapshot | TestKey::History => true, + } + } + assert!(TestKey::all().iter().copied().all(is_listed)); + assert_eq!(TestKey::all().len(), 3); + } + + #[test] + fn config_classifies_every_key_explicitly() { + let cfg = RateLimitConfig { + global: LimitPolicy::TokenBucket { + rate: 10, + burst: 20, + }, + local: HashMap::from([ + ( + TestKey::PlaceOrder, + LimitDecl::Policy(LimitPolicy::Concurrency { max: 1 }), + ), + ( + TestKey::Snapshot, + LimitDecl::Policy(LimitPolicy::TokenBucket { rate: 5, burst: 5 }), + ), + (TestKey::History, LimitDecl::GlobalOnly), + ]), + }; + assert_eq!(cfg.local.len(), 3); + assert_eq!( + cfg.global, + LimitPolicy::TokenBucket { + rate: 10, + burst: 20 + } + ); + assert_eq!(cfg.local[&TestKey::History], LimitDecl::GlobalOnly); + } +} From 1876434ee65e239db048ee54401b839d03a1e3df Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:47:55 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(net):=20BuildError=20+=20validate=5Fco?= =?UTF-8?q?verage=20=E2=80=94=20boot-time=20pacing=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/adapter/net/http/api/src/lib.rs | 2 +- crates/adapter/net/http/api/src/rate.rs | 177 +++++++++++++++++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/crates/adapter/net/http/api/src/lib.rs b/crates/adapter/net/http/api/src/lib.rs index d72243a..988a3f1 100644 --- a/crates/adapter/net/http/api/src/lib.rs +++ b/crates/adapter/net/http/api/src/lib.rs @@ -26,5 +26,5 @@ pub use auth::{Auth, AuthSource, NoAuth, SetHeaders}; pub use body::{BufferMode, Guarded, ResponseBody}; pub use client::HttpClient; pub use error::{BoxError, HttpError}; -pub use rate::{LimitDecl, LimitPolicy, RateKey, RateLimitConfig}; +pub use rate::{BuildError, LimitDecl, LimitPolicy, RateKey, RateLimitConfig, validate_coverage}; pub use service::Service; diff --git a/crates/adapter/net/http/api/src/rate.rs b/crates/adapter/net/http/api/src/rate.rs index 64acac0..2db8307 100644 --- a/crates/adapter/net/http/api/src/rate.rs +++ b/crates/adapter/net/http/api/src/rate.rs @@ -12,6 +12,7 @@ //! + one validator; the `RateLimit` layer that consumes it lands in Slice 1. use std::collections::HashMap; +use std::fmt; use std::hash::Hash; /// An adapter's rate-limit key with a **finite universe** — the enumeration @@ -73,9 +74,84 @@ pub struct RateLimitConfig { pub local: HashMap, } +impl LimitPolicy { + /// Reject non-sensical policy parameters (ADR-0034 §3 / spec: `rate == 0`, + /// `burst == 0`, `max == 0`). + fn validate(self) -> Result<(), BuildError> { + match self { + Self::TokenBucket { rate, burst } => { + if rate == 0 { + return Err(BuildError::InvalidPolicy(format!( + "token-bucket rate must be >= 1, got {rate}" + ))); + } + if burst == 0 { + return Err(BuildError::InvalidPolicy(format!( + "token-bucket burst must be >= 1, got {burst}" + ))); + } + Ok(()) + }, + Self::Concurrency { max } => { + if max == 0 { + return Err(BuildError::InvalidPolicy(format!( + "concurrency max must be >= 1, got {max}" + ))); + } + Ok(()) + }, + } + } +} + +/// A construction-time pacing-config failure. +/// +/// The boot-time guard that turns a missing or nonsensical bucket into a +/// startup error instead of a live 429 (ADR-0034 §3). Non-generic: the +/// offending key is rendered to a `String` so `stack()`/`build()` can return +/// `Result<_, BuildError>` regardless of `K`. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum BuildError { + /// A [`RateKey`] variant is not classified in `local` — the map is not + /// total over `K::all()`. + #[error( + "rate-limit key `{0}` is not classified in the config (every RateKey::all() variant must be declared)" + )] + UndeclaredKey(String), + /// A policy carries out-of-range parameters (`rate`/`burst`/`max` of 0). + #[error("invalid rate-limit policy: {0}")] + InvalidPolicy(String), +} + +/// Validate that `cfg` is a **total**, param-sane pacing configuration. +/// +/// The `global` policy is valid, and every [`RateKey`] variant is classified +/// with a valid policy (ADR-0034 §3). Slice 2's `stack()`/`build()` call this +/// before assembling the stack, so a coverage gap is a boot failure. +/// +/// # Errors +/// [`BuildError::UndeclaredKey`] if a `K::all()` variant is absent from +/// `cfg.local`; [`BuildError::InvalidPolicy`] if the global or any local policy +/// has an out-of-range parameter. +pub fn validate_coverage(cfg: &RateLimitConfig) -> Result<(), BuildError> +where + K: RateKey + fmt::Debug, +{ + cfg.global.validate()?; + for key in K::all() { + match cfg.local.get(key) { + None => return Err(BuildError::UndeclaredKey(format!("{key:?}"))), + Some(LimitDecl::Policy(policy)) => policy.validate()?, + Some(LimitDecl::GlobalOnly) => {}, + } + } + Ok(()) +} + #[cfg(test)] mod tests { - use super::{LimitDecl, LimitPolicy, RateKey, RateLimitConfig}; + use super::{BuildError, LimitDecl, LimitPolicy, RateKey, RateLimitConfig, validate_coverage}; use std::collections::HashMap; /// A stand-in endpoint key for the tests — the shape an adapter provides. @@ -136,4 +212,103 @@ mod tests { ); assert_eq!(cfg.local[&TestKey::History], LimitDecl::GlobalOnly); } + + /// A total, param-sane config over `TestKey` — the baseline the negative + /// tests mutate. + fn total_config() -> RateLimitConfig { + RateLimitConfig { + global: LimitPolicy::TokenBucket { + rate: 10, + burst: 20, + }, + local: HashMap::from([ + ( + TestKey::PlaceOrder, + LimitDecl::Policy(LimitPolicy::Concurrency { max: 1 }), + ), + ( + TestKey::Snapshot, + LimitDecl::Policy(LimitPolicy::TokenBucket { rate: 5, burst: 5 }), + ), + (TestKey::History, LimitDecl::GlobalOnly), + ]), + } + } + + #[test] + fn total_config_validates() { + assert_eq!(validate_coverage(&total_config()), Ok(())); + } + + #[test] + fn missing_key_is_undeclared() { + let mut cfg = total_config(); + cfg.local.remove(&TestKey::History); + let err = validate_coverage(&cfg).unwrap_err(); + assert!(matches!(err, BuildError::UndeclaredKey(ref k) if k.contains("History"))); + } + + #[test] + fn zero_rate_token_bucket_is_invalid() { + let mut cfg = total_config(); + cfg.local.insert( + TestKey::Snapshot, + LimitDecl::Policy(LimitPolicy::TokenBucket { rate: 0, burst: 5 }), + ); + assert!(matches!( + validate_coverage(&cfg), + Err(BuildError::InvalidPolicy(_)) + )); + } + + #[test] + fn zero_burst_token_bucket_is_invalid() { + let mut cfg = total_config(); + cfg.local.insert( + TestKey::Snapshot, + LimitDecl::Policy(LimitPolicy::TokenBucket { rate: 5, burst: 0 }), + ); + assert!(matches!( + validate_coverage(&cfg), + Err(BuildError::InvalidPolicy(_)) + )); + } + + #[test] + fn zero_concurrency_max_is_invalid() { + let mut cfg = total_config(); + cfg.local.insert( + TestKey::PlaceOrder, + LimitDecl::Policy(LimitPolicy::Concurrency { max: 0 }), + ); + assert!(matches!( + validate_coverage(&cfg), + Err(BuildError::InvalidPolicy(_)) + )); + } + + #[test] + fn bad_global_policy_is_invalid() { + let mut cfg = total_config(); + cfg.global = LimitPolicy::TokenBucket { rate: 0, burst: 1 }; + assert!(matches!( + validate_coverage(&cfg), + Err(BuildError::InvalidPolicy(_)) + )); + } + + #[test] + fn global_only_endpoints_need_no_local_params() { + // A `GlobalOnly` decl carries no policy, so it is always coverage-valid + // (it is paced by the already-validated global). + let cfg = RateLimitConfig { + global: LimitPolicy::Concurrency { max: 2 }, + local: HashMap::from([ + (TestKey::PlaceOrder, LimitDecl::GlobalOnly), + (TestKey::Snapshot, LimitDecl::GlobalOnly), + (TestKey::History, LimitDecl::GlobalOnly), + ]), + }; + assert_eq!(validate_coverage(&cfg), Ok(())); + } } From 0e568f18fbdebacb1d778f94ac0e3389b0449a16 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:54:07 +0000 Subject: [PATCH 3/4] docs(changelog): net-http boot-time pacing coverage (Slice 0 PR 4) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdceb67..0cacf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dynamic wins), and `Guarded` (response body carrying an optional `async-lock` concurrency permit, released at the earlier of stream-end or drop). ADR-0034 records the construction-surface decisions and the ADR-0030/0031 amendments. +- `oath-adapter-net-http-api` boot-time pacing coverage — the `RateKey` trait + (finite universe via `all()`), the `LimitPolicy`/`LimitDecl` classification + vocabulary, the total `RateLimitConfig` map, `BuildError`, and the + standalone `validate_coverage` check: an unclassified endpoint or an + out-of-range policy param is a boot failure, not a first-live-order 429 + (ADR-0034 §3). Closes Slice 0 of the net-http construction surface. - `oath-adapter-net-ws-api` WebSocket contract (ADR-0032/0033) — `Frame`/`CloseFrame` (RFC 6455 frame vocabulary), `WsError` (one concrete transport error with `HasErrorKind`), the split owned halves (`WsSink` one-shot RPITIT send half with From 0d3a4d69fe556ce9a9622bbe71b002ca39a589a4 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:00:30 +0000 Subject: [PATCH 4/4] docs(net): clarify RateKey drift-guard comment The exhaustive match, not the length assertion, is the actual drift tripwire. --- crates/adapter/net/http/api/src/rate.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/adapter/net/http/api/src/rate.rs b/crates/adapter/net/http/api/src/rate.rs index 2db8307..3a5b77a 100644 --- a/crates/adapter/net/http/api/src/rate.rs +++ b/crates/adapter/net/http/api/src/rate.rs @@ -171,9 +171,10 @@ mod tests { #[test] fn rate_key_all_is_drift_proof() { // Exhaustive `match` with no wildcard arm: adding a `TestKey` variant - // fails to compile HERE, forcing whoever adds it to also list it in - // `all()`; the length assertion catches a variant added to the enum - // but dropped from `all()`. + // fails to compile HERE, forcing whoever adds it to also update + // `all()` by hand — that compile error is the actual drift guard. + // The length assertion only catches `all()` shrinking (e.g. an + // accidental removal), not a variant omitted from it. fn is_listed(k: TestKey) -> bool { match k { TestKey::PlaceOrder | TestKey::Snapshot | TestKey::History => true,