beck_rt/
quota.rs

1//! F3's per-actor write quota: how much one actor may turn into permanent storage.
2//!
3//! [`docs/14`](../../../../../docs/14-review-findings.md) F3 splits the "events are forever"
4//! problem in two. Channel (a) — rejected garbage — was closed by §3.7's rule that **only validated
5//! events are durably logged**, so a refused command leaves nothing behind. Channel (b) is the one
6//! this module is for:
7//!
8//! > *validated* spam from a legitimate but abusive session — permanent by design. Remediation:
9//! > per-actor rate/volume quotas … on by default with generous limits.
10//!
11//! On by default is the load-bearing half. A quota a program has to ask for is a quota most
12//! programs do not have, and F3's whole point is that the *default* deployment should not turn an
13//! abusive session into a permanent cost.
14//!
15//! # The table is bounded, which is the part that is easy to get wrong
16//!
17//! The obvious implementation is a map from actor to a counter. That map is **unbounded memory
18//! keyed by a string the client chooses** — the same denial of service the quota exists to prevent,
19//! moved one level down and made harder to see. Under `DevIdentity` the actor *is* whatever the
20//! client says, so an attacker sending a fresh name per proposal would both evade the quota and
21//! grow the table.
22//!
23//! So the counters are **sharded**: a fixed number of buckets, an actor hashed into one, and no
24//! per-actor allocation ever. Memory is [`BUCKETS`] × a few bytes, for the life of the process,
25//! whatever arrives. Two consequences, both deliberate:
26//!
27//! * **Two actors can share a bucket, and therefore a budget.** That is why the limit is generous
28//!   rather than tight: a shared bucket must still be ample for both. [`Quota::default`] says what
29//!   the numbers are and why.
30//! * **The hash is keyed per process** (`RandomState`), so a client cannot compute a name that
31//!   lands in a chosen bucket. Without that, sharing a bucket stops being an accident an operator
32//!   accepts and becomes a way to spend somebody else's budget on purpose.
33//!
34//! # What this is not
35//!
36//! It binds an **actor**, so it is worth exactly what the actor is worth. Under
37//! [`crate::identity::DevIdentity`] the actor is the claim the client sent, so an attacker who
38//! rotates names spreads across buckets rather than being stopped — the *total* is still bounded by
39//! [`BUCKETS`] × the limit, which is a bound rather than the bound anybody wanted.
40//! [`docs/48`](../../../../../docs/48-identity-report.md) is the seam that fixes this, and
41//! [`docs/82`](../../../../../docs/82-the-edge-report.md) §82.5 is the
42//! composition written out rather than left to be inferred.
43
44use std::sync::atomic::{AtomicU64, Ordering};
45
46/// How many counters exist, for all actors, forever.
47///
48/// A power of two so the index is a mask. 1,024 × 16 bytes is 16 KiB, which is small enough that
49/// the table never has to be swept, resized or evicted from — and "never has to be swept" is the
50/// property that makes this bounded rather than merely large.
51pub const BUCKETS: usize = 1024;
52
53/// The limits, and the window they are counted over.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct Quota {
56    /// How many events one bucket may add to the log per window. `None` disables the quota.
57    pub events_per_window: Option<u32>,
58    /// How long a window is, in milliseconds.
59    pub window_ms: u64,
60}
61
62impl Default for Quota {
63    /// "On by default with generous limits" — F3's own words, with numbers attached.
64    ///
65    /// **600 events a minute**, which is ten a second sustained. The number is chosen from what a
66    /// *person* can produce: a fast typist committing a todo per keystroke does not reach it, and a
67    /// UI that batches at all is nowhere near. It is deliberately far above any interactive use and
68    /// far below what a script can do in a second, which is the gap F3 asks to be closed.
69    ///
70    /// It is generous for the reason the module doc gives as well: two actors can share a bucket,
71    /// so the limit has to be ample for both and still bite a script.
72    fn default() -> Self {
73        Quota {
74            events_per_window: Some(600),
75            window_ms: 60_000,
76        }
77    }
78}
79
80impl Quota {
81    /// A quota that refuses nothing — for a deployment that enforces elsewhere, and for the tests
82    /// that assert what the runtime does when the limit is not the thing under test.
83    pub fn unlimited() -> Quota {
84        Quota {
85            events_per_window: None,
86            window_ms: 60_000,
87        }
88    }
89}
90
91/// One bucket: how many, and when the window it belongs to started.
92///
93/// Two atomics rather than a lock, because this is read and written on the path every proposal
94/// takes and a lock there would serialise the merge point on bookkeeping.
95#[derive(Default)]
96struct Bucket {
97    count: AtomicU64,
98    window_start_ms: AtomicU64,
99}
100
101/// The sharded counters, and the process-random hash that decides which bucket an actor lands in.
102pub struct RateLimit {
103    quota: Quota,
104    buckets: Box<[Bucket]>,
105    /// `RandomState` is the standard library's answer to exactly this question — it is what makes a
106    /// `HashMap` resistant to a caller choosing colliding keys — and it seeds itself once per
107    /// process from the OS. Using it here rather than minting a key by hand keeps the workspace's
108    /// `forbid(unsafe)` intact and keeps this module out of the business of finding entropy.
109    hash: std::collections::hash_map::RandomState,
110}
111
112impl RateLimit {
113    pub fn new(quota: Quota) -> RateLimit {
114        let mut buckets = Vec::with_capacity(BUCKETS);
115        buckets.resize_with(BUCKETS, Bucket::default);
116        RateLimit {
117            quota,
118            buckets: buckets.into_boxed_slice(),
119            hash: std::collections::hash_map::RandomState::new(),
120        }
121    }
122
123    pub fn quota(&self) -> Quota {
124        self.quota
125    }
126
127    /// Charge one event to this actor, and say whether it is allowed.
128    ///
129    /// `now_ms` is passed in rather than read, for the reason `Proposal::at` is: the merge point is
130    /// the one place time enters, and it enters from the clock the process was configured with
131    /// (§3.7, F11). A quota that read the wall clock itself would be a second, ambient one.
132    pub fn admit(&self, actor: &str, now_ms: i64) -> bool {
133        let Some(limit) = self.quota.events_per_window else {
134            return true;
135        };
136        let now = now_ms.max(0) as u64;
137        let window = now - (now % self.quota.window_ms.max(1));
138        let bucket = &self.buckets[self.index(actor)];
139
140        // Roll the window if this bucket is still in an older one. A racing pair may both roll;
141        // both write the same `window`, and the loser's `count` reset is the same reset, so the
142        // worst case is one event's worth of slack rather than a wrong window.
143        if bucket.window_start_ms.swap(window, Ordering::Relaxed) != window {
144            bucket.count.store(0, Ordering::Relaxed);
145        }
146        bucket.count.fetch_add(1, Ordering::Relaxed) < u64::from(limit)
147    }
148
149    fn index(&self, actor: &str) -> usize {
150        // Keyed, so a client cannot pick a name that shares a bucket with somebody else's. What is
151        // required is not that the hash be cryptographic but that an attacker cannot *search* for a
152        // collision — and there is no oracle to search against, because which bucket a name landed
153        // in is never observable from outside.
154        use std::hash::BuildHasher;
155        (self.hash.hash_one(actor) as usize) & (BUCKETS - 1)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn an_actor_is_admitted_up_to_the_limit_and_refused_after_it() {
165        let rl = RateLimit::new(Quota {
166            events_per_window: Some(3),
167            window_ms: 1000,
168        });
169        assert!(rl.admit("ana", 0));
170        assert!(rl.admit("ana", 10));
171        assert!(rl.admit("ana", 20));
172        assert!(!rl.admit("ana", 30), "the fourth is over the limit");
173    }
174
175    #[test]
176    fn the_next_window_starts_the_count_again() {
177        let rl = RateLimit::new(Quota {
178            events_per_window: Some(2),
179            window_ms: 1000,
180        });
181        assert!(rl.admit("ana", 0));
182        assert!(rl.admit("ana", 1));
183        assert!(!rl.admit("ana", 2));
184        assert!(rl.admit("ana", 1000), "a new window is a new budget");
185        assert!(rl.admit("ana", 1999));
186        assert!(!rl.admit("ana", 1999));
187    }
188
189    /// The disabled setting really does disable it, which the runtime's own tests depend on.
190    #[test]
191    fn an_unlimited_quota_admits_everything() {
192        let rl = RateLimit::new(Quota::unlimited());
193        for i in 0..10_000 {
194            assert!(rl.admit("ana", i));
195        }
196    }
197
198    /// The property the whole design exists for: the table does not grow.
199    ///
200    /// Ten thousand distinct actors — which is what a client rotating names produces — and the
201    /// memory afterwards is the same [`BUCKETS`] counters it was before. There is nothing to
202    /// measure because there is nothing to allocate; the assertion is that the structure has no
203    /// per-actor storage to have grown.
204    #[test]
205    fn ten_thousand_actors_allocate_nothing() {
206        let rl = RateLimit::new(Quota::default());
207        for i in 0..10_000 {
208            rl.admit(&format!("actor-{i}"), 0);
209        }
210        assert_eq!(rl.buckets.len(), BUCKETS);
211    }
212
213    /// Rotating names is not free, even though it is not stopped.
214    ///
215    /// `docs/82` §82.5: under `DevIdentity` an attacker chooses the actor, so a fresh name per
216    /// proposal spreads across buckets rather than exhausting one. The total is still bounded —
217    /// `BUCKETS × limit` per window — and this test is what says that bound is real rather than
218    /// asserted.
219    #[test]
220    fn rotating_actor_names_is_bounded_by_the_table_rather_than_by_the_limit() {
221        let limit = 4;
222        let rl = RateLimit::new(Quota {
223            events_per_window: Some(limit),
224            window_ms: 60_000,
225        });
226        let admitted = (0..200_000)
227            .filter(|i| rl.admit(&format!("actor-{i}"), 0))
228            .count();
229        let ceiling = BUCKETS * limit as usize;
230        assert!(
231            admitted <= ceiling,
232            "{admitted} admitted, and the table can only ever allow {ceiling}"
233        );
234        assert!(
235            admitted > ceiling / 2,
236            "{admitted} — the buckets should fill roughly evenly, or the hash is the problem"
237        );
238    }
239}