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 #[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}