//! Handler for assertion supersession endpoint. //! //! The supersede endpoint enables error correction without violating append-only //! semantics. Instead of mutating an assertion, we create a supersession record //! that points from the old (target) to the new (replacement) assertion. //! //! # Use Case: 3am Incident Investigation //! //! 1. SRE finds agent deployed bad config //! 2. Trace shows agent queried assertion X with hash `abc123` //! 3. Supervisor creates supersession: `abc123` → `new_hash` (Invalidate) //! 4. Future queries skip `abc123` automatically //! 5. Audit trail preserved: "Who fixed it? When? Why?" use axum::{extract::State, http::StatusCode, Json}; use tracing::instrument; use crate::{ dto::{SupersedeRequest, SupersedeResponse}, error::{ApiError, Result}, hex, state::AppState, }; use stemedb_core::types::{Supersession, SupersessionType}; use stemedb_storage::{GenericSupersessionStore, SupersessionStore}; /// Create a supersession record for error correction. /// /// This endpoint accepts a supersession request, validates the fields, /// and stores the supersession record. Future queries will automatically /// filter out superseded assertions based on type. /// /// # Request Body /// /// - `target_hash`: Hash of the assertion being superseded (hex-encoded) /// - `supersession_type`: How the target is being superseded (Invalidate, Temporal, etc.) /// - `reason`: Human-readable explanation for audit trail /// - `new_hash`: Hash of replacement assertion (optional) /// - `agent_id`: Public key of agent creating the supersession /// - `signature`: Signature over the supersession content /// /// # Example /// /// ```text /// POST /v1/supersede /// { /// "target_hash": "abc123...", /// "supersession_type": "Invalidate", /// "reason": "Proposal treated as approved. See incident INC-2024-001", /// "new_hash": "def456...", /// "agent_id": "deadbeef...", /// "signature": "..." /// } /// ``` #[utoipa::path( post, path = "/v1/supersede", request_body = SupersedeRequest, responses( (status = 201, description = "Supersession recorded successfully", body = SupersedeResponse), (status = 400, description = "Invalid request parameters", body = crate::dto::ErrorResponse), (status = 404, description = "Target assertion not found", body = crate::dto::ErrorResponse), (status = 409, description = "Assertion already superseded", body = crate::dto::ErrorResponse), (status = 500, description = "Internal server error", body = crate::dto::ErrorResponse) ), tag = "supersession" )] #[instrument(skip(state), fields( target_hash = %req.target_hash, supersession_type = ?req.supersession_type, agent_id = %req.agent_id ))] pub async fn supersede( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/supersede") .increment(1); // Decode and validate hex fields let target_hash = hex::decode_hash_32(&req.target_hash)?; let agent_id = hex::decode_agent_id(&req.agent_id)?; let signature = hex::decode_signature(&req.signature)?; let new_hash = req.new_hash.map(|h| hex::decode_hash_32(&h)).transpose()?; // Validate reason is not empty if req.reason.trim().is_empty() { return Err(ApiError::InvalidRequest( "Reason is required for supersession audit trail".to_string(), )); } // Get current timestamp let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| ApiError::InvalidRequest(format!("System clock error: {}", e)))? .as_secs(); // Create supersession store let supersession_store = GenericSupersessionStore::new(state.store.clone()); // Check if already superseded if supersession_store .is_superseded(&target_hash) .await .map_err(|e| ApiError::Internal(format!("Failed to check supersession status: {}", e)))? { return Err(ApiError::Conflict(format!( "Assertion {} is already superseded", req.target_hash ))); } // Convert DTO type to core type let supersession_type: SupersessionType = req.supersession_type.into(); // Create supersession record // NOTE: hlc_timestamp is None for API-created supersessions. In distributed mode, // supersessions flow through the IngestWorker which generates HLC timestamps. // Direct API creation is for single-node deployments or manual corrections. let supersession = Supersession { target_hash, supersession_type, reason: req.reason.clone(), new_hash, timestamp, hlc_timestamp: None, // Single-node mode; distributed mode uses IngestWorker agent_id, signature, }; // Store the supersession supersession_store .put_supersession(&supersession) .await .map_err(|e| ApiError::Internal(format!("Failed to store supersession: {}", e)))?; // Build response let response = SupersedeResponse { status: "superseded".to_string(), target_hash: req.target_hash, supersession_type: req.supersession_type, timestamp, }; // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/supersede", "status" => "201" ) .record(start.elapsed().as_secs_f64()); Ok((StatusCode::CREATED, Json(response))) } #[cfg(test)] mod tests { use super::*; use crate::dto::SupersessionTypeDto; #[test] fn test_supersession_type_conversion() { // Test all DTO types convert correctly assert_eq!( SupersessionType::from(SupersessionTypeDto::Invalidate), SupersessionType::Invalidate ); assert_eq!( SupersessionType::from(SupersessionTypeDto::Temporal), SupersessionType::Temporal ); assert_eq!( SupersessionType::from(SupersessionTypeDto::Refinement), SupersessionType::Refinement ); assert_eq!( SupersessionType::from(SupersessionTypeDto::RequiresReview), SupersessionType::RequiresReview ); assert_eq!( SupersessionType::from(SupersessionTypeDto::Additive), SupersessionType::Additive ); } }