Skip to main content

split_brain_harness/
audit.rs

1/// Append-only audit log for the Ephemeral Tool Forge.
2///
3/// Every forge run (success or failure) writes one JSON line to the configured
4/// log file. The log is append-only and never modified in place.
5///
6/// Source code is never stored. Each entry carries a 64-bit FNV-1a fingerprint
7/// of the generated source so entries can be correlated to code that was
8/// compiled and executed without storing the source itself.
9use std::io::Write;
10
11use serde::{Deserialize, Serialize};
12
13// ---------------------------------------------------------------------------
14// Entry
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Serialize, Deserialize, Clone)]
18pub struct AuditEntry {
19    /// ISO 8601 UTC timestamp of the run.
20    pub timestamp: String,
21    /// Human-readable capability description.
22    pub capability: String,
23    /// Stable problem_signature derived from the capability request.
24    pub signature: String,
25    /// Number of generation attempts made.
26    pub attempt_count: usize,
27    /// Reputation tier before this run.
28    pub tier_before: String,
29    /// Reputation tier after this run.
30    pub tier_after: String,
31    /// Whether the forge produced a working tool.
32    pub succeeded: bool,
33    /// FNV-1a-64 hex fingerprint of the last generated source (if any).
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub source_fingerprint: Option<String>,
36    /// First 200 chars of the last failure reason (if failed).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub error_summary: Option<String>,
39}
40
41// ---------------------------------------------------------------------------
42// Append
43// ---------------------------------------------------------------------------
44
45pub fn append(path: &str, entry: &AuditEntry) -> std::io::Result<()> {
46    let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
47    line.push('\n');
48    let mut file = std::fs::OpenOptions::new()
49        .create(true)
50        .append(true)
51        .open(path)?;
52    file.write_all(line.as_bytes())
53}
54
55// ---------------------------------------------------------------------------
56// Read
57// ---------------------------------------------------------------------------
58
59pub fn read_all(path: &str) -> std::io::Result<Vec<AuditEntry>> {
60    let raw = std::fs::read_to_string(path)?;
61    let entries = raw
62        .lines()
63        .filter(|l| !l.trim().is_empty())
64        .filter_map(|l| serde_json::from_str::<AuditEntry>(l).ok())
65        .collect();
66    Ok(entries)
67}
68
69// ---------------------------------------------------------------------------
70// Display helpers
71// ---------------------------------------------------------------------------
72
73pub fn print_summary(path: &str, entries: &[AuditEntry]) {
74    let total = entries.len();
75    let succeeded = entries.iter().filter(|e| e.succeeded).count();
76    let failed = total - succeeded;
77    let last_ts = entries.last().map(|e| e.timestamp.as_str()).unwrap_or("—");
78
79    // Unique capabilities by signature
80    let mut caps: std::collections::HashMap<&str, (usize, usize, usize, &str)> =
81        std::collections::HashMap::new();
82    for e in entries {
83        let entry = caps.entry(e.capability.as_str()).or_insert((0, 0, 0, ""));
84        entry.0 += 1;
85        if e.succeeded {
86            entry.1 += 1;
87        } else {
88            entry.2 += 1;
89        }
90        entry.3 = e.tier_after.as_str();
91    }
92
93    println!("forge audit: {path}  ({total} entries)");
94    println!();
95    println!("summary");
96    println!("  total runs:    {total}  ({succeeded} succeeded, {failed} failed)");
97    println!("  unique caps:   {}", caps.len());
98    println!("  last run:      {last_ts}");
99    println!();
100
101    if caps.is_empty() {
102        return;
103    }
104
105    println!(
106        "{:<38} {:>5}  {:>4}  {:>4}  tier",
107        "capability", "runs", "pass", "fail"
108    );
109    println!("{}", "─".repeat(62));
110
111    let mut rows: Vec<(&str, usize, usize, usize, &str)> = caps
112        .iter()
113        .map(|(&cap, &(runs, pass, fail, tier))| (cap, runs, pass, fail, tier))
114        .collect();
115    rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
116
117    for (cap, runs, pass, fail, tier) in rows {
118        let display = if cap.len() > 37 {
119            format!("{}…", &cap[..36])
120        } else {
121            cap.to_string()
122        };
123        println!(
124            "{:<38} {:>5}  {:>4}  {:>4}  {tier}",
125            display, runs, pass, fail
126        );
127    }
128}
129
130pub fn print_tail(entries: &[AuditEntry], n: usize) {
131    let slice = if entries.len() > n {
132        &entries[entries.len() - n..]
133    } else {
134        entries
135    };
136
137    if slice.is_empty() {
138        println!("(no entries)");
139        return;
140    }
141
142    println!(
143        "{:<22}  {:<32}  {:>5}  {:>6}  tier_after",
144        "timestamp", "capability", "atts", "result"
145    );
146    println!("{}", "─".repeat(78));
147
148    for e in slice {
149        let cap = if e.capability.len() > 31 {
150            format!("{}…", &e.capability[..30])
151        } else {
152            e.capability.clone()
153        };
154        let result = if e.succeeded { "ok" } else { "FAIL" };
155        println!(
156            "{:<22}  {:<32}  {:>5}  {:>6}  {}",
157            e.timestamp, cap, e.attempt_count, result, e.tier_after
158        );
159    }
160}
161
162// ---------------------------------------------------------------------------
163// Fingerprint — FNV-1a-64, no external deps
164// ---------------------------------------------------------------------------
165
166pub fn fingerprint(data: &[u8]) -> String {
167    let mut hash: u64 = 0xcbf29ce484222325;
168    for &byte in data {
169        hash ^= byte as u64;
170        hash = hash.wrapping_mul(0x00000100000001b3);
171    }
172    format!("{hash:016x}")
173}
174
175// ---------------------------------------------------------------------------
176// ISO 8601 UTC timestamp — no external deps
177// ---------------------------------------------------------------------------
178
179pub fn iso_now() -> String {
180    use std::time::{SystemTime, UNIX_EPOCH};
181    let secs = SystemTime::now()
182        .duration_since(UNIX_EPOCH)
183        .map(|d| d.as_secs())
184        .unwrap_or(0);
185    unix_secs_to_iso(secs)
186}
187
188fn is_leap(year: u64) -> bool {
189    year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100))
190}
191
192fn unix_secs_to_iso(mut secs: u64) -> String {
193    let s = secs % 60;
194    secs /= 60;
195    let m = secs % 60;
196    secs /= 60;
197    let h = secs % 24;
198    let mut days = secs / 24;
199
200    let mut year = 1970u64;
201    loop {
202        let dy = if is_leap(year) { 366 } else { 365 };
203        if days < dy {
204            break;
205        }
206        days -= dy;
207        year += 1;
208    }
209
210    let month_days: [u64; 12] = [
211        31,
212        if is_leap(year) { 29 } else { 28 },
213        31,
214        30,
215        31,
216        30,
217        31,
218        31,
219        30,
220        31,
221        30,
222        31,
223    ];
224    let mut month = 1u64;
225    for &dm in &month_days {
226        if days < dm {
227            break;
228        }
229        days -= dm;
230        month += 1;
231    }
232    let day = days + 1;
233
234    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
235}
236
237// ---------------------------------------------------------------------------
238// Tests
239// ---------------------------------------------------------------------------
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn fingerprint_is_deterministic() {
247        let a = fingerprint(b"hello world");
248        let b = fingerprint(b"hello world");
249        assert_eq!(a, b);
250        assert_eq!(a.len(), 16);
251    }
252
253    #[test]
254    fn fingerprint_differs_on_different_input() {
255        assert_ne!(fingerprint(b"foo"), fingerprint(b"bar"));
256    }
257
258    #[test]
259    fn iso_now_looks_right() {
260        let ts = iso_now();
261        assert!(ts.ends_with('Z'), "expected Z suffix: {ts}");
262        assert!(ts.contains('T'), "expected T separator: {ts}");
263        assert!(ts.starts_with("20"), "expected 20xx year: {ts}");
264    }
265
266    #[test]
267    fn unix_secs_to_iso_epoch() {
268        assert_eq!(unix_secs_to_iso(0), "1970-01-01T00:00:00Z");
269    }
270
271    #[test]
272    fn unix_secs_to_iso_known_date() {
273        // 2026-01-01T00:00:00Z = 1767225600
274        assert_eq!(unix_secs_to_iso(1767225600), "2026-01-01T00:00:00Z");
275    }
276
277    #[test]
278    fn append_and_read_roundtrip() {
279        let dir = tempfile::tempdir().unwrap();
280        let path = dir.path().join("audit.jsonl");
281        let path_str = path.to_str().unwrap();
282
283        let entry = AuditEntry {
284            timestamp: "2026-01-01T00:00:00Z".into(),
285            capability: "word count".into(),
286            signature: "abc123".into(),
287            attempt_count: 1,
288            tier_before: "Untrusted".into(),
289            tier_after: "Emerging".into(),
290            succeeded: true,
291            source_fingerprint: Some("deadbeef12345678".into()),
292            error_summary: None,
293        };
294
295        append(path_str, &entry).unwrap();
296        append(path_str, &entry).unwrap();
297
298        let entries = read_all(path_str).unwrap();
299        assert_eq!(entries.len(), 2);
300        assert_eq!(entries[0].capability, "word count");
301        assert!(entries[1].succeeded);
302    }
303}