Skip to main content

guardrail_core/
pipeline.rs

1//! The pipeline abstraction: `Stage` trait, `Pipeline`, and `PipelineBuilder`.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::{
7    decision::{BlockCode, Decision},
8    error::GuardrailError,
9    request::GuardrailRequest,
10};
11
12/// Block code returned when a stage configured with [`FailMode::Closed`]
13/// errors instead of producing a decision.
14pub const STAGE_ERROR_CODE: &str = "stage_error";
15
16/// What the pipeline does when a stage returns `Err` instead of a decision.
17///
18/// Set per stage with [`PipelineBuilder::stage_with_fail_mode`]. Stages added
19/// with [`PipelineBuilder::stage`] use [`FailMode::Open`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum FailMode {
22    /// Treat the error as `Decision::Allow` and keep evaluating later
23    /// stages (fail-open). The default.
24    #[default]
25    Open,
26    /// Stop and return `Decision::Block` with code [`STAGE_ERROR_CODE`]
27    /// (fail-closed).
28    Closed,
29}
30
31/// How one stage evaluation ended. Reported to the observer passed to
32/// [`Pipeline::run_with_events`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum StageOutcome {
36    /// The stage returned a decision (allow, redact, or block).
37    Completed,
38    /// The stage errored and the request continued unchecked by it
39    /// ([`FailMode::Open`]).
40    ErrorAllowed,
41    /// The stage errored and the request was blocked ([`FailMode::Closed`]).
42    ErrorBlocked,
43}
44
45/// One stage evaluation, as reported to [`Pipeline::run_with_events`].
46#[derive(Debug, Clone, Copy)]
47pub struct StageEvent {
48    /// The stage's [`Stage::name`].
49    pub stage: &'static str,
50    /// Wall-clock time the stage's `evaluate` call took.
51    pub elapsed: Duration,
52    /// How the evaluation ended.
53    pub outcome: StageOutcome,
54}
55
56/// Every processing unit in the pipeline implements this trait.
57///
58/// Stages are stateless with respect to individual requests; all shared state
59/// (e.g., compiled regexes, ONNX sessions) is held behind `Arc` in the
60/// implementor. This allows stages to be shared across many concurrent requests
61/// without synchronization overhead on the hot path.
62///
63/// ## Contract
64///
65/// - A stage **must not** mutate the request in-place; instead it expresses
66///   mutations by returning `Decision::Redact` with a new `GuardrailRequest`.
67/// - A stage **must** be `Send + Sync + 'static` so it can be used in a
68///   multi-threaded Tokio runtime.
69/// - On error (e.g. a failed model inference), return `Err` rather than
70///   choosing an outcome yourself. The pipeline applies the stage's
71///   [`FailMode`]: allow and continue by default, or block if the stage was
72///   added with [`FailMode::Closed`]. Swallowing the error inside the stage
73///   would silently bypass a fail-closed configuration.
74// `async_trait` marks the generated method `#[must_use]` even though its
75// boxed-future return type already is; clippy 1.99+ flags the redundancy
76// inside the macro expansion, which we can't change from here.
77#[allow(clippy::double_must_use)]
78#[async_trait::async_trait]
79pub trait Stage: Send + Sync + 'static {
80    /// Human-readable identifier used in logs, metrics, and traces.
81    fn name(&self) -> &'static str;
82
83    /// Evaluate a request and return a `Decision`.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the stage could not reach a decision (for
88    /// example, model inference failed). The pipeline turns the error into
89    /// `Allow` or `Block` according to the stage's [`FailMode`].
90    async fn evaluate(&self, req: &GuardrailRequest) -> Result<Decision, GuardrailError>;
91}
92
93/// A sequential pipeline of stages evaluated in order.
94///
95/// The first `Block` decision short-circuits the pipeline; no subsequent stages
96/// are run. `Redact` decisions update the working request for subsequent stages.
97///
98/// # Examples
99///
100/// ```rust
101/// use guardrail_core::{Pipeline, PipelineBuilder, Decision};
102///
103/// # tokio_test::block_on(async {
104/// let pipeline = PipelineBuilder::default().build();
105/// let req = guardrail_core::test_helpers::clean_request();
106/// let (decision, _) = pipeline.run(req).await.unwrap();
107/// assert_eq!(decision, Decision::Allow);
108/// # });
109/// ```
110pub struct Pipeline {
111    stages: Vec<(Arc<dyn Stage>, FailMode)>,
112}
113
114impl Pipeline {
115    /// Create a pipeline from a list of stages, all using [`FailMode::Open`].
116    ///
117    /// Use [`PipelineBuilder::stage_with_fail_mode`] to set a different
118    /// fail mode per stage.
119    pub fn new(stages: Vec<Arc<dyn Stage>>) -> Self {
120        Self {
121            stages: stages.into_iter().map(|s| (s, FailMode::Open)).collect(),
122        }
123    }
124
125    /// Start building a pipeline fluently.
126    ///
127    /// Equivalent to [`PipelineBuilder::default()`]; provided as a
128    /// convenience entry point so call sites can write `Pipeline::builder()`
129    /// without importing `PipelineBuilder` directly.
130    ///
131    /// # Examples
132    ///
133    /// ```rust
134    /// use guardrail_core::Pipeline;
135    /// use guardrail_core::test_helpers::PassthroughStage;
136    ///
137    /// let pipeline = Pipeline::builder()
138    ///     .stage(PassthroughStage)
139    ///     .build();
140    /// assert_eq!(pipeline.len(), 1);
141    /// ```
142    pub fn builder() -> PipelineBuilder {
143        PipelineBuilder::default()
144    }
145
146    /// Returns the number of stages in this pipeline.
147    pub fn len(&self) -> usize {
148        self.stages.len()
149    }
150
151    /// Returns `true` if the pipeline contains no stages.
152    pub fn is_empty(&self) -> bool {
153        self.stages.is_empty()
154    }
155
156    /// Evaluate a request against all configured stages and return a final decision.
157    ///
158    /// Stages are run in order. The first `Block` decision short-circuits evaluation.
159    /// `Redact` decisions update the request for downstream stages.
160    ///
161    /// Run every stage in order against `req`, short-circuiting on the
162    /// first `Decision::Block`.
163    ///
164    /// If one or more stages return `Decision::Redact`, evaluation
165    /// continues (subsequent stages see the mutated request), and the
166    /// **final returned decision is `Decision::Redact`** — not `Allow` —
167    /// with `reason` being every redacting stage's reason joined by `"; "`
168    /// and `entities` being the de-duplicated union of every redacting
169    /// stage's entity list. Only if no stage redacted *and* no stage
170    /// blocked does the pipeline return `Decision::Allow`.
171    ///
172    /// A stage that returns `Err` is handled by its [`FailMode`]: with
173    /// [`FailMode::Open`] (the default) evaluation continues as if it had
174    /// allowed; with [`FailMode::Closed`] the pipeline stops and returns
175    /// `Decision::Block` with code [`STAGE_ERROR_CODE`]. Use
176    /// [`Pipeline::run_with_events`] to find out whether that happened.
177    ///
178    /// # Errors
179    ///
180    /// Currently never returns `Err`: stage errors are resolved by each
181    /// stage's [`FailMode`] as described above. The `Result` is kept so
182    /// future pipeline-level failures can be reported without an API break.
183    ///
184    /// # Examples
185    ///
186    /// ```rust
187    /// use guardrail_core::{Pipeline, PipelineBuilder, Decision};
188    ///
189    /// # tokio_test::block_on(async {
190    /// let pipeline = PipelineBuilder::default().build();
191    /// let req = guardrail_core::test_helpers::clean_request();
192    /// let (decision, _req) = pipeline.run(req).await.unwrap();
193    /// assert_eq!(decision, Decision::Allow);
194    /// # });
195    /// ```
196    ///
197    /// A pipeline with a redacting stage returns `Decision::Redact` as its
198    /// **final** decision, not `Allow` — this is the behavior callers such
199    /// as `guardrail-proxy`'s audit log and `redacted_total` metric depend
200    /// on:
201    ///
202    /// ```rust
203    /// use guardrail_core::{Pipeline, Decision};
204    /// # use guardrail_core::{Stage, error::GuardrailError, request::GuardrailRequest};
205    /// # use async_trait::async_trait;
206    /// #
207    /// # struct AlwaysRedacts;
208    /// # #[async_trait]
209    /// # impl Stage for AlwaysRedacts {
210    /// #     fn name(&self) -> &'static str { "always_redacts" }
211    /// #     async fn evaluate(&self, req: &GuardrailRequest) -> Result<Decision, GuardrailError> {
212    /// #         Ok(Decision::Redact {
213    /// #             reason: "test redaction".into(),
214    /// #             mutated: req.clone(),
215    /// #             entities: vec!["email".into()],
216    /// #         })
217    /// #     }
218    /// # }
219    /// # tokio_test::block_on(async {
220    /// let pipeline = Pipeline::builder().stage(AlwaysRedacts).build();
221    /// let req = guardrail_core::test_helpers::clean_request();
222    /// let (decision, _req) = pipeline.run(req).await.unwrap();
223    /// match decision {
224    ///     Decision::Redact { entities, .. } => assert_eq!(entities, vec!["email"]),
225    ///     other => panic!("expected Redact, got {other:?}"),
226    /// }
227    /// # });
228    /// ```
229    pub async fn run(
230        &self,
231        req: GuardrailRequest,
232    ) -> Result<(Decision, GuardrailRequest), GuardrailError> {
233        self.run_with_observer(req, |_stage, _elapsed| {}).await
234    }
235
236    /// Like [`Pipeline::run`], but invokes `observer(stage_name, elapsed)`
237    /// after each stage evaluation (including failed/blocked stages).
238    ///
239    /// This is used by `guardrail-proxy` to record per-stage latency
240    /// histograms without coupling `guardrail-core` to a metrics backend.
241    ///
242    /// # Examples
243    ///
244    /// ```rust
245    /// use guardrail_core::{PipelineBuilder, test_helpers::clean_request};
246    /// use std::time::Duration;
247    ///
248    /// # tokio_test::block_on(async {
249    /// let pipeline = PipelineBuilder::default().build();
250    /// let mut observed: Vec<(String, Duration)> = Vec::new();
251    ///
252    /// let (decision, _req) = pipeline
253    ///     .run_with_observer(clean_request(), |stage, elapsed| {
254    ///         observed.push((stage.to_string(), elapsed));
255    ///     })
256    ///     .await
257    ///     .unwrap();
258    ///
259    /// assert!(observed.is_empty()); // empty pipeline has no stages
260    /// # });
261    /// ```
262    pub async fn run_with_observer<F>(
263        &self,
264        req: GuardrailRequest,
265        mut observer: F,
266    ) -> Result<(Decision, GuardrailRequest), GuardrailError>
267    where
268        F: FnMut(&str, std::time::Duration),
269    {
270        self.run_with_events(req, |event| observer(event.stage, event.elapsed))
271            .await
272    }
273
274    /// Like [`Pipeline::run`], but invokes `observer` with a [`StageEvent`]
275    /// after each stage evaluation, including whether the stage errored and
276    /// how its [`FailMode`] resolved the error.
277    ///
278    /// This is how callers make fail-open events visible: count
279    /// [`StageOutcome::ErrorAllowed`] to see requests that went through
280    /// without a stage's check.
281    ///
282    /// # Errors
283    ///
284    /// Same as [`Pipeline::run`]: currently never returns `Err`.
285    ///
286    /// # Examples
287    ///
288    /// ```rust
289    /// use guardrail_core::pipeline::{FailMode, StageOutcome};
290    /// use guardrail_core::test_helpers::{clean_request, ErrorStage};
291    /// use guardrail_core::{Decision, PipelineBuilder};
292    ///
293    /// # tokio_test::block_on(async {
294    /// let pipeline = PipelineBuilder::default()
295    ///     .stage_with_fail_mode(ErrorStage, FailMode::Closed)
296    ///     .build();
297    ///
298    /// let mut outcomes = Vec::new();
299    /// let (decision, _req) = pipeline
300    ///     .run_with_events(clean_request(), |event| outcomes.push(event.outcome))
301    ///     .await
302    ///     .unwrap();
303    ///
304    /// assert!(matches!(decision, Decision::Block { .. }));
305    /// assert_eq!(outcomes, vec![StageOutcome::ErrorBlocked]);
306    /// # });
307    /// ```
308    pub async fn run_with_events<F>(
309        &self,
310        mut req: GuardrailRequest,
311        mut observer: F,
312    ) -> Result<(Decision, GuardrailRequest), GuardrailError>
313    where
314        F: FnMut(&StageEvent),
315    {
316        // Accumulated across all stages that redact (not just the first or
317        // last). A pipeline with both `pii_redactor` and a custom
318        // redacting stage must report the union of both as the final
319        // decision — previously this state was discarded entirely and the
320        // pipeline always returned `Decision::Allow` even after a
321        // successful redaction, which meant `Decision::Redact` could never
322        // actually reach callers (the audit log's "redact" case, the
323        // `guardrail_redacted_total` metric, and `guardrail check`'s
324        // redact output were all unreachable dead code).
325        let mut redaction_reasons: Vec<String> = Vec::new();
326        let mut redaction_entities: Vec<String> = Vec::new();
327
328        for (stage, fail_mode) in &self.stages {
329            let stage_name = stage.name();
330            tracing::debug!(stage = stage_name, "evaluating stage");
331
332            let stage_start = std::time::Instant::now();
333            let result = stage.evaluate(&req).await;
334            let elapsed = stage_start.elapsed();
335
336            let decision = match result {
337                Ok(d) => {
338                    observer(&StageEvent {
339                        stage: stage_name,
340                        elapsed,
341                        outcome: StageOutcome::Completed,
342                    });
343                    d
344                }
345                Err(e) => match fail_mode {
346                    FailMode::Open => {
347                        tracing::warn!(
348                            stage = stage_name,
349                            error = %e,
350                            "stage failed; failing open (allow)"
351                        );
352                        observer(&StageEvent {
353                            stage: stage_name,
354                            elapsed,
355                            outcome: StageOutcome::ErrorAllowed,
356                        });
357                        Decision::Allow
358                    }
359                    FailMode::Closed => {
360                        tracing::error!(
361                            stage = stage_name,
362                            error = %e,
363                            "stage failed; failing closed (block)"
364                        );
365                        observer(&StageEvent {
366                            stage: stage_name,
367                            elapsed,
368                            outcome: StageOutcome::ErrorBlocked,
369                        });
370                        // The error text stays in the log: it can name model
371                        // paths and other internals, and this reason is
372                        // returned to the caller in the 403 body.
373                        Decision::Block {
374                            reason: format!(
375                                "stage '{stage_name}' failed and is configured to fail closed"
376                            ),
377                            code: BlockCode::Custom(STAGE_ERROR_CODE.into()),
378                        }
379                    }
380                },
381            };
382
383            tracing::debug!(
384                stage = stage_name,
385                decision = decision.name(),
386                "stage decision"
387            );
388
389            match decision {
390                Decision::Allow => continue,
391                Decision::Redact {
392                    reason,
393                    mutated,
394                    entities,
395                } => {
396                    tracing::info!(stage = stage_name, reason = %reason, "request redacted");
397                    req = mutated;
398                    redaction_reasons.push(reason);
399                    for entity in entities {
400                        if !redaction_entities.contains(&entity) {
401                            redaction_entities.push(entity);
402                        }
403                    }
404                }
405                Decision::Block { reason, code } => {
406                    tracing::info!(
407                        stage = stage_name,
408                        reason = %reason,
409                        code = %code,
410                        "request blocked"
411                    );
412                    return Ok((Decision::Block { reason, code }, req));
413                }
414            }
415        }
416
417        if redaction_reasons.is_empty() {
418            Ok((Decision::Allow, req))
419        } else {
420            Ok((
421                Decision::Redact {
422                    reason: redaction_reasons.join("; "),
423                    mutated: req.clone(),
424                    entities: redaction_entities,
425                },
426                req,
427            ))
428        }
429    }
430}
431
432/// Builder for ergonomic pipeline construction.
433///
434/// # Examples
435///
436/// ```rust
437/// use guardrail_core::PipelineBuilder;
438///
439/// let pipeline = PipelineBuilder::default()
440///     // .stage(MyStage::new())  // add stages here
441///     .build();
442///
443/// assert_eq!(pipeline.len(), 0);
444/// ```
445#[derive(Default)]
446pub struct PipelineBuilder {
447    stages: Vec<(Arc<dyn Stage>, FailMode)>,
448}
449
450impl PipelineBuilder {
451    /// Add a stage to the pipeline with [`FailMode::Open`]. Stages are
452    /// evaluated in the order they are added.
453    pub fn stage(self, s: impl Stage) -> Self {
454        self.stage_with_fail_mode(s, FailMode::Open)
455    }
456
457    /// Add a pre-boxed stage (useful when the stage is already behind an
458    /// `Arc`) with [`FailMode::Open`].
459    pub fn arc_stage(self, s: Arc<dyn Stage>) -> Self {
460        self.arc_stage_with_fail_mode(s, FailMode::Open)
461    }
462
463    /// Add a stage with an explicit [`FailMode`] for when it returns `Err`.
464    ///
465    /// # Examples
466    ///
467    /// ```rust
468    /// use guardrail_core::pipeline::{FailMode, STAGE_ERROR_CODE};
469    /// use guardrail_core::test_helpers::{clean_request, ErrorStage};
470    /// use guardrail_core::{Decision, PipelineBuilder};
471    ///
472    /// # tokio_test::block_on(async {
473    /// let pipeline = PipelineBuilder::default()
474    ///     .stage_with_fail_mode(ErrorStage, FailMode::Closed)
475    ///     .build();
476    /// let (decision, _req) = pipeline.run(clean_request()).await.unwrap();
477    /// match decision {
478    ///     Decision::Block { code, .. } => assert_eq!(code.as_str(), STAGE_ERROR_CODE),
479    ///     other => panic!("expected Block, got {other:?}"),
480    /// }
481    /// # });
482    /// ```
483    pub fn stage_with_fail_mode(self, s: impl Stage, fail_mode: FailMode) -> Self {
484        self.arc_stage_with_fail_mode(Arc::new(s), fail_mode)
485    }
486
487    /// Add a pre-boxed stage with an explicit [`FailMode`].
488    pub fn arc_stage_with_fail_mode(mut self, s: Arc<dyn Stage>, fail_mode: FailMode) -> Self {
489        self.stages.push((s, fail_mode));
490        self
491    }
492
493    /// Consume the builder and return a configured `Pipeline`.
494    pub fn build(self) -> Pipeline {
495        Pipeline {
496            stages: self.stages,
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::test_helpers::{clean_request, BlockingStage, PassthroughStage};
505
506    #[tokio::test]
507    async fn test_empty_pipeline_allows() {
508        let pipeline = PipelineBuilder::default().build();
509        let req = clean_request();
510        let (decision, _) = pipeline.run(req).await.unwrap();
511        assert_eq!(decision, Decision::Allow);
512    }
513
514    #[tokio::test]
515    async fn test_single_pass_stage_allows() {
516        let pipeline = PipelineBuilder::default().stage(PassthroughStage).build();
517        let req = clean_request();
518        let (decision, _) = pipeline.run(req).await.unwrap();
519        assert_eq!(decision, Decision::Allow);
520    }
521
522    #[tokio::test]
523    async fn test_blocking_stage_short_circuits() {
524        let pipeline = PipelineBuilder::default()
525            .stage(BlockingStage)
526            .stage(PassthroughStage)
527            .build();
528        let req = clean_request();
529        let (decision, _) = pipeline.run(req).await.unwrap();
530        assert!(matches!(decision, Decision::Block { .. }));
531    }
532
533    #[tokio::test]
534    async fn test_single_redacting_stage_returns_redact_not_allow() {
535        // Regression test: the pipeline previously always collapsed a
536        // successful redaction down to `Decision::Allow` by the time
537        // `run()` returned, even though the stage itself correctly
538        // returned `Decision::Redact`. This made `Decision::Redact`
539        // unreachable from any caller of `Pipeline::run`.
540        use crate::test_helpers::RedactingStage;
541
542        let pipeline = PipelineBuilder::default()
543            .stage(RedactingStage::new(
544                "PII detected and redacted: Email",
545                vec!["email".to_string()],
546            ))
547            .build();
548
549        let req = clean_request();
550        let (decision, _) = pipeline.run(req).await.unwrap();
551
552        match decision {
553            Decision::Redact {
554                reason, entities, ..
555            } => {
556                assert_eq!(reason, "PII detected and redacted: Email");
557                assert_eq!(entities, vec!["email".to_string()]);
558            }
559            other => panic!("expected Decision::Redact, got {other:?}"),
560        }
561    }
562
563    #[tokio::test]
564    async fn test_multiple_redacting_stages_accumulate_reasons_and_entities() {
565        use crate::test_helpers::RedactingStage;
566
567        let pipeline = PipelineBuilder::default()
568            .stage(RedactingStage::with_name(
569                "stage_a",
570                "redacted email",
571                vec!["email".to_string()],
572            ))
573            .stage(RedactingStage::with_name(
574                "stage_b",
575                "redacted phone",
576                vec!["phone".to_string()],
577            ))
578            .build();
579
580        let req = clean_request();
581        let (decision, _) = pipeline.run(req).await.unwrap();
582
583        match decision {
584            Decision::Redact {
585                reason, entities, ..
586            } => {
587                assert_eq!(reason, "redacted email; redacted phone");
588                assert_eq!(entities, vec!["email".to_string(), "phone".to_string()]);
589            }
590            other => panic!("expected Decision::Redact, got {other:?}"),
591        }
592    }
593
594    #[tokio::test]
595    async fn test_duplicate_entities_across_stages_are_deduplicated() {
596        use crate::test_helpers::RedactingStage;
597
598        // Two stages both report "email" — the final entity list must not
599        // contain it twice.
600        let pipeline = PipelineBuilder::default()
601            .stage(RedactingStage::with_name(
602                "stage_a",
603                "first pass",
604                vec!["email".to_string()],
605            ))
606            .stage(RedactingStage::with_name(
607                "stage_b",
608                "second pass",
609                vec!["email".to_string(), "phone".to_string()],
610            ))
611            .build();
612
613        let req = clean_request();
614        let (decision, _) = pipeline.run(req).await.unwrap();
615
616        match decision {
617            Decision::Redact { entities, .. } => {
618                assert_eq!(entities, vec!["email".to_string(), "phone".to_string()]);
619            }
620            other => panic!("expected Decision::Redact, got {other:?}"),
621        }
622    }
623
624    #[tokio::test]
625    async fn test_redact_then_block_returns_block_not_redact() {
626        // A request that gets redacted by an earlier stage and then blocked
627        // by a later stage must surface as Block — blocking always takes
628        // priority since it's a stronger guarantee (the request never
629        // reaches the upstream either way).
630        use crate::test_helpers::RedactingStage;
631
632        let pipeline = PipelineBuilder::default()
633            .stage(RedactingStage::new(
634                "redacted first",
635                vec!["email".to_string()],
636            ))
637            .stage(BlockingStage)
638            .build();
639
640        let req = clean_request();
641        let (decision, _) = pipeline.run(req).await.unwrap();
642
643        assert!(matches!(decision, Decision::Block { .. }));
644    }
645
646    #[tokio::test]
647    async fn test_redacted_request_is_passed_to_subsequent_stages() {
648        // The mutated request from a redacting stage must be visible to
649        // stages that run after it (already correctly implemented before
650        // this fix, but verified here alongside the new accumulation logic
651        // to guard against a future regression).
652        use crate::test_helpers::RedactingStage;
653
654        let pipeline = PipelineBuilder::default()
655            .stage(RedactingStage::new("redact", vec!["email".to_string()]))
656            .stage(PassthroughStage)
657            .build();
658
659        let req = clean_request();
660        let (decision, final_req) = pipeline.run(req.clone()).await.unwrap();
661
662        assert!(matches!(decision, Decision::Redact { .. }));
663        // RedactingStage's `mutated` is `req.clone()` (identical content in
664        // this test helper), but the key invariant is that the SAME final
665        // request flows through to PassthroughStage and out the other end
666        // without being silently reset to the original.
667        assert_eq!(final_req.user_text(), req.user_text());
668    }
669
670    #[tokio::test]
671    async fn test_pipeline_len() {
672        let pipeline = PipelineBuilder::default()
673            .stage(PassthroughStage)
674            .stage(PassthroughStage)
675            .build();
676        assert_eq!(pipeline.len(), 2);
677        assert!(!pipeline.is_empty());
678    }
679
680    #[tokio::test]
681    async fn test_run_with_observer_records_each_stage() {
682        let pipeline = PipelineBuilder::default()
683            .stage(PassthroughStage)
684            .stage(PassthroughStage)
685            .build();
686
687        let mut observed = Vec::new();
688        let (decision, _) = pipeline
689            .run_with_observer(clean_request(), |stage, elapsed| {
690                observed.push((stage.to_string(), elapsed));
691            })
692            .await
693            .unwrap();
694
695        assert_eq!(decision, Decision::Allow);
696        assert_eq!(observed.len(), 2);
697        assert!(observed.iter().all(|(name, _)| name == "passthrough"));
698    }
699
700    #[tokio::test]
701    async fn test_erroring_stage_fails_open_by_default_and_continues() {
702        use crate::test_helpers::ErrorStage;
703
704        let pipeline = PipelineBuilder::default()
705            .stage(ErrorStage)
706            .stage(BlockingStage)
707            .build();
708
709        let mut outcomes = Vec::new();
710        let (decision, _) = pipeline
711            .run_with_events(clean_request(), |e| outcomes.push((e.stage, e.outcome)))
712            .await
713            .unwrap();
714
715        // The error is treated as Allow, so the next stage still runs.
716        assert!(matches!(decision, Decision::Block { .. }));
717        assert_eq!(
718            outcomes,
719            vec![
720                ("error_stage", StageOutcome::ErrorAllowed),
721                ("blocking", StageOutcome::Completed),
722            ]
723        );
724    }
725
726    #[tokio::test]
727    async fn test_erroring_stage_with_fail_closed_blocks_and_short_circuits() {
728        use crate::test_helpers::ErrorStage;
729
730        let pipeline = PipelineBuilder::default()
731            .stage_with_fail_mode(ErrorStage, FailMode::Closed)
732            .stage(PassthroughStage)
733            .build();
734
735        let mut outcomes = Vec::new();
736        let (decision, _) = pipeline
737            .run_with_events(clean_request(), |e| outcomes.push((e.stage, e.outcome)))
738            .await
739            .unwrap();
740
741        match decision {
742            Decision::Block { code, reason } => {
743                assert_eq!(code.as_str(), STAGE_ERROR_CODE);
744                assert!(reason.contains("error_stage"));
745                // The internal error text must not reach the caller.
746                assert!(!reason.contains("simulated stage failure"));
747            }
748            other => panic!("expected Block, got {other:?}"),
749        }
750        assert_eq!(outcomes, vec![("error_stage", StageOutcome::ErrorBlocked)]);
751    }
752
753    #[tokio::test]
754    async fn test_fail_mode_is_per_stage() {
755        use crate::test_helpers::ErrorStage;
756
757        // A fail-open erroring stage followed by a fail-closed healthy one:
758        // only the stage that errors has its fail mode applied.
759        let pipeline = PipelineBuilder::default()
760            .stage_with_fail_mode(ErrorStage, FailMode::Open)
761            .stage_with_fail_mode(PassthroughStage, FailMode::Closed)
762            .build();
763
764        let (decision, _) = pipeline.run(clean_request()).await.unwrap();
765        assert_eq!(decision, Decision::Allow);
766    }
767
768    #[tokio::test]
769    async fn test_pipeline_new_uses_fail_open() {
770        use crate::test_helpers::ErrorStage;
771
772        let pipeline = Pipeline::new(vec![Arc::new(ErrorStage)]);
773        let (decision, _) = pipeline.run(clean_request()).await.unwrap();
774        assert_eq!(decision, Decision::Allow);
775    }
776
777    #[tokio::test]
778    async fn test_run_with_observer_stops_after_block() {
779        let pipeline = PipelineBuilder::default()
780            .stage(BlockingStage)
781            .stage(PassthroughStage)
782            .build();
783
784        let mut observed = Vec::new();
785        let (decision, _) = pipeline
786            .run_with_observer(clean_request(), |stage, elapsed| {
787                observed.push((stage.to_string(), elapsed));
788            })
789            .await
790            .unwrap();
791
792        assert!(matches!(decision, Decision::Block { .. }));
793        // Only the blocking stage should have been observed.
794        assert_eq!(observed.len(), 1);
795        assert_eq!(observed[0].0, "blocking");
796    }
797}