Skip to main content

proofborne_verifier/
lib.rs

1//! Provider-free semantic verification for Proofborne contracts and proof graphs.
2//!
3//! This crate performs no network access and executes no tools. It validates the
4//! captured proof structure, deterministically reduces the graph, and reports
5//! whether a result is allowed to represent task verification.
6
7use 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/// Complete, deterministic result of semantic proof verification.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "camelCase")]
19pub struct VerificationReport {
20    /// Report schema version.
21    pub schema_version: String,
22    /// Contract being verified.
23    pub contract_id: Uuid,
24    /// Whether no semantic error was found.
25    pub valid: bool,
26    /// Maximum claim the verified contract may make.
27    pub claim_scope: ClaimScope,
28    /// Lowest assurance among evidence supporting required passed criteria.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub assurance_level: Option<AssuranceLevel>,
31    /// Recomputed completion outcome, absent when reduction was impossible.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub outcome: Option<RunOutcome>,
34    /// Per-criterion reduction details, including active counterevidence.
35    #[serde(default)]
36    pub criteria: Vec<CriterionEvaluation>,
37    /// Stable, machine-readable semantic findings.
38    #[serde(default)]
39    pub issues: Vec<VerificationIssue>,
40}
41
42impl VerificationReport {
43    /// Returns true only for a valid, task-scoped completion without waivers.
44    pub fn proves_task(&self) -> bool {
45        self.valid
46            && self.claim_scope == ClaimScope::Task
47            && self.outcome == Some(RunOutcome::Verified)
48    }
49
50    /// Returns true for either verified outcome within the report's declared scope.
51    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/// Severity of a semantic verification finding.
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
62#[serde(rename_all = "snake_case")]
63pub enum VerificationSeverity {
64    /// Reviewable condition that does not invalidate the captured proof.
65    Warning,
66    /// Semantic inconsistency that invalidates the captured proof.
67    Error,
68}
69
70/// Stable category for a semantic verification finding.
71#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
72#[serde(rename_all = "snake_case")]
73pub enum VerificationIssueCode {
74    /// Runtime observations cannot represent completed task work.
75    RuntimeClaimNotTaskCompletion,
76    /// No required criterion has qualifying machine-checkable task evidence.
77    NoMachineCheckableTaskEvidence,
78    /// Contract or proof graph uses an unsupported schema version.
79    UnsupportedSchema,
80    /// Contract validation failed.
81    InvalidContract,
82    /// Contract and proof graph identifiers differ or are nil.
83    ContractMismatch,
84    /// An evidence map key differs from the evidence object's identifier.
85    EvidenceKeyMismatch,
86    /// A link references evidence that is not present.
87    UnknownEvidenceLink,
88    /// A link references a criterion that is not present.
89    UnknownCriterionLink,
90    /// The same evidence-to-criterion edge occurs more than once.
91    DuplicateLink,
92    /// Evidence completed before it started.
93    TimestampOrder,
94    /// Attempt identifier and sequence were not recorded together.
95    IncompleteAttempt,
96    /// Action identifier or public evidence text is empty.
97    EmptyIdentity,
98    /// A state binding exists without a workspace generation.
99    IncompleteStateBinding,
100    /// The same superseded identifier occurs more than once.
101    DuplicateSupersession,
102    /// Supersession references evidence that is not present.
103    UnknownSupersession,
104    /// Evidence attempts to supersede itself.
105    SelfSupersession,
106    /// Supersession does not point from a strictly later attempt.
107    SupersessionNotLater,
108    /// Successor and predecessor do not support a common criterion.
109    SupersessionWithoutCommonCriterion,
110    /// Persisted criterion state differs from deterministic reduction.
111    CriterionStateMismatch,
112    /// Persisted criterion evidence identifiers differ from graph links.
113    CriterionEvidenceMismatch,
114    /// Termination or waiver metadata is structurally incomplete.
115    IncompleteDecision,
116    /// The core reducer could not evaluate the proof.
117    EvaluationFailed,
118}
119
120/// One semantic finding with optional criterion/evidence location.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "camelCase")]
123pub struct VerificationIssue {
124    /// Stable category.
125    pub code: VerificationIssueCode,
126    /// Finding severity.
127    pub severity: VerificationSeverity,
128    /// Reviewable explanation.
129    pub message: String,
130    /// Related criterion, when applicable.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub criterion_id: Option<String>,
133    /// Related evidence node, when applicable.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub evidence_id: Option<Uuid>,
136}
137
138/// Validates and deterministically reduces a captured contract and proof graph.
139pub 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}