use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use uuid::Uuid;
use crate::{SCHEMA_VERSION, hash_bytes, hash_json};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClaimScope {
#[default]
Runtime,
Task,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum AssuranceLevel {
#[default]
Recorded,
Observed,
Isolated,
IndependentlyReproduced,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceFreshness {
#[default]
Any,
FinalWorkspaceGeneration,
FinalWorkspaceState,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceRequirement {
#[serde(default)]
pub allowed_kinds: BTreeSet<EvidenceKind>,
#[serde(default)]
pub allowed_producers: BTreeSet<String>,
#[serde(default = "one")]
pub minimum_observations: u32,
#[serde(default)]
pub freshness: EvidenceFreshness,
#[serde(default)]
pub require_artifacts: bool,
#[serde(default = "one")]
pub minimum_independent_producers: u32,
#[serde(default)]
pub minimum_assurance: AssuranceLevel,
}
impl Default for EvidenceRequirement {
fn default() -> Self {
Self {
allowed_kinds: BTreeSet::new(),
allowed_producers: BTreeSet::new(),
minimum_observations: 1,
freshness: EvidenceFreshness::Any,
require_artifacts: false,
minimum_independent_producers: 1,
minimum_assurance: AssuranceLevel::Recorded,
}
}
}
impl EvidenceRequirement {
pub fn task() -> Self {
Self {
allowed_kinds: [EvidenceKind::Tool, EvidenceKind::Process, EvidenceKind::Git]
.into_iter()
.collect(),
minimum_assurance: AssuranceLevel::Observed,
..Self::default()
}
}
pub fn runtime_completion() -> Self {
Self {
allowed_kinds: [EvidenceKind::Runtime].into_iter().collect(),
allowed_producers: ["proofborne.runtime".to_owned()].into_iter().collect(),
minimum_assurance: AssuranceLevel::Observed,
..Self::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TaskContract {
pub schema_version: String,
pub id: Uuid,
pub goal: String,
#[serde(default)]
pub claim_scope: ClaimScope,
#[serde(default)]
pub constraints: Vec<String>,
pub criteria: Vec<Criterion>,
pub created_at: DateTime<Utc>,
pub confirmed: bool,
}
impl TaskContract {
pub fn new(goal: impl Into<String>, criteria: Vec<Criterion>) -> Self {
Self {
schema_version: SCHEMA_VERSION.to_owned(),
id: Uuid::now_v7(),
goal: goal.into(),
claim_scope: ClaimScope::Task,
constraints: Vec::new(),
criteria,
created_at: Utc::now(),
confirmed: false,
}
}
pub fn automatic(goal: impl Into<String>) -> Self {
let mut runtime_criterion = Criterion::required(
"runtime_completed",
"The agent loop completed without an unhandled runtime error",
);
runtime_criterion.evidence_requirement = EvidenceRequirement::runtime_completion();
let mut contract = Self::new(goal, vec![runtime_criterion]);
contract.claim_scope = ClaimScope::Runtime;
contract.confirmed = true;
contract.constraints.push(
"Runtime completion evidence does not imply task or program correctness".to_owned(),
);
contract
}
pub fn validate(&self) -> Result<(), ProofError> {
if self.goal.trim().is_empty() {
return Err(ProofError::InvalidContract(
"goal must not be empty".to_owned(),
));
}
if self.criteria.is_empty() {
return Err(ProofError::InvalidContract(
"at least one acceptance criterion is required".to_owned(),
));
}
if !self.criteria.iter().any(|criterion| criterion.required) {
return Err(ProofError::InvalidContract(
"at least one required acceptance criterion is required".to_owned(),
));
}
let mut ids = BTreeSet::new();
for criterion in &self.criteria {
if criterion.id.trim().is_empty() || criterion.description.trim().is_empty() {
return Err(ProofError::InvalidContract(
"criterion id and description must not be empty".to_owned(),
));
}
if !ids.insert(&criterion.id) {
return Err(ProofError::DuplicateCriterion(criterion.id.clone()));
}
criterion.validate_requirement()?;
match (criterion.state, criterion.waiver.as_ref()) {
(CriterionState::Waived, Some(waiver))
if waiver.actor.trim().is_empty() || waiver.reason.trim().is_empty() =>
{
return Err(ProofError::InvalidContract(format!(
"criterion {} has incomplete waiver metadata",
criterion.id
)));
}
(CriterionState::Waived, None) => {
return Err(ProofError::InvalidContract(format!(
"criterion {} is waived without waiver metadata",
criterion.id
)));
}
(
CriterionState::Pending | CriterionState::Passed | CriterionState::Failed,
Some(_),
) => {
return Err(ProofError::InvalidContract(format!(
"criterion {} has waiver metadata but is not waived",
criterion.id
)));
}
_ => {}
}
}
if self.claim_scope == ClaimScope::Task {
if !self
.criteria
.iter()
.filter(|criterion| criterion.required)
.any(|criterion| {
!criterion.evidence_requirement.allowed_kinds.is_empty()
&& criterion
.evidence_requirement
.allowed_kinds
.iter()
.any(|kind| {
matches!(
kind,
EvidenceKind::Tool | EvidenceKind::Process | EvidenceKind::Git
)
})
})
{
return Err(ProofError::InvalidContract(
"a task-scoped contract requires task-capable evidence; runtime-only criteria cannot prove a task"
.to_owned(),
));
}
for criterion in self.criteria.iter().filter(|criterion| criterion.required) {
if criterion.evidence_requirement.allowed_producers.is_empty() {
return Err(ProofError::InvalidContract(format!(
"required task criterion {} must name at least one allowed producer",
criterion.id
)));
}
if criterion.evidence_requirement.freshness == EvidenceFreshness::Any {
return Err(ProofError::InvalidContract(format!(
"required task criterion {} must bind evidence to the final workspace generation or state",
criterion.id
)));
}
}
}
Ok(())
}
pub fn waive(
&mut self,
criterion_id: &str,
actor: impl Into<String>,
reason: impl Into<String>,
) -> Result<(), ProofError> {
let criterion = self
.criteria
.iter_mut()
.find(|criterion| criterion.id == criterion_id)
.ok_or_else(|| ProofError::UnknownCriterion(criterion_id.to_owned()))?;
let actor = actor.into();
if actor.trim().is_empty() {
return Err(ProofError::InvalidContract(
"waiver actor must not be empty".to_owned(),
));
}
let reason = reason.into();
if reason.trim().is_empty() {
return Err(ProofError::EmptyWaiverReason);
}
criterion.state = CriterionState::Waived;
criterion.waiver = Some(Waiver {
actor,
reason,
at: Utc::now(),
});
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Criterion {
pub id: String,
pub description: String,
pub required: bool,
#[serde(default = "one")]
pub minimum_evidence: u32,
#[serde(default)]
pub evidence_requirement: EvidenceRequirement,
pub state: CriterionState,
#[serde(default)]
pub evidence_ids: Vec<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub waiver: Option<Waiver>,
}
impl Criterion {
pub fn required(id: impl Into<String>, description: impl Into<String>) -> Self {
Self {
id: id.into(),
description: description.into(),
required: true,
minimum_evidence: 1,
evidence_requirement: EvidenceRequirement::task(),
state: CriterionState::Pending,
evidence_ids: Vec::new(),
waiver: None,
}
}
pub fn optional(id: impl Into<String>, description: impl Into<String>) -> Self {
Self {
required: false,
..Self::required(id, description)
}
}
fn validate_requirement(&self) -> Result<(), ProofError> {
let requirement = &self.evidence_requirement;
if self.minimum_evidence == 0 || requirement.minimum_observations == 0 {
return Err(ProofError::InvalidContract(format!(
"criterion {} requires at least one observation",
self.id
)));
}
if requirement.minimum_independent_producers == 0 {
return Err(ProofError::InvalidContract(format!(
"criterion {} requires at least one independent producer",
self.id
)));
}
if requirement
.allowed_producers
.iter()
.any(|producer| producer.trim().is_empty())
{
return Err(ProofError::InvalidContract(format!(
"criterion {} contains an empty allowed producer",
self.id
)));
}
Ok(())
}
}
const fn one() -> u32 {
1
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CriterionState {
Pending,
Passed,
Failed,
Waived,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Waiver {
pub actor: String,
pub reason: String,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Evidence {
pub id: Uuid,
pub kind: EvidenceKind,
pub producer: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub success: bool,
pub started_at: DateTime<Utc>,
pub completed_at: DateTime<Utc>,
#[serde(default)]
pub artifacts: Vec<ArtifactRef>,
pub redacted: bool,
#[serde(default)]
pub metadata: Value,
#[serde(default)]
pub assurance_level: AssuranceLevel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt_sequence: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_binding: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supersedes: Vec<Uuid>,
}
impl Evidence {
pub fn observed(
kind: EvidenceKind,
producer: impl Into<String>,
summary: impl Into<String>,
input: Option<&Value>,
output: Option<&[u8]>,
exit_code: Option<i32>,
success: bool,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::now_v7(),
kind,
producer: producer.into(),
summary: summary.into(),
input_hash: input.map(hash_json),
output_hash: output.map(hash_bytes),
exit_code,
success,
started_at: now,
completed_at: now,
artifacts: Vec::new(),
redacted: false,
metadata: Value::Object(serde_json::Map::new()),
assurance_level: AssuranceLevel::Observed,
attempt_id: None,
attempt_sequence: None,
action_id: None,
workspace_generation: None,
state_binding: None,
supersedes: Vec::new(),
}
}
#[must_use]
pub fn with_attempt(mut self, attempt_id: Uuid, sequence: u32) -> Self {
self.attempt_id = Some(attempt_id);
self.attempt_sequence = Some(sequence);
self
}
#[must_use]
pub fn with_action(mut self, action_id: impl Into<String>) -> Self {
self.action_id = Some(action_id.into());
self
}
#[must_use]
pub fn bound_to_workspace(mut self, generation: u64, state_binding: Option<String>) -> Self {
self.workspace_generation = Some(generation);
self.state_binding = state_binding;
self
}
#[must_use]
pub fn with_assurance(mut self, assurance_level: AssuranceLevel) -> Self {
self.assurance_level = assurance_level;
self
}
#[must_use]
pub fn superseding(mut self, evidence_ids: impl IntoIterator<Item = Uuid>) -> Self {
self.supersedes = evidence_ids.into_iter().collect();
self
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceKind {
Tool,
Context,
Routing,
Process,
Runtime,
Git,
Human,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactRef {
pub hash: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceLink {
pub evidence_id: Uuid,
pub criterion_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CriterionEvaluation {
pub criterion_id: String,
pub state: CriterionState,
pub evidence_ids: Vec<Uuid>,
pub active_evidence_ids: Vec<Uuid>,
pub qualifying_evidence_ids: Vec<Uuid>,
pub unresolved_counterevidence_ids: Vec<Uuid>,
pub unmet_requirements: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProofEvaluation {
pub claim_scope: ClaimScope,
pub assurance_level: AssuranceLevel,
pub outcome: RunOutcome,
pub criteria: Vec<CriterionEvaluation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProofGraph {
pub schema_version: String,
pub contract_id: Uuid,
pub evidence: BTreeMap<Uuid, Evidence>,
pub links: Vec<EvidenceLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_workspace_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_state_binding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub termination: Option<ProofTermination>,
}
impl ProofGraph {
pub fn new(contract_id: Uuid) -> Self {
Self {
schema_version: SCHEMA_VERSION.to_owned(),
contract_id,
evidence: BTreeMap::new(),
links: Vec::new(),
final_workspace_generation: None,
final_state_binding: None,
termination: None,
}
}
pub fn bind_final_workspace(&mut self, generation: u64, state_binding: Option<String>) {
self.final_workspace_generation = Some(generation);
self.final_state_binding = state_binding;
}
pub fn block(&mut self, reason: impl Into<String>) {
self.termination = Some(ProofTermination::Blocked {
reason: reason.into(),
});
}
pub fn cancel(&mut self, reason: impl Into<String>) {
self.termination = Some(ProofTermination::Cancelled {
reason: reason.into(),
});
}
pub fn record(&mut self, evidence: Evidence) -> Uuid {
let id = evidence.id;
self.evidence.insert(id, evidence);
id
}
pub fn link(
&mut self,
contract: &TaskContract,
evidence_id: Uuid,
criterion_id: impl Into<String>,
rationale: Option<String>,
) -> Result<(), ProofError> {
if !self.evidence.contains_key(&evidence_id) {
return Err(ProofError::UnknownEvidence(evidence_id));
}
let criterion_id = criterion_id.into();
if !contract
.criteria
.iter()
.any(|criterion| criterion.id == criterion_id)
{
return Err(ProofError::UnknownCriterion(criterion_id));
}
if !self
.links
.iter()
.any(|link| link.evidence_id == evidence_id && link.criterion_id == criterion_id)
{
self.links.push(EvidenceLink {
evidence_id,
criterion_id,
rationale,
});
}
Ok(())
}
pub fn evaluate_detailed(
&self,
contract: &mut TaskContract,
) -> Result<ProofEvaluation, ProofError> {
if self.contract_id != contract.id {
return Err(ProofError::ContractMismatch);
}
contract.validate()?;
let mut evaluations = Vec::with_capacity(contract.criteria.len());
for criterion in &mut contract.criteria {
if criterion.state == CriterionState::Waived {
let mut evaluation = self.evaluate_criterion(criterion);
evaluation.state = CriterionState::Waived;
criterion.evidence_ids.clone_from(&evaluation.evidence_ids);
evaluations.push(evaluation);
continue;
}
let evaluation = self.evaluate_criterion(criterion);
criterion.state = evaluation.state;
criterion.evidence_ids.clone_from(&evaluation.evidence_ids);
evaluations.push(evaluation);
}
let mut outcome = if let Some(termination) = &self.termination {
match termination {
ProofTermination::Blocked { .. } => RunOutcome::Blocked,
ProofTermination::Cancelled { .. } => RunOutcome::Cancelled,
}
} else {
outcome_for(contract.claim_scope, &contract.criteria)
};
if contract.claim_scope == ClaimScope::Task
&& outcome == RunOutcome::Verified
&& !has_machine_checkable_task_evidence(contract, &evaluations, &self.evidence)
{
outcome = RunOutcome::Blocked;
}
let assurance_level = contract
.criteria
.iter()
.filter(|criterion| criterion.required && criterion.state == CriterionState::Passed)
.filter_map(|criterion| {
evaluations
.iter()
.find(|evaluation| evaluation.criterion_id == criterion.id)
})
.flat_map(|evaluation| evaluation.qualifying_evidence_ids.iter())
.filter_map(|id| self.evidence.get(id))
.map(|evidence| evidence.assurance_level)
.min()
.unwrap_or(AssuranceLevel::Recorded);
Ok(ProofEvaluation {
claim_scope: contract.claim_scope,
assurance_level,
outcome,
criteria: evaluations,
})
}
pub fn evaluate(&self, contract: &mut TaskContract) -> Result<RunOutcome, ProofError> {
self.evaluate_detailed(contract)
.map(|evaluation| evaluation.outcome)
}
fn evaluate_criterion(&self, criterion: &Criterion) -> CriterionEvaluation {
let evidence_ids: Vec<_> = self
.links
.iter()
.filter(|link| link.criterion_id == criterion.id)
.map(|link| link.evidence_id)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let linked_ids: BTreeSet<_> = evidence_ids.iter().copied().collect();
let superseded: BTreeSet<_> = evidence_ids
.iter()
.filter_map(|successor_id| self.evidence.get(successor_id))
.flat_map(|successor| {
successor
.supersedes
.iter()
.filter(|predecessor_id| {
linked_ids.contains(predecessor_id)
&& valid_supersession(
successor,
self.evidence
.get(predecessor_id)
.expect("linked evidence exists"),
)
})
.copied()
})
.collect();
let active_evidence_ids: Vec<_> = evidence_ids
.iter()
.filter(|id| !superseded.contains(id))
.copied()
.collect();
let unresolved_counterevidence_ids: Vec<_> = active_evidence_ids
.iter()
.filter(|id| {
self.evidence
.get(id)
.is_some_and(|evidence| !evidence.success)
})
.copied()
.collect();
let qualifying_evidence_ids: Vec<_> = active_evidence_ids
.iter()
.filter(|id| {
self.evidence.get(id).is_some_and(|evidence| {
evidence.success
&& evidence_qualifies(
evidence,
&criterion.evidence_requirement,
self.final_workspace_generation,
self.final_state_binding.as_deref(),
)
})
})
.copied()
.collect();
let minimum_observations = criterion
.minimum_evidence
.max(criterion.evidence_requirement.minimum_observations);
let distinct_producers = qualifying_evidence_ids
.iter()
.filter_map(|id| self.evidence.get(id))
.map(|evidence| evidence.producer.as_str())
.collect::<BTreeSet<_>>()
.len();
let mut unmet_requirements = Vec::new();
if qualifying_evidence_ids.len()
< usize::try_from(minimum_observations).unwrap_or(usize::MAX)
{
unmet_requirements.push(format!(
"requires {minimum_observations} qualifying observations, found {}",
qualifying_evidence_ids.len()
));
}
if distinct_producers
< usize::try_from(criterion.evidence_requirement.minimum_independent_producers)
.unwrap_or(usize::MAX)
{
unmet_requirements.push(format!(
"requires {} independent producers, found {distinct_producers}",
criterion.evidence_requirement.minimum_independent_producers
));
}
if !unresolved_counterevidence_ids.is_empty() {
unmet_requirements.push(format!(
"{} unresolved counterevidence observations remain",
unresolved_counterevidence_ids.len()
));
}
let state = if !unresolved_counterevidence_ids.is_empty() {
CriterionState::Failed
} else if unmet_requirements.is_empty() {
CriterionState::Passed
} else {
CriterionState::Pending
};
CriterionEvaluation {
criterion_id: criterion.id.clone(),
state,
evidence_ids,
active_evidence_ids,
qualifying_evidence_ids,
unresolved_counterevidence_ids,
unmet_requirements,
}
}
}
fn outcome_for(claim_scope: ClaimScope, criteria: &[Criterion]) -> RunOutcome {
let required: Vec<_> = criteria
.iter()
.filter(|criterion| criterion.required)
.collect();
if required
.iter()
.any(|criterion| criterion.state == CriterionState::Failed)
{
return RunOutcome::Failed;
}
if required
.iter()
.any(|criterion| criterion.state == CriterionState::Pending)
{
return RunOutcome::Blocked;
}
if claim_scope == ClaimScope::Runtime {
return RunOutcome::Blocked;
}
if required
.iter()
.any(|criterion| criterion.state == CriterionState::Waived)
{
return RunOutcome::VerifiedWithWaivers;
}
RunOutcome::Verified
}
fn has_machine_checkable_task_evidence(
contract: &TaskContract,
evaluations: &[CriterionEvaluation],
evidence: &BTreeMap<Uuid, Evidence>,
) -> bool {
contract
.criteria
.iter()
.filter(|criterion| criterion.required && criterion.state == CriterionState::Passed)
.filter_map(|criterion| {
evaluations
.iter()
.find(|evaluation| evaluation.criterion_id == criterion.id)
})
.flat_map(|evaluation| evaluation.qualifying_evidence_ids.iter())
.filter_map(|id| evidence.get(id))
.any(|evidence| {
matches!(
evidence.kind,
EvidenceKind::Tool | EvidenceKind::Process | EvidenceKind::Git
)
})
}
fn valid_supersession(successor: &Evidence, predecessor: &Evidence) -> bool {
if successor.id == predecessor.id {
return false;
}
matches!(
(
successor.attempt_id,
successor.attempt_sequence,
predecessor.attempt_id,
predecessor.attempt_sequence,
),
(
Some(successor_id),
Some(successor_sequence),
Some(predecessor_id),
Some(predecessor_sequence),
) if successor_id != predecessor_id && successor_sequence > predecessor_sequence
)
}
fn evidence_qualifies(
evidence: &Evidence,
requirement: &EvidenceRequirement,
final_workspace_generation: Option<u64>,
final_state_binding: Option<&str>,
) -> bool {
if !requirement.allowed_kinds.is_empty() && !requirement.allowed_kinds.contains(&evidence.kind)
{
return false;
}
if !requirement.allowed_producers.is_empty()
&& !requirement.allowed_producers.contains(&evidence.producer)
{
return false;
}
if requirement.require_artifacts && evidence.artifacts.is_empty() {
return false;
}
if evidence.assurance_level < requirement.minimum_assurance {
return false;
}
match requirement.freshness {
EvidenceFreshness::Any => true,
EvidenceFreshness::FinalWorkspaceGeneration => {
final_workspace_generation.is_some()
&& evidence.workspace_generation == final_workspace_generation
}
EvidenceFreshness::FinalWorkspaceState => {
final_workspace_generation.is_some()
&& final_state_binding.is_some()
&& evidence.workspace_generation == final_workspace_generation
&& evidence.state_binding.as_deref() == final_state_binding
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ProofTermination {
Blocked {
reason: String,
},
Cancelled {
reason: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RunOutcome {
Verified,
VerifiedWithWaivers,
Failed,
Blocked,
Cancelled,
}
#[derive(Debug, Error)]
pub enum ProofError {
#[error("invalid task contract: {0}")]
InvalidContract(String),
#[error("duplicate criterion id: {0}")]
DuplicateCriterion(String),
#[error("unknown criterion: {0}")]
UnknownCriterion(String),
#[error("unknown evidence: {0}")]
UnknownEvidence(Uuid),
#[error("proof graph belongs to a different task contract")]
ContractMismatch,
#[error("waiver reason must not be empty")]
EmptyWaiverReason,
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use serde_json::json;
use super::*;
fn evidence(kind: EvidenceKind, producer: &str, success: bool) -> Evidence {
Evidence::observed(kind, producer, "observation", None, None, Some(0), success)
.bound_to_workspace(1, None)
}
fn task_criterion(id: &str, description: &str, producers: &[&str]) -> Criterion {
let mut criterion = Criterion::required(id, description);
criterion.evidence_requirement.allowed_producers = producers
.iter()
.map(|producer| (*producer).to_owned())
.collect();
criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
criterion
}
fn link_one(contract: &TaskContract, graph: &mut ProofGraph, evidence: Evidence) -> Uuid {
if contract.claim_scope == ClaimScope::Task && graph.final_workspace_generation.is_none() {
graph.bind_final_workspace(1, None);
}
let id = graph.record(evidence);
graph.link(contract, id, "criterion", None).unwrap();
id
}
#[test]
fn automatic_contract_is_runtime_scoped_only() {
let mut contract = TaskContract::automatic("model returned text");
let mut graph = ProofGraph::new(contract.id);
let id = graph.record(evidence(EvidenceKind::Runtime, "proofborne.runtime", true));
graph
.link(&contract, id, "runtime_completed", None)
.unwrap();
let evaluation = graph.evaluate_detailed(&mut contract).unwrap();
assert_eq!(evaluation.outcome, RunOutcome::Blocked);
assert_eq!(evaluation.claim_scope, ClaimScope::Runtime);
assert_ne!(evaluation.claim_scope, ClaimScope::Task);
}
#[test]
fn runtime_only_criteria_cannot_be_relabelled_as_task_proof() {
let mut contract = TaskContract::automatic("model returned text");
contract.claim_scope = ClaimScope::Task;
assert!(matches!(
contract.validate(),
Err(ProofError::InvalidContract(message))
if message.contains("runtime-only criteria cannot prove a task")
));
}
#[test]
fn human_only_criterion_is_not_machine_checkable_task_proof() {
let mut criterion = Criterion::required("human", "a person says it is done");
criterion.evidence_requirement.allowed_kinds = [EvidenceKind::Human].into_iter().collect();
criterion.evidence_requirement.allowed_producers =
["human.user".to_owned()].into_iter().collect();
criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
let contract = TaskContract::new("task", vec![criterion]);
assert!(matches!(
contract.validate(),
Err(ProofError::InvalidContract(message)) if message.contains("task-capable evidence")
));
}
#[test]
fn mixed_requirement_cannot_use_runtime_observation_as_task_evidence() {
let mut criterion = Criterion::required("criterion", "task result");
criterion.evidence_requirement.allowed_kinds =
[EvidenceKind::Runtime, EvidenceKind::Process]
.into_iter()
.collect();
criterion.evidence_requirement.allowed_producers =
["proofborne.runtime".to_owned()].into_iter().collect();
criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
let mut contract = TaskContract::new("task", vec![criterion]);
let mut graph = ProofGraph::new(contract.id);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Runtime, "proofborne.runtime", true),
);
assert!(contract.validate().is_ok());
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
assert_eq!(contract.criteria[0].state, CriterionState::Passed);
}
#[test]
fn task_contract_rejects_wildcard_producer() {
let mut criterion = Criterion::required("criterion", "task result");
criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceGeneration;
let contract = TaskContract::new("task", vec![criterion]);
assert!(matches!(
contract.validate(),
Err(ProofError::InvalidContract(message)) if message.contains("allowed producer")
));
}
#[test]
fn task_contract_rejects_evidence_not_bound_to_final_workspace() {
let mut criterion = Criterion::required("criterion", "task result");
criterion.evidence_requirement.allowed_producers =
["proofborne.verify".to_owned()].into_iter().collect();
let contract = TaskContract::new("task", vec![criterion]);
assert!(matches!(
contract.validate(),
Err(ProofError::InvalidContract(message)) if message.contains("final workspace")
));
}
#[test]
fn failed_evidence_prevents_verified_outcome() {
let mut contract = TaskContract::automatic("test");
let mut graph = ProofGraph::new(contract.id);
let evidence = evidence(EvidenceKind::Runtime, "proofborne.runtime", false);
let id = graph.record(evidence);
graph
.link(&contract, id, "runtime_completed", None)
.unwrap();
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Failed);
assert_eq!(contract.criteria[0].state, CriterionState::Failed);
}
#[test]
fn explicit_later_attempt_supersedes_failure_but_preserves_history() {
let mut contract = TaskContract::new(
"fix it",
vec![task_criterion("criterion", "tests pass", &["test"])],
);
let mut graph = ProofGraph::new(contract.id);
let first_attempt = Uuid::now_v7();
let failed = evidence(EvidenceKind::Process, "test", false).with_attempt(first_attempt, 1);
let failed_id = link_one(&contract, &mut graph, failed);
let passed = evidence(EvidenceKind::Process, "test", true)
.with_attempt(Uuid::now_v7(), 2)
.superseding([failed_id]);
let passed_id = link_one(&contract, &mut graph, passed);
let evaluation = graph.evaluate_detailed(&mut contract).unwrap();
assert_eq!(evaluation.outcome, RunOutcome::Verified);
assert_eq!(graph.evidence.len(), 2);
assert_eq!(
evaluation.criteria[0].evidence_ids,
vec![failed_id, passed_id]
);
assert_eq!(evaluation.criteria[0].active_evidence_ids, vec![passed_id]);
assert!(
evaluation.criteria[0]
.unresolved_counterevidence_ids
.is_empty()
);
}
#[test]
fn failure_without_valid_later_attempt_remains_counterevidence() {
let mut contract = TaskContract::new(
"fix it",
vec![task_criterion("criterion", "tests pass", &["test"])],
);
let mut graph = ProofGraph::new(contract.id);
let failed = evidence(EvidenceKind::Process, "test", false).with_attempt(Uuid::now_v7(), 2);
let failed_id = link_one(&contract, &mut graph, failed);
let earlier_success = evidence(EvidenceKind::Process, "test", true)
.with_attempt(Uuid::now_v7(), 1)
.superseding([failed_id]);
link_one(&contract, &mut graph, earlier_success);
let evaluation = graph.evaluate_detailed(&mut contract).unwrap();
assert_eq!(evaluation.outcome, RunOutcome::Failed);
assert_eq!(
evaluation.criteria[0].unresolved_counterevidence_ids,
vec![failed_id]
);
}
#[test]
fn typed_requirement_filters_kind_producer_artifact_and_assurance() {
let mut criterion = task_criterion("criterion", "verified output", &["trusted.test"]);
criterion.evidence_requirement.allowed_kinds =
[EvidenceKind::Process].into_iter().collect();
criterion.evidence_requirement.allowed_producers =
["trusted.test".to_owned()].into_iter().collect();
criterion.evidence_requirement.require_artifacts = true;
criterion.evidence_requirement.minimum_assurance = AssuranceLevel::Isolated;
let mut contract = TaskContract::new("verify", vec![criterion]);
let mut graph = ProofGraph::new(contract.id);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "trusted.test", true),
);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
let mut qualifying = evidence(EvidenceKind::Process, "trusted.test", true)
.with_assurance(AssuranceLevel::Isolated);
qualifying.artifacts.push(ArtifactRef {
hash: "abc".to_owned(),
name: "report.xml".to_owned(),
media_type: Some("application/xml".to_owned()),
size: 3,
});
link_one(&contract, &mut graph, qualifying);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Verified);
}
#[test]
fn freshness_requires_final_generation_and_state_binding() {
let mut criterion = task_criterion("criterion", "fresh tests", &["test"]);
criterion.evidence_requirement.freshness = EvidenceFreshness::FinalWorkspaceState;
let mut contract = TaskContract::new("verify", vec![criterion]);
let mut graph = ProofGraph::new(contract.id);
graph.bind_final_workspace(2, Some("final-hash".to_owned()));
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test", true)
.bound_to_workspace(1, Some("old-hash".to_owned())),
);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test", true)
.bound_to_workspace(2, Some("final-hash".to_owned())),
);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Verified);
}
#[test]
fn independent_producers_are_counted_by_identity() {
let mut criterion =
task_criterion("criterion", "independent checks", &["test-a", "test-b"]);
criterion.evidence_requirement.minimum_observations = 2;
criterion.evidence_requirement.minimum_independent_producers = 2;
let mut contract = TaskContract::new("verify", vec![criterion]);
let mut graph = ProofGraph::new(contract.id);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test-a", true),
);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test-a", true),
);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test-b", true),
);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Verified);
}
#[test]
fn waiver_is_visible_in_outcome() {
let mut contract = TaskContract::new(
"test",
vec![task_criterion("criterion", "task result", &["test"])],
);
contract
.waive("criterion", "user", "accepted manually")
.unwrap();
let graph = ProofGraph::new(contract.id);
assert_eq!(
graph.evaluate(&mut contract).unwrap(),
RunOutcome::VerifiedWithWaivers
);
}
#[test]
fn runtime_waiver_still_cannot_produce_exit_zero_outcome() {
let mut contract = TaskContract::automatic("test");
contract
.waive("runtime_completed", "user", "accepted manually")
.unwrap();
let graph = ProofGraph::new(contract.id);
assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Blocked);
}
#[test]
fn old_json_defaults_to_runtime_scope_and_conservative_assurance() {
let id = Uuid::now_v7();
let contract: TaskContract = serde_json::from_value(json!({
"schemaVersion": SCHEMA_VERSION,
"id": id,
"goal": "old contract",
"criteria": [{
"id": "criterion",
"description": "old criterion",
"required": true,
"minimumEvidence": 2,
"state": "pending",
"evidenceIds": []
}],
"createdAt": "2026-07-29T00:00:00Z",
"confirmed": true
}))
.unwrap();
assert_eq!(contract.claim_scope, ClaimScope::Runtime);
assert_eq!(
contract.criteria[0]
.evidence_requirement
.minimum_observations,
1
);
assert_eq!(contract.criteria[0].minimum_evidence, 2);
}
#[test]
fn old_evidence_json_gets_backward_safe_metadata_defaults() {
let evidence = evidence(EvidenceKind::Process, "legacy", true);
let mut value = serde_json::to_value(evidence).unwrap();
let object = value.as_object_mut().unwrap();
object.remove("assuranceLevel");
object.remove("attemptId");
object.remove("attemptSequence");
object.remove("actionId");
object.remove("workspaceGeneration");
object.remove("stateBinding");
object.remove("supersedes");
let restored: Evidence = serde_json::from_value(value).unwrap();
assert_eq!(restored.assurance_level, AssuranceLevel::Recorded);
assert_eq!(restored.attempt_id, None);
assert_eq!(restored.workspace_generation, None);
assert!(restored.supersedes.is_empty());
}
#[test]
fn public_json_uses_camel_case_fields() {
let contract = TaskContract::automatic("json");
let value = serde_json::to_value(contract).unwrap();
assert_eq!(value["claimScope"], "runtime");
assert!(value["criteria"][0].get("evidenceRequirement").is_some());
assert!(
value["criteria"][0]["evidenceRequirement"]
.get("minimumObservations")
.is_some()
);
}
proptest! {
#[test]
fn unresolved_failure_always_beats_any_number_of_successes(successes in 0_usize..32) {
let mut contract = TaskContract::new(
"property",
vec![task_criterion("criterion", "must hold", &["test"])],
);
let mut graph = ProofGraph::new(contract.id);
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test", false),
);
for _ in 0..successes {
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test", true),
);
}
prop_assert_eq!(graph.evaluate(&mut contract).unwrap(), RunOutcome::Failed);
}
#[test]
fn minimum_observation_count_is_enforced(required in 1_u32..16, present in 0_u32..16) {
let mut criterion = task_criterion("criterion", "counted", &["test"]);
criterion.minimum_evidence = required;
criterion.evidence_requirement.minimum_observations = required;
let mut contract = TaskContract::new("property", vec![criterion]);
let mut graph = ProofGraph::new(contract.id);
for _ in 0..present {
link_one(
&contract,
&mut graph,
evidence(EvidenceKind::Process, "test", true),
);
}
let outcome = graph.evaluate(&mut contract).unwrap();
prop_assert_eq!(outcome == RunOutcome::Verified, present >= required);
}
}
}