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}