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}