Skip to main content

stygian_charon/change_feed/
event.rs

1//! Change-feed event payloads (T88).
2//!
3//! Every [`ChangeEvent`] the detector emits carries:
4//!
5//! - **affected targets** — the domain(s) the event
6//!   applies to (one event per target, per detection
7//!   cycle).
8//! - **delta summary** — the headline + score +
9//!   contributing sources + severities, so the
10//!   runbook consumer can render the event without
11//!   re-running the classifier.
12//! - **recommended mitigation path** — a stable
13//!   pointer to the runbook section + a one-line
14//!   hint the operator can act on.
15//!
16//! [`ChangeFeedReport`] aggregates the per-target
17//! events into a single, serialisable structure that
18//! the runbook diagnostics surface consumes. The
19//! schema is **additive** — older serialisers ignore
20//! fields they do not know.
21//!
22//! # Wire format
23//!
24//! ```text
25//! {
26//!   "aggregate_classification": "probable",
27//!   "aggregate_score": 0.81,
28//!   "noise_targets": ["quiet.example.com"],
29//!   "suspected_targets": ["watch.example.com"],
30//!   "probable_targets": ["hot.example.com"],
31//!   "events": [
32//!     {
33//!       "event_id": "cf-<unix-secs>-hot.example.com",
34//!       "detected_at_unix_secs": 1718616000,
35//!       "affected_target": "hot.example.com",
36//!       "classification": "probable",
37//!       "delta_summary": {
38//!         "headline": "integrity probe webdriver regressed",
39//!         "score": 0.81,
40//!         "sources": ["canary"],
41//!         "severities": ["critical"],
42//!         "highest_severity": "critical"
43//!       },
44//!       "vendor_hint": "datadome",
45//!       "target_class": "high_security",
46//!       "recommended_mitigation_path": {
47//!         "runbook_section": "category-a-fingerprint-identity-regression",
48//!         "hint": "apply browser+sticky escalation",
49//!         "url": "docs/incident-runbook.md#category-a-fingerprintidentity-regression"
50//!       },
51//!       "evidence": { "canary.baseline_score": "0.85" }
52//!     }
53//!   ],
54//!   "thresholds": {
55//!     "noise_ceiling": 0.20,
56//!     "probable_floor": 0.55,
57//!     "canary_weight": 1.00,
58//!     "proxy_weight": 0.80,
59//!     "extraction_weight": 0.70
60//!   }
61//! }
62//! ```
63//!
64//! ## Determinism
65//!
66//! The `event_id` is a stable composite of
67//! `cf-<detected_at_unix_secs>-<affected_target>`
68//! so downstream tooling can dedupe by event ID
69//! without depending on the order deltas were
70//! received in.
71
72use std::collections::BTreeMap;
73
74use serde::{Deserialize, Serialize};
75
76use crate::change_feed::classification::{ChangeClassification, ChangeFeedThresholds};
77use crate::change_feed::delta::{DeltaSeverity, DeltaSource};
78use crate::types::TargetClass;
79use crate::vendor_classifier::VendorId;
80
81/// Stable pointer to the runbook section an
82/// operator should consult when responding to a
83/// [`ChangeEvent`].
84///
85/// The [`path`][Self::path] field is the
86/// canonical, kebab-case identifier; the
87/// [`hint`][Self::hint] is a short human-readable
88/// action the operator can take immediately; the
89/// [`url`][Self::url] is the relative path into
90/// the crate's runbook docs.
91///
92/// # Example
93///
94/// ```
95/// use stygian_charon::change_feed::{
96///     ChangeClassification, MitigationPath,
97/// };
98/// use stygian_charon::vendor_classifier::VendorId;
99///
100/// let path = MitigationPath::for_classification(
101///     ChangeClassification::Probable,
102///     Some(VendorId::DataDome),
103/// );
104/// assert!(path.path.starts_with("category-"));
105/// assert!(path.url.contains("incident-runbook.md"));
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct MitigationPath {
109    /// Stable, kebab-case runbook section identifier.
110    pub path: String,
111    /// One-line actionable hint for the operator.
112    pub hint: String,
113    /// Relative path into the runbook docs (e.g.
114    /// `docs/incident-runbook.md#category-a-fingerprintidentity-regression`).
115    pub url: String,
116}
117
118impl MitigationPath {
119    /// Pick a mitigation path from the classification
120    /// band and (optional) vendor hint.
121    ///
122    /// The mapping is:
123    ///
124    /// | Classification | Vendor hint                | Runbook section                  |
125    /// |----------------|----------------------------|----------------------------------|
126    /// | `Suspected`    | any / none                 | `category-a-fingerprint-identity-regression` |
127    /// | `Probable`     | `DataDome`                 | `category-a-fingerprint-identity-regression` |
128    /// | `Probable`     | `PerimeterX` / `Akamai`    | `category-b-rate-limit-backoff-regression` |
129    /// | `Probable`     | `Cloudflare`               | `category-b-rate-limit-backoff-regression` |
130    /// | `Probable`     | other / none               | `category-b-rate-limit-backoff-regression` |
131    /// | `Noise`        | (event not emitted)        | n/a                              |
132    ///
133    /// The mapping mirrors the existing runbook
134    /// categories in
135    /// `crates/stygian-charon/docs/incident-runbook.md`:
136    /// fingerprint/identity regressions are
137    /// Category A; rate-limit / backoff / proxy
138    /// regressions are Category B; the rest fall
139    /// back to Category B as the safest operator
140    /// action.
141    #[must_use]
142    pub fn for_classification(
143        classification: ChangeClassification,
144        vendor: Option<VendorId>,
145    ) -> Self {
146        match classification {
147            ChangeClassification::Noise => Self {
148                path: "no-action".to_string(),
149                hint: "no action — delta scored below noise ceiling".to_string(),
150                url: "docs/incident-runbook.md".to_string(),
151            },
152            ChangeClassification::Suspected => Self {
153                path: "category-a-fingerprint-identity-regression".to_string(),
154                hint: "annotate target, watch canary trend".to_string(),
155                url: "docs/incident-runbook.md#category-afingerprintidentity-regression"
156                    .to_string(),
157            },
158            ChangeClassification::Probable => match vendor {
159                Some(VendorId::DataDome) => Self {
160                    path: "category-a-fingerprint-identity-regression".to_string(),
161                    hint: "apply browser+sticky escalation, refresh fingerprint profile"
162                        .to_string(),
163                    url: "docs/incident-runbook.md#category-afingerprintidentity-regression"
164                        .to_string(),
165                },
166                Some(VendorId::PerimeterX | VendorId::Akamai | VendorId::Imperva) => Self {
167                    path: "category-b-rate-limit-backoff-regression".to_string(),
168                    hint: "rotate proxy pool, increase backoff".to_string(),
169                    url: "docs/incident-runbook.md#category-b-rate-limiting-backoff-regression"
170                        .to_string(),
171                },
172                Some(VendorId::Cloudflare) => Self {
173                    path: "category-b-rate-limit-backoff-regression".to_string(),
174                    hint: "verify cf-clearance flow, check UA/browser coherence".to_string(),
175                    url: "docs/incident-runbook.md#category-b-rate-limiting-backoff-regression"
176                        .to_string(),
177                },
178                _ => Self {
179                    path: "category-b-rate-limit-backoff-regression".to_string(),
180                    hint: "rotate proxy pool, slow pacing, escalate per runbook".to_string(),
181                    url: "docs/incident-runbook.md#category-b-rate-limiting-backoff-regression"
182                        .to_string(),
183                },
184            },
185        }
186    }
187}
188
189/// Per-event delta summary.
190///
191/// The summary is the **operator-facing view** of
192/// the regression. The headline is a one-line
193/// description; the score is the per-target score
194/// the classifier assigned; `sources` and
195/// `severities` list the contributing channels
196/// (sorted for determinism); `highest_severity`
197/// is the worst severity tier across the deltas.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct DeltaSummary {
200    /// One-line headline describing the regression.
201    pub headline: String,
202    /// Per-target score the classifier assigned,
203    /// in `[0.0, 1.0]`.
204    pub score: f64,
205    /// Source channels that contributed deltas.
206    /// Sorted for determinism.
207    pub sources: Vec<DeltaSource>,
208    /// Distinct severity tiers the contributing
209    /// deltas attached. Sorted for determinism.
210    pub severities: Vec<DeltaSeverity>,
211    /// Highest severity tier across the
212    /// contributing deltas.
213    pub highest_severity: DeltaSeverity,
214}
215
216impl DeltaSummary {
217    /// Build a summary from the per-target aggregate.
218    /// `sources` and `severities` are deduplicated
219    /// and sorted so the wire form is deterministic.
220    #[must_use]
221    pub fn new(
222        headline: impl Into<String>,
223        score: f64,
224        sources: Vec<DeltaSource>,
225        severities: Vec<DeltaSeverity>,
226        highest_severity: DeltaSeverity,
227    ) -> Self {
228        let mut sources = sources;
229        sources.sort();
230        sources.dedup();
231        let mut severities = severities;
232        severities.sort();
233        severities.dedup();
234        Self {
235            headline: headline.into(),
236            score: sanitise_score(score),
237            sources,
238            severities,
239            highest_severity,
240        }
241    }
242}
243
244const fn sanitise_score(score: f64) -> f64 {
245    if score.is_nan() {
246        0.0
247    } else {
248        score.clamp(0.0, 1.0)
249    }
250}
251
252/// A single change-feed event.
253///
254/// One [`ChangeEvent`] is emitted per `Suspected`
255/// / `Probable` target per detection cycle. The
256/// `event_id` is stable so downstream tooling can
257/// dedupe by ID without depending on order.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct ChangeEvent {
260    /// Stable event identifier —
261    /// `cf-<detected_at_unix_secs>-<affected_target>`.
262    pub event_id: String,
263    /// Wall-clock timestamp the event was assembled.
264    pub detected_at_unix_secs: u64,
265    /// Target the event applies to (domain).
266    pub affected_target: String,
267    /// Classification band the detector assigned.
268    pub classification: ChangeClassification,
269    /// Per-target delta summary.
270    pub delta_summary: DeltaSummary,
271    /// Optional vendor hint preserved from the
272    /// upstream deltas.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub vendor_hint: Option<VendorId>,
275    /// Optional target class preserved from the
276    /// upstream deltas.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub target_class: Option<TargetClass>,
279    /// Runbook mitigation pointer.
280    pub recommended_mitigation_path: MitigationPath,
281    /// Structured evidence preserved verbatim from
282    /// the upstream deltas. Keys are namespaced
283    /// by source (e.g. `canary.baseline_score`).
284    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
285    pub evidence: BTreeMap<String, String>,
286}
287
288impl ChangeEvent {
289    /// Build a new event. The `event_id` is generated
290    /// deterministically from
291    /// `cf-<detected_at_unix_secs>-<affected_target>`
292    /// so downstream consumers can dedupe without
293    /// trusting insertion order.
294    #[must_use]
295    pub fn new(
296        affected_target: impl Into<String>,
297        classification: ChangeClassification,
298        delta_summary: DeltaSummary,
299        vendor_hint: Option<VendorId>,
300        target_class: Option<TargetClass>,
301        recommended_mitigation_path: MitigationPath,
302        evidence: BTreeMap<String, String>,
303    ) -> Self {
304        let target = affected_target.into();
305        let event_id = format!("cf-{}-{}", unix_timestamp_secs(), sanitize_segment(&target));
306        Self {
307            event_id,
308            detected_at_unix_secs: unix_timestamp_secs(),
309            affected_target: target,
310            classification,
311            delta_summary,
312            vendor_hint,
313            target_class,
314            recommended_mitigation_path,
315            evidence,
316        }
317    }
318
319    /// Build a new event with an explicit wall-clock
320    /// timestamp. Useful for deterministic tests
321    /// and for callers that hold their own clock.
322    #[allow(clippy::too_many_arguments)]
323    #[must_use]
324    pub fn new_at(
325        detected_at_unix_secs: u64,
326        affected_target: impl Into<String>,
327        classification: ChangeClassification,
328        delta_summary: DeltaSummary,
329        vendor_hint: Option<VendorId>,
330        target_class: Option<TargetClass>,
331        recommended_mitigation_path: MitigationPath,
332        evidence: BTreeMap<String, String>,
333    ) -> Self {
334        let target = affected_target.into();
335        let event_id = format!("cf-{}-{}", detected_at_unix_secs, sanitize_segment(&target));
336        Self {
337            event_id,
338            detected_at_unix_secs,
339            affected_target: target,
340            classification,
341            delta_summary,
342            vendor_hint,
343            target_class,
344            recommended_mitigation_path,
345            evidence,
346        }
347    }
348}
349
350fn unix_timestamp_secs() -> u64 {
351    use std::time::{SystemTime, UNIX_EPOCH};
352    SystemTime::now()
353        .duration_since(UNIX_EPOCH)
354        .map_or(0, |d| d.as_secs())
355}
356
357fn sanitize_segment(input: &str) -> String {
358    input
359        .chars()
360        .map(|ch| {
361            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
362                ch
363            } else {
364                '_'
365            }
366        })
367        .collect()
368}
369
370/// Aggregated change-feed report for a single
371/// detection cycle.
372///
373/// The report carries:
374/// - the aggregate classification (the worst
375///   per-target band);
376/// - the per-target lists grouped by band;
377/// - the emitted [`ChangeEvent`] records;
378/// - the threshold configuration the detector
379///   used (so downstream consumers can audit the
380///   banding decision without consulting the
381///   detector config separately).
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct ChangeFeedReport {
384    /// Worst per-target band across the cycle.
385    pub aggregate_classification: ChangeClassification,
386    /// Highest per-target score across the cycle.
387    pub aggregate_score: f64,
388    /// Targets scored below `noise_ceiling`.
389    pub noise_targets: Vec<String>,
390    /// Targets scored between `noise_ceiling` and
391    /// `probable_floor`.
392    pub suspected_targets: Vec<String>,
393    /// Targets scored at or above `probable_floor`.
394    pub probable_targets: Vec<String>,
395    /// Emitted events, one per `Suspected` /
396    /// `Probable` target.
397    pub events: Vec<ChangeEvent>,
398    /// Thresholds the detector used for this cycle.
399    pub thresholds: ChangeFeedThresholds,
400}
401
402impl ChangeFeedReport {
403    /// Whether the report contains any events that
404    /// should be surfaced to operators.
405    #[must_use]
406    pub const fn has_actionable_events(&self) -> bool {
407        !self.events.is_empty()
408    }
409
410    /// Total target count (noise + suspected +
411    /// probable).
412    #[must_use]
413    pub const fn target_count(&self) -> usize {
414        self.noise_targets.len() + self.suspected_targets.len() + self.probable_targets.len()
415    }
416}
417
418#[cfg(test)]
419#[allow(
420    clippy::unwrap_used,
421    clippy::expect_used,
422    clippy::panic,
423    clippy::indexing_slicing
424)]
425mod tests {
426    use super::*;
427
428    fn summary() -> DeltaSummary {
429        DeltaSummary::new(
430            "integrity probe webdriver regressed",
431            0.40,
432            vec![DeltaSource::Canary],
433            vec![DeltaSeverity::Advisory],
434            DeltaSeverity::Advisory,
435        )
436    }
437
438    fn path() -> MitigationPath {
439        MitigationPath::for_classification(ChangeClassification::Suspected, None)
440    }
441
442    #[test]
443    fn event_id_is_stable_composite() {
444        let event = ChangeEvent::new_at(
445            1_718_616_000,
446            "example.com",
447            ChangeClassification::Suspected,
448            summary(),
449            None,
450            None,
451            path(),
452            BTreeMap::new(),
453        );
454        assert_eq!(event.event_id, "cf-1718616000-example.com");
455    }
456
457    #[test]
458    fn event_id_sanitises_non_alphanumeric_target() {
459        let event = ChangeEvent::new_at(
460            1_718_616_000,
461            "weird host.example.com/path",
462            ChangeClassification::Suspected,
463            summary(),
464            None,
465            None,
466            path(),
467            BTreeMap::new(),
468        );
469        assert!(event.event_id.starts_with("cf-1718616000-"));
470        // Spaces and slashes get replaced with
471        // underscores; alphanumerics survive.
472        assert!(!event.event_id.contains(' '));
473        assert!(!event.event_id.contains('/'));
474    }
475
476    #[test]
477    fn event_round_trips_through_serde_json() {
478        let event = ChangeEvent::new_at(
479            1_718_616_000,
480            "example.com",
481            ChangeClassification::Probable,
482            summary(),
483            Some(VendorId::DataDome),
484            Some(TargetClass::HighSecurity),
485            path(),
486            BTreeMap::new(),
487        );
488        let json = serde_json::to_string(&event).expect("serialise");
489        let parsed: ChangeEvent = serde_json::from_str(&json).expect("deserialise");
490        assert_eq!(event, parsed);
491    }
492
493    #[test]
494    fn delta_summary_dedupes_sources_and_severities() {
495        let summary = DeltaSummary::new(
496            "multi-source regression",
497            0.50,
498            vec![DeltaSource::Canary, DeltaSource::Canary, DeltaSource::Proxy],
499            vec![
500                DeltaSeverity::Advisory,
501                DeltaSeverity::Advisory,
502                DeltaSeverity::Warning,
503            ],
504            DeltaSeverity::Warning,
505        );
506        assert_eq!(
507            summary.sources,
508            vec![DeltaSource::Canary, DeltaSource::Proxy]
509        );
510        assert_eq!(
511            summary.severities,
512            vec![DeltaSeverity::Advisory, DeltaSeverity::Warning]
513        );
514    }
515
516    #[test]
517    fn delta_summary_clamps_score_and_nan() {
518        let summary = DeltaSummary::new(
519            "score bounds",
520            f64::NAN,
521            vec![DeltaSource::Canary],
522            vec![DeltaSeverity::Advisory],
523            DeltaSeverity::Advisory,
524        );
525        assert!(summary.score.abs() < 1e-9);
526        let summary = DeltaSummary::new(
527            "score bounds",
528            1.5,
529            vec![DeltaSource::Canary],
530            vec![DeltaSeverity::Advisory],
531            DeltaSeverity::Advisory,
532        );
533        assert!((summary.score - 1.0).abs() < 1e-9);
534    }
535
536    #[test]
537    fn mitigation_path_picks_category_a_for_datadome() {
538        let path = MitigationPath::for_classification(
539            ChangeClassification::Probable,
540            Some(VendorId::DataDome),
541        );
542        assert!(path.path.starts_with("category-a"));
543        assert!(path.url.contains("incident-runbook.md"));
544    }
545
546    #[test]
547    fn mitigation_path_picks_category_b_for_akamai() {
548        let path = MitigationPath::for_classification(
549            ChangeClassification::Probable,
550            Some(VendorId::Akamai),
551        );
552        assert!(path.path.starts_with("category-b"));
553    }
554
555    #[test]
556    fn mitigation_path_picks_category_b_for_unknown() {
557        let path = MitigationPath::for_classification(ChangeClassification::Probable, None);
558        assert!(path.path.starts_with("category-b"));
559    }
560
561    #[test]
562    fn mitigation_path_suspected_always_uses_category_a() {
563        for vendor in [
564            None,
565            Some(VendorId::DataDome),
566            Some(VendorId::Cloudflare),
567            Some(VendorId::Akamai),
568        ] {
569            let path = MitigationPath::for_classification(ChangeClassification::Suspected, vendor);
570            assert!(
571                path.path.starts_with("category-a"),
572                "suspected band should pick category-a regardless of vendor"
573            );
574        }
575    }
576
577    #[test]
578    fn report_target_count_sums_bands() {
579        let report = ChangeFeedReport {
580            aggregate_classification: ChangeClassification::Suspected,
581            aggregate_score: 0.40,
582            noise_targets: vec!["a".to_string(), "b".to_string()],
583            suspected_targets: vec!["c".to_string()],
584            probable_targets: vec!["d".to_string()],
585            events: Vec::new(),
586            thresholds: ChangeFeedThresholds::default(),
587        };
588        assert_eq!(report.target_count(), 4);
589        assert!(!report.has_actionable_events());
590    }
591}