- 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>
124 lines
4.5 KiB
Rust
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 })
|
|
}
|