stemedb/crates/stemedb-api/src/handlers/constraints.rs
jml 3b5f88b4f0 feat(aphoria): implement claims architecture (A1-A5) with verify engine, corpus, coverage, and explain
Complete Aphoria claims system overhaul:
- A1: Rename ExtractedClaim to Observation (extractors produce observations, not claims)
- A2: Add AuthoredClaim with full provenance, invariants, and authority tiers
- A3: Verify engine comparing observations against authored claims, CLI + formatters
- A4: Corpus as first-class assertions with predicate indexing, authority lens, trust packs
- A5: Coverage analysis, explain/docs generation, self-audit extractor, claim suggester skill

Also includes: 42 extractors updated for Observation type, verifiable_predicates trait,
conflict detection with comparison modes, claims TOML persistence, Grafana dashboard,
backup/restore scripts, and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:11:47 +00:00

151 lines
4.8 KiB
Rust

//! Handler for constraints (agent pre-flight check) queries.
use axum::{
extract::{Query as AxumQuery, State},
Json,
};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::instrument;
use crate::{
dto::{ConstraintEntryDto, ConstraintsQueryParams, ConstraintsResponse, ErrorResponse},
error::Result,
state::AppState,
};
use stemedb_core::types::Assertion;
use stemedb_lens::ConstraintsLens;
use stemedb_query::Query;
/// Query for constraint analysis using ConstraintsLens.
///
/// This endpoint provides pre-flight constraint checking for AI agents,
/// categorizing assertions by predicate pattern into must_use, forbidden, and prefer.
///
/// # Predicate Patterns
///
/// - `must_use:*` → Required, non-negotiable constraints
/// - `forbidden:*` → Explicitly banned items
/// - `prefer:*` → Recommended but optional
///
/// # Response
///
/// Returns a `ConstraintsResponse` with:
/// - `must_use`: Required constraints sorted by confidence
/// - `forbidden`: Banned items sorted by confidence
/// - `prefer`: Recommendations sorted by confidence
///
/// # Example
///
/// ```ignore
/// GET /v1/constraints?subject=project_alpha
///
/// {
/// "subject": "project_alpha",
/// "must_use": [
/// { "category": "http_client", "value": {"type": "Text", "value": "axios"}, "confidence": 0.95 }
/// ],
/// "forbidden": [
/// { "category": "http_client", "value": {"type": "Text", "value": "requests"}, "confidence": 0.9 }
/// ],
/// "prefer": [
/// { "category": "language", "value": {"type": "Text", "value": "typescript"}, "confidence": 0.8 }
/// ]
/// }
/// ```
#[utoipa::path(
get,
path = "/v1/constraints",
params(ConstraintsQueryParams),
responses(
(status = 200, description = "Constraint analysis successful", body = ConstraintsResponse),
(status = 400, description = "Invalid request", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "query"
)]
#[instrument(skip(state), fields(subject = %params.subject))]
pub async fn constraints_query(
State(state): State<AppState>,
AxumQuery(params): AxumQuery<ConstraintsQueryParams>,
) -> Result<Json<ConstraintsResponse>> {
let query_start = std::time::Instant::now();
metrics::counter!("stemedb_queries_total", "endpoint" => "constraints").increment(1);
// Build query for all assertions with this subject
// We need ALL predicates, not just one specific one
let query = Query::builder().subject(&params.subject).build();
// Execute the query
let query_engine = state.query_engine();
let result = query_engine.execute(&query).await?;
// Apply ConstraintsLens to categorize
let lens = ConstraintsLens;
let constraint_set = lens.resolve_constraints(&result.assertions);
// Get current timestamp
let computed_at =
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
// Convert to DTOs
let must_use = constraint_set
.must_use
.into_iter()
.map(|a| assertion_to_constraint_entry(a, "must_use:"))
.collect();
let forbidden = constraint_set
.forbidden
.into_iter()
.map(|a| assertion_to_constraint_entry(a, "forbidden:"))
.collect();
let prefer = constraint_set
.prefer
.into_iter()
.map(|a| assertion_to_constraint_entry(a, "prefer:"))
.collect();
metrics::histogram!("stemedb_query_latency_seconds", "endpoint" => "constraints")
.record(query_start.elapsed().as_secs_f64());
Ok(Json(ConstraintsResponse {
subject: params.subject,
must_use,
forbidden,
prefer,
candidates_count: constraint_set.candidates_count,
conflict_score: constraint_set.conflict_score,
computed_at,
}))
}
/// Convert an assertion to a ConstraintEntryDto.
///
/// Extracts the category from the predicate by removing the prefix.
fn assertion_to_constraint_entry(assertion: Assertion, prefix: &str) -> ConstraintEntryDto {
// Extract category from predicate (e.g., "must_use:http_client" -> "http_client")
let category =
assertion.predicate.strip_prefix(prefix).unwrap_or(&assertion.predicate).to_string();
// Compute assertion hash
let hash = stemedb_core::serde::serialize(&assertion)
.map(|bytes| hex::encode(blake3::hash(&bytes).as_bytes()))
.unwrap_or_default();
ConstraintEntryDto {
category,
value: assertion.object.into(),
confidence: assertion.confidence,
assertion_hash: hash,
timestamp: assertion.timestamp,
source_class: assertion.source_class.into(),
}
}
#[cfg(test)]
mod tests {
// Integration tests would go here
// For now, the unit tests in stemedb-lens/src/constraints.rs cover the core functionality
}