stemedb/crates/stemedb-api/src/handlers/vote.rs
jml 3e7eddc074 feat: add enterprise production readiness infrastructure
This commit implements comprehensive production hardening across multiple
layers to prepare StemeDB for enterprise pilot deployments:

## API Layer
- Add rate limiting middleware with configurable limits per endpoint
- Enhance error handling with detailed context and proper HTTP status codes
- Add security hardening tests for input validation and boundary conditions
- Create store_helpers module for defensive storage access patterns

## Storage & WAL
- Optimize group commit batching for higher throughput
- Add defensive error handling in hybrid backend with proper fallbacks
- Enhance WAL journal durability guarantees with fsync validation
- Improve index store query performance with better caching

## Operations & Deployment
- Add comprehensive operations documentation (deployment, monitoring, DR)
- Create systemd units for backup, WAL archival, and verification
- Add monitoring configs (Prometheus alerts, metrics exporters)
- Implement backup/restore scripts with verification and S3 archival
- Add DR drill automation and runbook procedures
- Create load balancer configs (nginx, envoy) with health checks

## Documentation
- Update CLAUDE.md with operations and troubleshooting guides
- Expand roadmap with production readiness milestones
- Add pilot success criteria and deployment reference architecture
- Document TLS setup, monitoring integration, and incident response

## Configuration
- Add .env.example with all required environment variables
- Document resource sizing for different deployment scales
- Add configuration examples for various deployment topologies

This positions StemeDB for successful enterprise pilots with proper
operational discipline, monitoring, backup/DR, and security hardening.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 06:08:15 +00:00

134 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,
})
}