Skip to main content

proxima_primitives/pipe/
filter.rs

1use alloc::sync::Arc;
2use bytes::Bytes;
3use core::fmt::Debug;
4use core::future::Future;
5use core::marker::PhantomData;
6use portable_atomic::{AtomicU64, Ordering};
7
8use crate::pipe::SendPipe;
9use crate::pipe::ext::PipeExt;
10use crate::pipe::handler::{PipeHandle, into_handle};
11use crate::pipe::primitives::{Pipe, UnpinPipe, UnpinSendPipe};
12use crate::pipe::when::When;
13use serde::{Deserialize, Serialize};
14
15use crate::pipe::request::Request;
16use proxima_core::ProximaError;
17
18#[cfg(feature = "std")]
19use crate::pipe::pipe_factory::PipeFactory;
20#[cfg(feature = "std")]
21use serde_json::Value;
22#[cfg(feature = "std")]
23use std::pin::Pin;
24
25// ── predicate seam ────────────────────────────────────────────────────────────
26//
27// A filter used to be a bespoke combinator (`Filter<Inner, Predicate>`)
28// re-implementing pass/reject short-circuiting next to `AndThen`, fed by a
29// `Decide<In>::decide(&self, &In) -> bool` seam that threw the item and the
30// rejection reason away — so `Rejectable` and `OnReject` grew up beside it to
31// carry them back. The collapse: a decision IS a pipe, `In -> Result<In,
32// Err>` (`Ok` = admit, the item survives; `Err` = reject). A filter is then
33// just `predicate.and_then(inner)` — `AndThen`'s own `?` already
34// short-circuits the inner pipe on a first-stage `Err` (see
35// `primitives.rs`'s `and_then_short_circuits_before_the_second_stage_on_first_stage_error`),
36// so nothing new is needed here.
37//
38// `Predicate`/`FilterConfig` themselves used to hardcode `In = Out =
39// Request<Bytes>`, `Err = ProximaError` in every trait impl — first-pass
40// residue from this crate being HTTP-only at the time, not a design
41// constraint. The pattern (a decision is `In -> Result<In, Err>`) is
42// payload-agnostic, so the types are now generic over `In`/`Err` too, each
43// defaulted to the crate's own HTTP types so every existing caller keeps
44// compiling unparameterized. `Err: From<RejectMode>` is the seam that
45// lets a reject build an `Err` value of a type this module has never
46// heard of -- the same error law `AndThen` already uses for its own
47// `Second::Err: From<First::Err>` bound (`primitives.rs:207`), not a
48// parallel one.
49
50impl From<RejectMode> for ProximaError {
51    fn from(mode: RejectMode) -> Self {
52        match mode {
53            RejectMode::Drop => ProximaError::Forbidden("forbidden".into()),
54            RejectMode::Error => ProximaError::Config("filter: predicate rejected request".into()),
55        }
56    }
57}
58
59/// The config-expressible predicate set, generic over the payload `In` it
60/// passes through unchanged on admit and the `Err` it rejects with.
61/// Implements [`SendPipe`]/[`Pipe`] directly (`In -> Result<In, Err>`):
62/// `Ok(input)` on admit, `Err::forbidden()` on reject — the same payload
63/// [`FilterConfig`]'s [`RejectMode::Drop`] produces, since drop was always
64/// this crate's default reject mode ([`FilterConfig::default`]). Defaults to
65/// the crate's own HTTP types (`Request<Bytes>`, [`ProximaError`]) so a
66/// caller instantiates `Predicate::<MyIn, MyErr>` to gate their own payload
67/// without minting a type and without touching this one.
68#[derive(Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case", bound = "")]
70pub enum Predicate<In = Request<Bytes>, Err = ProximaError> {
71    Always {
72        #[serde(skip)]
73        _marker: PhantomData<fn(In) -> Err>,
74    },
75    Never {
76        #[serde(skip)]
77        _marker: PhantomData<fn(In) -> Err>,
78    },
79    When {
80        #[serde(flatten)]
81        gate: When,
82        #[serde(skip)]
83        calls: Arc<AtomicU64>,
84        #[serde(skip)]
85        _marker: PhantomData<fn(In) -> Err>,
86    },
87    Unless {
88        #[serde(flatten)]
89        gate: When,
90        #[serde(skip)]
91        calls: Arc<AtomicU64>,
92        #[serde(skip)]
93        _marker: PhantomData<fn(In) -> Err>,
94    },
95}
96
97// hand-rolled instead of `#[derive(Debug, Clone, PartialEq)]`: rustc's
98// built-in derives add an `In: Trait, Err: Trait` bound for every generic
99// parameter that appears anywhere in the definition, including inside
100// `PhantomData` — which would force every instantiation's payload and error
101// type to implement Debug/Clone/PartialEq even though neither is ever
102// stored. Same fix `Race<Sink, Policy>` (`race.rs`) already uses for its own
103// `PhantomData<fn() -> Policy>` marker.
104impl<In, Err> Debug for Predicate<In, Err> {
105    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
106        match self {
107            Predicate::Always { .. } => formatter.debug_struct("Always").finish(),
108            Predicate::Never { .. } => formatter.debug_struct("Never").finish(),
109            Predicate::When { gate, calls, .. } => formatter
110                .debug_struct("When")
111                .field("gate", gate)
112                .field("calls", &calls.load(Ordering::Relaxed))
113                .finish(),
114            Predicate::Unless { gate, calls, .. } => formatter
115                .debug_struct("Unless")
116                .field("gate", gate)
117                .field("calls", &calls.load(Ordering::Relaxed))
118                .finish(),
119        }
120    }
121}
122
123impl<In, Err> Clone for Predicate<In, Err> {
124    fn clone(&self) -> Self {
125        match self {
126            Predicate::Always { .. } => Predicate::Always {
127                _marker: PhantomData,
128            },
129            Predicate::Never { .. } => Predicate::Never {
130                _marker: PhantomData,
131            },
132            Predicate::When { gate, calls, .. } => Predicate::When {
133                gate: *gate,
134                calls: Arc::clone(calls),
135                _marker: PhantomData,
136            },
137            Predicate::Unless { gate, calls, .. } => Predicate::Unless {
138                gate: *gate,
139                calls: Arc::clone(calls),
140                _marker: PhantomData,
141            },
142        }
143    }
144}
145
146impl<In, Err> PartialEq for Predicate<In, Err> {
147    fn eq(&self, other: &Self) -> bool {
148        match (self, other) {
149            (Predicate::Always { .. }, Predicate::Always { .. }) => true,
150            (Predicate::Never { .. }, Predicate::Never { .. }) => true,
151            (Predicate::When { gate: left, .. }, Predicate::When { gate: right, .. }) => {
152                left == right
153            }
154            (Predicate::Unless { gate: left, .. }, Predicate::Unless { gate: right, .. }) => {
155                left == right
156            }
157            _ => false,
158        }
159    }
160}
161
162impl<In, Err> Predicate<In, Err> {
163    #[must_use]
164    pub fn always() -> Self {
165        Predicate::Always {
166            _marker: PhantomData,
167        }
168    }
169
170    #[must_use]
171    pub fn never() -> Self {
172        Predicate::Never {
173            _marker: PhantomData,
174        }
175    }
176
177    #[must_use]
178    pub fn when(gate: When) -> Self {
179        Predicate::When {
180            gate,
181            calls: Arc::new(AtomicU64::new(0)),
182            _marker: PhantomData,
183        }
184    }
185
186    #[must_use]
187    pub fn unless(gate: When) -> Self {
188        Predicate::Unless {
189            gate,
190            calls: Arc::new(AtomicU64::new(0)),
191            _marker: PhantomData,
192        }
193    }
194
195    /// Whether this call admits — the gate's own answer, ignoring the item
196    /// (every variant here decides from its own state, never the payload).
197    fn admits(&self) -> bool {
198        match self {
199            Predicate::Always { .. } => true,
200            Predicate::Never { .. } => false,
201            Predicate::When { gate, calls, .. } => {
202                let index = calls.fetch_add(1, Ordering::Relaxed);
203                gate.fires(index)
204            }
205            Predicate::Unless { gate, calls, .. } => {
206                let index = calls.fetch_add(1, Ordering::Relaxed);
207                !gate.fires(index)
208            }
209        }
210    }
211}
212
213impl<In, Err> SendPipe for Predicate<In, Err>
214where
215    In: Send + 'static,
216    Err: From<RejectMode> + Debug + Send + 'static,
217{
218    type In = In;
219    type Out = In;
220    type Err = Err;
221
222    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> + Send {
223        let admits = self.admits();
224        async move {
225            if admits {
226                Ok(input)
227            } else {
228                Err(Err::from(RejectMode::Drop))
229            }
230        }
231    }
232}
233
234// base tier stands alone rather than delegating to `SendPipe::call`: `Pipe`
235// carries no `Send` bound, so a `Predicate<In, Err>` instantiated at a
236// non-`Send` `In`/`Err` must still get this impl.
237impl<In, Err> Pipe for Predicate<In, Err>
238where
239    In: 'static,
240    Err: From<RejectMode> + Debug + 'static,
241{
242    type In = In;
243    type Out = In;
244    type Err = Err;
245
246    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> {
247        let admits = self.admits();
248        async move {
249            if admits {
250                Ok(input)
251            } else {
252                Err(Err::from(RejectMode::Drop))
253            }
254        }
255    }
256}
257
258// `admits()` resolves synchronously (a plain in-memory decision, no `.await`
259// anywhere in the real body above) — `core::future::ready` is the exact
260// future that describes that, and it's `Unpin` unconditionally, so no
261// hand-written poll struct is needed for a leaf pipe that never suspends.
262impl<In, Err> UnpinPipe for Predicate<In, Err>
263where
264    In: 'static,
265    Err: From<RejectMode> + Debug + 'static,
266{
267    type In = In;
268    type Out = In;
269    type Err = Err;
270
271    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> + Unpin {
272        let admits = self.admits();
273        core::future::ready(if admits {
274            Ok(input)
275        } else {
276            Err(Err::from(RejectMode::Drop))
277        })
278    }
279}
280
281impl<In, Err> UnpinSendPipe for Predicate<In, Err>
282where
283    In: Send + 'static,
284    Err: From<RejectMode> + Debug + Send + 'static,
285{
286    type In = In;
287    type Out = In;
288    type Err = Err;
289
290    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> + Send + Unpin {
291        let admits = self.admits();
292        core::future::ready(if admits {
293            Ok(input)
294        } else {
295            Err(Err::from(RejectMode::Drop))
296        })
297    }
298}
299
300/// What error a rejected call produces — a rename of the former `OnReject`,
301/// same two variants, same JSON strings. [`FilterConfig::call`] converts it
302/// straight into `Err` via `Err::from(self.on_reject)`; it is plain data
303/// read once per call, not a combinator, and carries no `In`/`Err` of its
304/// own.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub enum RejectMode {
308    Error,
309    Drop,
310}
311
312// ── serde config + factory ───────────────────────────────────────────────────
313
314/// The predicate-gated pass/reject decision, as config and as the pipe
315/// itself: `FilterConfig` is both the 1:1 serialisable mirror AND the
316/// `SendPipe`/`Pipe` implementor (`In -> Result<In, Err>`, generic over both
317/// like [`Predicate`]) — its two fields are exactly what a decision needs
318/// (the gate, and which error a reject produces), so no separate combinator
319/// carries them, and no separate type is needed to use it at a payload of
320/// the caller's own: `FilterConfig { predicate, on_reject }.and_then(inner)`
321/// composes at any `In`/`Err` the same way `Predicate` does. Only
322/// [`FilterConfig::into_filter`]/[`FilterConfig::from_spec`] stay pinned to
323/// this crate's HTTP [`PipeHandle`], because [`PipeFactory`]'s own signature
324/// is pinned there — not a constraint this module introduces.
325#[derive(Serialize, Deserialize)]
326#[serde(bound = "")]
327pub struct FilterConfig<In = Request<Bytes>, Err = ProximaError> {
328    pub predicate: Predicate<In, Err>,
329    pub on_reject: RejectMode,
330}
331
332// hand-rolled for the same reason as `Predicate`'s own Debug/Clone/PartialEq
333// (see the comment above those impls): a derive would add a spurious
334// `In: Trait, Err: Trait` bound neither field actually needs.
335impl<In, Err> Debug for FilterConfig<In, Err> {
336    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
337        formatter
338            .debug_struct("FilterConfig")
339            .field("predicate", &self.predicate)
340            .field("on_reject", &self.on_reject)
341            .finish()
342    }
343}
344
345impl<In, Err> Clone for FilterConfig<In, Err> {
346    fn clone(&self) -> Self {
347        Self {
348            predicate: self.predicate.clone(),
349            on_reject: self.on_reject,
350        }
351    }
352}
353
354impl<In, Err> PartialEq for FilterConfig<In, Err> {
355    fn eq(&self, other: &Self) -> bool {
356        self.predicate == other.predicate && self.on_reject == other.on_reject
357    }
358}
359
360impl<In, Err> Default for FilterConfig<In, Err> {
361    fn default() -> Self {
362        Self {
363            predicate: Predicate::always(),
364            on_reject: RejectMode::Drop,
365        }
366    }
367}
368
369impl<In, Err> SendPipe for FilterConfig<In, Err>
370where
371    In: Send + 'static,
372    Err: From<RejectMode> + Debug + Send + 'static,
373{
374    type In = In;
375    type Out = In;
376    type Err = Err;
377
378    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> + Send {
379        let admits = self.predicate.admits();
380        let on_reject = self.on_reject;
381        async move {
382            if admits {
383                Ok(input)
384            } else {
385                Err(Err::from(on_reject))
386            }
387        }
388    }
389}
390
391impl<In, Err> Pipe for FilterConfig<In, Err>
392where
393    In: 'static,
394    Err: From<RejectMode> + Debug + 'static,
395{
396    type In = In;
397    type Out = In;
398    type Err = Err;
399
400    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> {
401        let admits = self.predicate.admits();
402        let on_reject = self.on_reject;
403        async move {
404            if admits {
405                Ok(input)
406            } else {
407                Err(Err::from(on_reject))
408            }
409        }
410    }
411}
412
413// same rationale as `Predicate`'s `UnpinPipe`/`UnpinSendPipe` impls: the
414// decision is synchronous, so `core::future::ready` is the exact future,
415// unconditionally `Unpin`.
416impl<In, Err> UnpinPipe for FilterConfig<In, Err>
417where
418    In: 'static,
419    Err: From<RejectMode> + Debug + 'static,
420{
421    type In = In;
422    type Out = In;
423    type Err = Err;
424
425    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> + Unpin {
426        let admits = self.predicate.admits();
427        let on_reject = self.on_reject;
428        core::future::ready(if admits {
429            Ok(input)
430        } else {
431            Err(Err::from(on_reject))
432        })
433    }
434}
435
436impl<In, Err> UnpinSendPipe for FilterConfig<In, Err>
437where
438    In: Send + 'static,
439    Err: From<RejectMode> + Debug + Send + 'static,
440{
441    type In = In;
442    type Out = In;
443    type Err = Err;
444
445    fn call(&self, input: In) -> impl Future<Output = Result<In, Err>> + Send + Unpin {
446        let admits = self.predicate.admits();
447        let on_reject = self.on_reject;
448        core::future::ready(if admits {
449            Ok(input)
450        } else {
451            Err(Err::from(on_reject))
452        })
453    }
454}
455
456// `into_filter`/`from_spec` stay pinned to `Request<Bytes>`/`ProximaError`:
457// they compose with `crate::pipe::handler::PipeHandle`, which IS that fixed
458// alias (`alloc_tier::PipeHandle<Request<Bytes>, Response<Bytes>>`), and
459// `from_spec` backs `FilterFactory`, whose `PipeFactory` trait is dyn-object
460// safe and fixed to the same alias for the same reason every other factory
461// in this module is (`DelayFactory`, `RetryFactory`, ...). That pin is
462// pre-existing workspace architecture (`pipe_factory.rs`, `alloc_tier.rs`),
463// not something `Predicate`/`FilterConfig` themselves impose any more.
464impl FilterConfig<Request<Bytes>, ProximaError> {
465    /// Compose the decision in front of `inner` and erase — `predicate.
466    /// and_then(inner)` in one call, matching every other `into_*` factory
467    /// entry point in this crate (`Delay::into_delay`, `Transform::
468    /// into_transform`, ...).
469    #[must_use]
470    pub fn into_filter(self, inner: PipeHandle) -> PipeHandle {
471        into_handle(PipeExt::and_then(self, inner))
472    }
473
474    #[cfg(feature = "std")]
475    pub fn from_spec(inner: PipeHandle, value: &Value) -> Result<PipeHandle, ProximaError> {
476        let config: FilterConfig<Request<Bytes>, ProximaError> =
477            serde_json::from_value(value.clone())
478                .map_err(|err| ProximaError::Config(format!("filter config: {err}")))?;
479        Ok(config.into_filter(inner))
480    }
481}
482
483#[cfg(feature = "std")]
484pub struct FilterFactory;
485
486#[cfg(feature = "std")]
487impl PipeFactory for FilterFactory {
488    fn name(&self) -> &str {
489        "filter"
490    }
491
492    fn build(
493        &self,
494        spec: &Value,
495        inner: Option<PipeHandle>,
496    ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
497        let spec = spec.clone();
498        Box::pin(async move {
499            let inner = inner
500                .ok_or_else(|| ProximaError::Config("filter requires an inner pipe".into()))?;
501            FilterConfig::from_spec(inner, &spec)
502        })
503    }
504}
505
506// `#[proxima::test]` pulls in the `proxima` dev-dependency, which the
507// loom build keeps out of the graph (see
508// `[target.'cfg(not(loom))'.dev-dependencies]` in Cargo.toml); these
509// tests are unrelated to the Notify/watch loom protocol.
510#[cfg(all(test, not(loom)))]
511#[allow(clippy::unwrap_used, clippy::expect_used)]
512mod tests {
513    use std::future::Future;
514    use std::sync::atomic::{AtomicUsize, Ordering as StdOrdering};
515
516    use super::*;
517    use crate::pipe::handler::into_handle;
518    use crate::pipe::request::{Request, Response};
519
520    fn echo_pipe() -> PipeHandle {
521        struct EchoPipe;
522        impl SendPipe for EchoPipe {
523            type In = Request<Bytes>;
524            type Out = Response<Bytes>;
525            type Err = ProximaError;
526
527            fn call(
528                &self,
529                request: Request<Bytes>,
530            ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
531                async move {
532                    let (_, body) = request.body_bytes().await?;
533                    Ok(Response::new(200).with_body(body))
534                }
535            }
536        }
537        into_handle(EchoPipe)
538    }
539
540    // proves the inner pipe is never reached on a reject — the same claim
541    // `reject_with_drop_produces_a_forbidden_error` made by inspecting the
542    // old sentinel `Out`, now made directly since a reject no longer reaches
543    // the inner pipe at all (it short-circuits in the `Err` channel).
544    fn counting_echo_pipe() -> (PipeHandle, Arc<AtomicUsize>) {
545        struct CountingEcho(Arc<AtomicUsize>);
546        impl SendPipe for CountingEcho {
547            type In = Request<Bytes>;
548            type Out = Response<Bytes>;
549            type Err = ProximaError;
550
551            fn call(
552                &self,
553                request: Request<Bytes>,
554            ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
555                self.0.fetch_add(1, StdOrdering::Relaxed);
556                async move {
557                    let (_, body) = request.body_bytes().await?;
558                    Ok(Response::new(200).with_body(body))
559                }
560            }
561        }
562        let calls = Arc::new(AtomicUsize::new(0));
563        (into_handle(CountingEcho(calls.clone())), calls)
564    }
565
566    fn build_request() -> Request<Bytes> {
567        Request::builder()
568            .method("POST")
569            .path("/")
570            .body(bytes::Bytes::from_static(b"hello"))
571            .build()
572            .expect("builder")
573    }
574
575    #[proxima::test]
576    async fn passes_through_when_predicate_is_true() {
577        let stack = FilterConfig {
578            predicate: Predicate::always(),
579            on_reject: RejectMode::Drop,
580        }
581        .into_filter(echo_pipe());
582        let response = SendPipe::call(&stack, build_request()).await.expect("call");
583        assert_eq!(response.status, 200);
584        let body = response.collect_body().await.expect("body");
585        assert_eq!(
586            &body[..],
587            b"hello",
588            "passing request reaches the inner echo pipe"
589        );
590    }
591
592    #[proxima::test]
593    async fn reject_with_error_returns_err() {
594        let stack = FilterConfig {
595            predicate: Predicate::never(),
596            on_reject: RejectMode::Error,
597        }
598        .into_filter(echo_pipe());
599        let outcome = SendPipe::call(&stack, build_request()).await;
600        assert!(
601            matches!(outcome, Err(ProximaError::Config(_))),
602            "RejectMode::Error surfaces the config error"
603        );
604    }
605
606    #[proxima::test]
607    async fn reject_with_drop_produces_a_forbidden_error() {
608        let (inner, calls) = counting_echo_pipe();
609        let stack = FilterConfig {
610            predicate: Predicate::never(),
611            on_reject: RejectMode::Drop,
612        }
613        .into_filter(inner);
614        let outcome = SendPipe::call(&stack, build_request()).await;
615        match outcome {
616            Err(ProximaError::Forbidden(payload)) => {
617                assert_eq!(
618                    payload, "forbidden",
619                    "RejectMode::Drop's payload is the edge's 403 body verbatim"
620                );
621            }
622            other => panic!("expected Forbidden, got {other:?}"),
623        }
624        assert_eq!(
625            calls.load(StdOrdering::Relaxed),
626            0,
627            "a rejected call never reaches the inner pipe"
628        );
629    }
630
631    #[test]
632    fn config_clone_and_serde_round_trip_are_lossless() {
633        let config = FilterConfig {
634            predicate: Predicate::never(),
635            on_reject: RejectMode::Error,
636        };
637
638        assert_eq!(
639            config.clone(),
640            config,
641            "the config's own Clone/PartialEq round-trips"
642        );
643        let json = serde_json::to_value(&config).expect("serialize");
644        let parsed: FilterConfig = serde_json::from_value(json).expect("deserialize");
645        assert_eq!(parsed, config, "serde round-trip is lossless");
646    }
647
648    #[proxima::test]
649    async fn from_spec_parses_serde_config() {
650        let value = serde_json::json!({
651            "predicate": {"kind": "never"},
652            "on_reject": "drop",
653        });
654        let stack = FilterConfig::from_spec(echo_pipe(), &value).expect("from spec");
655        let outcome = SendPipe::call(&stack, build_request()).await;
656        assert!(
657            matches!(outcome, Err(ProximaError::Forbidden(_))),
658            "never + drop rejects with Forbidden, proving the parsed config actually runs"
659        );
660    }
661
662    #[proxima::test]
663    async fn when_gated_filter_drops_and_passes_by_seed() {
664        let gate = When::prob(0.5).seed(0x5EED);
665        let stack = FilterConfig {
666            predicate: Predicate::when(gate),
667            on_reject: RejectMode::Drop,
668        }
669        .into_filter(echo_pipe());
670
671        let mut observed = Vec::new();
672        for _ in 0..32_u64 {
673            let outcome = SendPipe::call(&stack, build_request()).await;
674            observed.push(outcome.is_ok());
675        }
676
677        let expected: Vec<bool> = (0..32_u64).map(|index| gate.fires(index)).collect();
678        assert_eq!(
679            observed, expected,
680            "filter pass/drop tracks the gate's deterministic sequence"
681        );
682    }
683
684    #[proxima::test]
685    async fn when_prob_one_always_passes_and_prob_zero_always_drops() {
686        let always = FilterConfig {
687            predicate: Predicate::when(When::prob(1.0).seed(3)),
688            on_reject: RejectMode::Drop,
689        }
690        .into_filter(echo_pipe());
691        let never = FilterConfig {
692            predicate: Predicate::when(When::prob(0.0).seed(3)),
693            on_reject: RejectMode::Drop,
694        }
695        .into_filter(echo_pipe());
696        for _ in 0..16 {
697            let passed = SendPipe::call(&always, build_request()).await;
698            assert!(passed.is_ok(), "prob 1.0 always passes");
699            let dropped = SendPipe::call(&never, build_request()).await;
700            assert!(dropped.is_err(), "prob 0.0 always drops");
701        }
702    }
703
704    #[test]
705    fn when_config_round_trips_and_flattens_to_a_single_tagged_object() {
706        let config = FilterConfig {
707            predicate: Predicate::when(When::prob(0.3).seed(0xC0FFEE)),
708            on_reject: RejectMode::Drop,
709        };
710
711        let json = serde_json::to_value(&config).expect("serialize");
712        let parsed: FilterConfig = serde_json::from_value(json.clone()).expect("deserialize");
713
714        assert_eq!(parsed, config, "serde round-trip is lossless");
715        assert_eq!(
716            json,
717            serde_json::json!({
718                "predicate": {"kind": "when", "prob": 0.3, "seed": 0xC0FFEE_u64},
719                "on_reject": "drop",
720            }),
721            "When flattens to a single tagged object"
722        );
723    }
724
725    #[proxima::test]
726    async fn unless_gated_filter_rejects_on_a_fire() {
727        let gate = When::prob(0.5).seed(0x5EED);
728        let stack = FilterConfig {
729            predicate: Predicate::unless(gate),
730            on_reject: RejectMode::Drop,
731        }
732        .into_filter(echo_pipe());
733
734        let mut observed = Vec::new();
735        for _ in 0..32_u64 {
736            let outcome = SendPipe::call(&stack, build_request()).await;
737            observed.push(outcome.is_err());
738        }
739
740        let expected: Vec<bool> = (0..32_u64).map(|index| gate.fires(index)).collect();
741        assert_eq!(
742            observed, expected,
743            "Unless drops exactly when the gate fires"
744        );
745    }
746
747    #[test]
748    fn unless_config_round_trips_with_its_own_tag() {
749        let config = FilterConfig {
750            predicate: Predicate::unless(When::prob(0.3).seed(7)),
751            on_reject: RejectMode::Drop,
752        };
753        let json = serde_json::to_value(&config).expect("serialize");
754        let parsed: FilterConfig = serde_json::from_value(json.clone()).expect("deserialize");
755        assert_eq!(parsed, config, "Unless round-trips through config");
756        assert_eq!(
757            json,
758            serde_json::json!({
759                "predicate": {"kind": "unless", "prob": 0.3, "seed": 7},
760                "on_reject": "drop",
761            }),
762            "Unless carries the `unless` tag, distinct from When"
763        );
764    }
765
766    #[proxima::test]
767    async fn from_spec_parses_when_predicate() {
768        let value = serde_json::json!({
769            "predicate": {"kind": "when", "prob": 0.25, "seed": 11},
770            "on_reject": "error",
771        });
772        let stack = FilterConfig::from_spec(echo_pipe(), &value).expect("from spec");
773
774        let gate = When::prob(0.25).seed(11);
775        let mut observed = Vec::new();
776        for _ in 0..16_u64 {
777            let outcome = SendPipe::call(&stack, build_request()).await;
778            observed.push(outcome.is_ok());
779            if let Err(error) = outcome {
780                assert!(
781                    matches!(error, ProximaError::Config(_)),
782                    "on_reject: error surfaces ProximaError::Config, got {error:?}"
783                );
784            }
785        }
786
787        let expected: Vec<bool> = (0..16_u64).map(|index| gate.fires(index)).collect();
788        assert_eq!(
789            observed, expected,
790            "from_spec's when predicate matches the gate's deterministic sequence"
791        );
792    }
793
794    #[derive(Clone, PartialEq, Debug)]
795    struct SensorReading {
796        celsius: i32,
797    }
798
799    // the reject sentinel a non-HTTP `Err` needs: `Predicate`/`FilterConfig`
800    // pass `In` straight through on admit, so reusing `SensorReading` as
801    // both `Out` and `Err` (as `Threshold` below already does by hand) is
802    // the natural instantiation, not a special case this impl carves out.
803    impl From<RejectMode> for SensorReading {
804        fn from(_mode: RejectMode) -> Self {
805            SensorReading { celsius: i32::MIN }
806        }
807    }
808
809    #[derive(Clone)]
810    struct Threshold {
811        max_celsius: i32,
812    }
813
814    // no `Rejectable`/`Decide` seam: Threshold IS the decision pipe, reusing
815    // SensorReading as both the admit `Out` and the reject `Err` — the same
816    // shape the `filter`/`gate`/`signal` examples teach for a non-HTTP payload.
817    impl SendPipe for Threshold {
818        type In = SensorReading;
819        type Out = SensorReading;
820        type Err = SensorReading;
821
822        fn call(
823            &self,
824            reading: SensorReading,
825        ) -> impl Future<Output = Result<SensorReading, SensorReading>> + Send {
826            let admits = reading.celsius <= self.max_celsius;
827            async move {
828                if admits {
829                    Ok(reading)
830                } else {
831                    Err(SensorReading { celsius: i32::MIN })
832                }
833            }
834        }
835    }
836
837    // base-tier mirror, delegating straight through — every pipe implements
838    // the root `Pipe` too, which is what lets `PipeExt::and_then` reach it.
839    impl Pipe for Threshold {
840        type In = SensorReading;
841        type Out = SensorReading;
842        type Err = SensorReading;
843
844        fn call(
845            &self,
846            reading: SensorReading,
847        ) -> impl Future<Output = Result<SensorReading, SensorReading>> {
848            SendPipe::call(self, reading)
849        }
850    }
851
852    #[derive(Clone)]
853    struct ReadingSink;
854
855    impl SendPipe for ReadingSink {
856        type In = SensorReading;
857        type Out = SensorReading;
858        type Err = SensorReading;
859
860        fn call(
861            &self,
862            input: SensorReading,
863        ) -> impl Future<Output = Result<SensorReading, SensorReading>> + Send {
864            async move {
865                Ok(SensorReading {
866                    celsius: input.celsius + 1,
867                })
868            }
869        }
870    }
871
872    impl Pipe for ReadingSink {
873        type In = SensorReading;
874        type Out = SensorReading;
875        type Err = SensorReading;
876
877        fn call(
878            &self,
879            input: SensorReading,
880        ) -> impl Future<Output = Result<SensorReading, SensorReading>> {
881            SendPipe::call(self, input)
882        }
883    }
884
885    #[proxima::test]
886    async fn filter_is_generic_over_a_non_http_payload() {
887        let stack = Threshold { max_celsius: 100 }.and_then(ReadingSink);
888
889        let admitted = SendPipe::call(&stack, SensorReading { celsius: 20 }).await;
890        assert_eq!(admitted, Ok(SensorReading { celsius: 21 }));
891
892        let dropped = SendPipe::call(&stack, SensorReading { celsius: 250 }).await;
893        assert_eq!(dropped, Err(SensorReading { celsius: i32::MIN }));
894    }
895
896    // the same claim as `filter_is_generic_over_a_non_http_payload`, but for
897    // `Predicate`/`FilterConfig` themselves rather than a hand-rolled
898    // decision pipe: `Predicate::<SensorReading, SensorReading>` and
899    // `FilterConfig::<SensorReading, SensorReading>` are instantiated here
900    // purely by type inference from `.and_then(ReadingSink)` and the
901    // `SendPipe::call` argument below — no turbofish, no new type minted,
902    // and `filter.rs` was never touched to add this payload.
903    #[proxima::test]
904    async fn predicate_and_filter_config_are_generic_over_a_non_http_payload() {
905        let always_admits =
906            Predicate::<SensorReading, SensorReading>::when(When::prob(1.0).seed(1))
907                .and_then(ReadingSink);
908        let admitted = SendPipe::call(&always_admits, SensorReading { celsius: 20 }).await;
909        assert_eq!(admitted, Ok(SensorReading { celsius: 21 }));
910
911        let stack = FilterConfig::<SensorReading, SensorReading> {
912            predicate: Predicate::never(),
913            on_reject: RejectMode::Drop,
914        }
915        .and_then(ReadingSink);
916        let dropped = SendPipe::call(&stack, SensorReading { celsius: 20 }).await;
917        assert_eq!(dropped, Err(SensorReading { celsius: i32::MIN }));
918
919        let error_mode = FilterConfig::<SensorReading, SensorReading> {
920            predicate: Predicate::never(),
921            on_reject: RejectMode::Error,
922        }
923        .and_then(ReadingSink);
924        let errored = SendPipe::call(&error_mode, SensorReading { celsius: 20 }).await;
925        assert_eq!(
926            errored,
927            Err(SensorReading { celsius: i32::MIN }),
928            "RejectMode::Error still routes through From<RejectMode> for a non-HTTP Err"
929        );
930    }
931
932    // ── UnpinPipe / UnpinSendPipe tier (Stage 2) ────────────────────────────
933
934    fn poll_ready<F: Future + core::marker::Unpin>(mut future: F) -> F::Output {
935        let waker = core::task::Waker::noop();
936        let mut cx = core::task::Context::from_waker(waker);
937        match Pin::new(&mut future).poll(&mut cx) {
938            core::task::Poll::Ready(output) => output,
939            core::task::Poll::Pending => {
940                panic!("Predicate/FilterConfig never suspend; expected Ready")
941            }
942        }
943    }
944
945    #[test]
946    fn predicate_unpin_pipe_matches_send_pipe_on_admit_and_reject() {
947        let admits: Predicate = Predicate::always();
948        let rejects: Predicate = Predicate::never();
949
950        assert!(
951            poll_ready(UnpinPipe::call(&admits, build_request())).is_ok(),
952            "Always admits"
953        );
954        assert!(
955            matches!(
956                poll_ready(UnpinPipe::call(&rejects, build_request())),
957                Err(ProximaError::Forbidden(_))
958            ),
959            "Never rejects with Forbidden"
960        );
961    }
962
963    #[test]
964    fn predicate_unpin_send_pipe_is_send_and_unpin() {
965        fn needs_send_unpin<F: Future + Send + Unpin>(_: &F) {}
966        let predicate: Predicate = Predicate::always();
967        let call = UnpinSendPipe::call(&predicate, build_request());
968        needs_send_unpin(&call);
969    }
970
971    #[test]
972    fn filter_config_unpin_pipe_matches_send_pipe_on_admit_and_reject() {
973        let admits: FilterConfig = FilterConfig {
974            predicate: Predicate::always(),
975            on_reject: RejectMode::Drop,
976        };
977        let rejects: FilterConfig = FilterConfig {
978            predicate: Predicate::never(),
979            on_reject: RejectMode::Error,
980        };
981
982        assert!(poll_ready(UnpinPipe::call(&admits, build_request())).is_ok());
983        assert!(matches!(
984            poll_ready(UnpinPipe::call(&rejects, build_request())),
985            Err(ProximaError::Config(_))
986        ));
987    }
988
989    #[test]
990    fn filter_config_unpin_send_pipe_is_send_and_unpin() {
991        fn needs_send_unpin<F: Future + Send + Unpin>(_: &F) {}
992        let config: FilterConfig = FilterConfig::default();
993        let call = UnpinSendPipe::call(&config, build_request());
994        needs_send_unpin(&call);
995    }
996}