stemedb/crates/stemedb-api/src/handlers/supersede.rs
jordan 422e2d4416 feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1
Claims now flow through StemeDB's append-only knowledge graph instead of
mutable TOML files. This resolves all 6 critical claim-bypass code paths:

- Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping)
- LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index
- EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation)
- CLI handlers: all claim commands read/write through StemeDB
- Scanner: loads claims from StemeDB with auto-migration fallback to TOML
- Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON

Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations,
fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes
pre-existing clippy warnings (too_many_arguments, filter_next→rfind).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 02:02:51 -07:00

190 lines
6.5 KiB
Rust

//! 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<AppState>,
Json(req): Json<SupersedeRequest>,
) -> Result<(StatusCode, Json<SupersedeResponse>)> {
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
);
}
}