use std::collections::BTreeMap;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::auth::PrincipalId;
use crate::card::drift::{DriftMethod, DriftProfile};
use crate::card::operator::VerifierCounts;
use crate::card::verifier::{VerifierImplementation, VerifierTask};
use crate::error::WyrdError;
use crate::ids::{
BindingId, CardUid, FeatureName, OperatorDispatchId, VerificationExecutionId,
VerificationResultId, VerificationRunId,
};
use crate::reference::CardRef;
use crate::vala::eval::EvalTask;
use crate::vala::eval::ids::TaskId;
use crate::vala::eval::media::MediaRef;
use crate::vala::eval::result::AssertionResult;
use crate::vala::ids::RunId;
pub const MAX_MANUAL_DRIFT_WINDOW_DAYS: i64 = 31;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum FrozenTarget {
Uid(CardUid),
Digest(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct DriftWindow {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
impl DriftWindow {
pub fn validate_manual(&self) -> Result<(), WyrdError> {
if self.start >= self.end {
return Err(WyrdError::VerificationInvalidWindow {
message: "drift_window start must be before end".to_owned(),
details: serde_json::json!({ "start": self.start, "end": self.end }),
});
}
if self.end - self.start > Duration::days(MAX_MANUAL_DRIFT_WINDOW_DAYS) {
return Err(WyrdError::VerificationInvalidWindow {
message: format!(
"drift_window may span at most {MAX_MANUAL_DRIFT_WINDOW_DAYS} days"
),
details: serde_json::json!({
"start": self.start,
"end": self.end,
"max_days": MAX_MANUAL_DRIFT_WINDOW_DAYS,
}),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum VerificationRunTarget {
Binding {
binding_id: BindingId,
},
Verifier {
verifier_card_uid: CardUid,
card_uid: CardUid,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum VerificationRunInput {
DriftWindow {
start: DateTime<Utc>,
end: DateTime<Utc>,
},
}
impl VerificationRunInput {
#[must_use]
pub fn drift_window(&self) -> DriftWindow {
match self {
Self::DriftWindow { start, end } => DriftWindow {
start: *start,
end: *end,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct StartVerificationRunRequest {
pub target: VerificationRunTarget,
pub input: VerificationRunInput,
}
impl StartVerificationRunRequest {
pub fn validate(&self) -> Result<DriftWindow, WyrdError> {
let window = self.input.drift_window();
window.validate_manual()?;
Ok(window)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct StartVerificationRunResponse {
pub run_id: VerificationRunId,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::EnumString,
strum::IntoStaticStr,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum VerifierReadiness {
Ready,
BaselineNotReady,
VerifierUnavailable,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::EnumString,
strum::IntoStaticStr,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum VerificationExecutionStatus {
Pending,
Running,
Retrying,
Completed,
Cancelled,
TimedOut,
Errored,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::EnumString,
strum::IntoStaticStr,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum VerificationVerdict {
Passed,
Failed,
Inconclusive,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::EnumString,
strum::IntoStaticStr,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum OperatorDispatchStatus {
Pending,
Running,
Retrying,
Delivered,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationBindingStatus {
pub binding_id: BindingId,
pub owner_card_uid: CardUid,
pub card_uid: CardUid,
pub verifier_card_uid: CardUid,
pub active: bool,
pub readiness: VerifierReadiness,
pub next_run_at: Option<DateTime<Utc>>,
pub last_activated_at: Option<DateTime<Utc>>,
pub last_run_id: Option<VerificationRunId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct OperatorDispatchState {
pub dispatch_id: OperatorDispatchId,
pub operator: FrozenTarget,
pub status: OperatorDispatchStatus,
pub error: Option<VerificationError>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationRunStatus {
pub run_id: VerificationRunId,
pub status: VerificationExecutionStatus,
pub sub_principal_id: PrincipalId,
pub act_principal_id: PrincipalId,
pub result_id: Option<VerificationResultId>,
pub error: Option<VerificationError>,
pub dispatches: Vec<OperatorDispatchState>,
}
pub const MAX_EXECUTE_BODY_BYTES: usize = 1024 * 1024;
pub const MAX_DRIFT_SAMPLE_COLUMNS: usize = 64;
pub const MAX_DRIFT_SAMPLE_VALUES: usize = 100_000;
pub const MAX_EVAL_CONTEXT_BYTES: usize = 256 * 1024;
pub const EXECUTION_DEADLINE: std::time::Duration = std::time::Duration::from_mins(1);
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
schemars::JsonSchema,
strum::IntoStaticStr,
)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum VerifierKind {
DriftPsi,
DriftSpc,
DriftCustom,
EvalAssertion,
EvalLlmJudge,
EvalOther,
TaskAssertion,
TaskLlmJudge,
Unknown,
}
impl VerifierKind {
#[must_use]
pub fn of(implementation: &VerifierImplementation) -> Self {
match implementation {
VerifierImplementation::Drift(spec) => match &spec.profile {
Some(DriftProfile::Psi(_)) => Self::DriftPsi,
Some(DriftProfile::Spc(_)) => Self::DriftSpc,
Some(DriftProfile::Custom(_)) => Self::DriftCustom,
None => Self::Unknown,
},
VerifierImplementation::Eval(spec) => {
let mut tasks = spec.tasks.values();
if tasks
.clone()
.any(|task| matches!(task, EvalTask::LlmJudge(_)))
{
Self::EvalLlmJudge
} else if !spec.tasks.is_empty()
&& tasks.all(|task| matches!(task, EvalTask::Assertion(_)))
{
Self::EvalAssertion
} else {
Self::EvalOther
}
}
VerifierImplementation::Task(spec) => match spec.definition {
VerifierTask::Assertion(_) => Self::TaskAssertion,
VerifierTask::LlmJudge(_) => Self::TaskLlmJudge,
},
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum DriftSample {
Number(f64),
Text(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum DirectVerificationInput {
EvalRecord {
#[cfg_attr(feature = "server", schema(value_type = Object))]
context: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "server", schema(value_type = Option<Vec<Object>>))]
media: Option<Vec<MediaRef>>,
},
TaskContext {
#[cfg_attr(feature = "server", schema(value_type = Object))]
context: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "server", schema(value_type = Option<Vec<Object>>))]
media: Option<Vec<MediaRef>>,
},
DriftSamples {
columns: BTreeMap<String, Vec<Option<DriftSample>>>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ExecuteVerificationRequest {
pub verifier_card_uid: CardUid,
pub card_uid: CardUid,
pub input: DirectVerificationInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "server", schema(value_type = Option<String>))]
pub run_id: Option<RunId>,
}
impl ExecuteVerificationRequest {
pub fn decode(body: serde_json::Value) -> Result<Self, WyrdError> {
serde_json::from_value(body).map_err(|error| WyrdError::VerificationInputInvalid {
message: format!("verification execute request is invalid: {error}"),
details: serde_json::json!({}),
})
}
pub fn validate(&self) -> Result<(), WyrdError> {
let too_large = |message: String| WyrdError::VerificationInputTooLarge {
message,
details: serde_json::json!({}),
};
match &self.input {
DirectVerificationInput::DriftSamples { columns } => {
if columns.len() > MAX_DRIFT_SAMPLE_COLUMNS {
return Err(too_large(format!(
"drift_samples may carry at most {MAX_DRIFT_SAMPLE_COLUMNS} columns"
)));
}
if columns
.values()
.any(|values| values.len() > MAX_DRIFT_SAMPLE_VALUES)
{
return Err(too_large(format!(
"a drift_samples column may carry at most {MAX_DRIFT_SAMPLE_VALUES} values"
)));
}
}
DirectVerificationInput::EvalRecord { context, .. } => {
let bytes = serde_json::to_vec(context).map_or(usize::MAX, |body| body.len());
if bytes > MAX_EVAL_CONTEXT_BYTES {
return Err(too_large(format!(
"eval_record.context may be at most {MAX_EVAL_CONTEXT_BYTES} bytes"
)));
}
}
DirectVerificationInput::TaskContext { context, .. } => {
let bytes = serde_json::to_vec(context).map_or(usize::MAX, |body| body.len());
if bytes > MAX_EVAL_CONTEXT_BYTES {
return Err(too_large(format!(
"task_context.context may be at most {MAX_EVAL_CONTEXT_BYTES} bytes"
)));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum VerificationExecutionDetail {
Drift(DriftReport),
Eval(EvalReport),
Task {
#[cfg_attr(feature = "server", schema(value_type = Object))]
result: Box<AssertionResult>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub enum DriftVerdict {
NoDrift,
Drift,
Inconclusive,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct DriftReport {
pub method: DriftMethod,
pub features: BTreeMap<FeatureName, FeatureDriftReport>,
pub verdict: DriftVerdict,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct FeatureDriftReport {
pub feature: FeatureName,
#[serde(default)]
pub score: Option<f64>,
#[serde(default)]
pub threshold: Option<f64>,
pub verdict: DriftVerdict,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "server", schema(value_type = Option<Object>))]
pub evidence: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct EvalReport {
#[cfg_attr(feature = "server", schema(value_type = Vec<Object>))]
pub results: Vec<AssertionResult>,
pub skipped: Vec<SkippedTask>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct SkippedTask {
#[cfg_attr(feature = "server", schema(value_type = String))]
pub task_id: TaskId,
pub reason: SkipReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "server", schema(value_type = Option<String>))]
#[schemars(schema_with = "crate::schema::skipped_option::<TaskId>")]
pub upstream_task_id: Option<TaskId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum SkipReason {
ConditionFalse,
DependencySkipped,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct Judgment {
pub execution_id: VerificationExecutionId,
pub verifier: CardRef,
pub subject: CardRef,
pub kind: VerifierKind,
pub verdict: VerificationVerdict,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "crate::schema::skipped_option::<VerifierCounts>")]
pub counts: Option<VerifierCounts>,
pub detail: VerificationExecutionDetail,
}
impl Judgment {
#[must_use]
pub fn passed(&self) -> bool {
self.verdict == VerificationVerdict::Passed
}
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
fn at(hour: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 17, hour, 0, 0)
.single()
.expect("test_setup: unambiguous UTC instant")
}
#[test]
fn documented_request_parses_and_validates() {
let binding = BindingId::new_v7();
let request: StartVerificationRunRequest = serde_json::from_value(serde_json::json!({
"target": { "kind": "binding", "binding_id": binding },
"input": { "kind": "drift_window", "start": "2026-09-17T00:00:00Z", "end": "2026-09-17T01:00:00Z" }
}))
.expect("documented request parses");
assert_eq!(
request.target,
VerificationRunTarget::Binding {
binding_id: binding
}
);
assert_eq!(
request.validate().expect("valid window"),
DriftWindow {
start: at(0),
end: at(1)
}
);
}
#[test]
fn window_validation_refuses_invalid_windows() {
let input: VerificationRunInput = serde_json::from_value(serde_json::json!({
"kind": "drift_window", "start": "2026-09-17T02:00:00+02:00", "end": "2026-09-17T01:00:00Z"
}))
.expect("offset timestamps parse");
assert_eq!(input.drift_window().start, at(0));
let limit = Duration::days(MAX_MANUAL_DRIFT_WINDOW_DAYS);
let cases = [
(at(1), at(1)),
(at(2), at(1)),
(at(0), at(0) + limit + Duration::seconds(1)),
];
for (start, end) in cases {
let error = DriftWindow { start, end }
.validate_manual()
.expect_err("invalid window is refused");
assert_eq!(error.code(), "WYRD_VERIFICATION_400_INVALID_WINDOW");
}
assert!(
DriftWindow {
start: at(0),
end: at(0) + limit
}
.validate_manual()
.is_ok(),
"the maximum span is inclusive"
);
}
#[test]
fn malformed_targets_are_refused() {
let v4 = uuid::Uuid::new_v4();
for target in [
serde_json::json!({ "kind": "service", "binding_id": BindingId::new_v7() }),
serde_json::json!({ "kind": "binding", "binding_id": v4 }),
serde_json::json!({ "kind": "binding", "binding_id": BindingId::new_v7(), "extra": 1 }),
] {
assert!(serde_json::from_value::<VerificationRunTarget>(target).is_err());
}
}
#[test]
fn status_names_round_trip() {
for status in [
VerificationExecutionStatus::Pending,
VerificationExecutionStatus::TimedOut,
VerificationExecutionStatus::Errored,
] {
let name: &'static str = status.into();
assert_eq!(
name.parse::<VerificationExecutionStatus>().ok(),
Some(status)
);
}
let readiness: &'static str = VerifierReadiness::BaselineNotReady.into();
assert_eq!(readiness, "baseline_not_ready");
}
#[test]
fn kind_classification_follows_the_closed_precedence() {
let assertion = serde_json::json!({
"kind": "assertion", "id": "a", "context_path": "$.x",
"operator": "is_not_null", "expected": null
});
let judge = serde_json::json!({
"kind": "llm_judge", "id": "j",
"judge_ref": {"prompt": {
"kind": "Prompt", "name": "judge", "version": "1.0.0", "space": "default"
}, "execution_route": {"kind": "wyrd_gateway", "deployment": "judge"}},
"operator": "greater_than_or_equals", "expected": 0.5
});
let trace = serde_json::json!({
"kind": "trace_assertion", "id": "t", "span_selector": "$.spans",
"operator": "is_non_empty", "expected": null
});
let eval = |tasks: serde_json::Value| -> VerifierImplementation {
serde_json::from_value(serde_json::json!({"kind": "eval", "spec": {"tasks": tasks}}))
.expect("eval implementation decodes")
};
assert_eq!(
VerifierKind::of(&eval(serde_json::json!({"a": assertion}))),
VerifierKind::EvalAssertion
);
assert_eq!(
VerifierKind::of(&eval(
serde_json::json!({"a": assertion, "j": judge, "t": trace})
)),
VerifierKind::EvalLlmJudge
);
assert_eq!(
VerifierKind::of(&eval(serde_json::json!({"a": assertion, "t": trace}))),
VerifierKind::EvalOther
);
assert_eq!(
VerifierKind::of(&eval(serde_json::json!({}))),
VerifierKind::EvalOther
);
}
}