Skip to main content

evidential_protocol/
audit.rs

1//! Bilateral Audit Ledger for the Evidential Protocol.
2//!
3//! Records every interaction between callers, targets, and tools with full
4//! evidence classification. Supports indexed queries and hot-spot analysis.
5
6use crate::types::*;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fmt;
10
11// ---------------------------------------------------------------------------
12// AuditAction
13// ---------------------------------------------------------------------------
14
15/// The type of action performed in an audited interaction.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum AuditAction {
19    /// Read-only access to a resource.
20    Read,
21    /// Mutation of a resource.
22    Write,
23    /// Removal of a resource.
24    Delete,
25    /// Invocation of a tool or sub-agent.
26    Invoke,
27}
28
29impl fmt::Display for AuditAction {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Read => write!(f, "read"),
33            Self::Write => write!(f, "write"),
34            Self::Delete => write!(f, "delete"),
35            Self::Invoke => write!(f, "invoke"),
36        }
37    }
38}
39
40// ---------------------------------------------------------------------------
41// AuditEntry
42// ---------------------------------------------------------------------------
43
44/// A single audited interaction in the ledger.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AuditEntry {
47    /// The agent or system that performed the action.
48    pub caller: String,
49    /// The resource or entity acted upon.
50    pub target: String,
51    /// What kind of action was performed.
52    pub action: AuditAction,
53    /// The tool used to perform the action.
54    pub tool: String,
55    /// Evidence classification of the action's output.
56    pub evidence_class: EvidenceClass,
57    /// ISO-8601 timestamp of when the action occurred.
58    pub timestamp: String,
59    /// Duration of the action in milliseconds.
60    pub duration_ms: u64,
61    /// Whether the action succeeded.
62    pub success: bool,
63}
64
65// ---------------------------------------------------------------------------
66// AuditLedger
67// ---------------------------------------------------------------------------
68
69/// Indexed bilateral audit ledger that records and queries interactions.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct AuditLedger {
72    entries: Vec<AuditEntry>,
73    #[serde(skip)]
74    caller_index: HashMap<String, Vec<usize>>,
75    #[serde(skip)]
76    target_index: HashMap<String, Vec<usize>>,
77    #[serde(skip)]
78    tool_index: HashMap<String, Vec<usize>>,
79}
80
81impl Default for AuditLedger {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl AuditLedger {
88    /// Create an empty audit ledger.
89    pub fn new() -> Self {
90        Self {
91            entries: Vec::new(),
92            caller_index: HashMap::new(),
93            target_index: HashMap::new(),
94            tool_index: HashMap::new(),
95        }
96    }
97
98    /// Rebuild all indexes from the entries vector.
99    ///
100    /// Call this after deserialization to restore index state.
101    pub fn rebuild_indexes(&mut self) {
102        self.caller_index.clear();
103        self.target_index.clear();
104        self.tool_index.clear();
105        for (i, entry) in self.entries.iter().enumerate() {
106            self.caller_index
107                .entry(entry.caller.clone())
108                .or_default()
109                .push(i);
110            self.target_index
111                .entry(entry.target.clone())
112                .or_default()
113                .push(i);
114            self.tool_index
115                .entry(entry.tool.clone())
116                .or_default()
117                .push(i);
118        }
119    }
120
121    /// Record a new audit entry and return a reference to it.
122    #[allow(clippy::too_many_arguments)]
123    pub fn record(
124        &mut self,
125        caller: &str,
126        target: &str,
127        action: AuditAction,
128        tool: &str,
129        evidence_class: EvidenceClass,
130        duration_ms: u64,
131        success: bool,
132    ) -> &AuditEntry {
133        let idx = self.entries.len();
134        let entry = AuditEntry {
135            caller: caller.to_string(),
136            target: target.to_string(),
137            action,
138            tool: tool.to_string(),
139            evidence_class,
140            timestamp: chrono::Utc::now().to_rfc3339(),
141            duration_ms,
142            success,
143        };
144        self.entries.push(entry);
145
146        self.caller_index
147            .entry(caller.to_string())
148            .or_default()
149            .push(idx);
150        self.target_index
151            .entry(target.to_string())
152            .or_default()
153            .push(idx);
154        self.tool_index
155            .entry(tool.to_string())
156            .or_default()
157            .push(idx);
158
159        &self.entries[idx]
160    }
161
162    /// Return all entries for a given caller.
163    pub fn query_caller(&self, name: &str) -> Vec<&AuditEntry> {
164        self.caller_index
165            .get(name)
166            .map(|indices| indices.iter().map(|&i| &self.entries[i]).collect())
167            .unwrap_or_default()
168    }
169
170    /// Return all entries targeting a given resource.
171    pub fn query_target(&self, resource: &str) -> Vec<&AuditEntry> {
172        self.target_index
173            .get(resource)
174            .map(|indices| indices.iter().map(|&i| &self.entries[i]).collect())
175            .unwrap_or_default()
176    }
177
178    /// Return all entries that used a given tool.
179    pub fn query_tool(&self, tool: &str) -> Vec<&AuditEntry> {
180        self.tool_index
181            .get(tool)
182            .map(|indices| indices.iter().map(|&i| &self.entries[i]).collect())
183            .unwrap_or_default()
184    }
185
186    /// Return the top `n` most-touched targets by entry count.
187    pub fn hot_targets(&self, n: usize) -> Vec<(&str, usize)> {
188        let mut counts: Vec<(&str, usize)> = self
189            .target_index
190            .iter()
191            .map(|(k, v)| (k.as_str(), v.len()))
192            .collect();
193        counts.sort_by(|a, b| b.1.cmp(&a.1));
194        counts.truncate(n);
195        counts
196    }
197
198    /// Return the top `n` most-active callers by entry count.
199    pub fn active_callers(&self, n: usize) -> Vec<(&str, usize)> {
200        let mut counts: Vec<(&str, usize)> = self
201            .caller_index
202            .iter()
203            .map(|(k, v)| (k.as_str(), v.len()))
204            .collect();
205        counts.sort_by(|a, b| b.1.cmp(&a.1));
206        counts.truncate(n);
207        counts
208    }
209
210    /// Return all entries where a specific caller touched a specific target.
211    pub fn cross_reference(&self, caller: &str, target: &str) -> Vec<&AuditEntry> {
212        let caller_indices = match self.caller_index.get(caller) {
213            Some(v) => v,
214            None => return Vec::new(),
215        };
216        let target_indices = match self.target_index.get(target) {
217            Some(v) => v,
218            None => return Vec::new(),
219        };
220
221        // Intersect the two sorted index lists.
222        let mut result = Vec::new();
223        let (mut ci, mut ti) = (0, 0);
224        while ci < caller_indices.len() && ti < target_indices.len() {
225            match caller_indices[ci].cmp(&target_indices[ti]) {
226                std::cmp::Ordering::Equal => {
227                    result.push(&self.entries[caller_indices[ci]]);
228                    ci += 1;
229                    ti += 1;
230                }
231                std::cmp::Ordering::Less => ci += 1,
232                std::cmp::Ordering::Greater => ti += 1,
233            }
234        }
235        result
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn record_and_query() {
245        let mut ledger = AuditLedger::new();
246        ledger.record(
247            "agent-a",
248            "file.rs",
249            AuditAction::Read,
250            "Read",
251            EvidenceClass::Direct,
252            42,
253            true,
254        );
255        ledger.record(
256            "agent-b",
257            "file.rs",
258            AuditAction::Write,
259            "Edit",
260            EvidenceClass::Inferred,
261            100,
262            true,
263        );
264        ledger.record(
265            "agent-a",
266            "config.toml",
267            AuditAction::Read,
268            "Read",
269            EvidenceClass::Direct,
270            10,
271            true,
272        );
273
274        assert_eq!(ledger.query_caller("agent-a").len(), 2);
275        assert_eq!(ledger.query_target("file.rs").len(), 2);
276        assert_eq!(ledger.query_tool("Read").len(), 2);
277        assert_eq!(ledger.cross_reference("agent-a", "file.rs").len(), 1);
278
279        let hot = ledger.hot_targets(1);
280        assert_eq!(hot[0].0, "file.rs");
281        assert_eq!(hot[0].1, 2);
282
283        let active = ledger.active_callers(1);
284        assert_eq!(active[0].0, "agent-a");
285        assert_eq!(active[0].1, 2);
286    }
287}