use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use skald_spec::MediaFormat;
use super::{
DriftReport, DriftWindow, GroupSnapshot, OperatorDispatchState, VerificationError,
VerificationExecutionStatus, VerificationVerdict,
};
use crate::auth::Sha256Hex;
use crate::card::agent::{BuiltinTool, ExecutionRoute};
use crate::card::verifier::{Check, CodeRuntime};
use crate::error::WyrdError;
use crate::ids::{CardUid, ExecutionId, GroupId, SlotName};
use crate::reference::CardRef;
use crate::storage::upload::UploadPlan;
use crate::vala::ids::{RunId, TraceId};
use crate::verification::TaskId;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct VerificationRequest {
pub verifier_card_uid: CardUid,
pub card_uid: CardUid,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<GroupId>,
#[serde(default)]
pub inputs: BTreeMap<SlotName, InputDeclaration>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub execution_route_overrides: BTreeMap<TaskId, ExecutionRoute>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum InputDeclaration {
Json {
value: Value,
},
File {
#[cfg_attr(feature = "server", schema(value_type = String))]
sha256: Sha256Hex,
size_bytes: u64,
#[cfg_attr(feature = "server", schema(value_type = String))]
format: MediaFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
},
Artifact {
artifact: CardRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
Trace {
trace_id: TraceId,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct EvidenceFile {
#[cfg_attr(feature = "server", schema(value_type = String))]
pub sha256: Sha256Hex,
pub size_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct EvidencePlanRequest {
pub files: Vec<EvidenceFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct EvidencePlanResponse {
pub uploads: Vec<EvidenceUpload>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct EvidenceUpload {
#[cfg_attr(feature = "server", schema(value_type = String))]
pub sha256: Sha256Hex,
pub plan: UploadPlan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct EnqueueVerificationResponse {
pub execution_id: ExecutionId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationExecutionState {
pub execution_id: ExecutionId,
pub status: VerificationExecutionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<VerificationError>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<VerificationResult>,
#[serde(default)]
pub dispatches: Vec<OperatorDispatchState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<GroupId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_card_uid: Option<CardUid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<GroupSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationResult {
pub execution_id: ExecutionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<RunId>,
pub verifier: CardRef,
pub subject: CardRef,
pub outcome: VerificationOutcome,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct VerificationOutcome {
pub verdict: VerificationVerdict,
pub requirements: Vec<RequirementResult>,
pub steps: Vec<StepResult>,
pub evidence: BTreeMap<SlotName, InputDeclaration>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct RequirementResult {
pub id: TaskId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<TaskId>,
pub statement: String,
pub outcome: RequirementOutcome,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub citations: Vec<Citation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<RequirementDetail>,
pub check: Check,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<CallProvenance>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum RequirementOutcome {
Passed,
Failed,
Inconclusive,
NotApplicable,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum RequirementDetail {
Drift(DriftReport),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CallProvenance {
Agent {
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<CardRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt: Option<CardRef>,
route: ExecutionRoute,
},
Code {
#[cfg_attr(feature = "server", schema(value_type = String))]
sha256: Sha256Hex,
runtime: CodeRuntime,
},
Extract {
version: String,
},
Drift {
#[serde(default, skip_serializing_if = "Option::is_none")]
window: Option<DriftWindow>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct StepResult {
pub id: TaskId,
pub status: StepStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<Value>,
pub attempts: u32,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<CallProvenance>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCallRecord>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Succeeded,
Failed,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct ToolCallRecord {
pub tool: BuiltinTool,
pub arguments: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "server", schema(value_type = Option<String>))]
pub output_sha256: Option<Sha256Hex>,
pub returned_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Verdict {
pub outcome: VerificationVerdict,
pub reason: String,
pub citations: Vec<Citation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Citation {
pub slot: SlotName,
pub locator: Locator,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Locator {
Page(u32),
Slide(u32),
Lines {
start: u32,
end: u32,
},
JsonPointer(String),
Region {
page: u32,
x: f32,
y: f32,
width: f32,
height: f32,
},
}
impl From<&WyrdError> for VerificationError {
fn from(error: &WyrdError) -> Self {
Self {
code: error.code().to_owned(),
message: error.message_details().0.into_owned(),
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn verification_request_round_trips_every_input_kind() {
let digest = "a".repeat(64);
let uid = || CardUid::from_uuid(uuid::Uuid::now_v7()).expect("v7 uid");
let request = json!({
"verifier_card_uid": uid(),
"card_uid": uid(),
"inputs": {
"answer": {"kind": "json", "value": {"text": "yes"}},
"policy": {"kind": "file", "sha256": digest, "size_bytes": 10, "format": "pdf", "filename": "p.pdf"},
"deck": {"kind": "artifact", "artifact": {"kind": "Artifact", "space": "legal", "name": "decks", "version": "1.0.0"}, "path": "q3.pptx"},
"trace": {"kind": "trace", "trace_id": "0af7651916cd43dd8448eb211c80319c"}
},
"execution_route_overrides": {"judge": {"kind": "wyrd_gateway", "deployment": "eu-west"}}
});
let parsed: VerificationRequest =
serde_json::from_value(request.clone()).expect("documented request");
assert_eq!(serde_json::to_value(&parsed).expect("serialize"), request);
for invalid in [
json!({"kind": "file", "sha256": "A".repeat(64), "size_bytes": 1, "format": "pdf"}),
json!({"kind": "file", "sha256": digest, "size_bytes": 1, "format": "exe"}),
json!({"kind": "json", "value": 1, "extra": true}),
] {
assert!(
serde_json::from_value::<InputDeclaration>(invalid.clone()).is_err(),
"{invalid}"
);
}
}
#[test]
fn verification_result_round_trips_documented_outcome() {
let result = json!({
"execution_id": ExecutionId::new_v7(),
"verifier": {"kind": "Verifier", "space": "legal", "name": "msa-policy", "version": "1.0.0"},
"subject": {"kind": "Agent", "space": "legal", "name": "drafter", "version": "1.0.0"},
"outcome": {
"verdict": "failed",
"requirements": [{
"id": "liability-cap", "statement": "Cap at 12 months",
"outcome": "failed", "reason": "cap is 24 months",
"citations": [
{"slot": "contract", "locator": {"page": 7}},
{"slot": "deck", "locator": {"slide": 2}},
{"slot": "notes", "locator": {"lines": {"start": 1, "end": 3}}},
{"slot": "workbook", "locator": {"json_pointer": "/sheets/0/cells/1/value"}},
{"slot": "contract", "locator": {"region": {"page": 7, "x": 0.5, "y": 1.0, "width": 2.0, "height": 0.25}}}
],
"check": {"kind": "assert", "path": "inputs.contract", "operator": "is_not_null"},
"provenance": {"kind": "agent", "route": {"kind": "wyrd_gateway", "deployment": "eu-west"}}
}, {
"id": "stable", "statement": "Inputs are stable",
"outcome": "not_applicable", "reason": "step skipped",
"detail": {"drift": {"method": "Psi", "features": {}, "verdict": "NoDrift"}},
"check": {"kind": "assert", "path": "inputs.contract", "operator": "is_not_null"},
"step": "structure"
}],
"steps": [{
"id": "structure", "status": "skipped", "attempts": 0, "duration_ms": 0,
"reason": "when was false",
"tool_calls": [
{"tool": "fs.read", "arguments": {"path": "/inputs/a.pdf"}, "output_sha256": "c".repeat(64), "returned_bytes": 512},
{"tool": "fs.search", "arguments": {"query": "cap"}, "returned_bytes": 0, "error": "workspace read timed out"}
]
}],
"evidence": {"contract": {"kind": "file", "sha256": "b".repeat(64), "size_bytes": 4, "format": "pdf"}}
}
});
let parsed: VerificationResult =
serde_json::from_value(result.clone()).expect("documented result");
assert_eq!(serde_json::to_value(&parsed).expect("serialize"), result);
assert!(
serde_json::from_value::<Verdict>(
json!({"outcome": "passed", "reason": "ok", "citations": [], "score": 1})
)
.is_err()
);
for record in [
json!({"tool": "fs.read", "arguments": {}}),
json!({"tool": "http.fetch", "arguments": {}, "returned_bytes": 0}),
json!({"tool": "fs.read", "arguments": {}, "output_sha256": "C".repeat(64), "returned_bytes": 1}),
] {
assert!(
serde_json::from_value::<ToolCallRecord>(record.clone()).is_err(),
"{record}"
);
}
}
#[test]
fn verification_error_carries_idempotency_conflict() {
let error = VerificationError::from(&WyrdError::RegistryIdempotencyConflict {
message: "key reused with different content".to_owned(),
details: json!({}),
});
assert_eq!(error.code, "WYRD_REGISTRY_409_IDEMPOTENCY_CONFLICT");
assert_eq!(error.message, "key reused with different content");
}
}