Skip to main content

lean_ctx/core/
evidence_ledger.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5const MAX_ITEMS: usize = 500;
6const MAX_KEY_CHARS: usize = 128;
7const MAX_VALUE_EXCERPT_CHARS: usize = 512;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EvidenceItemKindV1 {
12    ToolReceipt,
13    Manual,
14    ProofArtifact,
15    CiReceipt,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct EvidenceItemV1 {
20    pub id: String,
21    pub kind: EvidenceItemKindV1,
22    pub key: String,
23    #[serde(default)]
24    pub value_md5: Option<String>,
25    #[serde(default)]
26    pub value_excerpt: Option<String>,
27    #[serde(default)]
28    pub tool: Option<String>,
29    #[serde(default)]
30    pub action: Option<String>,
31    #[serde(default)]
32    pub input_md5: Option<String>,
33    #[serde(default)]
34    pub output_md5: Option<String>,
35    #[serde(default)]
36    pub agent_id: Option<String>,
37    #[serde(default)]
38    pub client_name: Option<String>,
39    /// For referenced evidence payloads, store the **basename only** to avoid leaking full paths.
40    #[serde(default)]
41    pub artifact_name: Option<String>,
42    pub timestamp: DateTime<Utc>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct EvidenceLedgerV1 {
47    pub schema_version: u32,
48    pub created_at: DateTime<Utc>,
49    pub updated_at: DateTime<Utc>,
50    #[serde(default)]
51    pub items: Vec<EvidenceItemV1>,
52}
53
54impl Default for EvidenceLedgerV1 {
55    fn default() -> Self {
56        let now = Utc::now();
57        Self {
58            schema_version: crate::core::contracts::WORKFLOW_EVIDENCE_LEDGER_V1_SCHEMA_VERSION,
59            created_at: now,
60            updated_at: now,
61            items: Vec::new(),
62        }
63    }
64}
65
66fn ledger_path() -> Option<PathBuf> {
67    crate::core::data_dir::lean_ctx_data_dir()
68        .ok()
69        .map(|d| d.join("workflows").join("evidence-ledger-v1.json"))
70}
71
72impl EvidenceLedgerV1 {
73    pub fn load() -> Self {
74        let Some(path) = ledger_path() else {
75            return Self::default();
76        };
77        let content = std::fs::read_to_string(&path).unwrap_or_default();
78        serde_json::from_str::<Self>(&content).unwrap_or_default()
79    }
80
81    pub fn save(&self) -> Result<(), String> {
82        let Some(path) = ledger_path() else {
83            return Err("no data dir".to_string());
84        };
85        if let Some(parent) = path.parent() {
86            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
87        }
88        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
89        crate::config_io::write_atomic_with_backup(&path, &json)?;
90        Ok(())
91    }
92
93    pub fn has_key(&self, key: &str) -> bool {
94        self.items.iter().any(|i| i.key == key)
95    }
96
97    pub fn record_tool_receipt(
98        &mut self,
99        tool: &str,
100        action: Option<&str>,
101        input_md5: &str,
102        output_md5: &str,
103        agent_id: Option<&str>,
104        client_name: Option<&str>,
105        created_at: DateTime<Utc>,
106    ) {
107        let r = ToolReceiptRecord {
108            tool,
109            action,
110            input_md5,
111            output_md5,
112            agent_id,
113            client_name,
114            ts: created_at,
115        };
116        self.record_tool_receipt_key(&format!("tool:{tool}"), &r);
117        if let Some(a) = action.filter(|a| !a.trim().is_empty()) {
118            let r = ToolReceiptRecord {
119                action: Some(a),
120                ..r
121            };
122            self.record_tool_receipt_key(&format!("tool:{tool}:{a}"), &r);
123        }
124        self.prune_in_place();
125    }
126
127    fn record_tool_receipt_key(&mut self, key: &str, r: &ToolReceiptRecord<'_>) {
128        let key = truncate(key, MAX_KEY_CHARS);
129        let id = crate::core::hasher::hash_str(&format!(
130            "tool_receipt|{key}|{}|{}|{}|{}",
131            r.tool,
132            r.action.unwrap_or(""),
133            r.input_md5,
134            r.output_md5
135        ));
136        self.upsert_item(EvidenceItemV1 {
137            id,
138            kind: EvidenceItemKindV1::ToolReceipt,
139            key,
140            value_md5: None,
141            value_excerpt: None,
142            tool: Some(r.tool.to_string()),
143            action: r.action.map(std::string::ToString::to_string),
144            input_md5: Some(r.input_md5.to_string()),
145            output_md5: Some(r.output_md5.to_string()),
146            agent_id: r.agent_id.map(std::string::ToString::to_string),
147            client_name: r.client_name.map(std::string::ToString::to_string),
148            artifact_name: None,
149            timestamp: r.ts,
150        });
151    }
152
153    pub fn record_manual(&mut self, key: &str, value: Option<&str>, created_at: DateTime<Utc>) {
154        let key = truncate(key, MAX_KEY_CHARS);
155        let value_redacted = value.map(crate::core::redaction::redact_text);
156        let value_md5 = value_redacted.as_deref().map(crate::core::hasher::hash_str);
157        let value_excerpt = value_redacted
158            .as_deref()
159            .map(|v| truncate(v, MAX_VALUE_EXCERPT_CHARS));
160        let id = crate::core::hasher::hash_str(&format!(
161            "manual|{key}|{}",
162            value_md5.as_deref().unwrap_or("")
163        ));
164        self.upsert_item(EvidenceItemV1 {
165            id,
166            kind: EvidenceItemKindV1::Manual,
167            key,
168            value_md5,
169            value_excerpt,
170            tool: None,
171            action: None,
172            input_md5: None,
173            output_md5: None,
174            agent_id: None,
175            client_name: None,
176            artifact_name: None,
177            timestamp: created_at,
178        });
179        self.prune_in_place();
180    }
181
182    pub fn record_artifact_file(
183        &mut self,
184        key: &str,
185        path: &Path,
186        created_at: DateTime<Utc>,
187    ) -> Result<(), String> {
188        let key = truncate(key, MAX_KEY_CHARS);
189        let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
190        let value_md5 = Some(crate::core::hasher::hash_hex(&bytes));
191        let name = path
192            .file_name()
193            .map(|n| n.to_string_lossy().to_string())
194            .unwrap_or_default();
195        let id = crate::core::hasher::hash_str(&format!(
196            "artifact|{key}|{}|{}",
197            name,
198            value_md5.as_deref().unwrap_or("")
199        ));
200        self.upsert_item(EvidenceItemV1 {
201            id,
202            kind: EvidenceItemKindV1::ProofArtifact,
203            key,
204            value_md5,
205            value_excerpt: None,
206            tool: None,
207            action: None,
208            input_md5: None,
209            output_md5: None,
210            agent_id: None,
211            client_name: None,
212            artifact_name: Some(name),
213            timestamp: created_at,
214        });
215        self.prune_in_place();
216        Ok(())
217    }
218
219    fn upsert_item(&mut self, item: EvidenceItemV1) {
220        if let Some(existing) = self.items.iter_mut().find(|i| i.id == item.id) {
221            existing.timestamp = item.timestamp;
222            existing.key = item.key;
223            existing.value_md5 = item.value_md5;
224            existing.value_excerpt = item.value_excerpt;
225            existing.tool = item.tool;
226            existing.action = item.action;
227            existing.input_md5 = item.input_md5;
228            existing.output_md5 = item.output_md5;
229            existing.agent_id = item.agent_id;
230            existing.client_name = item.client_name;
231            existing.artifact_name = item.artifact_name;
232            self.updated_at = Utc::now();
233            return;
234        }
235        self.items.push(item);
236        self.updated_at = Utc::now();
237    }
238
239    fn prune_in_place(&mut self) {
240        if self.items.len() <= MAX_ITEMS {
241            return;
242        }
243        self.items.sort_by_key(|i| i.timestamp.timestamp_millis());
244        while self.items.len() > MAX_ITEMS {
245            self.items.remove(0);
246        }
247    }
248}
249
250fn truncate(s: &str, max: usize) -> String {
251    let s = s.trim();
252    if s.len() <= max {
253        return s.to_string();
254    }
255    let mut out = s[..max].to_string();
256    out.push('…');
257    out
258}
259
260#[derive(Clone, Copy)]
261struct ToolReceiptRecord<'a> {
262    tool: &'a str,
263    action: Option<&'a str>,
264    input_md5: &'a str,
265    output_md5: &'a str,
266    agent_id: Option<&'a str>,
267    client_name: Option<&'a str>,
268    ts: DateTime<Utc>,
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn tool_receipt_id_is_deterministic() {
277        let ts = Utc::now();
278        let mut a = EvidenceLedgerV1::default();
279        a.record_tool_receipt(
280            "ctx_read",
281            Some("full"),
282            "in",
283            "out",
284            Some("agent"),
285            Some("cursor"),
286            ts,
287        );
288        let id1 = a.items[0].id.clone();
289
290        let mut b = EvidenceLedgerV1::default();
291        b.record_tool_receipt(
292            "ctx_read",
293            Some("full"),
294            "in",
295            "out",
296            Some("agent"),
297            Some("cursor"),
298            ts,
299        );
300        let id2 = b.items[0].id.clone();
301        assert_eq!(id1, id2);
302    }
303
304    #[test]
305    fn prunes_to_max_items() {
306        let ts = Utc::now();
307        let mut l = EvidenceLedgerV1::default();
308        for i in 0..(MAX_ITEMS + 50) {
309            l.record_manual(&format!("k{i}"), None, ts);
310        }
311        assert!(l.items.len() <= MAX_ITEMS);
312    }
313}