Skip to main content

proofborne_core/
contract.rs

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/// The claim a successful completion gate is allowed to make.
12#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum ClaimScope {
15    /// Only the agent/runtime loop itself was observed to complete.
16    #[default]
17    Runtime,
18    /// The task-specific acceptance criteria were satisfied.
19    Task,
20}
21
22/// Strength of the environment in which an observation was made.
23#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
24#[serde(rename_all = "snake_case")]
25pub enum AssuranceLevel {
26    /// A record exists, without an asserted runtime observation boundary.
27    #[default]
28    Recorded,
29    /// The Proofborne runtime directly observed the evidence.
30    Observed,
31    /// The observation was produced inside an isolated execution boundary.
32    Isolated,
33    /// A separate runner independently reproduced the observation.
34    IndependentlyReproduced,
35}
36
37/// How tightly evidence must be bound to the final workspace state.
38#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum EvidenceFreshness {
41    /// No workspace generation or state binding is required.
42    #[default]
43    Any,
44    /// Evidence must have been observed at the graph's final workspace generation.
45    FinalWorkspaceGeneration,
46    /// Evidence must match both the final generation and final state binding.
47    FinalWorkspaceState,
48}
49
50/// Typed rules deciding which successful observations may satisfy a criterion.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct EvidenceRequirement {
54    /// Accepted evidence categories. An empty set is an explicit wildcard for old contracts.
55    #[serde(default)]
56    pub allowed_kinds: BTreeSet<EvidenceKind>,
57    /// Accepted exact producer identities. An empty set accepts any producer.
58    #[serde(default)]
59    pub allowed_producers: BTreeSet<String>,
60    /// Minimum number of active, qualifying observations.
61    #[serde(default = "one")]
62    pub minimum_observations: u32,
63    /// Required relationship to the final workspace state.
64    #[serde(default)]
65    pub freshness: EvidenceFreshness,
66    /// Whether every qualifying observation must reference at least one artifact.
67    #[serde(default)]
68    pub require_artifacts: bool,
69    /// Minimum number of distinct qualifying producer identities.
70    #[serde(default = "one")]
71    pub minimum_independent_producers: u32,
72    /// Minimum execution assurance required from each qualifying observation.
73    #[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    /// Creates the safe default used by task-specific criteria.
93    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    /// Creates the narrow requirement used for runtime-completion claims.
104    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/// A user-visible contract that defines when a task may be called complete.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116#[serde(rename_all = "camelCase")]
117pub struct TaskContract {
118    /// Schema version for forward-compatible persistence.
119    pub schema_version: String,
120    /// Stable contract identifier.
121    pub id: Uuid,
122    /// User goal in plain language.
123    pub goal: String,
124    /// Maximum claim a successful evaluation may make.
125    #[serde(default)]
126    pub claim_scope: ClaimScope,
127    /// Hard constraints that remain pinned during context compaction.
128    #[serde(default)]
129    pub constraints: Vec<String>,
130    /// Acceptance criteria evaluated by the runtime.
131    pub criteria: Vec<Criterion>,
132    /// Creation timestamp.
133    pub created_at: DateTime<Utc>,
134    /// Whether a human explicitly confirmed the contract.
135    pub confirmed: bool,
136}
137
138impl TaskContract {
139    /// Creates an unconfirmed, task-scoped contract.
140    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    /// Creates the deliberately modest contract used by `--auto-contract`.
154    ///
155    /// This contract is permanently runtime-scoped. Its criterion proves that
156    /// the provider loop terminated without a runtime error; it cannot make a
157    /// task-correctness claim.
158    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    /// Validates identifiers, claim scope, and evidence requirements before execution.
174    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    /// Records a human waiver for one criterion.
274    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/// One independently evaluable acceptance criterion.
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307#[serde(rename_all = "camelCase")]
308pub struct Criterion {
309    /// Stable, human-readable identifier.
310    pub id: String,
311    /// Plain-language requirement.
312    pub description: String,
313    /// Whether this criterion gates completion.
314    pub required: bool,
315    /// Legacy minimum retained for v0.1 contract compatibility.
316    ///
317    /// Evaluation uses the greater of this value and the typed requirement's
318    /// `minimumObservations` value.
319    #[serde(default = "one")]
320    pub minimum_evidence: u32,
321    /// Typed qualification rules for linked evidence.
322    #[serde(default)]
323    pub evidence_requirement: EvidenceRequirement,
324    /// Current runtime-derived state.
325    pub state: CriterionState,
326    /// Evidence linked by the proof graph, including superseded history.
327    #[serde(default)]
328    pub evidence_ids: Vec<Uuid>,
329    /// Explicit human waiver, when present.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub waiver: Option<Waiver>,
332}
333
334impl Criterion {
335    /// Creates a required task criterion with one required observation.
336    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    /// Creates an optional task criterion.
350    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/// Runtime state for an acceptance criterion.
390#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
391#[serde(rename_all = "snake_case")]
392pub enum CriterionState {
393    /// No conclusive qualifying evidence exists yet.
394    Pending,
395    /// Enough successful evidence exists and no unresolved counterevidence remains.
396    Passed,
397    /// At least one linked counterevidence record remains unresolved.
398    Failed,
399    /// A human explicitly accepted the missing evidence.
400    Waived,
401}
402
403/// Human authorization to complete without satisfying one criterion.
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
405#[serde(rename_all = "camelCase")]
406pub struct Waiver {
407    /// User or client identity making the decision.
408    pub actor: String,
409    /// Required explanation.
410    pub reason: String,
411    /// Decision timestamp.
412    pub at: DateTime<Utc>,
413}
414
415/// Runtime evidence that can support one or more criteria.
416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
417#[serde(rename_all = "camelCase")]
418pub struct Evidence {
419    /// Stable evidence identifier.
420    pub id: Uuid,
421    /// Evidence category.
422    pub kind: EvidenceKind,
423    /// Tool or runtime producer.
424    pub producer: String,
425    /// Short, reviewable statement of what was observed.
426    pub summary: String,
427    /// Hash of normalized inputs, when applicable.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub input_hash: Option<String>,
430    /// Hash of the complete output.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub output_hash: Option<String>,
433    /// Process-style exit code, when applicable.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub exit_code: Option<i32>,
436    /// Runtime determination, never model prose.
437    pub success: bool,
438    /// Start timestamp.
439    pub started_at: DateTime<Utc>,
440    /// Completion timestamp.
441    pub completed_at: DateTime<Utc>,
442    /// Content-addressed artifacts.
443    #[serde(default)]
444    pub artifacts: Vec<ArtifactRef>,
445    /// Whether sensitive content was removed before persistence.
446    pub redacted: bool,
447    /// Producer-specific, public metadata.
448    #[serde(default)]
449    pub metadata: Value,
450    /// Assurance boundary in which the observation was made.
451    #[serde(default)]
452    pub assurance_level: AssuranceLevel,
453    /// Stable identifier shared by evidence from one logical attempt.
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub attempt_id: Option<Uuid>,
456    /// Monotonically increasing attempt sequence used to prove supersession order.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub attempt_sequence: Option<u32>,
459    /// Runtime-owned action identifier that caused this observation.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub action_id: Option<String>,
462    /// Workspace generation observed by this evidence.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub workspace_generation: Option<u64>,
465    /// Digest or other canonical binding for the observed workspace state.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub state_binding: Option<String>,
468    /// Earlier evidence explicitly replaced by this later attempt.
469    #[serde(default, skip_serializing_if = "Vec::is_empty")]
470    pub supersedes: Vec<Uuid>,
471}
472
473impl Evidence {
474    /// Creates runtime evidence and hashes its JSON input/output values.
475    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    /// Associates this observation with a logical, ordered attempt.
510    #[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    /// Associates this observation with a runtime-owned action identifier.
518    #[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    /// Binds this observation to a workspace generation and optional state digest.
525    #[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    /// Records the observation's execution assurance.
533    #[must_use]
534    pub fn with_assurance(mut self, assurance_level: AssuranceLevel) -> Self {
535        self.assurance_level = assurance_level;
536        self
537    }
538
539    /// Explicitly replaces evidence from one or more earlier attempts.
540    #[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/// Evidence categories used by the completion gate.
548#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
549#[serde(rename_all = "snake_case")]
550pub enum EvidenceKind {
551    /// Evidence emitted by a built-in or MCP tool.
552    Tool,
553    /// Structured child-process result.
554    Process,
555    /// Runtime state transition.
556    Runtime,
557    /// Git diff/status observation.
558    Git,
559    /// Human-authored observation.
560    Human,
561}
562
563/// A content-addressed artifact referenced by evidence.
564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
565#[serde(rename_all = "camelCase")]
566pub struct ArtifactRef {
567    /// BLAKE3 content digest.
568    pub hash: String,
569    /// Original display name.
570    pub name: String,
571    /// MIME type when known.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub media_type: Option<String>,
574    /// Artifact size in bytes.
575    pub size: u64,
576}
577
578/// Directed proof edge from evidence to a criterion.
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
580#[serde(rename_all = "camelCase")]
581pub struct EvidenceLink {
582    /// Evidence node.
583    pub evidence_id: Uuid,
584    /// Criterion node.
585    pub criterion_id: String,
586    /// Optional explanation for reviewers.
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub rationale: Option<String>,
589}
590
591/// Deterministic details of one criterion evaluation.
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
593#[serde(rename_all = "camelCase")]
594pub struct CriterionEvaluation {
595    /// Criterion identifier.
596    pub criterion_id: String,
597    /// Derived state.
598    pub state: CriterionState,
599    /// Every linked evidence identifier, including historical observations.
600    pub evidence_ids: Vec<Uuid>,
601    /// Linked evidence not replaced by a valid later attempt.
602    pub active_evidence_ids: Vec<Uuid>,
603    /// Active successful evidence satisfying every typed requirement.
604    pub qualifying_evidence_ids: Vec<Uuid>,
605    /// Active failed observations that prevent completion.
606    pub unresolved_counterevidence_ids: Vec<Uuid>,
607    /// Human-readable, deterministic descriptions of unmet requirements.
608    pub unmet_requirements: Vec<String>,
609}
610
611/// Deterministic result of reducing a contract and proof graph.
612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
613#[serde(rename_all = "camelCase")]
614pub struct ProofEvaluation {
615    /// Claim made by this evaluation.
616    pub claim_scope: ClaimScope,
617    /// Lowest assurance among the evidence supporting required passed criteria.
618    pub assurance_level: AssuranceLevel,
619    /// Completion-gate result.
620    pub outcome: RunOutcome,
621    /// Per-criterion derivation details.
622    pub criteria: Vec<CriterionEvaluation>,
623}
624
625/// Evidence nodes and their links to contract criteria.
626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
627#[serde(rename_all = "camelCase")]
628pub struct ProofGraph {
629    /// Graph schema version.
630    pub schema_version: String,
631    /// Contract this graph evaluates.
632    pub contract_id: Uuid,
633    /// Evidence keyed for deterministic serialization.
634    pub evidence: BTreeMap<Uuid, Evidence>,
635    /// Directed evidence-to-criterion edges.
636    pub links: Vec<EvidenceLink>,
637    /// Final workspace generation against which fresh evidence is evaluated.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub final_workspace_generation: Option<u64>,
640    /// Canonical digest or other binding for the final workspace state.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub final_state_binding: Option<String>,
643    /// Runtime terminal state that prevents normal completion evaluation.
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub termination: Option<ProofTermination>,
646}
647
648impl ProofGraph {
649    /// Creates an empty graph for a contract.
650    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    /// Sets the final workspace state used by freshness requirements.
663    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    /// Records a policy, authorization, or external dependency blocker.
669    pub fn block(&mut self, reason: impl Into<String>) {
670        self.termination = Some(ProofTermination::Blocked {
671            reason: reason.into(),
672        });
673    }
674
675    /// Records user or client cancellation.
676    pub fn cancel(&mut self, reason: impl Into<String>) {
677        self.termination = Some(ProofTermination::Cancelled {
678            reason: reason.into(),
679        });
680    }
681
682    /// Adds evidence and returns its identifier.
683    pub fn record(&mut self, evidence: Evidence) -> Uuid {
684        let id = evidence.id;
685        self.evidence.insert(id, evidence);
686        id
687    }
688
689    /// Links existing evidence to an existing criterion.
690    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    /// Recomputes every non-waived criterion and returns detailed derivation data.
723    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    /// Recomputes every non-waived criterion and returns the overall outcome.
788    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    // A runtime observation is useful evidence, but it is not task completion.
924    // Keeping this gate in the deterministic reducer prevents every surface
925    // (including old callers) from turning a runtime-only contract into exit 0.
926    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/// Runtime-owned reason normal criterion evaluation could not complete.
1019#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1020#[serde(tag = "state", rename_all = "snake_case")]
1021pub enum ProofTermination {
1022    /// Policy, authorization, or external state prevented progress.
1023    Blocked {
1024        /// Reviewable public reason.
1025        reason: String,
1026    },
1027    /// The user or client cancelled the run.
1028    Cancelled {
1029        /// Reviewable public reason.
1030        reason: String,
1031    },
1032}
1033
1034/// Final runtime outcome; only the first two represent completed work.
1035#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1036#[serde(rename_all = "snake_case")]
1037pub enum RunOutcome {
1038    /// Every required criterion passed with evidence.
1039    Verified,
1040    /// Every required criterion passed or was explicitly waived.
1041    VerifiedWithWaivers,
1042    /// At least one required criterion has unresolved failing evidence.
1043    Failed,
1044    /// Required evidence or authorization is still missing.
1045    Blocked,
1046    /// The user or client cancelled execution.
1047    Cancelled,
1048}
1049
1050/// Proof and contract validation errors.
1051#[derive(Debug, Error)]
1052pub enum ProofError {
1053    /// Contract content is structurally invalid.
1054    #[error("invalid task contract: {0}")]
1055    InvalidContract(String),
1056    /// Two criteria share an identifier.
1057    #[error("duplicate criterion id: {0}")]
1058    DuplicateCriterion(String),
1059    /// A requested criterion does not exist.
1060    #[error("unknown criterion: {0}")]
1061    UnknownCriterion(String),
1062    /// A requested evidence node does not exist.
1063    #[error("unknown evidence: {0}")]
1064    UnknownEvidence(Uuid),
1065    /// Graph and contract identifiers differ.
1066    #[error("proof graph belongs to a different task contract")]
1067    ContractMismatch,
1068    /// Waivers require a reviewable explanation.
1069    #[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}