use std::collections::{BTreeMap, BTreeSet};
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use skald_spec::{MediaFormat, NamedResponseFormat, SkaldError, check_output_schema};
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::ids::UploadId;
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployment_environment: Option<String>,
}
#[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 upload_id: UploadId,
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))]
#[schemars(rename = "VerificationExecutionState")]
#[cfg_attr(feature = "server", schema(bound = ""))]
pub struct VerificationExecutionState<T = Map<String, Value>> {
pub execution_id: ExecutionId,
pub status: VerificationExecutionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<VerificationError>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(default)]
#[cfg_attr(feature = "server", schema(value_type = Option<VerificationResult>))]
pub result: Option<VerificationResult<T>>,
#[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))]
#[schemars(rename = "VerificationResult")]
#[cfg_attr(feature = "server", schema(bound = ""))]
pub struct VerificationResult<T = Map<String, Value>> {
pub execution_id: ExecutionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<RunId>,
pub verifier: CardRef,
pub subject: CardRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployment_environment: Option<String>,
#[cfg_attr(feature = "server", schema(value_type = VerificationOutcome))]
pub outcome: VerificationOutcome<T>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[schemars(rename = "VerificationOutcome")]
#[cfg_attr(feature = "server", schema(bound = ""))]
pub struct VerificationOutcome<T = Map<String, Value>> {
pub verdict: VerificationVerdict,
#[cfg_attr(feature = "server", schema(value_type = Vec<RequirementResult>))]
pub requirements: Vec<RequirementResult<T>>,
pub steps: Vec<StepResult>,
pub evidence: BTreeMap<SlotName, InputDeclaration>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[schemars(rename = "RequirementResult")]
#[cfg_attr(feature = "server", schema(bound = ""))]
pub struct RequirementResult<T = Map<String, Value>> {
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(skip_serializing_if = "Option::is_none")]
#[schemars(
default,
schema_with = "crate::schema::skipped_option::<Map<String, Value>>"
)]
#[cfg_attr(feature = "server", schema(value_type = Option<Object>))]
pub data: Option<T>,
#[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>,
}
pub const MAX_VERDICT_DATA_BYTES: usize = 65_536;
pub const MAX_VERDICT_DATA_DEPTH: usize = 16;
pub const VERDICT_FORMAT: &str = "verdict";
const DATA_RESOURCE: &str = "verdict-data";
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "crate::schema::skipped_option::<Map<String, Value>>")]
pub data: Option<Map<String, Value>>,
}
impl VerificationExecutionState {
pub fn decode_data<T: DeserializeOwned>(
self,
) -> Result<VerificationExecutionState<T>, WyrdError> {
let Self {
execution_id,
status,
error,
result,
dispatches,
group_id,
owner_card_uid,
group,
} = self;
Ok(VerificationExecutionState {
execution_id,
status,
error,
result: result.map(VerificationResult::decode_data).transpose()?,
dispatches,
group_id,
owner_card_uid,
group,
})
}
}
impl VerificationResult {
pub fn decode_data<T: DeserializeOwned>(self) -> Result<VerificationResult<T>, WyrdError> {
let Self {
execution_id,
run_id,
verifier,
subject,
deployment_environment,
outcome,
} = self;
let requirements = outcome
.requirements
.into_iter()
.map(RequirementResult::decode_data)
.collect::<Result<_, _>>()?;
Ok(VerificationResult {
execution_id,
run_id,
verifier,
subject,
deployment_environment,
outcome: VerificationOutcome {
verdict: outcome.verdict,
requirements,
steps: outcome.steps,
evidence: outcome.evidence,
},
})
}
}
impl RequirementResult {
fn decode_data<T: DeserializeOwned>(self) -> Result<RequirementResult<T>, WyrdError> {
let data = match self.data {
None => None,
Some(data) => Some(serde_json::from_value(Value::Object(data)).map_err(|_| {
WyrdError::SdkVerificationDataInvalid {
message: format!(
"requirement `{}` data does not match the requested type",
self.id.as_str()
),
details: serde_json::json!({"requirement": self.id, "path": ""}),
}
})?),
};
Ok(RequirementResult {
id: self.id,
step: self.step,
statement: self.statement,
outcome: self.outcome,
reason: self.reason,
actual: self.actual,
citations: self.citations,
data,
detail: self.detail,
check: self.check,
provenance: self.provenance,
})
}
}
impl Verdict {
pub fn response_format(format: &NamedResponseFormat) -> Result<Map<String, Value>, SkaldError> {
let invalid = |field: &str, message: &str| SkaldError::PromptInvalid {
field: field.to_owned(),
message: message.to_owned(),
};
if format.name() != VERDICT_FORMAT {
return Err(invalid(
"response_format",
&format!(
"unknown response format `{}`; the named format is `verdict`",
format.name()
),
));
}
let Ok(mut generated) = serde_json::to_value(crate::schema::schema_for::<Self>()) else {
unreachable!("a generated root schema serializes to a JSON object");
};
portable(&mut generated);
let Value::Object(mut schema) = generated else {
unreachable!("a generated root schema serializes to a JSON object");
};
let required = |schema: &mut Map<String, Value>| {
schema
.get_mut("required")
.and_then(Value::as_array_mut)
.map(|required| required.retain(|name| name != "data"))
};
required(&mut schema);
let properties = schema
.get_mut("properties")
.and_then(Value::as_object_mut)
.expect("the generated Verdict schema declares properties");
properties.remove("data");
let Some(data) = format.data() else {
return Ok(schema);
};
if let Some(dialect) = data.get("$schema")
&& !matches!(
dialect.as_str(),
Some(
"https://json-schema.org/draft/2020-12/schema"
| "https://json-schema.org/draft/2020-12/schema#"
)
)
{
return Err(invalid(
"response_format.data.$schema",
"a Verdict data schema is JSON Schema 2020-12; omit `$schema` or declare https://json-schema.org/draft/2020-12/schema",
));
}
if data.contains_key("$id") {
return Err(invalid(
"response_format.data.$id",
"a Verdict data schema is embedded as its own resource; remove the root `$id`",
));
}
check_output_schema(&Value::Object(data.clone()), "response_format.data", true)?;
let mut data = data.clone();
data.insert("$id".to_owned(), Value::from(DATA_RESOURCE));
schema["properties"]["data"] = Value::Object(data);
schema
.get_mut("required")
.and_then(Value::as_array_mut)
.expect("the generated Verdict schema requires fields")
.push(Value::from("data"));
Ok(schema)
}
pub fn restrict_slots(schema: &mut Value, slots: &BTreeSet<SlotName>) {
if slots.is_empty() {
return;
}
if let Some(slot) = schema.pointer_mut("/$defs/SlotName") {
*slot = serde_json::json!({
"type": "string",
"enum": slots.iter().map(SlotName::as_str).collect::<Vec<_>>(),
});
}
}
pub fn check_data_bounds(&self) -> Result<(), String> {
fn depth(value: &Value) -> usize {
match value {
Value::Object(map) => 1 + map.values().map(depth).max().unwrap_or(0),
Value::Array(items) => 1 + items.iter().map(depth).max().unwrap_or(0),
_ => 0,
}
}
let Some(data) = &self.data else {
return Ok(());
};
let bytes = serde_json::to_vec(data).map_or(usize::MAX, |encoded| encoded.len());
if bytes > MAX_VERDICT_DATA_BYTES {
return Err(format!(
"verdict data is {bytes} bytes; the limit is {MAX_VERDICT_DATA_BYTES} bytes of compact JSON"
));
}
let nested = 1 + data.values().map(depth).max().unwrap_or(0);
if nested > MAX_VERDICT_DATA_DEPTH {
return Err(format!(
"verdict data nests {nested} containers; the limit is {MAX_VERDICT_DATA_DEPTH}"
));
}
Ok(())
}
}
fn portable(schema: &mut Value) {
let Value::Object(node) = schema else {
return;
};
if node.contains_key("$ref") {
node.retain(|key, _| key == "$ref");
return;
}
node.remove("format");
node.remove("minimum");
if let Some(Value::Array(alternatives)) = node.remove("oneOf") {
let constants: Option<Vec<Value>> = alternatives
.iter()
.map(|alternative| alternative.get("const").cloned())
.collect();
let kind = alternatives
.first()
.and_then(|first| first.get("type"))
.cloned();
match constants {
Some(values)
if alternatives
.iter()
.all(|alternative| alternative.get("type") == kind.as_ref()) =>
{
if let Some(kind) = kind {
node.insert("type".to_owned(), kind);
}
node.insert("enum".to_owned(), Value::Array(values));
}
_ => {
node.insert("anyOf".to_owned(), Value::Array(alternatives));
}
}
}
for (keyword, value) in node.iter_mut() {
match (keyword.as_str(), value) {
("properties" | "$defs", Value::Object(named)) => named.values_mut().for_each(portable),
(_, Value::Array(items)) => items.iter_mut().for_each(portable),
(_, value) => portable(value),
}
}
}
#[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", "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", "name": "msa-policy", "version": "1.0.0"},
"subject": {"kind": "Agent", "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 typed_data_decodes_whole_results() {
#[derive(Debug, PartialEq, Deserialize)]
struct Judge {
score: f64,
}
let requirement = |id: &str, data: Value| {
let mut requirement = json!({
"id": id, "statement": "s", "outcome": "failed", "reason": "r",
"check": {"kind": "assert", "path": "inputs.answer", "operator": "is_not_null"}
});
if !data.is_null() {
requirement["data"] = data;
}
requirement
};
let result = |requirements: Vec<Value>| -> VerificationResult {
serde_json::from_value(json!({
"execution_id": ExecutionId::new_v7(),
"verifier": {"kind": "Verifier", "name": "judge", "version": "1.0.0"},
"subject": {"kind": "Agent", "name": "drafter", "version": "1.0.0"},
"outcome": {"verdict": "failed", "requirements": requirements, "steps": [], "evidence": {}}
}))
.expect("result")
};
let typed = result(vec![
requirement("scored", json!({"score": 0.25})),
requirement("plain", Value::Null),
])
.decode_data::<Judge>()
.expect("typed");
assert_eq!(
typed.outcome.requirements[0].data,
Some(Judge { score: 0.25 })
);
assert_eq!(typed.outcome.requirements[1].data, None);
let error = result(vec![
requirement("scored", json!({"score": 0.25})),
requirement("secretive", json!({"score": "hunter2"})),
])
.decode_data::<Judge>()
.expect_err("mismatch");
assert_eq!(error.code(), "WYRD_SDK_422_VERIFICATION_DATA_INVALID");
assert_eq!(
error.problem().details,
json!({"requirement": "secretive", "path": ""})
);
assert!(!error.to_string().contains("hunter2"), "{error}");
let error = result(vec![requirement(
"keyed",
json!({"outer": {"customer-secret@example.test": "wrong"}}),
)])
.decode_data::<std::collections::BTreeMap<String, std::collections::BTreeMap<String, u8>>>()
.expect_err("mismatch under a returned key");
let problem = serde_json::to_string(&error.problem()).expect("problem");
assert!(!problem.contains("customer-secret"), "{problem}");
assert!(!error.to_string().contains("customer-secret"), "{error}");
}
#[test]
fn custom_verdict_data_obeys_limits() {
let verdict = |data: Value| Verdict {
outcome: VerificationVerdict::Passed,
reason: "ok".to_owned(),
citations: Vec::new(),
data: Some(data.as_object().expect("object data").clone()),
};
let text = |unit: &str, count: usize, tail: &str| json!({"s": format!("{}{tail}", unit.repeat(count))});
let wide = |last: u32| {
let mut items = vec![json!(0); 32_763];
items.push(json!(last));
json!({"a": items})
};
let nested = |containers: usize| {
let mut value = json!(1);
for level in 1..containers {
value = if level % 2 == 0 {
json!({"o": value})
} else {
json!([value])
};
}
json!({"r": value})
};
for (data, fits) in [
(text("x", 65_528, ""), true),
(text("x", 65_529, ""), false),
(text("é", 32_764, ""), true),
(text("é", 32_764, "x"), false),
(text("\n", 32_764, ""), true),
(text("\n", 32_764, "x"), false),
(text("\u{1}", 10_921, "xx"), true),
(text("\u{1}", 10_921, "xxx"), false),
(wide(9), true),
(wide(10), true),
(wide(100), false),
(nested(16), true),
(nested(17), false),
] {
let bytes = serde_json::to_vec(&data).expect("json").len();
match verdict(data).check_data_bounds() {
Ok(()) => assert!(fits, "{bytes} bytes must fail"),
Err(message) => {
assert!(!fits, "{bytes} bytes must pass: {message}");
assert!(
message.contains("65536") || message.contains("16"),
"{message}"
);
assert!(
!message.contains("xxx") && !message.contains('é'),
"{message}"
);
}
}
}
assert_eq!(
serde_json::to_vec(&wide(10)).expect("json").len(),
MAX_VERDICT_DATA_BYTES
);
let plain = Verdict {
data: None,
..verdict(json!({}))
};
assert_eq!(plain.check_data_bounds(), Ok(()));
assert!(
serde_json::to_value(&plain)
.expect("json")
.get("data")
.is_none(),
"absent data is omitted"
);
}
#[test]
fn plain_verdict_schema_is_portable() {
fn portable(value: &Value) {
match value {
Value::Object(map) => {
assert!(!map.contains_key("$ref") || map.len() == 1, "{map:?}");
for keyword in ["oneOf", "const", "minimum", "format"] {
assert!(!map.contains_key(keyword), "{keyword} in {map:?}");
}
map.values().for_each(portable);
}
Value::Array(items) => items.iter().for_each(portable),
_ => {}
}
}
fn structure(value: &Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.iter()
.filter(|(key, _)| !matches!(key.as_str(), "title" | "description"))
.map(|(key, value)| (key.clone(), structure(value)))
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(structure).collect()),
other => other.clone(),
}
}
let schema = Value::Object(
Verdict::response_format(&NamedResponseFormat::Name(VERDICT_FORMAT.to_owned()))
.expect("resolves"),
);
let integer = || json!({"type": "integer"});
let number = || json!({"type": "number"});
let closed = |key: &str, value: Value| json!({"type": "object", "properties": {key: value}, "required": [key], "additionalProperties": false});
let target = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"outcome": {"$ref": "#/$defs/VerificationVerdict"},
"reason": {"type": "string"},
"citations": {"type": "array", "items": {"$ref": "#/$defs/Citation"}}
},
"additionalProperties": false,
"required": ["outcome", "reason", "citations"],
"$defs": {
"VerificationVerdict": {"type": "string", "enum": ["passed", "failed", "inconclusive"]},
"Citation": {
"type": "object",
"properties": {
"slot": {"$ref": "#/$defs/SlotName"},
"locator": {"$ref": "#/$defs/Locator"}
},
"additionalProperties": false,
"required": ["slot", "locator"]
},
"SlotName": {"type": "string"},
"Locator": {"anyOf": [
closed("page", integer()),
closed("slide", integer()),
closed("lines", json!({
"type": "object",
"properties": {"start": integer(), "end": integer()},
"required": ["start", "end"],
"additionalProperties": false
})),
closed("json_pointer", json!({"type": "string"})),
closed("region", json!({
"type": "object",
"properties": {
"page": integer(), "x": number(), "y": number(),
"width": number(), "height": number()
},
"required": ["page", "x", "y", "width", "height"],
"additionalProperties": false
})),
]}
}
});
assert_eq!(structure(&schema), target, "{schema:#}");
portable(&schema);
let validator = jsonschema::validator_for(&schema).expect("valid schema");
let verdict = |outcome: Value, locator: Value| json!({"outcome": outcome, "reason": "r", "citations": [{"slot": "order", "locator": locator}]});
for locator in [
json!({"page": 1}),
json!({"slide": 2}),
json!({"lines": {"start": 1, "end": 3}}),
json!({"json_pointer": "/a"}),
json!({"region": {"page": 1, "x": 0.5, "y": 1, "width": 2, "height": 3}}),
] {
let value = verdict(json!("failed"), locator);
assert!(validator.is_valid(&value), "{value}");
serde_json::from_value::<Verdict>(value.clone())
.unwrap_or_else(|error| panic!("{value}: {error}"));
}
for value in [
verdict(json!("FAIL"), json!({"page": 1})),
verdict(json!("passed"), json!({"page": 1, "slide": 2})),
verdict(json!("passed"), json!({"lines": {"start": 1}})),
verdict(json!("passed"), json!({"page": 1, "extra": 1})),
json!({"outcome": "passed", "reason": "r"}),
json!({"outcome": "passed", "reason": "r", "citations": [], "extra": 1}),
json!({"outcome": "passed", "reason": "r", "citations": [{"slot": "order"}]}),
] {
assert!(!validator.is_valid(&value), "{value}");
}
for page in [json!(-1), json!(4_294_967_296_u64)] {
let value = verdict(json!("passed"), json!({"page": page}));
assert!(validator.is_valid(&value), "{value}");
assert!(
serde_json::from_value::<Verdict>(value.clone()).is_err(),
"{value}"
);
}
}
#[test]
fn expanded_verdict_keeps_authored_data_schema() {
let data = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 1, "format": "int32", "description": "kept"},
"kind": {"oneOf": [{"const": "a"}, {"const": "b"}]},
"item": {"$ref": "#/$defs/item", "description": "kept"}
},
"required": ["score"],
"$defs": {"item": {"type": "string", "minLength": 2}}
});
let format: NamedResponseFormat =
serde_json::from_value(json!({"kind": "verdict", "data": data})).expect("format");
let schema = Value::Object(Verdict::response_format(&format).expect("resolves"));
let mut embedded = data.clone();
embedded["$id"] = json!(DATA_RESOURCE);
assert_eq!(schema["properties"]["data"], embedded);
assert!(
schema["required"]
.as_array()
.expect("required")
.contains(&json!("data"))
);
assert!(schema["$defs"]["Locator"].get("anyOf").is_some());
let validator = jsonschema::validator_for(&schema).expect("valid schema");
let verdict = |data: Value| json!({"outcome": "passed", "reason": "r", "citations": [], "data": data});
assert!(validator.is_valid(&verdict(json!({"score": 1, "kind": "a", "item": "ok"}))));
for data in [
json!({"score": 0}),
json!({"score": 1, "kind": "c"}),
json!({"score": 1, "item": "x"}),
json!({}),
] {
assert!(!validator.is_valid(&verdict(data.clone())), "{data}");
}
}
#[test]
fn restrict_slots_admits_only_reachable_slots() {
let plain = Value::Object(
Verdict::response_format(&NamedResponseFormat::Name(VERDICT_FORMAT.to_owned()))
.expect("resolves"),
);
let mut unchanged = plain.clone();
Verdict::restrict_slots(&mut unchanged, &BTreeSet::new());
assert_eq!(unchanged, plain);
let mut schema = plain;
let slots = ["order", "receipt"].map(|slot| SlotName::new(slot).expect(slot));
Verdict::restrict_slots(&mut schema, &slots.into_iter().collect());
let slot = jsonschema::validator_for(&schema["$defs"]["SlotName"]).expect("valid");
assert!(slot.is_valid(&json!("order")) && slot.is_valid(&json!("receipt")));
for wrong in ["inputs.order", "other", ""] {
assert!(!slot.is_valid(&json!(wrong)), "{wrong}");
}
}
#[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");
}
}