1use std::collections::{BTreeMap, BTreeSet};
8
9use proofborne_core::{
10 AssuranceLevel, ClaimScope, CriterionEvaluation, CriterionState, ProofGraph, ProofTermination,
11 RunOutcome, SCHEMA_VERSION, TaskContract,
12};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "camelCase")]
19pub struct VerificationReport {
20 pub schema_version: String,
22 pub contract_id: Uuid,
24 pub valid: bool,
26 pub claim_scope: ClaimScope,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub assurance_level: Option<AssuranceLevel>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub outcome: Option<RunOutcome>,
34 #[serde(default)]
36 pub criteria: Vec<CriterionEvaluation>,
37 #[serde(default)]
39 pub issues: Vec<VerificationIssue>,
40}
41
42impl VerificationReport {
43 pub fn proves_task(&self) -> bool {
45 self.valid
46 && self.claim_scope == ClaimScope::Task
47 && self.outcome == Some(RunOutcome::Verified)
48 }
49
50 pub fn is_verified(&self) -> bool {
52 self.valid
53 && matches!(
54 self.outcome,
55 Some(RunOutcome::Verified | RunOutcome::VerifiedWithWaivers)
56 )
57 }
58}
59
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
62#[serde(rename_all = "snake_case")]
63pub enum VerificationSeverity {
64 Warning,
66 Error,
68}
69
70#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
72#[serde(rename_all = "snake_case")]
73pub enum VerificationIssueCode {
74 RuntimeClaimNotTaskCompletion,
76 NoMachineCheckableTaskEvidence,
78 UnsupportedSchema,
80 InvalidContract,
82 ContractMismatch,
84 EvidenceKeyMismatch,
86 UnknownEvidenceLink,
88 UnknownCriterionLink,
90 DuplicateLink,
92 TimestampOrder,
94 IncompleteAttempt,
96 EmptyIdentity,
98 IncompleteStateBinding,
100 DuplicateSupersession,
102 UnknownSupersession,
104 SelfSupersession,
106 SupersessionNotLater,
108 SupersessionWithoutCommonCriterion,
110 CriterionStateMismatch,
112 CriterionEvidenceMismatch,
114 IncompleteDecision,
116 EvaluationFailed,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "camelCase")]
123pub struct VerificationIssue {
124 pub code: VerificationIssueCode,
126 pub severity: VerificationSeverity,
128 pub message: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub criterion_id: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub evidence_id: Option<Uuid>,
136}
137
138pub fn verify(contract: &TaskContract, proof: &ProofGraph) -> VerificationReport {
140 let mut issues = Vec::new();
141 validate_versions(contract, proof, &mut issues);
142 validate_contract(contract, &mut issues);
143 validate_graph(contract, proof, &mut issues);
144 if contract.claim_scope == ClaimScope::Runtime {
145 push_warning(
146 &mut issues,
147 VerificationIssueCode::RuntimeClaimNotTaskCompletion,
148 "runtime-scoped evidence cannot produce a verified task outcome".to_owned(),
149 None,
150 None,
151 );
152 }
153
154 let mut reduced_contract = contract.clone();
155 let evaluation = match proof.evaluate_detailed(&mut reduced_contract) {
156 Ok(evaluation) => {
157 validate_persisted_derivation(contract, &reduced_contract, &mut issues);
158 if contract.claim_scope == ClaimScope::Task
159 && evaluation.outcome != RunOutcome::VerifiedWithWaivers
160 && !has_machine_checkable_task_evidence(contract, proof, &evaluation.criteria)
161 {
162 push_warning(
163 &mut issues,
164 VerificationIssueCode::NoMachineCheckableTaskEvidence,
165 "no required criterion has qualifying tool, process, or git evidence"
166 .to_owned(),
167 None,
168 None,
169 );
170 }
171 Some(evaluation)
172 }
173 Err(error) => {
174 push_issue(
175 &mut issues,
176 VerificationIssueCode::EvaluationFailed,
177 format!("deterministic proof reduction failed: {error}"),
178 None,
179 None,
180 );
181 None
182 }
183 };
184
185 issues.sort_by(|left, right| {
186 (
187 left.severity,
188 left.code,
189 &left.criterion_id,
190 left.evidence_id,
191 &left.message,
192 )
193 .cmp(&(
194 right.severity,
195 right.code,
196 &right.criterion_id,
197 right.evidence_id,
198 &right.message,
199 ))
200 });
201 let valid = !issues
202 .iter()
203 .any(|issue| issue.severity == VerificationSeverity::Error);
204
205 VerificationReport {
206 schema_version: SCHEMA_VERSION.to_owned(),
207 contract_id: contract.id,
208 valid,
209 claim_scope: contract.claim_scope,
210 assurance_level: evaluation
211 .as_ref()
212 .map(|evaluation| evaluation.assurance_level),
213 outcome: evaluation.as_ref().map(|evaluation| evaluation.outcome),
214 criteria: evaluation
215 .map(|evaluation| evaluation.criteria)
216 .unwrap_or_default(),
217 issues,
218 }
219}
220
221fn has_machine_checkable_task_evidence(
222 contract: &TaskContract,
223 proof: &ProofGraph,
224 evaluations: &[CriterionEvaluation],
225) -> bool {
226 contract
227 .criteria
228 .iter()
229 .filter(|criterion| criterion.required && criterion.state == CriterionState::Passed)
230 .filter_map(|criterion| {
231 evaluations
232 .iter()
233 .find(|evaluation| evaluation.criterion_id == criterion.id)
234 })
235 .flat_map(|evaluation| evaluation.qualifying_evidence_ids.iter())
236 .filter_map(|id| proof.evidence.get(id))
237 .any(|evidence| {
238 matches!(
239 evidence.kind,
240 proofborne_core::EvidenceKind::Tool
241 | proofborne_core::EvidenceKind::Process
242 | proofborne_core::EvidenceKind::Git
243 )
244 })
245}
246
247fn validate_versions(
248 contract: &TaskContract,
249 proof: &ProofGraph,
250 issues: &mut Vec<VerificationIssue>,
251) {
252 if contract.schema_version != SCHEMA_VERSION {
253 push_issue(
254 issues,
255 VerificationIssueCode::UnsupportedSchema,
256 format!(
257 "contract schema {} is not supported; expected {SCHEMA_VERSION}",
258 contract.schema_version
259 ),
260 None,
261 None,
262 );
263 }
264 if proof.schema_version != SCHEMA_VERSION {
265 push_issue(
266 issues,
267 VerificationIssueCode::UnsupportedSchema,
268 format!(
269 "proof schema {} is not supported; expected {SCHEMA_VERSION}",
270 proof.schema_version
271 ),
272 None,
273 None,
274 );
275 }
276}
277
278fn validate_contract(contract: &TaskContract, issues: &mut Vec<VerificationIssue>) {
279 if contract.id.is_nil() {
280 push_issue(
281 issues,
282 VerificationIssueCode::ContractMismatch,
283 "contract identifier must not be nil".to_owned(),
284 None,
285 None,
286 );
287 }
288 if let Err(error) = contract.validate() {
289 push_issue(
290 issues,
291 VerificationIssueCode::InvalidContract,
292 error.to_string(),
293 None,
294 None,
295 );
296 }
297 if !contract.confirmed {
298 push_issue(
299 issues,
300 VerificationIssueCode::IncompleteDecision,
301 "finalized proof requires a confirmed task contract".to_owned(),
302 None,
303 None,
304 );
305 }
306 for criterion in &contract.criteria {
307 match (criterion.state, criterion.waiver.as_ref()) {
308 (CriterionState::Waived, None) => push_issue(
309 issues,
310 VerificationIssueCode::IncompleteDecision,
311 "waived criterion has no waiver metadata".to_owned(),
312 Some(criterion.id.clone()),
313 None,
314 ),
315 (CriterionState::Waived, Some(waiver))
316 if waiver.actor.trim().is_empty() || waiver.reason.trim().is_empty() =>
317 {
318 push_issue(
319 issues,
320 VerificationIssueCode::IncompleteDecision,
321 "waiver actor and reason must not be empty".to_owned(),
322 Some(criterion.id.clone()),
323 None,
324 );
325 }
326 (
327 CriterionState::Pending | CriterionState::Passed | CriterionState::Failed,
328 Some(_),
329 ) => {
330 push_issue(
331 issues,
332 VerificationIssueCode::IncompleteDecision,
333 "non-waived criterion contains waiver metadata".to_owned(),
334 Some(criterion.id.clone()),
335 None,
336 );
337 }
338 _ => {}
339 }
340 }
341}
342
343fn validate_graph(
344 contract: &TaskContract,
345 proof: &ProofGraph,
346 issues: &mut Vec<VerificationIssue>,
347) {
348 if proof.contract_id != contract.id || proof.contract_id.is_nil() {
349 push_issue(
350 issues,
351 VerificationIssueCode::ContractMismatch,
352 "proof graph and contract identifiers do not agree".to_owned(),
353 None,
354 None,
355 );
356 }
357 if proof.final_state_binding.is_some() && proof.final_workspace_generation.is_none() {
358 push_issue(
359 issues,
360 VerificationIssueCode::IncompleteStateBinding,
361 "final state binding requires a final workspace generation".to_owned(),
362 None,
363 None,
364 );
365 }
366 if proof
367 .final_state_binding
368 .as_ref()
369 .is_some_and(|binding| binding.trim().is_empty())
370 {
371 push_issue(
372 issues,
373 VerificationIssueCode::IncompleteStateBinding,
374 "final state binding must not be empty".to_owned(),
375 None,
376 None,
377 );
378 }
379 if let Some(termination) = &proof.termination {
380 let reason = match termination {
381 ProofTermination::Blocked { reason } | ProofTermination::Cancelled { reason } => reason,
382 };
383 if reason.trim().is_empty() {
384 push_issue(
385 issues,
386 VerificationIssueCode::IncompleteDecision,
387 "proof termination reason must not be empty".to_owned(),
388 None,
389 None,
390 );
391 }
392 }
393
394 let criterion_ids: BTreeSet<_> = contract
395 .criteria
396 .iter()
397 .map(|criterion| criterion.id.as_str())
398 .collect();
399 let mut evidence_criteria: BTreeMap<Uuid, BTreeSet<&str>> = BTreeMap::new();
400 let mut seen_links = BTreeSet::new();
401 for link in &proof.links {
402 if !proof.evidence.contains_key(&link.evidence_id) {
403 push_issue(
404 issues,
405 VerificationIssueCode::UnknownEvidenceLink,
406 "proof link references missing evidence".to_owned(),
407 Some(link.criterion_id.clone()),
408 Some(link.evidence_id),
409 );
410 }
411 if !criterion_ids.contains(link.criterion_id.as_str()) {
412 push_issue(
413 issues,
414 VerificationIssueCode::UnknownCriterionLink,
415 "proof link references an unknown criterion".to_owned(),
416 Some(link.criterion_id.clone()),
417 Some(link.evidence_id),
418 );
419 }
420 if !seen_links.insert((link.evidence_id, link.criterion_id.as_str())) {
421 push_issue(
422 issues,
423 VerificationIssueCode::DuplicateLink,
424 "evidence-to-criterion link occurs more than once".to_owned(),
425 Some(link.criterion_id.clone()),
426 Some(link.evidence_id),
427 );
428 }
429 evidence_criteria
430 .entry(link.evidence_id)
431 .or_default()
432 .insert(link.criterion_id.as_str());
433 }
434
435 for (key, evidence) in &proof.evidence {
436 if *key != evidence.id {
437 push_issue(
438 issues,
439 VerificationIssueCode::EvidenceKeyMismatch,
440 format!(
441 "evidence map key {key} does not match embedded id {}",
442 evidence.id
443 ),
444 None,
445 Some(*key),
446 );
447 }
448 if evidence.producer.trim().is_empty()
449 || evidence.summary.trim().is_empty()
450 || evidence
451 .action_id
452 .as_ref()
453 .is_some_and(|action| action.trim().is_empty())
454 {
455 push_issue(
456 issues,
457 VerificationIssueCode::EmptyIdentity,
458 "evidence producer, summary, and present action id must not be empty".to_owned(),
459 None,
460 Some(*key),
461 );
462 }
463 if evidence.completed_at < evidence.started_at {
464 push_issue(
465 issues,
466 VerificationIssueCode::TimestampOrder,
467 "evidence completed before it started".to_owned(),
468 None,
469 Some(*key),
470 );
471 }
472 if evidence.attempt_id.is_some() != evidence.attempt_sequence.is_some() {
473 push_issue(
474 issues,
475 VerificationIssueCode::IncompleteAttempt,
476 "attemptId and attemptSequence must be recorded together".to_owned(),
477 None,
478 Some(*key),
479 );
480 }
481 if evidence.state_binding.is_some() && evidence.workspace_generation.is_none() {
482 push_issue(
483 issues,
484 VerificationIssueCode::IncompleteStateBinding,
485 "evidence state binding requires a workspace generation".to_owned(),
486 None,
487 Some(*key),
488 );
489 }
490 if evidence
491 .state_binding
492 .as_ref()
493 .is_some_and(|binding| binding.trim().is_empty())
494 {
495 push_issue(
496 issues,
497 VerificationIssueCode::IncompleteStateBinding,
498 "evidence state binding must not be empty".to_owned(),
499 None,
500 Some(*key),
501 );
502 }
503
504 let mut seen_supersessions = BTreeSet::new();
505 for predecessor_id in &evidence.supersedes {
506 if !seen_supersessions.insert(*predecessor_id) {
507 push_issue(
508 issues,
509 VerificationIssueCode::DuplicateSupersession,
510 "superseded evidence identifier occurs more than once".to_owned(),
511 None,
512 Some(*key),
513 );
514 }
515 if predecessor_id == key || predecessor_id == &evidence.id {
516 push_issue(
517 issues,
518 VerificationIssueCode::SelfSupersession,
519 "evidence cannot supersede itself".to_owned(),
520 None,
521 Some(*key),
522 );
523 continue;
524 }
525 let Some(predecessor) = proof.evidence.get(predecessor_id) else {
526 push_issue(
527 issues,
528 VerificationIssueCode::UnknownSupersession,
529 format!("superseded evidence {predecessor_id} is not present"),
530 None,
531 Some(*key),
532 );
533 continue;
534 };
535 if !is_strictly_later(evidence, predecessor) {
536 push_issue(
537 issues,
538 VerificationIssueCode::SupersessionNotLater,
539 "supersession requires distinct attempts and a greater attempt sequence"
540 .to_owned(),
541 None,
542 Some(*key),
543 );
544 }
545 let successor_criteria = evidence_criteria.get(key);
546 let predecessor_criteria = evidence_criteria.get(predecessor_id);
547 let shares_criterion =
548 successor_criteria
549 .zip(predecessor_criteria)
550 .is_some_and(|(left, right)| {
551 left.iter().any(|criterion| right.contains(criterion))
552 });
553 if !shares_criterion {
554 push_issue(
555 issues,
556 VerificationIssueCode::SupersessionWithoutCommonCriterion,
557 "superseding evidence and predecessor share no linked criterion".to_owned(),
558 None,
559 Some(*key),
560 );
561 }
562 }
563 }
564}
565
566fn is_strictly_later(
567 successor: &proofborne_core::Evidence,
568 predecessor: &proofborne_core::Evidence,
569) -> bool {
570 matches!(
571 (
572 successor.attempt_id,
573 successor.attempt_sequence,
574 predecessor.attempt_id,
575 predecessor.attempt_sequence,
576 ),
577 (
578 Some(successor_id),
579 Some(successor_sequence),
580 Some(predecessor_id),
581 Some(predecessor_sequence),
582 ) if successor_id != predecessor_id && successor_sequence > predecessor_sequence
583 )
584}
585
586fn validate_persisted_derivation(
587 original: &TaskContract,
588 reduced: &TaskContract,
589 issues: &mut Vec<VerificationIssue>,
590) {
591 for (original_criterion, reduced_criterion) in original.criteria.iter().zip(&reduced.criteria) {
592 if original_criterion.state != reduced_criterion.state {
593 push_issue(
594 issues,
595 VerificationIssueCode::CriterionStateMismatch,
596 format!(
597 "persisted state {:?} recomputes to {:?}",
598 original_criterion.state, reduced_criterion.state
599 ),
600 Some(original_criterion.id.clone()),
601 None,
602 );
603 }
604 let original_ids: BTreeSet<_> = original_criterion.evidence_ids.iter().copied().collect();
605 let reduced_ids: BTreeSet<_> = reduced_criterion.evidence_ids.iter().copied().collect();
606 if original_ids != reduced_ids {
607 push_issue(
608 issues,
609 VerificationIssueCode::CriterionEvidenceMismatch,
610 "persisted evidenceIds differ from deterministic graph links".to_owned(),
611 Some(original_criterion.id.clone()),
612 None,
613 );
614 }
615 }
616}
617
618fn push_issue(
619 issues: &mut Vec<VerificationIssue>,
620 code: VerificationIssueCode,
621 message: String,
622 criterion_id: Option<String>,
623 evidence_id: Option<Uuid>,
624) {
625 issues.push(VerificationIssue {
626 code,
627 severity: VerificationSeverity::Error,
628 message,
629 criterion_id,
630 evidence_id,
631 });
632}
633
634fn push_warning(
635 issues: &mut Vec<VerificationIssue>,
636 code: VerificationIssueCode,
637 message: String,
638 criterion_id: Option<String>,
639 evidence_id: Option<Uuid>,
640) {
641 issues.push(VerificationIssue {
642 code,
643 severity: VerificationSeverity::Warning,
644 message,
645 criterion_id,
646 evidence_id,
647 });
648}
649
650#[cfg(test)]
651mod tests {
652 use chrono::Duration;
653 use proofborne_core::{Criterion, Evidence, EvidenceFreshness, EvidenceKind};
654 use serde_json::to_value;
655
656 use super::*;
657
658 fn finalized_task() -> (TaskContract, ProofGraph, Uuid) {
659 let mut criterion = Criterion::required("tests", "tests pass");
660 criterion.evidence_requirement.allowed_producers =
661 ["proofborne.verify".to_owned()].into_iter().collect();
662 criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
663 let mut contract = TaskContract::new("prove the test", vec![criterion]);
664 contract.confirmed = true;
665 let mut proof = ProofGraph::new(contract.id);
666 proof.bind_final_workspace(1, None);
667 let evidence = Evidence::observed(
668 EvidenceKind::Process,
669 "proofborne.verify",
670 "test passed",
671 None,
672 None,
673 Some(0),
674 true,
675 )
676 .bound_to_workspace(1, None);
677 let evidence_id = proof.record(evidence);
678 proof.link(&contract, evidence_id, "tests", None).unwrap();
679 proof.evaluate(&mut contract).unwrap();
680 (contract, proof, evidence_id)
681 }
682
683 #[test]
684 fn valid_task_proof_returns_structured_report() {
685 let (contract, proof, evidence_id) = finalized_task();
686 let report = verify(&contract, &proof);
687 assert!(report.valid);
688 assert!(report.proves_task());
689 assert_eq!(report.claim_scope, ClaimScope::Task);
690 assert_eq!(report.outcome, Some(RunOutcome::Verified));
691 assert_eq!(report.assurance_level, Some(AssuranceLevel::Observed));
692 assert_eq!(
693 report.criteria[0].qualifying_evidence_ids,
694 vec![evidence_id]
695 );
696 assert!(report.issues.is_empty());
697 }
698
699 #[test]
700 fn former_false_completion_path_is_runtime_only() {
701 let mut contract = TaskContract::automatic("model merely stopped");
702 let mut proof = ProofGraph::new(contract.id);
703 let evidence = Evidence::observed(
704 EvidenceKind::Runtime,
705 "proofborne.runtime",
706 "terminal turn",
707 None,
708 None,
709 Some(0),
710 true,
711 );
712 let id = proof.record(evidence);
713 proof
714 .link(&contract, id, "runtime_completed", None)
715 .unwrap();
716 proof.evaluate(&mut contract).unwrap();
717
718 let report = verify(&contract, &proof);
719 assert!(report.valid);
720 assert!(!report.is_verified());
721 assert!(!report.proves_task());
722 assert_eq!(report.claim_scope, ClaimScope::Runtime);
723 assert_eq!(report.outcome, Some(RunOutcome::Blocked));
724 assert!(report.issues.iter().any(|issue| {
725 issue.code == VerificationIssueCode::RuntimeClaimNotTaskCompletion
726 && issue.severity == VerificationSeverity::Warning
727 }));
728 }
729
730 #[test]
731 fn relabelled_auto_contract_is_invalid() {
732 let mut contract = TaskContract::automatic("model merely stopped");
733 contract.claim_scope = ClaimScope::Task;
734 let proof = ProofGraph::new(contract.id);
735 let report = verify(&contract, &proof);
736 assert!(!report.valid);
737 assert!(!report.proves_task());
738 assert!(report.issues.iter().any(|issue| {
739 issue.code == VerificationIssueCode::InvalidContract
740 || issue.code == VerificationIssueCode::EvaluationFailed
741 }));
742 }
743
744 #[test]
745 fn unknown_link_and_stale_persisted_state_are_rejected() {
746 let (mut contract, mut proof, _) = finalized_task();
747 contract.criteria[0].state = CriterionState::Pending;
748 proof.links.push(proofborne_core::EvidenceLink {
749 evidence_id: Uuid::now_v7(),
750 criterion_id: "tests".to_owned(),
751 rationale: None,
752 });
753 let report = verify(&contract, &proof);
754 assert!(!report.valid);
755 assert!(
756 report
757 .issues
758 .iter()
759 .any(|issue| { issue.code == VerificationIssueCode::UnknownEvidenceLink })
760 );
761 assert!(
762 report
763 .issues
764 .iter()
765 .any(|issue| { issue.code == VerificationIssueCode::CriterionStateMismatch })
766 );
767 }
768
769 #[test]
770 fn invalid_supersession_and_timestamp_are_rejected() {
771 let (mut contract, mut proof, first_id) = finalized_task();
772 let attempt_id = Uuid::now_v7();
773 let first = proof.evidence.get_mut(&first_id).unwrap();
774 first.attempt_id = Some(attempt_id);
775 first.attempt_sequence = Some(2);
776 let mut successor = Evidence::observed(
777 EvidenceKind::Process,
778 "proofborne.verify",
779 "older result",
780 None,
781 None,
782 Some(0),
783 true,
784 )
785 .with_attempt(attempt_id, 1)
786 .superseding([first_id]);
787 successor.completed_at = successor.started_at - Duration::seconds(1);
788 let successor_id = proof.record(successor);
789 proof.link(&contract, successor_id, "tests", None).unwrap();
790 proof.evaluate(&mut contract).unwrap();
791
792 let report = verify(&contract, &proof);
793 assert!(!report.valid);
794 assert!(
795 report
796 .issues
797 .iter()
798 .any(|issue| { issue.code == VerificationIssueCode::SupersessionNotLater })
799 );
800 assert!(
801 report
802 .issues
803 .iter()
804 .any(|issue| issue.code == VerificationIssueCode::TimestampOrder)
805 );
806 }
807
808 #[test]
809 fn report_serialization_is_camel_case_and_deterministic() {
810 let (contract, proof, _) = finalized_task();
811 let first = to_value(verify(&contract, &proof)).unwrap();
812 let second = to_value(verify(&contract, &proof)).unwrap();
813 assert_eq!(first, second);
814 assert!(first.get("schemaVersion").is_some());
815 assert!(first.get("claimScope").is_some());
816 assert!(first.get("assuranceLevel").is_some());
817 }
818}