stemedb/crates/stemedb-api/src/handlers/assert.rs
jordan c59066949a feat: Add quickstart "Beyond Hello World" sections with Skeptic and Layered endpoints
- Add Layered() method to Go SDK for per-source-class consensus queries
- Add LayeredQueryParams, LayeredResult, TierResolution types to Go SDK
- Create conflict example demonstrating Skeptic and Layered endpoints
- Update quickstart.md with sections 6 (conflict detection) and 7 (authority tiers)
- Remove tracked Go binary and add data/ to .gitignore

The new quickstart sections demonstrate Episteme's differentiating features:
- Skeptic endpoint shows "Trust but Verify" conflict analysis
- Layered endpoint shows per-tier resolution (Clinical vs Anecdotal)

Note: Pre-existing large files flagged by pre-commit hook (technical debt from prior sessions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:00:59 -07:00

124 lines
4.5 KiB
Rust

//! 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<AppState>,
Json(req): Json<CreateAssertionRequest>,
) -> Result<(StatusCode, Json<CreateResponse>)> {
// 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<Assertion> {
// 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::<Result<Vec<_>>>()?;
// 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<SignatureEntry> {
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 })
}