Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 96 additions & 53 deletions gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
//! Every request MUST select an ontology (x-il-ontology header or
//! il_ontology body field) - extraction is always bound to one, so without
//! a selection there is nothing to translate against and the request is
//! rejected before any model call.
//! rejected before any model call. An x-il-trace: 1 header additionally
//! returns the validated extraction, the mapped IQL statements, and
//! timings inside the consistency block.

use anyhow::{Context, Result};
use axum::extract::State;
Expand Down Expand Up @@ -217,53 +219,10 @@ async fn verify(
);
}

// Ontology selection is REQUIRED: extraction is always bound to an
// ontology, so without one there is nothing to translate against. The
// header wins over the body field; a version assertion must match the
// pinned version. A malformed header is an explicit client intent that
// failed - reject it rather than silently falling through.
let header_selection = match headers.get("x-il-ontology") {
Some(value) => match value.to_str() {
Ok(v) => Some(v.trim().to_string()),
Err(_) => {
return bad_request("x-il-ontology header is not valid ASCII".to_string());
}
},
None => None,
};
let selected = header_selection.filter(|v| !v.is_empty()).or_else(|| {
request
.il_ontology
.as_deref()
.map(str::trim)
.map(str::to_string)
.filter(|v| !v.is_empty())
});
let Some(spec) = selected else {
return bad_request(format!(
"an ontology must be selected (x-il-ontology header or il_ontology field); \
available: {}",
available_list(&state)
));
};
let (name, version) = match spec.split_once('@') {
Some((n, v)) => (n, Some(v)),
None => (spec.as_str(), None),
let ontology = match select_ontology(&state, &headers, request.il_ontology.as_deref()) {
Ok(ontology) => ontology,
Err(response) => return response,
};
let Some(ontology) = state.ontologies.get(name) else {
return bad_request(format!(
"ontology {name:?} is not published; available: {}",
available_list(&state)
));
};
if let Some(asserted) = version {
if asserted != ontology.version {
return bad_request(format!(
"ontology {name} is pinned at {}, request asserted {asserted}",
ontology.version
));
}
}
if request.messages.is_empty() {
return bad_request("messages must not be empty".to_string());
}
Expand All @@ -277,7 +236,24 @@ async fn verify(
let system_prompt = ontology.render_prompt(&today);
let user_content = render_conversation(&messages);

// Tracing is per-request opt-in: the response additionally carries the
// validated extraction, the mapped IQL statements, and timings - only
// data derived from the caller's own request. Like x-il-ontology, a
// malformed value is explicit intent that failed: reject it rather
// than silently not tracing.
let want_trace = match headers.get("x-il-trace") {
None => false,
Some(value) => match value.to_str().map(|v| v.trim().to_ascii_lowercase()) {
Ok(v) if v == "1" || v == "true" => true,
Ok(v) if v.is_empty() || v == "0" || v == "false" => false,
_ => {
return bad_request("x-il-trace must be 1/true or 0/false".to_string());
}
},
};

// Fail open from here: verifier trouble must not fail caller traffic.
let extract_started = std::time::Instant::now();
let extraction = match extractor
.extract(
&ontology.extraction_model,
Expand All @@ -290,22 +266,89 @@ async fn verify(
Ok(value) => value,
Err(err) => return (StatusCode::OK, Json(unverified(ontology, &err.to_string()))),
};
let extract_ms = extract_started.elapsed().as_millis();

match run_verify(&state.engine, ontology, extraction, &messages).await {
Ok(outcome) => (
StatusCode::OK,
Json(json!({ "inputlayer": { "consistency": {
match run_verify(&state.engine, ontology, extraction, &messages, want_trace).await {
Ok(outcome) => {
let mut consistency = json!({
"status": outcome.status,
"ontology": format!("{}@{}", ontology.name, ontology.version),
"digest": ontology.digest,
"findings": outcome.findings,
"dropped": outcome.dropped,
}}})),
),
});
if let Some(mut trace) = outcome.trace {
if let Some(t) = trace.as_object_mut() {
t.insert("model".to_string(), json!(ontology.extraction_model));
t.insert("extract_ms".to_string(), json!(extract_ms));
}
consistency["trace"] = trace;
}
(
StatusCode::OK,
Json(json!({ "inputlayer": { "consistency": consistency } })),
)
}
Err(err) => (StatusCode::OK, Json(unverified(ontology, &err.to_string()))),
}
}

/// Resolve the request's REQUIRED ontology selection: extraction is always
/// bound to an ontology, so without one there is nothing to translate
/// against. The x-il-ontology header wins over the body field; a
/// name@version assertion must match the pinned version. A malformed header
/// is an explicit client intent that failed - reject it rather than
/// silently falling through.
fn select_ontology<'a>(
state: &'a AppState,
headers: &HeaderMap,
body_field: Option<&str>,
) -> Result<&'a Arc<LoadedOntology>, (StatusCode, Json<Value>)> {
let header_selection = match headers.get("x-il-ontology") {
Some(value) => match value.to_str() {
Ok(v) => Some(v.trim().to_string()),
Err(_) => {
return Err(bad_request(
"x-il-ontology header is not valid ASCII".to_string(),
));
}
},
None => None,
};
let selected = header_selection.filter(|v| !v.is_empty()).or_else(|| {
body_field
.map(str::trim)
.map(str::to_string)
.filter(|v| !v.is_empty())
});
let Some(spec) = selected else {
return Err(bad_request(format!(
"an ontology must be selected (x-il-ontology header or il_ontology field); \
available: {}",
available_list(state)
)));
};
let (name, version) = match spec.split_once('@') {
Some((n, v)) => (n, Some(v)),
None => (spec.as_str(), None),
};
let Some(ontology) = state.ontologies.get(name) else {
return Err(bad_request(format!(
"ontology {name:?} is not published; available: {}",
available_list(state)
)));
};
if let Some(asserted) = version {
if asserted != ontology.version {
return Err(bad_request(format!(
"ontology {name} is pinned at {}, request asserted {asserted}",
ontology.version
)));
}
}
Ok(ontology)
}

fn available_list(state: &AppState) -> String {
let mut names: Vec<&str> = state.ontologies.keys().map(String::as_str).collect();
names.sort_unstable();
Expand Down
30 changes: 24 additions & 6 deletions gateway/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ pub struct VerifyOutcome {
pub status: &'static str,
pub findings: Vec<Value>,
pub dropped: Vec<String>,
/// Present when the request opted into tracing (x-il-trace): the
/// validated extraction, the mapped IQL statements, the ephemeral KG
/// name, and engine wall time. The caller sees only its own data.
pub trace: Option<Value>,
}

/// Drop extraction rows whose quote is not verbatim in the message it cites.
Expand Down Expand Up @@ -84,6 +88,7 @@ pub async fn run_verify(
ontology: &LoadedOntology,
mut extraction: Value,
messages: &[(String, String)],
want_trace: bool,
) -> Result<VerifyOutcome> {
let dropped = validate_quotes(&ontology.manifest, &mut extraction, messages);
let MapOutcome {
Expand All @@ -104,28 +109,40 @@ pub async fn run_verify(
// The KG name must be unique PER REQUEST, not per content: two
// concurrent identical requests sharing a name would race each other's
// create/insert/drop, and the loser's empty queries would read as a
// false "verified". The content hash stays for log correlation only.
// false "verified". Pid and sequence provide the uniqueness but are
// HASHED IN rather than embedded readably - the name is exposed to the
// caller via the trace, and a readable global counter would let one
// tenant measure another's traffic.
let seq = REQUEST_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut hasher = Sha256::new();
hasher.update(ontology.name.as_bytes());
hasher.update(serde_json::to_vec(messages).unwrap_or_default());
hasher.update(std::process::id().to_le_bytes());
hasher.update(seq.to_le_bytes());
let digest = hasher.finalize();
let kg = format!(
"_verify_{}_{}_{seq}",
hex::encode(&digest[..6]),
std::process::id()
);
let kg = format!("_verify_{}", hex::encode(&digest[..8]));

let mut engine = Engine::connect(&engine_config.url, &engine_config.api_key)
.await
.context("engine unreachable")?;

// Ephemeral KG lifecycle; cleanup is best-effort on every exit path.
// engine_ms covers create+deploy+insert+query+cleanup (not connect).
let engine_started = std::time::Instant::now();
let result = verify_in_kg(&mut engine, &kg, ontology, &statements).await;
let _ = engine.execute(".kg use default").await;
let _ = engine.execute(&format!(".kg drop {kg}")).await;
let engine_ms = engine_started.elapsed().as_millis();

let findings = result?;
let trace = want_trace.then(|| {
json!({
"extraction": extraction,
"statements": statements,
"kg": kg,
"engine_ms": engine_ms,
})
});
Ok(VerifyOutcome {
status: if findings.is_empty() {
"verified"
Expand All @@ -134,6 +151,7 @@ pub async fn run_verify(
},
findings,
dropped,
trace,
})
}

Expand Down
23 changes: 22 additions & 1 deletion gateway/tests/live_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,31 @@ async fn verify_pipeline_finds_functional_conflict_live() {
url: server,
api_key,
};
let outcome = run_verify(&engine, &loaded, extraction, &messages)
// The opt-out half of the trace contract: no request, no trace.
let untraced = run_verify(&engine, &loaded, extraction.clone(), &messages, false)
.await
.expect("pipeline (untraced)");
assert!(untraced.trace.is_none());

let outcome = run_verify(&engine, &loaded, extraction, &messages, true)
.await
.expect("pipeline");

// Trace carries the validated extraction (the dropped claim is gone),
// the mapped statements, and the ephemeral KG name.
let trace = outcome.trace.as_ref().expect("trace requested");
assert_eq!(
trace["extraction"]["claims"].as_array().map(Vec::len),
Some(2)
);
assert!(trace["statements"]
.as_array()
.is_some_and(|s| !s.is_empty()));
assert!(trace["kg"]
.as_str()
.is_some_and(|k| k.starts_with("_verify_")));
assert!(trace["engine_ms"].is_number());

assert_eq!(outcome.status, "conflicts_found");
assert_eq!(
outcome.dropped.len(),
Expand Down
Loading