Skip to main content

mockforge_intelligence/handlers/
contract_health.rs

1//! Unified contract health timeline
2//!
3//! This module provides a unified endpoint that combines:
4//! - Structural drift incidents
5//! - Semantic drift incidents
6//! - Threat assessments
7//! - Forecast predictions
8
9use super::structural_incidents::StructuralIncidents;
10use crate::incidents::SemanticIncidentManager;
11use axum::{
12    extract::{Query, State},
13    http::StatusCode,
14    response::Json,
15};
16use serde::{Deserialize, Serialize};
17
18#[cfg(feature = "database")]
19mod persistence;
20
21/// State supplied by downstream HTTP integrations for the contract timeline.
22pub trait ContractHealthStateProvider: Clone + Send + Sync + 'static {
23    /// Structural incident manager implementation.
24    type Incidents: StructuralIncidents;
25    /// Access the existing structural incident manager.
26    fn incident_manager(&self) -> &Self::Incidents;
27    /// Access the Intelligence semantic incident manager.
28    fn semantic_manager(&self) -> &SemanticIncidentManager;
29    /// Optional database. Default permits feature unification without requiring
30    /// a database field in downstream states compiled without that feature.
31    #[cfg(feature = "database")]
32    fn database(&self) -> Option<&crate::database::Database> {
33        None
34    }
35}
36
37/// Query parameters for timeline
38#[derive(Debug, Deserialize)]
39pub struct TimelineQuery {
40    /// Workspace ID filter
41    pub workspace_id: Option<String>,
42    /// Endpoint filter
43    pub endpoint: Option<String>,
44    /// Method filter
45    pub method: Option<String>,
46    /// Start date (ISO 8601)
47    pub start_date: Option<String>,
48    /// End date (ISO 8601)
49    pub end_date: Option<String>,
50    /// Filter by type: "structural", "semantic", "threat", "forecast", or "all"
51    pub event_type: Option<String>,
52    /// Limit results
53    pub limit: Option<usize>,
54}
55
56/// Timeline event
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type")]
59pub enum TimelineEvent {
60    /// Structural drift incident
61    #[serde(rename = "structural_drift")]
62    StructuralDrift {
63        /// Incident ID
64        id: String,
65        /// Endpoint path
66        endpoint: String,
67        /// HTTP method
68        method: String,
69        /// Type of incident
70        incident_type: String,
71        /// Severity level
72        severity: String,
73        /// Current status
74        status: String,
75        /// Detection timestamp
76        detected_at: i64,
77        /// Additional details
78        details: serde_json::Value,
79    },
80    /// Semantic drift incident
81    #[serde(rename = "semantic_drift")]
82    SemanticDrift {
83        /// Incident ID
84        id: String,
85        /// Endpoint path
86        endpoint: String,
87        /// HTTP method
88        method: String,
89        /// Type of semantic change
90        change_type: String,
91        /// Severity level
92        severity: String,
93        /// Current status
94        status: String,
95        /// Semantic confidence score
96        semantic_confidence: f64,
97        /// Soft-breaking score
98        soft_breaking_score: f64,
99        /// Detection timestamp
100        detected_at: i64,
101        /// Additional details
102        details: serde_json::Value,
103    },
104    /// Threat assessment
105    #[serde(rename = "threat_assessment")]
106    ThreatAssessment {
107        /// Assessment ID
108        id: String,
109        /// Endpoint path (optional for workspace-level)
110        endpoint: Option<String>,
111        /// HTTP method (optional for workspace-level)
112        method: Option<String>,
113        /// Threat level
114        threat_level: String,
115        /// Threat score (0.0-1.0)
116        threat_score: f64,
117        /// Assessment timestamp
118        assessed_at: i64,
119        /// Number of findings
120        findings_count: usize,
121    },
122    /// Forecast prediction
123    #[serde(rename = "forecast")]
124    Forecast {
125        /// Forecast ID
126        id: String,
127        /// Endpoint path
128        endpoint: String,
129        /// HTTP method
130        method: String,
131        /// Forecast window in days
132        window_days: u32,
133        /// Change probability (0.0-1.0)
134        change_probability: f64,
135        /// Break probability (0.0-1.0)
136        break_probability: f64,
137        /// Next expected change timestamp (optional)
138        next_expected_change: Option<i64>,
139        /// Confidence score (0.0-1.0)
140        confidence: f64,
141        /// Prediction timestamp
142        predicted_at: i64,
143    },
144}
145
146/// Timeline response
147#[derive(Debug, Serialize)]
148pub struct TimelineResponse {
149    /// Timeline events
150    pub events: Vec<TimelineEvent>,
151    /// Total count
152    pub total: usize,
153}
154
155/// Get unified contract health timeline
156///
157/// GET /api/v1/contract-health/timeline
158pub async fn get_timeline<S: ContractHealthStateProvider>(
159    State(state): State<S>,
160    Query(params): Query<TimelineQuery>,
161) -> Result<Json<TimelineResponse>, StatusCode> {
162    let mut events = Vec::new();
163
164    let event_type_filter = params.event_type.as_deref().unwrap_or("all");
165
166    // Get structural drift incidents
167    if event_type_filter == "all" || event_type_filter == "structural" {
168        let query = mockforge_foundation::incidents_types::IncidentQuery {
169            workspace_id: params.workspace_id.clone(),
170            endpoint: params.endpoint.clone(),
171            method: params.method.clone(),
172            ..mockforge_foundation::incidents_types::IncidentQuery::default()
173        };
174
175        let incidents = state.incident_manager().query_incidents(query).await;
176        for incident in incidents {
177            events.push(TimelineEvent::StructuralDrift {
178                id: incident.id,
179                endpoint: incident.endpoint,
180                method: incident.method,
181                incident_type: format!("{:?}", incident.incident_type),
182                severity: format!("{:?}", incident.severity),
183                status: format!("{:?}", incident.status),
184                detected_at: incident.detected_at,
185                details: incident.details,
186            });
187        }
188    }
189
190    // Get semantic drift incidents
191    if event_type_filter == "all" || event_type_filter == "semantic" {
192        let status = None; // Get all statuses for timeline
193        let semantic_incidents = state
194            .semantic_manager()
195            .list_incidents(
196                params.workspace_id.as_deref(),
197                params.endpoint.as_deref(),
198                params.method.as_deref(),
199                status,
200                params.limit,
201            )
202            .await;
203
204        for incident in semantic_incidents {
205            events.push(TimelineEvent::SemanticDrift {
206                id: incident.id,
207                endpoint: incident.endpoint,
208                method: incident.method,
209                change_type: format!("{:?}", incident.semantic_change_type),
210                severity: format!("{:?}", incident.severity),
211                status: format!("{:?}", incident.status),
212                semantic_confidence: incident.semantic_confidence,
213                soft_breaking_score: incident.soft_breaking_score,
214                detected_at: incident.detected_at,
215                details: incident.details,
216            });
217        }
218    }
219
220    // Add database events through typed schema-specific row decoders.
221    #[cfg(feature = "database")]
222    if let Some(pool) = state.database().and_then(|db| db.pool()) {
223        let workspace = params
224            .workspace_id
225            .as_deref()
226            .map(uuid::Uuid::parse_str)
227            .transpose()
228            .map_err(|_| StatusCode::BAD_REQUEST)?;
229        events.extend(persistence::events(pool, workspace).await);
230    }
231
232    // Sort by timestamp (most recent first)
233    events.sort_by_key(|e| {
234        std::cmp::Reverse(match e {
235            TimelineEvent::StructuralDrift { detected_at, .. } => *detected_at,
236            TimelineEvent::SemanticDrift { detected_at, .. } => *detected_at,
237            TimelineEvent::ThreatAssessment { assessed_at, .. } => *assessed_at,
238            TimelineEvent::Forecast { predicted_at, .. } => *predicted_at,
239        })
240    });
241
242    // Apply limit
243    let total = events.len();
244    if let Some(limit) = params.limit {
245        events.truncate(limit);
246    }
247
248    Ok(Json(TimelineResponse { events, total }))
249}
250
251/// Create router for contract health endpoints
252pub fn contract_health_router<S: ContractHealthStateProvider>(state: S) -> axum::Router {
253    use axum::routing::get;
254    use axum::Router;
255
256    Router::new()
257        .route("/api/v1/contract-health/timeline", get(get_timeline::<S>))
258        .with_state(state)
259}