//! Handler for creating assertions. use axum::{extract::State, http::StatusCode, Json}; use tracing::instrument; use crate::{ dto::{CreateAssertionRequest, CreateResponse, ErrorResponse, SignatureDto}, error::{ApiError, Result}, hex, state::AppState, }; use stemedb_core::types::{Assertion, LifecycleStage, ObjectValue, SignatureEntry, SourceClass}; use stemedb_ingest::worker::serialize_assertion; /// Create a new assertion in the knowledge graph. /// /// This endpoint accepts an assertion DTO, validates confidence bounds (0.0-1.0), /// converts hex-encoded fields to binary, serializes the assertion to WAL format, /// and appends it to the journal. Returns the content-addressed BLAKE3 hash. /// /// # Validation /// - Confidence must be between 0.0 and 1.0 /// - At least one signature is required /// - All hex fields must have correct lengths (32 bytes for hashes, 64 bytes for signatures) #[utoipa::path( post, path = "/v1/assert", request_body = CreateAssertionRequest, responses( (status = 201, description = "Assertion created successfully", body = CreateResponse), (status = 400, description = "Invalid request", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "assertions" )] #[instrument(skip(state), fields(subject = %req.subject, predicate = %req.predicate))] pub async fn create_assertion( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json)> { // Convert DTO to internal Assertion type let assertion = dto_to_assertion(req)?; // Serialize to WAL format (includes record type header) let payload = serialize_assertion(&assertion) .map_err(|e| ApiError::Serialization(format!("Failed to serialize assertion: {}", e)))?; // Compute the content-addressed hash // This must match the hash computation in the ingest worker let serialized_assertion = stemedb_core::serde::serialize(&assertion) .map_err(|e| ApiError::Serialization(format!("Failed to serialize for hash: {}", e)))?; let hash = blake3::hash(&serialized_assertion); // 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 CreateAssertionRequest DTO to internal Assertion type. fn dto_to_assertion(req: CreateAssertionRequest) -> Result { // Validate confidence bounds (0.0 to 1.0) if req.confidence < 0.0 || req.confidence > 1.0 { return Err(ApiError::InvalidRequest(format!( "Confidence must be between 0.0 and 1.0, got {}", req.confidence ))); } // Decode hex fields using shared hex module let parent_hash = req.parent_hash.map(|h| hex::decode_hash_32(&h)).transpose()?; let source_hash = hex::decode_hash_32(&req.source_hash)?; let visual_hash = req.visual_hash.map(|h| hex::decode_hash_8(&h)).transpose()?; let epoch = req.epoch.map(|e| hex::decode_hash_32(&e)).transpose()?; // Convert signatures let signatures = req.signatures.into_iter().map(decode_signature).collect::>>()?; // Validate signatures are not empty if signatures.is_empty() { return Err(ApiError::InvalidRequest("At least one signature is required".to_string())); } // 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(Assertion { subject: req.subject, predicate: req.predicate, object: ObjectValue::from(req.object), parent_hash, source_hash, source_class: req.source_class.map(Into::into).unwrap_or(SourceClass::Expert), visual_hash, epoch, source_metadata: req.source_metadata.map(|s| s.into_bytes()), lifecycle: req.lifecycle.map(Into::into).unwrap_or(LifecycleStage::Proposed), signatures, confidence: req.confidence, timestamp, vector: req.vector, }) } /// Decode a signature DTO. fn decode_signature(dto: SignatureDto) -> Result { let agent_id = hex::decode_hash_32(&dto.agent_id)?; let signature = hex::decode_signature(&dto.signature)?; Ok(SignatureEntry { agent_id, signature, timestamp: dto.timestamp }) }