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>
136 lines
4.5 KiB
Rust
136 lines
4.5 KiB
Rust
//! Handler for creating votes.
|
|
|
|
use axum::{extract::State, http::StatusCode, Json};
|
|
use tracing::instrument;
|
|
|
|
use crate::{
|
|
dto::{CreateResponse, CreateVoteRequest, ErrorResponse},
|
|
error::{ApiError, Result},
|
|
hex,
|
|
state::AppState,
|
|
};
|
|
|
|
use stemedb_core::types::Vote;
|
|
use stemedb_ingest::worker::serialize_vote;
|
|
|
|
/// Create a new vote on an assertion.
|
|
///
|
|
/// This endpoint accepts a vote DTO, validates weight bounds (0.0-1.0),
|
|
/// converts hex-encoded fields to binary, serializes the vote to WAL format,
|
|
/// and appends it to the journal. Returns the content-addressed BLAKE3 hash.
|
|
///
|
|
/// # Validation
|
|
/// - Weight must be between 0.0 and 1.0
|
|
/// - All hex fields must have correct lengths (32 bytes for agent_id/assertion_hash, 64 bytes for signature)
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/vote",
|
|
request_body = CreateVoteRequest,
|
|
responses(
|
|
(status = 201, description = "Vote created successfully", body = CreateResponse),
|
|
(status = 400, description = "Invalid request", body = ErrorResponse),
|
|
(status = 500, description = "Internal server error", body = ErrorResponse)
|
|
),
|
|
tag = "votes"
|
|
)]
|
|
#[instrument(skip(state), fields(assertion_hash = %req.assertion_hash, weight = %req.weight))]
|
|
pub async fn create_vote(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CreateVoteRequest>,
|
|
) -> Result<(StatusCode, Json<CreateResponse>)> {
|
|
let start = std::time::Instant::now();
|
|
metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/vote")
|
|
.increment(1);
|
|
|
|
// Convert DTO to internal Vote type
|
|
let vote = dto_to_vote(req)?;
|
|
|
|
// Serialize to WAL format (includes record type header)
|
|
let payload = serialize_vote(&vote)
|
|
.map_err(|e| ApiError::Serialization(format!("Failed to serialize vote: {}", e)))?;
|
|
|
|
// Compute the content-addressed hash
|
|
let serialized_vote = stemedb_core::serde::serialize(&vote)
|
|
.map_err(|e| ApiError::Serialization(format!("Failed to serialize for hash: {}", e)))?;
|
|
let hash = blake3::hash(&serialized_vote);
|
|
|
|
// Append to WAL via group commit buffer
|
|
state.commit_buffer.append(payload).await?;
|
|
|
|
let response =
|
|
CreateResponse { hash: hash.to_hex().to_string(), status: "created".to_string() };
|
|
|
|
// Track request duration (success case)
|
|
metrics::histogram!("stemedb_http_request_duration_seconds",
|
|
"method" => "POST",
|
|
"path" => "/v1/vote",
|
|
"status" => "201"
|
|
)
|
|
.record(start.elapsed().as_secs_f64());
|
|
|
|
Ok((StatusCode::CREATED, Json(response)))
|
|
}
|
|
|
|
/// Convert CreateVoteRequest DTO to internal Vote type.
|
|
fn dto_to_vote(req: CreateVoteRequest) -> Result<Vote> {
|
|
// Validate weight bounds (0.0 to 1.0)
|
|
if req.weight < 0.0 || req.weight > 1.0 {
|
|
return Err(ApiError::InvalidRequest(format!(
|
|
"Weight must be between 0.0 and 1.0, got {}",
|
|
req.weight
|
|
)));
|
|
}
|
|
|
|
// Validate source_url if provided
|
|
if let Some(ref url) = req.source_url {
|
|
// Check if empty
|
|
if url.is_empty() {
|
|
return Err(ApiError::InvalidRequest(
|
|
"source_url must not be empty if provided".to_string(),
|
|
));
|
|
}
|
|
// Check max length (2048 characters)
|
|
if url.len() > 2048 {
|
|
return Err(ApiError::InvalidRequest(format!(
|
|
"source_url exceeds maximum length of 2048 characters, got {}",
|
|
url.len()
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Validate observed_context size if provided
|
|
let observed_context = if let Some(ref context_str) = req.observed_context {
|
|
let context_bytes = context_str.as_bytes();
|
|
// Check max size (64KB)
|
|
if context_bytes.len() > 65536 {
|
|
return Err(ApiError::InvalidRequest(format!(
|
|
"observed_context exceeds maximum size of 64KB, got {} bytes",
|
|
context_bytes.len()
|
|
)));
|
|
}
|
|
Some(context_bytes.to_vec())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let assertion_hash = hex::decode_hash_32(&req.assertion_hash)?;
|
|
let agent_id = hex::decode_hash_32(&req.agent_id)?;
|
|
let signature = hex::decode_signature(&req.signature)?;
|
|
|
|
// Get current timestamp
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_err(|e| ApiError::Serialization(format!("Failed to get timestamp: {}", e)))?
|
|
.as_secs();
|
|
|
|
Ok(Vote {
|
|
assertion_hash,
|
|
agent_id,
|
|
weight: req.weight,
|
|
signature,
|
|
timestamp,
|
|
source_url: req.source_url,
|
|
observed_context,
|
|
})
|
|
}
|