1use std::collections::{BTreeMap, BTreeSet};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7use uuid::Uuid;
8
9use crate::{SCHEMA_VERSION, hash_bytes, hash_json};
10
11#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum ClaimScope {
15 #[default]
17 Runtime,
18 Task,
20}
21
22#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
24#[serde(rename_all = "snake_case")]
25pub enum AssuranceLevel {
26 #[default]
28 Recorded,
29 Observed,
31 Isolated,
33 IndependentlyReproduced,
35}
36
37#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum EvidenceFreshness {
41 #[default]
43 Any,
44 FinalWorkspaceGeneration,
46 FinalWorkspaceState,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct EvidenceRequirement {
54 #[serde(default)]
56 pub allowed_kinds: BTreeSet<EvidenceKind>,
57 #[serde(default)]
59 pub allowed_producers: BTreeSet<String>,
60 #[serde(default = "one")]
62 pub minimum_observations: u32,
63 #[serde(default)]
65 pub freshness: EvidenceFreshness,
66 #[serde(default)]
68 pub require_artifacts: bool,
69 #[serde(default = "one")]
71 pub minimum_independent_producers: u32,
72 #[serde(default)]
74 pub minimum_assurance: AssuranceLevel,
75}
76
77impl Default for EvidenceRequirement {
78 fn default() -> Self {
79 Self {
80 allowed_kinds: BTreeSet::new(),
81 allowed_producers: BTreeSet::new(),
82 minimum_observations: 1,
83 freshness: EvidenceFreshness::Any,
84 require_artifacts: false,
85 minimum_independent_producers: 1,
86 minimum_assurance: AssuranceLevel::Recorded,
87 }
88 }
89}
90
91impl EvidenceRequirement {
92 pub fn task() -> Self {
94 Self {
95 allowed_kinds: [EvidenceKind::Tool, EvidenceKind::Process, EvidenceKind::Git]
96 .into_iter()
97 .collect(),
98 minimum_assurance: AssuranceLevel::Observed,
99 ..Self::default()
100 }
101 }
102
103 pub fn runtime_completion() -> Self {
105 Self {
106 allowed_kinds: [EvidenceKind::Runtime].into_iter().collect(),
107 allowed_producers: ["proofborne.runtime".to_owned()].into_iter().collect(),
108 minimum_assurance: AssuranceLevel::Observed,
109 ..Self::default()
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116#[serde(rename_all = "camelCase")]
117pub struct TaskContract {
118 pub schema_version: String,
120 pub id: Uuid,
122 pub goal: String,
124 #[serde(default)]
126 pub claim_scope: ClaimScope,
127 #[serde(default)]
129 pub constraints: Vec<String>,
130 pub criteria: Vec<Criterion>,
132 pub created_at: DateTime<Utc>,
134 pub confirmed: bool,
136}
137
138impl TaskContract {
139 pub fn new(goal: impl Into<String>, criteria: Vec<Criterion>) -> Self {
141 Self {
142 schema_version: SCHEMA_VERSION.to_owned(),
143 id: Uuid::now_v7(),
144 goal: goal.into(),
145 claim_scope: ClaimScope::Task,
146 constraints: Vec::new(),
147 criteria,
148 created_at: Utc::now(),
149 confirmed: false,
150 }
151 }
152
153 pub fn automatic(goal: impl Into<String>) -> Self {
159 let mut runtime_criterion = Criterion::required(
160 "runtime_completed",
161 "The agent loop completed without an unhandled runtime error",
162 );
163 runtime_criterion.evidence_requirement = EvidenceRequirement::runtime_completion();
164 let mut contract = Self::new(goal, vec![runtime_criterion]);
165 contract.claim_scope = ClaimScope::Runtime;
166 contract.confirmed = true;
167 contract.constraints.push(
168 "Runtime completion evidence does not imply task or program correctness".to_owned(),
169 );
170 contract
171 }
172
173 pub fn validate(&self) -> Result<(), ProofError> {
175 if self.goal.trim().is_empty() {
176 return Err(ProofError::InvalidContract(
177 "goal must not be empty".to_owned(),
178 ));
179 }
180 if self.criteria.is_empty() {
181 return Err(ProofError::InvalidContract(
182 "at least one acceptance criterion is required".to_owned(),
183 ));
184 }
185 if !self.criteria.iter().any(|criterion| criterion.required) {
186 return Err(ProofError::InvalidContract(
187 "at least one required acceptance criterion is required".to_owned(),
188 ));
189 }
190
191 let mut ids = BTreeSet::new();
192 for criterion in &self.criteria {
193 if criterion.id.trim().is_empty() || criterion.description.trim().is_empty() {
194 return Err(ProofError::InvalidContract(
195 "criterion id and description must not be empty".to_owned(),
196 ));
197 }
198 if !ids.insert(&criterion.id) {
199 return Err(ProofError::DuplicateCriterion(criterion.id.clone()));
200 }
201 criterion.validate_requirement()?;
202 match (criterion.state, criterion.waiver.as_ref()) {
203 (CriterionState::Waived, Some(waiver))
204 if waiver.actor.trim().is_empty() || waiver.reason.trim().is_empty() =>
205 {
206 return Err(ProofError::InvalidContract(format!(
207 "criterion {} has incomplete waiver metadata",
208 criterion.id
209 )));
210 }
211 (CriterionState::Waived, None) => {
212 return Err(ProofError::InvalidContract(format!(
213 "criterion {} is waived without waiver metadata",
214 criterion.id
215 )));
216 }
217 (
218 CriterionState::Pending | CriterionState::Passed | CriterionState::Failed,
219 Some(_),
220 ) => {
221 return Err(ProofError::InvalidContract(format!(
222 "criterion {} has waiver metadata but is not waived",
223 criterion.id
224 )));
225 }
226 _ => {}
227 }
228 }
229
230 if self.claim_scope == ClaimScope::Task {
231 if !self
232 .criteria
233 .iter()
234 .filter(|criterion| criterion.required)
235 .any(|criterion| {
236 !criterion.evidence_requirement.allowed_kinds.is_empty()
237 && criterion
238 .evidence_requirement
239 .allowed_kinds
240 .iter()
241 .any(|kind| {
242 matches!(
243 kind,
244 EvidenceKind::Tool | EvidenceKind::Process | EvidenceKind::Git
245 )
246 })
247 })
248 {
249 return Err(ProofError::InvalidContract(
250 "a task-scoped contract requires task-capable evidence; runtime-only criteria cannot prove a task"
251 .to_owned(),
252 ));
253 }
254
255 for criterion in self.criteria.iter().filter(|criterion| criterion.required) {
256 if criterion.evidence_requirement.allowed_producers.is_empty() {
257 return Err(ProofError::InvalidContract(format!(
258 "required task criterion {} must name at least one allowed producer",
259 criterion.id
260 )));
261 }
262 if criterion.evidence_requirement.freshness == EvidenceFreshness::Any {
263 return Err(ProofError::InvalidContract(format!(
264 "required task criterion {} must bind evidence to the final workspace generation or state",
265 criterion.id
266 )));
267 }
268 }
269 }
270 Ok(())
271 }
272
273 pub fn waive(
275 &mut self,
276 criterion_id: &str,
277 actor: impl Into<String>,
278 reason: impl Into<String>,
279 ) -> Result<(), ProofError> {
280 let criterion = self
281 .criteria
282 .iter_mut()
283 .find(|criterion| criterion.id == criterion_id)
284 .ok_or_else(|| ProofError::UnknownCriterion(criterion_id.to_owned()))?;
285 let actor = actor.into();
286 if actor.trim().is_empty() {
287 return Err(ProofError::InvalidContract(
288 "waiver actor must not be empty".to_owned(),
289 ));
290 }
291 let reason = reason.into();
292 if reason.trim().is_empty() {
293 return Err(ProofError::EmptyWaiverReason);
294 }
295 criterion.state = CriterionState::Waived;
296 criterion.waiver = Some(Waiver {
297 actor,
298 reason,
299 at: Utc::now(),
300 });
301 Ok(())
302 }
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307#[serde(rename_all = "camelCase")]
308pub struct Criterion {
309 pub id: String,
311 pub description: String,
313 pub required: bool,
315 #[serde(default = "one")]
320 pub minimum_evidence: u32,
321 #[serde(default)]
323 pub evidence_requirement: EvidenceRequirement,
324 pub state: CriterionState,
326 #[serde(default)]
328 pub evidence_ids: Vec<Uuid>,
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub waiver: Option<Waiver>,
332}
333
334impl Criterion {
335 pub fn required(id: impl Into<String>, description: impl Into<String>) -> Self {
337 Self {
338 id: id.into(),
339 description: description.into(),
340 required: true,
341 minimum_evidence: 1,
342 evidence_requirement: EvidenceRequirement::task(),
343 state: CriterionState::Pending,
344 evidence_ids: Vec::new(),
345 waiver: None,
346 }
347 }
348
349 pub fn optional(id: impl Into<String>, description: impl Into<String>) -> Self {
351 Self {
352 required: false,
353 ..Self::required(id, description)
354 }
355 }
356
357 fn validate_requirement(&self) -> Result<(), ProofError> {
358 let requirement = &self.evidence_requirement;
359 if self.minimum_evidence == 0 || requirement.minimum_observations == 0 {
360 return Err(ProofError::InvalidContract(format!(
361 "criterion {} requires at least one observation",
362 self.id
363 )));
364 }
365 if requirement.minimum_independent_producers == 0 {
366 return Err(ProofError::InvalidContract(format!(
367 "criterion {} requires at least one independent producer",
368 self.id
369 )));
370 }
371 if requirement
372 .allowed_producers
373 .iter()
374 .any(|producer| producer.trim().is_empty())
375 {
376 return Err(ProofError::InvalidContract(format!(
377 "criterion {} contains an empty allowed producer",
378 self.id
379 )));
380 }
381 Ok(())
382 }
383}
384
385const fn one() -> u32 {
386 1
387}
388
389#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
391#[serde(rename_all = "snake_case")]
392pub enum CriterionState {
393 Pending,
395 Passed,
397 Failed,
399 Waived,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
405#[serde(rename_all = "camelCase")]
406pub struct Waiver {
407 pub actor: String,
409 pub reason: String,
411 pub at: DateTime<Utc>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
417#[serde(rename_all = "camelCase")]
418pub struct Evidence {
419 pub id: Uuid,
421 pub kind: EvidenceKind,
423 pub producer: String,
425 pub summary: String,
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub input_hash: Option<String>,
430 #[serde(skip_serializing_if = "Option::is_none")]
432 pub output_hash: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
435 pub exit_code: Option<i32>,
436 pub success: bool,
438 pub started_at: DateTime<Utc>,
440 pub completed_at: DateTime<Utc>,
442 #[serde(default)]
444 pub artifacts: Vec<ArtifactRef>,
445 pub redacted: bool,
447 #[serde(default)]
449 pub metadata: Value,
450 #[serde(default)]
452 pub assurance_level: AssuranceLevel,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub attempt_id: Option<Uuid>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub attempt_sequence: Option<u32>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub action_id: Option<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub workspace_generation: Option<u64>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub state_binding: Option<String>,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 pub supersedes: Vec<Uuid>,
471}
472
473impl Evidence {
474 pub fn observed(
476 kind: EvidenceKind,
477 producer: impl Into<String>,
478 summary: impl Into<String>,
479 input: Option<&Value>,
480 output: Option<&[u8]>,
481 exit_code: Option<i32>,
482 success: bool,
483 ) -> Self {
484 let now = Utc::now();
485 Self {
486 id: Uuid::now_v7(),
487 kind,
488 producer: producer.into(),
489 summary: summary.into(),
490 input_hash: input.map(hash_json),
491 output_hash: output.map(hash_bytes),
492 exit_code,
493 success,
494 started_at: now,
495 completed_at: now,
496 artifacts: Vec::new(),
497 redacted: false,
498 metadata: Value::Object(serde_json::Map::new()),
499 assurance_level: AssuranceLevel::Observed,
500 attempt_id: None,
501 attempt_sequence: None,
502 action_id: None,
503 workspace_generation: None,
504 state_binding: None,
505 supersedes: Vec::new(),
506 }
507 }
508
509 #[must_use]
511 pub fn with_attempt(mut self, attempt_id: Uuid, sequence: u32) -> Self {
512 self.attempt_id = Some(attempt_id);
513 self.attempt_sequence = Some(sequence);
514 self
515 }
516
517 #[must_use]
519 pub fn with_action(mut self, action_id: impl Into<String>) -> Self {
520 self.action_id = Some(action_id.into());
521 self
522 }
523
524 #[must_use]
526 pub fn bound_to_workspace(mut self, generation: u64, state_binding: Option<String>) -> Self {
527 self.workspace_generation = Some(generation);
528 self.state_binding = state_binding;
529 self
530 }
531
532 #[must_use]
534 pub fn with_assurance(mut self, assurance_level: AssuranceLevel) -> Self {
535 self.assurance_level = assurance_level;
536 self
537 }
538
539 #[must_use]
541 pub fn superseding(mut self, evidence_ids: impl IntoIterator<Item = Uuid>) -> Self {
542 self.supersedes = evidence_ids.into_iter().collect();
543 self
544 }
545}
546
547#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
549#[serde(rename_all = "snake_case")]
550pub enum EvidenceKind {
551 Tool,
553 Process,
555 Runtime,
557 Git,
559 Human,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
565#[serde(rename_all = "camelCase")]
566pub struct ArtifactRef {
567 pub hash: String,
569 pub name: String,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub media_type: Option<String>,
574 pub size: u64,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
580#[serde(rename_all = "camelCase")]
581pub struct EvidenceLink {
582 pub evidence_id: Uuid,
584 pub criterion_id: String,
586 #[serde(skip_serializing_if = "Option::is_none")]
588 pub rationale: Option<String>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
593#[serde(rename_all = "camelCase")]
594pub struct CriterionEvaluation {
595 pub criterion_id: String,
597 pub state: CriterionState,
599 pub evidence_ids: Vec<Uuid>,
601 pub active_evidence_ids: Vec<Uuid>,
603 pub qualifying_evidence_ids: Vec<Uuid>,
605 pub unresolved_counterevidence_ids: Vec<Uuid>,
607 pub unmet_requirements: Vec<String>,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
613#[serde(rename_all = "camelCase")]
614pub struct ProofEvaluation {
615 pub claim_scope: ClaimScope,
617 pub assurance_level: AssuranceLevel,
619 pub outcome: RunOutcome,
621 pub criteria: Vec<CriterionEvaluation>,
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
627#[serde(rename_all = "camelCase")]
628pub struct ProofGraph {
629 pub schema_version: String,
631 pub contract_id: Uuid,
633 pub evidence: BTreeMap<Uuid, Evidence>,
635 pub links: Vec<EvidenceLink>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub final_workspace_generation: Option<u64>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub final_state_binding: Option<String>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub termination: Option<ProofTermination>,
646}
647
648impl ProofGraph {
649 pub fn new(contract_id: Uuid) -> Self {
651 Self {
652 schema_version: SCHEMA_VERSION.to_owned(),
653 contract_id,
654 evidence: BTreeMap::new(),
655 links: Vec::new(),
656 final_workspace_generation: None,
657 final_state_binding: None,
658 termination: None,
659 }
660 }
661
662 pub fn bind_final_workspace(&mut self, generation: u64, state_binding: Option<String>) {
664 self.final_workspace_generation = Some(generation);
665 self.final_state_binding = state_binding;
666 }
667
668 pub fn block(&mut self, reason: impl Into<String>) {
670 self.termination = Some(ProofTermination::Blocked {
671 reason: reason.into(),
672 });
673 }
674
675 pub fn cancel(&mut self, reason: impl Into<String>) {
677 self.termination = Some(ProofTermination::Cancelled {
678 reason: reason.into(),
679 });
680 }
681
682 pub fn record(&mut self, evidence: Evidence) -> Uuid {
684 let id = evidence.id;
685 self.evidence.insert(id, evidence);
686 id
687 }
688
689 pub fn link(
691 &mut self,
692 contract: &TaskContract,
693 evidence_id: Uuid,
694 criterion_id: impl Into<String>,
695 rationale: Option<String>,
696 ) -> Result<(), ProofError> {
697 if !self.evidence.contains_key(&evidence_id) {
698 return Err(ProofError::UnknownEvidence(evidence_id));
699 }
700 let criterion_id = criterion_id.into();
701 if !contract
702 .criteria
703 .iter()
704 .any(|criterion| criterion.id == criterion_id)
705 {
706 return Err(ProofError::UnknownCriterion(criterion_id));
707 }
708 if !self
709 .links
710 .iter()
711 .any(|link| link.evidence_id == evidence_id && link.criterion_id == criterion_id)
712 {
713 self.links.push(EvidenceLink {
714 evidence_id,
715 criterion_id,
716 rationale,
717 });
718 }
719 Ok(())
720 }
721
722 pub fn evaluate_detailed(
724 &self,
725 contract: &mut TaskContract,
726 ) -> Result<ProofEvaluation, ProofError> {
727 if self.contract_id != contract.id {
728 return Err(ProofError::ContractMismatch);
729 }
730 contract.validate()?;
731
732 let mut evaluations = Vec::with_capacity(contract.criteria.len());
733 for criterion in &mut contract.criteria {
734 if criterion.state == CriterionState::Waived {
735 let mut evaluation = self.evaluate_criterion(criterion);
736 evaluation.state = CriterionState::Waived;
737 criterion.evidence_ids.clone_from(&evaluation.evidence_ids);
738 evaluations.push(evaluation);
739 continue;
740 }
741
742 let evaluation = self.evaluate_criterion(criterion);
743 criterion.state = evaluation.state;
744 criterion.evidence_ids.clone_from(&evaluation.evidence_ids);
745 evaluations.push(evaluation);
746 }
747
748 let mut outcome = if let Some(termination) = &self.termination {
749 match termination {
750 ProofTermination::Blocked { .. } => RunOutcome::Blocked,
751 ProofTermination::Cancelled { .. } => RunOutcome::Cancelled,
752 }
753 } else {
754 outcome_for(contract.claim_scope, &contract.criteria)
755 };
756
757 if contract.claim_scope == ClaimScope::Task
758 && outcome == RunOutcome::Verified
759 && !has_machine_checkable_task_evidence(contract, &evaluations, &self.evidence)
760 {
761 outcome = RunOutcome::Blocked;
762 }
763
764 let assurance_level = contract
765 .criteria
766 .iter()
767 .filter(|criterion| criterion.required && criterion.state == CriterionState::Passed)
768 .filter_map(|criterion| {
769 evaluations
770 .iter()
771 .find(|evaluation| evaluation.criterion_id == criterion.id)
772 })
773 .flat_map(|evaluation| evaluation.qualifying_evidence_ids.iter())
774 .filter_map(|id| self.evidence.get(id))
775 .map(|evidence| evidence.assurance_level)
776 .min()
777 .unwrap_or(AssuranceLevel::Recorded);
778
779 Ok(ProofEvaluation {
780 claim_scope: contract.claim_scope,
781 assurance_level,
782 outcome,
783 criteria: evaluations,
784 })
785 }
786
787 pub fn evaluate(&self, contract: &mut TaskContract) -> Result<RunOutcome, ProofError> {
789 self.evaluate_detailed(contract)
790 .map(|evaluation| evaluation.outcome)
791 }
792
793 fn evaluate_criterion(&self, criterion: &Criterion) -> CriterionEvaluation {
794 let evidence_ids: Vec<_> = self
795 .links
796 .iter()
797 .filter(|link| link.criterion_id == criterion.id)
798 .map(|link| link.evidence_id)
799 .collect::<BTreeSet<_>>()
800 .into_iter()
801 .collect();
802 let linked_ids: BTreeSet<_> = evidence_ids.iter().copied().collect();
803 let superseded: BTreeSet<_> = evidence_ids
804 .iter()
805 .filter_map(|successor_id| self.evidence.get(successor_id))
806 .flat_map(|successor| {
807 successor
808 .supersedes
809 .iter()
810 .filter(|predecessor_id| {
811 linked_ids.contains(predecessor_id)
812 && valid_supersession(
813 successor,
814 self.evidence
815 .get(predecessor_id)
816 .expect("linked evidence exists"),
817 )
818 })
819 .copied()
820 })
821 .collect();
822 let active_evidence_ids: Vec<_> = evidence_ids
823 .iter()
824 .filter(|id| !superseded.contains(id))
825 .copied()
826 .collect();
827 let unresolved_counterevidence_ids: Vec<_> = active_evidence_ids
828 .iter()
829 .filter(|id| {
830 self.evidence
831 .get(id)
832 .is_some_and(|evidence| !evidence.success)
833 })
834 .copied()
835 .collect();
836 let qualifying_evidence_ids: Vec<_> = active_evidence_ids
837 .iter()
838 .filter(|id| {
839 self.evidence.get(id).is_some_and(|evidence| {
840 evidence.success
841 && evidence_qualifies(
842 evidence,
843 &criterion.evidence_requirement,
844 self.final_workspace_generation,
845 self.final_state_binding.as_deref(),
846 )
847 })
848 })
849 .copied()
850 .collect();
851
852 let minimum_observations = criterion
853 .minimum_evidence
854 .max(criterion.evidence_requirement.minimum_observations);
855 let distinct_producers = qualifying_evidence_ids
856 .iter()
857 .filter_map(|id| self.evidence.get(id))
858 .map(|evidence| evidence.producer.as_str())
859 .collect::<BTreeSet<_>>()
860 .len();
861 let mut unmet_requirements = Vec::new();
862 if qualifying_evidence_ids.len()
863 < usize::try_from(minimum_observations).unwrap_or(usize::MAX)
864 {
865 unmet_requirements.push(format!(
866 "requires {minimum_observations} qualifying observations, found {}",
867 qualifying_evidence_ids.len()
868 ));
869 }
870 if distinct_producers
871 < usize::try_from(criterion.evidence_requirement.minimum_independent_producers)
872 .unwrap_or(usize::MAX)
873 {
874 unmet_requirements.push(format!(
875 "requires {} independent producers, found {distinct_producers}",
876 criterion.evidence_requirement.minimum_independent_producers
877 ));
878 }
879 if !unresolved_counterevidence_ids.is_empty() {
880 unmet_requirements.push(format!(
881 "{} unresolved counterevidence observations remain",
882 unresolved_counterevidence_ids.len()
883 ));
884 }
885
886 let state = if !unresolved_counterevidence_ids.is_empty() {
887 CriterionState::Failed
888 } else if unmet_requirements.is_empty() {
889 CriterionState::Passed
890 } else {
891 CriterionState::Pending
892 };
893
894 CriterionEvaluation {
895 criterion_id: criterion.id.clone(),
896 state,
897 evidence_ids,
898 active_evidence_ids,
899 qualifying_evidence_ids,
900 unresolved_counterevidence_ids,
901 unmet_requirements,
902 }
903 }
904}
905
906fn outcome_for(claim_scope: ClaimScope, criteria: &[Criterion]) -> RunOutcome {
907 let required: Vec<_> = criteria
908 .iter()
909 .filter(|criterion| criterion.required)
910 .collect();
911 if required
912 .iter()
913 .any(|criterion| criterion.state == CriterionState::Failed)
914 {
915 return RunOutcome::Failed;
916 }
917 if required
918 .iter()
919 .any(|criterion| criterion.state == CriterionState::Pending)
920 {
921 return RunOutcome::Blocked;
922 }
923 if claim_scope == ClaimScope::Runtime {
927 return RunOutcome::Blocked;
928 }
929 if required
930 .iter()
931 .any(|criterion| criterion.state == CriterionState::Waived)
932 {
933 return RunOutcome::VerifiedWithWaivers;
934 }
935 RunOutcome::Verified
936}
937
938fn has_machine_checkable_task_evidence(
939 contract: &TaskContract,
940 evaluations: &[CriterionEvaluation],
941 evidence: &BTreeMap<Uuid, Evidence>,
942) -> bool {
943 contract
944 .criteria
945 .iter()
946 .filter(|criterion| criterion.required && criterion.state == CriterionState::Passed)
947 .filter_map(|criterion| {
948 evaluations
949 .iter()
950 .find(|evaluation| evaluation.criterion_id == criterion.id)
951 })
952 .flat_map(|evaluation| evaluation.qualifying_evidence_ids.iter())
953 .filter_map(|id| evidence.get(id))
954 .any(|evidence| {
955 matches!(
956 evidence.kind,
957 EvidenceKind::Tool | EvidenceKind::Process | EvidenceKind::Git
958 )
959 })
960}
961
962fn valid_supersession(successor: &Evidence, predecessor: &Evidence) -> bool {
963 if successor.id == predecessor.id {
964 return false;
965 }
966 matches!(
967 (
968 successor.attempt_id,
969 successor.attempt_sequence,
970 predecessor.attempt_id,
971 predecessor.attempt_sequence,
972 ),
973 (
974 Some(successor_id),
975 Some(successor_sequence),
976 Some(predecessor_id),
977 Some(predecessor_sequence),
978 ) if successor_id != predecessor_id && successor_sequence > predecessor_sequence
979 )
980}
981
982fn evidence_qualifies(
983 evidence: &Evidence,
984 requirement: &EvidenceRequirement,
985 final_workspace_generation: Option<u64>,
986 final_state_binding: Option<&str>,
987) -> bool {
988 if !requirement.allowed_kinds.is_empty() && !requirement.allowed_kinds.contains(&evidence.kind)
989 {
990 return false;
991 }
992 if !requirement.allowed_producers.is_empty()
993 && !requirement.allowed_producers.contains(&evidence.producer)
994 {
995 return false;
996 }
997 if requirement.require_artifacts && evidence.artifacts.is_empty() {
998 return false;
999 }
1000 if evidence.assurance_level < requirement.minimum_assurance {
1001 return false;
1002 }
1003 match requirement.freshness {
1004 EvidenceFreshness::Any => true,
1005 EvidenceFreshness::FinalWorkspaceGeneration => {
1006 final_workspace_generation.is_some()
1007 && evidence.workspace_generation == final_workspace_generation
1008 }
1009 EvidenceFreshness::FinalWorkspaceState => {
1010 final_workspace_generation.is_some()
1011 && final_state_binding.is_some()
1012 && evidence.workspace_generation == final_workspace_generation
1013 && evidence.state_binding.as_deref() == final_state_binding
1014 }
1015 }
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1020#[serde(tag = "state", rename_all = "snake_case")]
1021pub enum ProofTermination {
1022 Blocked {
1024 reason: String,
1026 },
1027 Cancelled {
1029 reason: String,
1031 },
1032}
1033
1034#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1036#[serde(rename_all = "snake_case")]
1037pub enum RunOutcome {
1038 Verified,
1040 VerifiedWithWaivers,
1042 Failed,
1044 Blocked,
1046 Cancelled,
1048}
1049
1050#[derive(Debug, Error)]
1052pub enum ProofError {
1053 #[error("invalid task contract: {0}")]
1055 InvalidContract(String),
1056 #[error("duplicate criterion id: {0}")]
1058 DuplicateCriterion(String),
1059 #[error("unknown criterion: {0}")]
1061 UnknownCriterion(String),
1062 #[error("unknown evidence: {0}")]
1064 UnknownEvidence(Uuid),
1065 #[error("proof graph belongs to a different task contract")]
1067 ContractMismatch,
1068 #[error("waiver reason must not be empty")]
1070 EmptyWaiverReason,
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use proptest::prelude::*;
1076 use serde_json::json;
1077
1078 use super::*;
1079
1080 fn evidence(kind: EvidenceKind, producer: &str, success: bool) -> Evidence {
1081 Evidence::observed(kind, producer, "observation", None, None, Some(0), success)
1082 .bound_to_workspace(1, None)
1083 }
1084
1085 fn task_criterion(id: &str, description: &str, producers: &[&str]) -> Criterion {
1086 let mut criterion = Criterion::required(id, description);
1087 criterion.evidence_requirement.allowed_producers = producers
1088 .iter()
1089 .map(|producer| (*producer).to_owned())
1090 .collect();
1091 criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
1092 criterion
1093 }
1094
1095 fn link_one(contract: &TaskContract, graph: &mut ProofGraph, evidence: Evidence) -> Uuid {
1096 if contract.claim_scope == ClaimScope::Task && graph.final_workspace_generation.is_none() {
1097 graph.bind_final_workspace(1, None);
1098 }
1099 let id = graph.record(evidence);
1100 graph.link(contract, id, "criterion", None).unwrap();
1101 id
1102 }
1103
1104 #[test]
1105 fn automatic_contract_is_runtime_scoped_only() {
1106 let mut contract = TaskContract::automatic("model returned text");
1107 let mut graph = ProofGraph::new(contract.id);
1108 let id = graph.record(evidence(EvidenceKind::Runtime, "proofborne.runtime", true));
1109 graph
1110 .link(&contract, id, "runtime_completed", None)
1111 .unwrap();
1112
1113 let evaluation = graph.evaluate_detailed(&mut contract).unwrap();
1114 assert_eq!(evaluation.outcome, RunOutcome::Blocked);
1115 assert_eq!(evaluation.claim_scope, ClaimScope::Runtime);
1116 assert_ne!(evaluation.claim_scope, ClaimScope::Task);
1117 }
1118
1119 #[test]
1120 fn runtime_only_criteria_cannot_be_relabelled_as_task_proof() {
1121 let mut contract = TaskContract::automatic("model returned text");
1122 contract.claim_scope = ClaimScope::Task;
1123 assert!(matches!(
1124 contract.validate(),
1125 Err(ProofError::InvalidContract(message))
1126 if message.contains("runtime-only criteria cannot prove a task")
1127 ));
1128 }
1129
1130 #[test]
1131 fn human_only_criterion_is_not_machine_checkable_task_proof() {
1132 let mut criterion = Criterion::required("human", "a person says it is done");
1133 criterion.evidence_requirement.allowed_kinds = [EvidenceKind::Human].into_iter().collect();
1134 criterion.evidence_requirement.allowed_producers =
1135 ["human.user".to_owned()].into_iter().collect();
1136 criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
1137 let contract = TaskContract::new("task", vec![criterion]);
1138 assert!(matches!(
1139 contract.validate(),
1140 Err(ProofError::InvalidContract(message)) if message.contains("task-capable evidence")
1141 ));
1142 }
1143
1144 #[test]
1145 fn mixed_requirement_cannot_use_runtime_observation_as_task_evidence() {
1146 let mut criterion = Criterion::required("criterion", "task result");
1147 criterion.evidence_requirement.allowed_kinds =
1148 [EvidenceKind::Runtime, EvidenceKind::Process]
1149 .into_iter()
1150 .collect();
1151 criterion.evidence_requirement.allowed_producers =
1152 ["proofborne.runtime".to_owned()].into_iter().collect();
1153 criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
1154 let mut contract = TaskContract::new("task", vec![criterion]);
1155 let mut graph = ProofGraph::new(contract.id);
1156 link_one(
1157 &contract,
1158 &mut graph,
1159 evidence(EvidenceKind::Runtime, "proofborne.runtime", true),
1160 );
1161 assert!(contract.validate().is_ok());
1162 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
1163 assert_eq!(contract.criteria[0].state, CriterionState::Passed);
1164 }
1165
1166 #[test]
1167 fn task_contract_rejects_wildcard_producer() {
1168 let mut criterion = Criterion::required("criterion", "task result");
1169 criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
1170 let contract = TaskContract::new("task", vec![criterion]);
1171 assert!(matches!(
1172 contract.validate(),
1173 Err(ProofError::InvalidContract(message)) if message.contains("allowed producer")
1174 ));
1175 }
1176
1177 #[test]
1178 fn task_contract_rejects_evidence_not_bound_to_final_workspace() {
1179 let mut criterion = Criterion::required("criterion", "task result");
1180 criterion.evidence_requirement.allowed_producers =
1181 ["proofborne.verify".to_owned()].into_iter().collect();
1182 let contract = TaskContract::new("task", vec![criterion]);
1183 assert!(matches!(
1184 contract.validate(),
1185 Err(ProofError::InvalidContract(message)) if message.contains("final workspace")
1186 ));
1187 }
1188
1189 #[test]
1190 fn failed_evidence_prevents_verified_outcome() {
1191 let mut contract = TaskContract::automatic("test");
1192 let mut graph = ProofGraph::new(contract.id);
1193 let evidence = evidence(EvidenceKind::Runtime, "proofborne.runtime", false);
1194 let id = graph.record(evidence);
1195 graph
1196 .link(&contract, id, "runtime_completed", None)
1197 .unwrap();
1198 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Failed);
1199 assert_eq!(contract.criteria[0].state, CriterionState::Failed);
1200 }
1201
1202 #[test]
1203 fn explicit_later_attempt_supersedes_failure_but_preserves_history() {
1204 let mut contract = TaskContract::new(
1205 "fix it",
1206 vec![task_criterion("criterion", "tests pass", &["test"])],
1207 );
1208 let mut graph = ProofGraph::new(contract.id);
1209 let first_attempt = Uuid::now_v7();
1210 let failed = evidence(EvidenceKind::Process, "test", false).with_attempt(first_attempt, 1);
1211 let failed_id = link_one(&contract, &mut graph, failed);
1212 let passed = evidence(EvidenceKind::Process, "test", true)
1213 .with_attempt(Uuid::now_v7(), 2)
1214 .superseding([failed_id]);
1215 let passed_id = link_one(&contract, &mut graph, passed);
1216
1217 let evaluation = graph.evaluate_detailed(&mut contract).unwrap();
1218 assert_eq!(evaluation.outcome, RunOutcome::Verified);
1219 assert_eq!(graph.evidence.len(), 2);
1220 assert_eq!(
1221 evaluation.criteria[0].evidence_ids,
1222 vec![failed_id, passed_id]
1223 );
1224 assert_eq!(evaluation.criteria[0].active_evidence_ids, vec![passed_id]);
1225 assert!(
1226 evaluation.criteria[0]
1227 .unresolved_counterevidence_ids
1228 .is_empty()
1229 );
1230 }
1231
1232 #[test]
1233 fn failure_without_valid_later_attempt_remains_counterevidence() {
1234 let mut contract = TaskContract::new(
1235 "fix it",
1236 vec![task_criterion("criterion", "tests pass", &["test"])],
1237 );
1238 let mut graph = ProofGraph::new(contract.id);
1239 let failed = evidence(EvidenceKind::Process, "test", false).with_attempt(Uuid::now_v7(), 2);
1240 let failed_id = link_one(&contract, &mut graph, failed);
1241 let earlier_success = evidence(EvidenceKind::Process, "test", true)
1242 .with_attempt(Uuid::now_v7(), 1)
1243 .superseding([failed_id]);
1244 link_one(&contract, &mut graph, earlier_success);
1245
1246 let evaluation = graph.evaluate_detailed(&mut contract).unwrap();
1247 assert_eq!(evaluation.outcome, RunOutcome::Failed);
1248 assert_eq!(
1249 evaluation.criteria[0].unresolved_counterevidence_ids,
1250 vec![failed_id]
1251 );
1252 }
1253
1254 #[test]
1255 fn typed_requirement_filters_kind_producer_artifact_and_assurance() {
1256 let mut criterion = task_criterion("criterion", "verified output", &["trusted.test"]);
1257 criterion.evidence_requirement.allowed_kinds =
1258 [EvidenceKind::Process].into_iter().collect();
1259 criterion.evidence_requirement.allowed_producers =
1260 ["trusted.test".to_owned()].into_iter().collect();
1261 criterion.evidence_requirement.require_artifacts = true;
1262 criterion.evidence_requirement.minimum_assurance = AssuranceLevel::Isolated;
1263 let mut contract = TaskContract::new("verify", vec![criterion]);
1264 let mut graph = ProofGraph::new(contract.id);
1265 link_one(
1266 &contract,
1267 &mut graph,
1268 evidence(EvidenceKind::Process, "trusted.test", true),
1269 );
1270 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
1271
1272 let mut qualifying = evidence(EvidenceKind::Process, "trusted.test", true)
1273 .with_assurance(AssuranceLevel::Isolated);
1274 qualifying.artifacts.push(ArtifactRef {
1275 hash: "abc".to_owned(),
1276 name: "report.xml".to_owned(),
1277 media_type: Some("application/xml".to_owned()),
1278 size: 3,
1279 });
1280 link_one(&contract, &mut graph, qualifying);
1281 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Verified);
1282 }
1283
1284 #[test]
1285 fn freshness_requires_final_generation_and_state_binding() {
1286 let mut criterion = task_criterion("criterion", "fresh tests", &["test"]);
1287 criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceState;
1288 let mut contract = TaskContract::new("verify", vec![criterion]);
1289 let mut graph = ProofGraph::new(contract.id);
1290 graph.bind_final_workspace(2, Some("final-hash".to_owned()));
1291 link_one(
1292 &contract,
1293 &mut graph,
1294 evidence(EvidenceKind::Process, "test", true)
1295 .bound_to_workspace(1, Some("old-hash".to_owned())),
1296 );
1297 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
1298
1299 link_one(
1300 &contract,
1301 &mut graph,
1302 evidence(EvidenceKind::Process, "test", true)
1303 .bound_to_workspace(2, Some("final-hash".to_owned())),
1304 );
1305 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Verified);
1306 }
1307
1308 #[test]
1309 fn independent_producers_are_counted_by_identity() {
1310 let mut criterion =
1311 task_criterion("criterion", "independent checks", &["test-a", "test-b"]);
1312 criterion.evidence_requirement.minimum_observations = 2;
1313 criterion.evidence_requirement.minimum_independent_producers = 2;
1314 let mut contract = TaskContract::new("verify", vec![criterion]);
1315 let mut graph = ProofGraph::new(contract.id);
1316 link_one(
1317 &contract,
1318 &mut graph,
1319 evidence(EvidenceKind::Process, "test-a", true),
1320 );
1321 link_one(
1322 &contract,
1323 &mut graph,
1324 evidence(EvidenceKind::Process, "test-a", true),
1325 );
1326 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
1327
1328 link_one(
1329 &contract,
1330 &mut graph,
1331 evidence(EvidenceKind::Process, "test-b", true),
1332 );
1333 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Verified);
1334 }
1335
1336 #[test]
1337 fn waiver_is_visible_in_outcome() {
1338 let mut contract = TaskContract::new(
1339 "test",
1340 vec![task_criterion("criterion", "task result", &["test"])],
1341 );
1342 contract
1343 .waive("criterion", "user", "accepted manually")
1344 .unwrap();
1345 let graph = ProofGraph::new(contract.id);
1346 assert_eq!(
1347 graph.evaluate(&mut contract).unwrap(),
1348 RunOutcome::VerifiedWithWaivers
1349 );
1350 }
1351
1352 #[test]
1353 fn runtime_waiver_still_cannot_produce_exit_zero_outcome() {
1354 let mut contract = TaskContract::automatic("test");
1355 contract
1356 .waive("runtime_completed", "user", "accepted manually")
1357 .unwrap();
1358 let graph = ProofGraph::new(contract.id);
1359 assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
1360 }
1361
1362 #[test]
1363 fn old_json_defaults_to_runtime_scope_and_conservative_assurance() {
1364 let id = Uuid::now_v7();
1365 let contract: TaskContract = serde_json::from_value(json!({
1366 "schemaVersion": SCHEMA_VERSION,
1367 "id": id,
1368 "goal": "old contract",
1369 "criteria": [{
1370 "id": "criterion",
1371 "description": "old criterion",
1372 "required": true,
1373 "minimumEvidence": 2,
1374 "state": "pending",
1375 "evidenceIds": []
1376 }],
1377 "createdAt": "2026-07-29T00:00:00Z",
1378 "confirmed": true
1379 }))
1380 .unwrap();
1381 assert_eq!(contract.claim_scope, ClaimScope::Runtime);
1382 assert_eq!(
1383 contract.criteria[0]
1384 .evidence_requirement
1385 .minimum_observations,
1386 1
1387 );
1388 assert_eq!(contract.criteria[0].minimum_evidence, 2);
1389 }
1390
1391 #[test]
1392 fn old_evidence_json_gets_backward_safe_metadata_defaults() {
1393 let evidence = evidence(EvidenceKind::Process, "legacy", true);
1394 let mut value = serde_json::to_value(evidence).unwrap();
1395 let object = value.as_object_mut().unwrap();
1396 object.remove("assuranceLevel");
1397 object.remove("attemptId");
1398 object.remove("attemptSequence");
1399 object.remove("actionId");
1400 object.remove("workspaceGeneration");
1401 object.remove("stateBinding");
1402 object.remove("supersedes");
1403
1404 let restored: Evidence = serde_json::from_value(value).unwrap();
1405 assert_eq!(restored.assurance_level, AssuranceLevel::Recorded);
1406 assert_eq!(restored.attempt_id, None);
1407 assert_eq!(restored.workspace_generation, None);
1408 assert!(restored.supersedes.is_empty());
1409 }
1410
1411 #[test]
1412 fn public_json_uses_camel_case_fields() {
1413 let contract = TaskContract::automatic("json");
1414 let value = serde_json::to_value(contract).unwrap();
1415 assert_eq!(value["claimScope"], "runtime");
1416 assert!(value["criteria"][0].get("evidenceRequirement").is_some());
1417 assert!(
1418 value["criteria"][0]["evidenceRequirement"]
1419 .get("minimumObservations")
1420 .is_some()
1421 );
1422 }
1423
1424 proptest! {
1425 #[test]
1426 fn unresolved_failure_always_beats_any_number_of_successes(successes in 0_usize..32) {
1427 let mut contract = TaskContract::new(
1428 "property",
1429 vec![task_criterion("criterion", "must hold", &["test"])],
1430 );
1431 let mut graph = ProofGraph::new(contract.id);
1432 link_one(
1433 &contract,
1434 &mut graph,
1435 evidence(EvidenceKind::Process, "test", false),
1436 );
1437 for _ in 0..successes {
1438 link_one(
1439 &contract,
1440 &mut graph,
1441 evidence(EvidenceKind::Process, "test", true),
1442 );
1443 }
1444 prop_assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Failed);
1445 }
1446
1447 #[test]
1448 fn minimum_observation_count_is_enforced(required in 1_u32..16, present in 0_u32..16) {
1449 let mut criterion = task_criterion("criterion", "counted", &["test"]);
1450 criterion.minimum_evidence = required;
1451 criterion.evidence_requirement.minimum_observations = required;
1452 let mut contract = TaskContract::new("property", vec![criterion]);
1453 let mut graph = ProofGraph::new(contract.id);
1454 for _ in 0..present {
1455 link_one(
1456 &contract,
1457 &mut graph,
1458 evidence(EvidenceKind::Process, "test", true),
1459 );
1460 }
1461 let outcome = graph.evaluate(&mut contract).unwrap();
1462 prop_assert_eq!(outcome == RunOutcome::Verified, present >= required);
1463 }
1464 }
1465}