stygian_charon/change_feed/mod.rs
1//! Anti-bot change-detection feed (T88).
2//!
3//! ## What this module does
4//!
5//! Anti-bot vendors rotate wall-logic, escalate
6//! challenges, and rewrite challenge JS without
7//! notice. The change feed is the **early-warning**
8//! surface: it consumes regression signals from the
9//! canary (T92 + T84), proxy intelligence (T86), and
10//! extraction reliability (T87) pipelines and emits
11//! actionable incident packets when the signals
12//! agree.
13//!
14//! The feed is **deterministic** — no `HashMap`,
15//! no stochastic thresholds, no ML. The same input
16//! always produces the same classification, so
17//! downstream runbook tooling can dedupe and audit
18//! without trusting the call site.
19//!
20//! ## Detection flow
21//!
22//! ```text
23//! ChangeDeltaInput[] (boundary type)
24//! │
25//! ▼
26//! ChangeDetector::detect() (this module)
27//! │
28//! ├── per-target aggregate
29//! │ - max(weight * source_weight)
30//! │ - banded via ChangeFeedThresholds
31//! │
32//! ├── ChangeFeedReport (always returned)
33//! │
34//! └── ChangeEventSink::record_change_event
35//! - InMemoryChangeFeedSink (always available)
36//! - MetricsCollector (with `metrics` feature)
37//! ```
38//!
39//! ## Classification
40//!
41//! | Band | Default score range | What the operator sees |
42//! |-------------|---------------------------|------------------------|
43//! | `Noise` | `< 0.20` | Log only, no event |
44//! | `Suspected` | `[0.20, 0.55)` | Advisory event |
45//! | `Probable` | `≥ 0.55` or critical tier | Runbook event |
46//!
47//! A single canary delta cannot reach `Probable`
48//! on its own — the default `canary_weight` is
49//! `1.00`, so a delta with weight `0.55` reaches the
50//! floor only when paired with another source or
51//! marked `Critical` by the upstream signal.
52//!
53//! ## Schema
54//!
55//! The [`ChangeEvent`] payload is the **operator-facing**
56//! view of the regression. It carries the affected
57//! target, the delta summary (headline + score +
58//! sources + severities), the vendor hint, the
59//! target class, the runbook mitigation pointer, and
60//! the structured evidence preserved from the
61//! upstream deltas. [`ChangeFeedReport`] is the
62//! per-cycle aggregate that the runbook diagnostics
63//! surface consumes.
64//!
65//! ## Metrics surface
66//!
67//! When the `metrics` feature is enabled, the
68//! `MetricsCollector` (see `crate::metrics::MetricsCollector`; only compiled
69//! with `--features metrics`)
70//! implements [`ChangeEventSink`] so the detector
71//! records events directly into the existing
72//! Prometheus exporter. The `change_feed_*` series
73//! are only emitted when at least one counter is
74//! non-zero, so dashboards that have not wired the
75//! change feed in keep their existing layout
76//! unchanged.
77//!
78//! ## Feature flag
79//!
80//! The module is **default-on** (gated behind the
81//! `caching` feature, which is part of the
82//! `stygian-charon` default feature set). No new
83//! feature gate is introduced — the public surface
84//! is purely additive and existing serialisers see
85//! no change unless they explicitly opt in via the
86//! new fields on [`ChangeFeedReport`] /
87//! [`ChangeEvent`].
88//!
89//! # Example
90//!
91//! ```
92//! use stygian_charon::change_feed::{
93//! ChangeDeltaInput, ChangeDetector, InMemoryChangeFeedSink, DeltaSeverity, DeltaSource,
94//! };
95//!
96//! let detector = ChangeDetector::new();
97//! let sink = InMemoryChangeFeedSink::new();
98//!
99//! // Two deltas on the same target — a canary
100//! // warning plus a proxy advisory. Neither
101//! // alone is enough for `Probable`, but the
102//! // canary marks itself `Critical`.
103//! let deltas = vec![
104//! ChangeDeltaInput::new(
105//! DeltaSource::Canary,
106//! "example.com",
107//! 0.40,
108//! DeltaSeverity::Critical,
109//! "integrity probe webdriver regressed",
110//! ),
111//! ChangeDeltaInput::new(
112//! DeltaSource::Proxy,
113//! "example.com",
114//! 0.50,
115//! DeltaSeverity::Warning,
116//! "proxy score dropped",
117//! ),
118//! ];
119//!
120//! let report = detector.detect(&deltas, &sink);
121//! assert_eq!(report.aggregate_classification, stygian_charon::change_feed::ChangeClassification::Probable);
122//! assert_eq!(report.probable_targets, vec!["example.com".to_string()]);
123//! assert_eq!(sink.len(), 1);
124//! ```
125
126mod classification;
127mod delta;
128mod event;
129
130pub use classification::{
131 ChangeClassification, ChangeDetector, ChangeEventSink, ChangeFeedThresholds,
132 DEFAULT_CANARY_WEIGHT, DEFAULT_EXTRACTION_WEIGHT, DEFAULT_NOISE_CEILING,
133 DEFAULT_PROBABLE_FLOOR, DEFAULT_PROXY_WEIGHT, InMemoryChangeFeedSink, record_change_event,
134};
135pub use delta::{ChangeDeltaInput, DeltaSeverity, DeltaSource};
136pub use event::{ChangeEvent, ChangeFeedReport, DeltaSummary, MitigationPath};
137
138// Wire the metrics surface into the change-feed
139// sink when the optional `metrics` feature is
140// enabled. The MetricsCollector implements
141// `ChangeEventSink` so the detector can record
142// events into the existing Prometheus exporter
143// without any extra glue.
144#[cfg(feature = "metrics")]
145impl ChangeEventSink for crate::metrics::MetricsCollector {
146 fn record_change_event(&self, event: &ChangeEvent) {
147 Self::record_change_event(self, event);
148 }
149}