Skip to main content

stygian_browser/interstitial_router/
report.rs

1//! Router decision and per-signature evidence types.
2//!
3//! The [`RouterDecision`] is the structured output of
4//! [`InterstitialRouter`][super::InterstitialRouter]: a
5//! classification kind, a dedicated
6//! [`InterstitialRoute`][super::policy::InterstitialRoute],
7//! a dedicated
8//! [`severity`][super::policy::InterstitialSeverity] field
9//! (the observability discriminator), the per-signature
10//! evidence that drove the decision, and a wall-clock
11//! timestamp for diagnostic routing.
12
13use std::fmt;
14
15use serde::{Deserialize, Serialize};
16
17use super::policy::{InterstitialKind, InterstitialRoute, InterstitialSeverity};
18
19/// Evidence the classifier extracted from a [`PageSignature`][super::PageSignature].
20///
21/// Carries the URL, status, and the matched body / URL /
22/// header patterns so downstream observability tooling can
23/// trace the decision back to the raw observation. The
24/// `evidence` is built by the router after the classifier
25/// has run, so a `RouterDecision` is self-describing.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct PageSignatureEvidence {
28    /// Lower-case host extracted from the signature URL.
29    pub host: Option<String>,
30    /// HTTP status code.
31    pub status_code: Option<u16>,
32    /// URL patterns that fired (e.g. `/cdn-cgi/challenge-platform`).
33    pub matched_url_patterns: Vec<String>,
34    /// Body markers that fired (lower-cased substrings).
35    pub matched_body_markers: Vec<String>,
36    /// Header names that fired (lower-cased).
37    pub matched_headers: Vec<String>,
38    /// Queue position hint observed in the signature, when
39    /// known.
40    pub queue_position: Option<u32>,
41    /// Vendor hint observed in the signature, when known.
42    pub vendor_hint: Option<String>,
43}
44
45/// Result of routing a [`PageSignature`][super::PageSignature].
46///
47/// The decision carries:
48///
49/// - `kind` — the structural classification from
50///   [`InterstitialClassifier`][super::InterstitialClassifier].
51/// - `severity` — **dedicated** observability field
52///   distinguishing retryable / requires-solve / terminal
53///   tiers. Observability tooling should branch on this
54///   field rather than the kind enum when the question is
55///   "is the run terminal vs retryable".
56/// - `route` — the dedicated acquisition route per kind.
57/// - `reason` — short human-readable rationale.
58/// - `evidence` — the per-signature matches that drove the
59///   decision.
60/// - `classified_at_unix_ms` — wall-clock timestamp the
61///   decision was produced.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct RouterDecision {
64    /// Structural classification.
65    pub kind: InterstitialKind,
66    /// Dedicated operational severity tier. Distinguishes
67    /// `Queue` (retryable) from `HardBlock` (terminal) for
68    /// observability tooling.
69    pub severity: InterstitialSeverity,
70    /// Dedicated acquisition route.
71    pub route: InterstitialRoute,
72    /// Compact human-readable rationale.
73    pub reason: String,
74    /// Per-signature evidence that drove the decision.
75    pub evidence: PageSignatureEvidence,
76    /// Wall-clock timestamp the decision was produced.
77    pub classified_at_unix_ms: u64,
78}
79
80impl RouterDecision {
81    /// Build a decision with the supplied fields and the
82    /// current wall-clock timestamp.
83    #[must_use]
84    pub fn new(
85        kind: InterstitialKind,
86        route: InterstitialRoute,
87        reason: impl Into<String>,
88        evidence: PageSignatureEvidence,
89    ) -> Self {
90        let severity = InterstitialSeverity::for_kind(kind);
91        let classified_at_unix_ms = unix_epoch_ms();
92        Self {
93            kind,
94            severity,
95            route,
96            reason: reason.into(),
97            evidence,
98            classified_at_unix_ms,
99        }
100    }
101
102    /// Build a decision with an explicit
103    /// `classified_at_unix_ms` (useful for tests and
104    /// deterministic replay).
105    #[must_use]
106    pub const fn with_timestamp(mut self, unix_ms: u64) -> Self {
107        self.classified_at_unix_ms = unix_ms;
108        self
109    }
110
111    /// Structural classification kind.
112    #[must_use]
113    pub const fn kind(&self) -> InterstitialKind {
114        self.kind
115    }
116
117    /// Operational severity tier (dedicated observability
118    /// field).
119    #[must_use]
120    pub const fn severity(&self) -> InterstitialSeverity {
121        self.severity
122    }
123
124    /// Dedicated acquisition route.
125    #[must_use]
126    pub const fn route(&self) -> &InterstitialRoute {
127        &self.route
128    }
129
130    /// Per-signature evidence.
131    #[must_use]
132    pub const fn evidence(&self) -> &PageSignatureEvidence {
133        &self.evidence
134    }
135
136    /// Compact human-readable rationale.
137    #[must_use]
138    pub fn reason(&self) -> &str {
139        &self.reason
140    }
141
142    /// `true` when the decision is for a classified
143    /// interstitial (kind != transient). Used by the runner
144    /// to decide whether to short-circuit.
145    #[must_use]
146    pub const fn is_classified(&self) -> bool {
147        !matches!(self.kind, InterstitialKind::Transient)
148    }
149
150    /// `true` when the severity is
151    /// [`InterstitialSeverity::Terminal`].
152    #[must_use]
153    pub const fn is_terminal(&self) -> bool {
154        matches!(self.severity, InterstitialSeverity::Terminal)
155    }
156
157    /// `true` when the severity is
158    /// [`InterstitialSeverity::RequiresSolve`].
159    #[must_use]
160    pub const fn requires_solve(&self) -> bool {
161        matches!(self.severity, InterstitialSeverity::RequiresSolve)
162    }
163
164    /// `true` when the severity is
165    /// [`InterstitialSeverity::Retryable`].
166    #[must_use]
167    pub const fn is_retryable(&self) -> bool {
168        matches!(self.severity, InterstitialSeverity::Retryable)
169    }
170
171    /// Emit a structured `tracing` event for the decision.
172    /// Always emits at `info` for classified decisions and
173    /// at `debug` for transient ones so a single
174    /// `tracing-subscriber` filter can be used to suppress
175    /// the noisy transient path while keeping the
176    /// classified path visible.
177    pub fn log(&self) {
178        if self.is_classified() {
179            tracing::info!(
180                target: "stygian::interstitial_router",
181                kind = self.kind.label(),
182                severity = self.severity.label(),
183                route = self.route.label(),
184                host = self.evidence.host.as_deref().unwrap_or(""),
185                status_code = self.evidence.status_code.unwrap_or(0),
186                queue_position = self.evidence.queue_position.unwrap_or(0),
187                vendor_hint = self.evidence.vendor_hint.as_deref().unwrap_or(""),
188                matched_url_patterns = self.evidence.matched_url_patterns.len(),
189                matched_body_markers = self.evidence.matched_body_markers.len(),
190                matched_headers = self.evidence.matched_headers.len(),
191                classified_at_unix_ms = self.classified_at_unix_ms,
192                "interstitial routing decision",
193            );
194        } else {
195            tracing::debug!(
196                target: "stygian::interstitial_router",
197                kind = self.kind.label(),
198                severity = self.severity.label(),
199                route = self.route.label(),
200                host = self.evidence.host.as_deref().unwrap_or(""),
201                status_code = self.evidence.status_code.unwrap_or(0),
202                "interstitial routing decision (transient)",
203            );
204        }
205    }
206}
207
208impl fmt::Display for RouterDecision {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        write!(
211            f,
212            "interstitial(kind={}, severity={}, route={}, reason={})",
213            self.kind.label(),
214            self.severity.label(),
215            self.route.label(),
216            self.reason,
217        )
218    }
219}
220
221/// Wrapper struct that records the router decision
222/// alongside the original [`PageSignature`][super::PageSignature]
223/// for audit / replay.
224///
225/// Not currently part of the acquisition result schema,
226/// but the type is exposed so downstream tooling that
227/// wants to log the full decision-derivation can build
228/// one.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct RouterDecisionLog {
231    /// The original signature.
232    pub signature: super::PageSignature,
233    /// The decision.
234    pub decision: RouterDecision,
235}
236
237impl RouterDecisionLog {
238    /// Build a log record from a signature + decision pair.
239    #[must_use]
240    pub const fn new(signature: super::PageSignature, decision: RouterDecision) -> Self {
241        Self {
242            signature,
243            decision,
244        }
245    }
246}
247
248/// Current Unix epoch in milliseconds, clamped to `u64`.
249#[must_use]
250pub fn unix_epoch_ms() -> u64 {
251    std::time::SystemTime::now()
252        .duration_since(std::time::UNIX_EPOCH)
253        .map_or(std::time::Duration::ZERO, |d| d)
254        .as_millis()
255        .try_into()
256        .unwrap_or(u64::MAX)
257}