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>
190 lines
6.5 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|