1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use crate::event_log::{
9 active_event_log, sanitize_topic_component, AnyEventLog, EventLog, LogError, LogEvent, Topic,
10};
11
12pub const OPENTRUSTGRAPH_SCHEMA_V0: &str = "opentrustgraph/v0";
13pub const TRUST_GRAPH_GLOBAL_TOPIC: &str = "trust.graph";
14pub const TRUST_GRAPH_TOPIC_PREFIX: &str = "trust.graph.";
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AutonomyTier {
19 Shadow,
20 Suggest,
21 ActWithApproval,
22 #[default]
23 ActAuto,
24}
25
26impl AutonomyTier {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::Shadow => "shadow",
30 Self::Suggest => "suggest",
31 Self::ActWithApproval => "act_with_approval",
32 Self::ActAuto => "act_auto",
33 }
34 }
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum TrustOutcome {
40 Success,
41 Failure,
42 Denied,
43 Timeout,
44}
45
46impl TrustOutcome {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::Success => "success",
50 Self::Failure => "failure",
51 Self::Denied => "denied",
52 Self::Timeout => "timeout",
53 }
54 }
55}
56
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58pub struct TrustRecord {
59 pub schema: String,
60 pub record_id: String,
61 pub agent: String,
62 pub action: String,
63 pub approver: Option<String>,
64 pub outcome: TrustOutcome,
65 pub trace_id: String,
66 pub autonomy_tier: AutonomyTier,
67 #[serde(with = "time::serde::rfc3339")]
68 pub timestamp: OffsetDateTime,
69 pub cost_usd: Option<f64>,
70 #[serde(default)]
71 pub metadata: BTreeMap<String, serde_json::Value>,
72}
73
74impl TrustRecord {
75 pub fn new(
76 agent: impl Into<String>,
77 action: impl Into<String>,
78 approver: Option<String>,
79 outcome: TrustOutcome,
80 trace_id: impl Into<String>,
81 autonomy_tier: AutonomyTier,
82 ) -> Self {
83 Self {
84 schema: OPENTRUSTGRAPH_SCHEMA_V0.to_string(),
85 record_id: Uuid::now_v7().to_string(),
86 agent: agent.into(),
87 action: action.into(),
88 approver,
89 outcome,
90 trace_id: trace_id.into(),
91 autonomy_tier,
92 timestamp: OffsetDateTime::now_utc(),
93 cost_usd: None,
94 metadata: BTreeMap::new(),
95 }
96 }
97}
98
99#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
100#[serde(default)]
101pub struct TrustQueryFilters {
102 pub agent: Option<String>,
103 pub action: Option<String>,
104 #[serde(with = "time::serde::rfc3339::option")]
105 pub since: Option<OffsetDateTime>,
106 #[serde(with = "time::serde::rfc3339::option")]
107 pub until: Option<OffsetDateTime>,
108 pub tier: Option<AutonomyTier>,
109 pub outcome: Option<TrustOutcome>,
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
113#[serde(default)]
114pub struct TrustAgentSummary {
115 pub agent: String,
116 pub total: u64,
117 pub success_rate: f64,
118 pub mean_cost_usd: Option<f64>,
119 pub tier_distribution: BTreeMap<String, u64>,
120 pub outcome_distribution: BTreeMap<String, u64>,
121}
122
123fn global_topic() -> Result<Topic, LogError> {
124 Topic::new(TRUST_GRAPH_GLOBAL_TOPIC)
125}
126
127pub fn topic_for_agent(agent: &str) -> Result<Topic, LogError> {
128 Topic::new(format!(
129 "{TRUST_GRAPH_TOPIC_PREFIX}{}",
130 sanitize_topic_component(agent)
131 ))
132}
133
134pub async fn append_trust_record(
135 log: &Arc<AnyEventLog>,
136 record: &TrustRecord,
137) -> Result<(), LogError> {
138 let payload = serde_json::to_value(record)
139 .map_err(|error| LogError::Serde(format!("trust record encode error: {error}")))?;
140 let mut headers = BTreeMap::new();
141 headers.insert("trace_id".to_string(), record.trace_id.clone());
142 headers.insert("agent".to_string(), record.agent.clone());
143 headers.insert(
144 "autonomy_tier".to_string(),
145 record.autonomy_tier.as_str().to_string(),
146 );
147 headers.insert("outcome".to_string(), record.outcome.as_str().to_string());
148 let event = LogEvent::new("trust_recorded", payload).with_headers(headers);
149 let per_agent = topic_for_agent(&record.agent)?;
150 log.append(&global_topic()?, event.clone()).await?;
151 log.append(&per_agent, event).await?;
152 Ok(())
153}
154
155pub async fn append_active_trust_record(record: &TrustRecord) -> Result<(), LogError> {
156 let log = active_event_log()
157 .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
158 append_trust_record(&log, record).await
159}
160
161pub async fn query_trust_records(
162 log: &Arc<AnyEventLog>,
163 filters: &TrustQueryFilters,
164) -> Result<Vec<TrustRecord>, LogError> {
165 let events = log.read_range(&global_topic()?, None, usize::MAX).await?;
166 let mut records = Vec::new();
167 for (_, event) in events {
168 if event.kind != "trust_recorded" {
169 continue;
170 }
171 let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
172 continue;
173 };
174 if !matches_filters(&record, filters) {
175 continue;
176 }
177 records.push(record);
178 }
179 records.sort_by(|left, right| {
180 left.timestamp
181 .cmp(&right.timestamp)
182 .then(left.agent.cmp(&right.agent))
183 .then(left.record_id.cmp(&right.record_id))
184 });
185 Ok(records)
186}
187
188pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
189 #[derive(Default)]
190 struct RunningSummary {
191 total: u64,
192 successes: u64,
193 cost_sum: f64,
194 cost_count: u64,
195 tier_distribution: BTreeMap<String, u64>,
196 outcome_distribution: BTreeMap<String, u64>,
197 }
198
199 let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
200 for record in records {
201 let entry = by_agent.entry(record.agent.clone()).or_default();
202 entry.total += 1;
203 if record.outcome == TrustOutcome::Success {
204 entry.successes += 1;
205 }
206 if let Some(cost_usd) = record.cost_usd {
207 entry.cost_sum += cost_usd;
208 entry.cost_count += 1;
209 }
210 *entry
211 .tier_distribution
212 .entry(record.autonomy_tier.as_str().to_string())
213 .or_default() += 1;
214 *entry
215 .outcome_distribution
216 .entry(record.outcome.as_str().to_string())
217 .or_default() += 1;
218 }
219
220 by_agent
221 .into_iter()
222 .map(|(agent, summary)| TrustAgentSummary {
223 agent,
224 total: summary.total,
225 success_rate: if summary.total == 0 {
226 0.0
227 } else {
228 summary.successes as f64 / summary.total as f64
229 },
230 mean_cost_usd: (summary.cost_count > 0)
231 .then_some(summary.cost_sum / summary.cost_count as f64),
232 tier_distribution: summary.tier_distribution,
233 outcome_distribution: summary.outcome_distribution,
234 })
235 .collect()
236}
237
238pub async fn resolve_agent_autonomy_tier(
239 log: &Arc<AnyEventLog>,
240 agent: &str,
241 default: AutonomyTier,
242) -> Result<AutonomyTier, LogError> {
243 let records = query_trust_records(
244 log,
245 &TrustQueryFilters {
246 agent: Some(agent.to_string()),
247 ..TrustQueryFilters::default()
248 },
249 )
250 .await?;
251 let mut current = default;
252 for record in records {
253 if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
254 && record.outcome == TrustOutcome::Success
255 {
256 current = record.autonomy_tier;
257 }
258 }
259 Ok(current)
260}
261
262fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
263 if let Some(agent) = filters.agent.as_deref() {
264 if record.agent != agent {
265 return false;
266 }
267 }
268 if let Some(action) = filters.action.as_deref() {
269 if record.action != action {
270 return false;
271 }
272 }
273 if let Some(since) = filters.since {
274 if record.timestamp < since {
275 return false;
276 }
277 }
278 if let Some(until) = filters.until {
279 if record.timestamp > until {
280 return false;
281 }
282 }
283 if let Some(tier) = filters.tier {
284 if record.autonomy_tier != tier {
285 return false;
286 }
287 }
288 if let Some(outcome) = filters.outcome {
289 if record.outcome != outcome {
290 return false;
291 }
292 }
293 true
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::event_log::MemoryEventLog;
300
301 #[tokio::test]
302 async fn append_and_query_round_trip() {
303 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
304 let mut record = TrustRecord::new(
305 "github-triage-bot",
306 "github.issue.opened",
307 Some("reviewer".to_string()),
308 TrustOutcome::Success,
309 "trace-1",
310 AutonomyTier::ActWithApproval,
311 );
312 record.cost_usd = Some(1.25);
313 append_trust_record(&log, &record).await.unwrap();
314
315 let records = query_trust_records(
316 &log,
317 &TrustQueryFilters {
318 agent: Some("github-triage-bot".to_string()),
319 ..TrustQueryFilters::default()
320 },
321 )
322 .await
323 .unwrap();
324
325 assert_eq!(records.len(), 1);
326 assert_eq!(records[0].agent, "github-triage-bot");
327 assert_eq!(records[0].cost_usd, Some(1.25));
328 }
329
330 #[tokio::test]
331 async fn resolve_autonomy_tier_prefers_latest_control_record() {
332 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
333 append_trust_record(
334 &log,
335 &TrustRecord::new(
336 "bot",
337 "trust.promote",
338 None,
339 TrustOutcome::Success,
340 "trace-1",
341 AutonomyTier::ActWithApproval,
342 ),
343 )
344 .await
345 .unwrap();
346 append_trust_record(
347 &log,
348 &TrustRecord::new(
349 "bot",
350 "trust.demote",
351 None,
352 TrustOutcome::Success,
353 "trace-2",
354 AutonomyTier::Shadow,
355 ),
356 )
357 .await
358 .unwrap();
359
360 let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
361 .await
362 .unwrap();
363 assert_eq!(tier, AutonomyTier::Shadow);
364 }
365}