Skip to main content

harn_vm/
trust_graph.rs

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}