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>
153 lines
5.1 KiB
Rust
153 lines
5.1 KiB
Rust
//! HTTP handlers for escalation event management.
|
|
|
|
use crate::{
|
|
dto::{ErrorResponse, EscalationEventDto, EscalationListResponse},
|
|
AppState,
|
|
};
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
Json,
|
|
};
|
|
use serde::Deserialize;
|
|
use stemedb_storage::EscalationStore;
|
|
use tracing::instrument;
|
|
|
|
/// Query parameters for listing escalations.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct EscalationQueryParams {
|
|
/// Only return escalations since this timestamp (nanoseconds).
|
|
pub since: Option<u64>,
|
|
}
|
|
|
|
/// GET /v1/admin/escalations
|
|
///
|
|
/// List all pending escalation events, or all escalations since a timestamp.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/v1/admin/escalations",
|
|
params(
|
|
("since" = Option<u64>, Query, description = "Only return escalations since this timestamp (nanoseconds)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Escalations retrieved successfully", body = EscalationListResponse),
|
|
(status = 500, description = "Internal server error", body = ErrorResponse)
|
|
),
|
|
tag = "admin"
|
|
)]
|
|
#[instrument(skip(state))]
|
|
pub async fn list_escalations(
|
|
State(state): State<AppState>,
|
|
Query(params): Query<EscalationQueryParams>,
|
|
) -> std::result::Result<Json<EscalationListResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
let store = &state.escalation_store;
|
|
let events = match params.since {
|
|
Some(since) => store.get_escalations_since(since).await.map_err(|e| {
|
|
tracing::error!(error = %e, "Failed to get escalations since timestamp");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse {
|
|
error: "Failed to retrieve escalations".to_string(),
|
|
code: "ESCALATION_RETRIEVAL_ERROR".to_string(),
|
|
}),
|
|
)
|
|
})?,
|
|
None => store.get_pending_escalations().await.map_err(|e| {
|
|
tracing::error!(error = %e, "Failed to get pending escalations");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse {
|
|
error: "Failed to retrieve pending escalations".to_string(),
|
|
code: "ESCALATION_RETRIEVAL_ERROR".to_string(),
|
|
}),
|
|
)
|
|
})?,
|
|
};
|
|
|
|
let dtos: Vec<EscalationEventDto> = events.iter().map(EscalationEventDto::from).collect();
|
|
|
|
Ok(Json(EscalationListResponse { escalations: dtos, count: events.len() }))
|
|
}
|
|
|
|
/// POST /v1/admin/escalations/{id}/resolve
|
|
///
|
|
/// Mark an escalation event as resolved.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/admin/escalations/{id}/resolve",
|
|
params(
|
|
("id" = String, Path, description = "Hex-encoded escalation event ID")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Escalation resolved successfully"),
|
|
(status = 404, description = "Escalation not found", body = ErrorResponse),
|
|
(status = 400, description = "Invalid escalation ID", body = ErrorResponse),
|
|
(status = 500, description = "Internal server error", body = ErrorResponse)
|
|
),
|
|
tag = "admin"
|
|
)]
|
|
#[instrument(skip(state))]
|
|
pub async fn resolve_escalation(
|
|
State(state): State<AppState>,
|
|
Path(id_hex): Path<String>,
|
|
) -> std::result::Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
|
let start = std::time::Instant::now();
|
|
metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/admin/escalations/{id}/resolve").increment(1);
|
|
|
|
let store = &state.escalation_store;
|
|
// Decode the hex ID
|
|
let id_bytes = hex::decode(&id_hex).map_err(|_| {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse {
|
|
error: "Invalid escalation ID format".to_string(),
|
|
code: "INVALID_ESCALATION_ID".to_string(),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
if id_bytes.len() != 32 {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse {
|
|
error: "Escalation ID must be 32 bytes".to_string(),
|
|
code: "INVALID_ESCALATION_ID".to_string(),
|
|
}),
|
|
));
|
|
}
|
|
|
|
let mut id = [0u8; 32];
|
|
id.copy_from_slice(&id_bytes);
|
|
|
|
let resolved = store.resolve_escalation(&id).await.map_err(|e| {
|
|
tracing::error!(error = %e, id = %id_hex, "Failed to resolve escalation");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse {
|
|
error: "Failed to resolve escalation".to_string(),
|
|
code: "ESCALATION_RESOLVE_ERROR".to_string(),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
if resolved {
|
|
// Track request duration (success case)
|
|
metrics::histogram!("stemedb_http_request_duration_seconds",
|
|
"method" => "POST",
|
|
"path" => "/v1/admin/escalations/{id}/resolve",
|
|
"status" => "200"
|
|
)
|
|
.record(start.elapsed().as_secs_f64());
|
|
|
|
Ok(StatusCode::OK)
|
|
} else {
|
|
Err((
|
|
StatusCode::NOT_FOUND,
|
|
Json(ErrorResponse {
|
|
error: "Escalation not found".to_string(),
|
|
code: "ESCALATION_NOT_FOUND".to_string(),
|
|
}),
|
|
))
|
|
}
|
|
}
|