Skip to main content

actl_core/
reports.rs

1//! Caller-authored claims and explicit evidence references, never an automatic truth verdict.
2use crate::{
3    CtlError,
4    history::{FeedbackSource, TaskReport, valid_id},
5};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::{io::Read, path::Path};
9
10#[derive(Debug, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "snake_case")]
12pub enum StatementKind {
13    Fact,
14    Inference,
15    UserFeedback,
16}
17#[derive(Debug, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "snake_case")]
19pub enum EvidenceKind {
20    LocalFile,
21    ExternalReference,
22}
23#[derive(Debug, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct EvidenceRef {
26    pub id: String,
27    pub kind: EvidenceKind,
28    pub reference: String,
29}
30
31pub fn validate(report: &TaskReport) -> Result<(), CtlError> {
32    let mut ids = std::collections::HashSet::new();
33    for artifact in &report.artifacts {
34        if !valid_id(&artifact.id)
35            || !ids.insert(&artifact.id)
36            || artifact.reference.is_empty()
37            || artifact.reference.len() > 4096
38        {
39            return Err(CtlError::protocol(
40                "artifact requires unique ID and bounded reference",
41            ));
42        }
43        if artifact.kind == EvidenceKind::LocalFile
44            && (!Path::new(&artifact.reference).is_absolute()
45                || artifact.reference.starts_with("\\\\")
46                || artifact.reference.starts_with("//"))
47        {
48            return Err(CtlError::protocol(
49                "local evidence requires an absolute non-UNC path; use external_reference otherwise",
50            ));
51        }
52    }
53    for item in &report.feedback {
54        if item.evidence_ids.iter().any(|id| !ids.contains(id)) {
55            return Err(CtlError::protocol(
56                "feedback references unknown artifact ID",
57            ));
58        }
59        match item.kind {
60            Some(StatementKind::Fact) if !item.verified || item.evidence_ids.is_empty() => {
61                return Err(CtlError::protocol(
62                    "facts require caller verification and evidence IDs",
63                ));
64            }
65            Some(StatementKind::Inference) if item.verified => {
66                return Err(CtlError::protocol("inference cannot be marked verified"));
67            }
68            Some(StatementKind::UserFeedback) if !matches!(item.source, FeedbackSource::User) => {
69                return Err(CtlError::protocol("user_feedback requires source=user"));
70            }
71            _ => {}
72        }
73    }
74    Ok(())
75}
76
77fn availability(artifact: &EvidenceRef) -> &'static str {
78    if artifact.kind == EvidenceKind::ExternalReference {
79        return "unchecked";
80    }
81    match std::fs::metadata(&artifact.reference) {
82        Ok(m) if m.is_file() => {
83            if std::fs::File::open(&artifact.reference).is_ok() {
84                "available"
85            } else {
86                "unreadable"
87            }
88        }
89        Ok(_) => "not_a_file",
90        Err(e) if e.kind() == std::io::ErrorKind::NotFound => "missing",
91        Err(_) => "unreadable",
92    }
93}
94pub fn read(root: &Path, task: &str, limit: usize) -> Result<Value, CtlError> {
95    let dir = root.join("tasks");
96    if !dir.exists() {
97        return Ok(json!([]));
98    }
99    let mut files = vec![];
100    for entry in std::fs::read_dir(dir).map_err(|e| CtlError::internal(e.to_string()))? {
101        let entry = entry.map_err(|e| CtlError::internal(e.to_string()))?;
102        if entry.file_type().is_ok_and(|t| t.is_file())
103            && entry
104                .file_name()
105                .to_string_lossy()
106                .starts_with(&format!("{task}-"))
107        {
108            files.push((
109                entry.metadata().and_then(|m| m.modified()).ok(),
110                entry.path(),
111            ));
112        }
113    }
114    files.sort_by_key(|f| std::cmp::Reverse(f.0));
115    let mut result = vec![];
116    for (_, path) in files {
117        if result.len() == limit {
118            break;
119        }
120        let mut bytes = vec![];
121        std::fs::File::open(&path)
122            .and_then(|f| f.take(131073).read_to_end(&mut bytes))
123            .map_err(|e| CtlError::internal(e.to_string()))?;
124        if bytes.len() > 131072 {
125            return Err(CtlError::protocol("archived report exceeds read limit"));
126        }
127        let mut doc: Value =
128            serde_json::from_slice(&bytes).map_err(|e| CtlError::protocol(e.to_string()))?;
129        if doc["report"]["task_id"].as_str() != Some(task) {
130            continue;
131        }
132        let report: TaskReport = serde_json::from_value(doc["report"].clone())
133            .map_err(|e| CtlError::protocol(e.to_string()))?;
134        validate(&report)?;
135        doc["evidence_status"] = json!(report.artifacts.iter().map(|a| json!({"id":a.id,"availability":availability(a),"checked_ms":crate::state::unix_ms()})).collect::<Vec<_>>());
136        doc["archive_path"] = json!(path);
137        result.push(doc);
138    }
139    Ok(json!(result))
140}