Break monolith source files into focused modules: - stemedb-core/types.rs → types/ directory (assertion, source, gold_standard, etc.) - stemedb-storage: audit_store, quota_store, trust_rank_store, vector_index, vote_store → module directories - stemedb-ingest/worker.rs → worker/ with separate test modules - stemedb-query: engine, materializer, query → module directories - stemedb-lens: epoch_aware, skeptic → module directories - stemedb-sim/lib.rs → agent, arenas/, helpers, runner, strategy, types - stemedb-api/tests: integration_tests → http_basic, http_validation, http_epoch, http_pipeline - stemedb-api/tests: e2e_flow_test → e2e_full_pipeline, e2e_lens_resolution - stemedb-query/tests: e2e_pipeline → e2e_pipeline + e2e_decay Also adds new features: gold standard verification, escalation handlers, admin endpoints, concept hierarchy spec, arena roadmap, and Go SDK. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
125 lines
4.1 KiB
Rust
125 lines
4.1 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>)> {
|
|
// 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
|
|
let mut journal = state.journal.lock().await;
|
|
journal.append(payload)?;
|
|
|
|
let response =
|
|
CreateResponse { hash: hash.to_hex().to_string(), status: "created".to_string() };
|
|
|
|
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,
|
|
})
|
|
}
|