Skip to main content

harn_vm/
trust_graph.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::event_log::{
10    active_event_log, sanitize_topic_component, AnyEventLog, EventId, EventLog, LogError, LogEvent,
11    Topic,
12};
13use crate::orchestration::CapabilityPolicy;
14
15pub const OPENTRUSTGRAPH_SCHEMA_V0: &str = "opentrustgraph/v0";
16pub const TRUST_GRAPH_GLOBAL_TOPIC: &str = "trust_graph";
17pub const TRUST_GRAPH_LEGACY_GLOBAL_TOPIC: &str = "trust.graph";
18pub const TRUST_GRAPH_TOPIC_PREFIX: &str = "trust_graph.";
19pub const TRUST_GRAPH_LEGACY_TOPIC_PREFIX: &str = "trust.graph.";
20pub const TRUST_GRAPH_EVENT_KIND: &str = "trust_recorded";
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AutonomyTier {
25    Shadow,
26    Suggest,
27    ActWithApproval,
28    #[default]
29    ActAuto,
30}
31
32impl AutonomyTier {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::Shadow => "shadow",
36            Self::Suggest => "suggest",
37            Self::ActWithApproval => "act_with_approval",
38            Self::ActAuto => "act_auto",
39        }
40    }
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum TrustOutcome {
46    Success,
47    Failure,
48    Denied,
49    Timeout,
50}
51
52impl TrustOutcome {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::Success => "success",
56            Self::Failure => "failure",
57            Self::Denied => "denied",
58            Self::Timeout => "timeout",
59        }
60    }
61}
62
63#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
64pub struct TrustRecord {
65    pub schema: String,
66    pub record_id: String,
67    pub agent: String,
68    pub action: String,
69    pub approver: Option<String>,
70    pub outcome: TrustOutcome,
71    pub trace_id: String,
72    pub autonomy_tier: AutonomyTier,
73    #[serde(with = "time::serde::rfc3339")]
74    pub timestamp: OffsetDateTime,
75    pub cost_usd: Option<f64>,
76    #[serde(default)]
77    pub chain_index: u64,
78    #[serde(default)]
79    pub previous_hash: Option<String>,
80    #[serde(default)]
81    pub entry_hash: String,
82    #[serde(default)]
83    pub metadata: BTreeMap<String, serde_json::Value>,
84}
85
86impl TrustRecord {
87    pub fn new(
88        agent: impl Into<String>,
89        action: impl Into<String>,
90        approver: Option<String>,
91        outcome: TrustOutcome,
92        trace_id: impl Into<String>,
93        autonomy_tier: AutonomyTier,
94    ) -> Self {
95        Self {
96            schema: OPENTRUSTGRAPH_SCHEMA_V0.to_string(),
97            record_id: Uuid::now_v7().to_string(),
98            agent: agent.into(),
99            action: action.into(),
100            approver,
101            outcome,
102            trace_id: trace_id.into(),
103            autonomy_tier,
104            timestamp: OffsetDateTime::now_utc(),
105            cost_usd: None,
106            chain_index: 0,
107            previous_hash: None,
108            entry_hash: String::new(),
109            metadata: BTreeMap::new(),
110        }
111    }
112}
113
114#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
115#[serde(default)]
116pub struct TrustQueryFilters {
117    pub agent: Option<String>,
118    pub action: Option<String>,
119    #[serde(with = "time::serde::rfc3339::option")]
120    pub since: Option<OffsetDateTime>,
121    #[serde(with = "time::serde::rfc3339::option")]
122    pub until: Option<OffsetDateTime>,
123    pub tier: Option<AutonomyTier>,
124    pub outcome: Option<TrustOutcome>,
125    pub limit: Option<usize>,
126    pub grouped_by_trace: bool,
127}
128
129#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
130#[serde(default)]
131pub struct TrustTraceGroup {
132    pub trace_id: String,
133    pub records: Vec<TrustRecord>,
134}
135
136#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
137#[serde(default)]
138pub struct TrustAgentSummary {
139    pub agent: String,
140    pub total: u64,
141    pub success_rate: f64,
142    pub mean_cost_usd: Option<f64>,
143    pub tier_distribution: BTreeMap<String, u64>,
144    pub outcome_distribution: BTreeMap<String, u64>,
145}
146
147#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
148#[serde(default)]
149pub struct TrustScore {
150    pub agent: String,
151    pub action: Option<String>,
152    pub total: u64,
153    pub successes: u64,
154    pub failures: u64,
155    pub denied: u64,
156    pub timeouts: u64,
157    pub success_rate: f64,
158    pub latest_outcome: Option<TrustOutcome>,
159    #[serde(with = "time::serde::rfc3339::option")]
160    pub latest_timestamp: Option<OffsetDateTime>,
161    pub effective_tier: AutonomyTier,
162    pub policy: CapabilityPolicy,
163}
164
165#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
166#[serde(default)]
167pub struct TrustChainReport {
168    pub topic: String,
169    pub total: u64,
170    pub verified: bool,
171    pub root_hash: Option<String>,
172    pub broken_at_event_id: Option<EventId>,
173    pub errors: Vec<String>,
174}
175
176fn global_topic() -> Result<Topic, LogError> {
177    Topic::new(TRUST_GRAPH_GLOBAL_TOPIC)
178}
179
180fn legacy_global_topic() -> Result<Topic, LogError> {
181    Topic::new(TRUST_GRAPH_LEGACY_GLOBAL_TOPIC)
182}
183
184pub fn topic_for_agent(agent: &str) -> Result<Topic, LogError> {
185    Topic::new(format!(
186        "{TRUST_GRAPH_TOPIC_PREFIX}{}",
187        sanitize_topic_component(agent)
188    ))
189}
190
191pub fn legacy_topic_for_agent(agent: &str) -> Result<Topic, LogError> {
192    Topic::new(format!(
193        "{TRUST_GRAPH_LEGACY_TOPIC_PREFIX}{}",
194        sanitize_topic_component(agent)
195    ))
196}
197
198pub async fn append_trust_record(
199    log: &Arc<AnyEventLog>,
200    record: &TrustRecord,
201) -> Result<TrustRecord, LogError> {
202    let finalized = finalize_trust_record(log, record.clone()).await?;
203    let payload = serde_json::to_value(&finalized)
204        .map_err(|error| LogError::Serde(format!("trust record encode error: {error}")))?;
205    let mut headers = BTreeMap::new();
206    headers.insert("trace_id".to_string(), finalized.trace_id.clone());
207    headers.insert("agent".to_string(), finalized.agent.clone());
208    headers.insert(
209        "autonomy_tier".to_string(),
210        finalized.autonomy_tier.as_str().to_string(),
211    );
212    headers.insert(
213        "outcome".to_string(),
214        finalized.outcome.as_str().to_string(),
215    );
216    headers.insert("entry_hash".to_string(), finalized.entry_hash.clone());
217    let event = LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers);
218    for topic in append_topics_for_record(&finalized)? {
219        log.append(&topic, event.clone()).await?;
220    }
221    Ok(finalized)
222}
223
224pub async fn append_active_trust_record(record: &TrustRecord) -> Result<TrustRecord, LogError> {
225    let log = active_event_log()
226        .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
227    append_trust_record(&log, record).await
228}
229
230pub async fn query_trust_records(
231    log: &Arc<AnyEventLog>,
232    filters: &TrustQueryFilters,
233) -> Result<Vec<TrustRecord>, LogError> {
234    let topics = query_topics(filters)?;
235    let mut records = Vec::new();
236    let mut seen = HashSet::new();
237    for topic in topics {
238        for (_, event) in log.read_range(&topic, None, usize::MAX).await? {
239            if event.kind != TRUST_GRAPH_EVENT_KIND {
240                continue;
241            }
242            let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
243                continue;
244            };
245            if !matches_filters(&record, filters) {
246                continue;
247            }
248            let dedupe_key = trust_record_dedupe_key(&record);
249            if seen.insert(dedupe_key) {
250                records.push(record);
251            }
252        }
253    }
254    records.sort_by(|left, right| {
255        left.timestamp
256            .cmp(&right.timestamp)
257            .then(left.chain_index.cmp(&right.chain_index))
258            .then(left.agent.cmp(&right.agent))
259            .then(left.record_id.cmp(&right.record_id))
260    });
261    apply_record_limit(&mut records, filters.limit);
262    Ok(records)
263}
264
265pub async fn trust_score_for(
266    log: &Arc<AnyEventLog>,
267    agent: &str,
268    action: Option<&str>,
269) -> Result<TrustScore, LogError> {
270    let records = query_trust_records(
271        log,
272        &TrustQueryFilters {
273            agent: Some(agent.to_string()),
274            action: action.map(ToString::to_string),
275            ..TrustQueryFilters::default()
276        },
277    )
278    .await?;
279    let effective_tier = resolve_agent_autonomy_tier(log, agent, AutonomyTier::ActAuto).await?;
280    Ok(score_from_records(agent, action, effective_tier, &records))
281}
282
283pub async fn policy_for_agent(
284    log: &Arc<AnyEventLog>,
285    agent: &str,
286) -> Result<CapabilityPolicy, LogError> {
287    Ok(trust_score_for(log, agent, None).await?.policy)
288}
289
290pub async fn verify_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainReport, LogError> {
291    let (topic, records) = preferred_chain_records(log).await?;
292    let mut previous_hash: Option<String> = None;
293    let mut errors = Vec::new();
294    let mut broken_at_event_id = None;
295
296    for (position, (event_id, record)) in records.iter().enumerate() {
297        let expected_index = (position as u64) + 1;
298        if record.chain_index != expected_index {
299            errors.push(format!(
300                "event {event_id}: expected chain_index {expected_index}, found {}",
301                record.chain_index
302            ));
303        }
304        if record.previous_hash != previous_hash {
305            errors.push(format!(
306                "event {event_id}: previous_hash mismatch; expected {:?}, found {:?}",
307                previous_hash, record.previous_hash
308            ));
309        }
310        match compute_trust_record_hash(record) {
311            Ok(expected_hash) if expected_hash == record.entry_hash => {}
312            Ok(expected_hash) => errors.push(format!(
313                "event {event_id}: entry_hash mismatch; expected {expected_hash}, found {}",
314                record.entry_hash
315            )),
316            Err(error) => errors.push(format!("event {event_id}: {error}")),
317        }
318        if !errors.is_empty() && broken_at_event_id.is_none() {
319            broken_at_event_id = Some(*event_id);
320        }
321        previous_hash = Some(record.entry_hash.clone());
322    }
323
324    Ok(TrustChainReport {
325        topic: topic.as_str().to_string(),
326        total: records.len() as u64,
327        verified: errors.is_empty(),
328        root_hash: records.last().map(|(_, record)| record.entry_hash.clone()),
329        broken_at_event_id,
330        errors,
331    })
332}
333
334pub fn compute_trust_record_hash(record: &TrustRecord) -> Result<String, LogError> {
335    let mut value = serde_json::to_value(record)
336        .map_err(|error| LogError::Serde(format!("trust record hash encode error: {error}")))?;
337    if let Some(object) = value.as_object_mut() {
338        object.remove("entry_hash");
339    }
340    let canonical = serde_json::to_string(&value)
341        .map_err(|error| LogError::Serde(format!("trust record canonicalize error: {error}")))?;
342    let digest = Sha256::digest(canonical.as_bytes());
343    Ok(format!("sha256:{}", hex::encode(digest)))
344}
345
346pub fn group_trust_records_by_trace(records: &[TrustRecord]) -> Vec<TrustTraceGroup> {
347    let mut groups: Vec<TrustTraceGroup> = Vec::new();
348    let mut positions: HashMap<String, usize> = HashMap::new();
349    for record in records {
350        if let Some(index) = positions.get(record.trace_id.as_str()).copied() {
351            groups[index].records.push(record.clone());
352            continue;
353        }
354        positions.insert(record.trace_id.clone(), groups.len());
355        groups.push(TrustTraceGroup {
356            trace_id: record.trace_id.clone(),
357            records: vec![record.clone()],
358        });
359    }
360    groups
361}
362
363pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
364    #[derive(Default)]
365    struct RunningSummary {
366        total: u64,
367        successes: u64,
368        cost_sum: f64,
369        cost_count: u64,
370        tier_distribution: BTreeMap<String, u64>,
371        outcome_distribution: BTreeMap<String, u64>,
372    }
373
374    let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
375    for record in records {
376        let entry = by_agent.entry(record.agent.clone()).or_default();
377        entry.total += 1;
378        if record.outcome == TrustOutcome::Success {
379            entry.successes += 1;
380        }
381        if let Some(cost_usd) = record.cost_usd {
382            entry.cost_sum += cost_usd;
383            entry.cost_count += 1;
384        }
385        *entry
386            .tier_distribution
387            .entry(record.autonomy_tier.as_str().to_string())
388            .or_default() += 1;
389        *entry
390            .outcome_distribution
391            .entry(record.outcome.as_str().to_string())
392            .or_default() += 1;
393    }
394
395    by_agent
396        .into_iter()
397        .map(|(agent, summary)| TrustAgentSummary {
398            agent,
399            total: summary.total,
400            success_rate: if summary.total == 0 {
401                0.0
402            } else {
403                summary.successes as f64 / summary.total as f64
404            },
405            mean_cost_usd: (summary.cost_count > 0)
406                .then_some(summary.cost_sum / summary.cost_count as f64),
407            tier_distribution: summary.tier_distribution,
408            outcome_distribution: summary.outcome_distribution,
409        })
410        .collect()
411}
412
413pub async fn resolve_agent_autonomy_tier(
414    log: &Arc<AnyEventLog>,
415    agent: &str,
416    default: AutonomyTier,
417) -> Result<AutonomyTier, LogError> {
418    let records = query_trust_records(
419        log,
420        &TrustQueryFilters {
421            agent: Some(agent.to_string()),
422            ..TrustQueryFilters::default()
423        },
424    )
425    .await?;
426    let mut current = default;
427    for record in records {
428        if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
429            && record.outcome == TrustOutcome::Success
430        {
431            current = record.autonomy_tier;
432        }
433    }
434    Ok(current)
435}
436
437fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
438    if let Some(agent) = filters.agent.as_deref() {
439        if record.agent != agent {
440            return false;
441        }
442    }
443    if let Some(action) = filters.action.as_deref() {
444        if record.action != action {
445            return false;
446        }
447    }
448    if let Some(since) = filters.since {
449        if record.timestamp < since {
450            return false;
451        }
452    }
453    if let Some(until) = filters.until {
454        if record.timestamp > until {
455            return false;
456        }
457    }
458    if let Some(tier) = filters.tier {
459        if record.autonomy_tier != tier {
460            return false;
461        }
462    }
463    if let Some(outcome) = filters.outcome {
464        if record.outcome != outcome {
465            return false;
466        }
467    }
468    true
469}
470
471fn query_topics(filters: &TrustQueryFilters) -> Result<Vec<Topic>, LogError> {
472    match filters.agent.as_deref() {
473        Some(agent) => unique_topics(vec![
474            topic_for_agent(agent)?,
475            legacy_topic_for_agent(agent)?,
476        ]),
477        None => unique_topics(vec![global_topic()?, legacy_global_topic()?]),
478    }
479}
480
481fn append_topics_for_record(record: &TrustRecord) -> Result<Vec<Topic>, LogError> {
482    unique_topics(vec![
483        global_topic()?,
484        legacy_global_topic()?,
485        topic_for_agent(&record.agent)?,
486        legacy_topic_for_agent(&record.agent)?,
487    ])
488}
489
490fn unique_topics(topics: Vec<Topic>) -> Result<Vec<Topic>, LogError> {
491    let mut seen = HashSet::new();
492    Ok(topics
493        .into_iter()
494        .filter(|topic| seen.insert(topic.as_str().to_string()))
495        .collect())
496}
497
498async fn finalize_trust_record(
499    log: &Arc<AnyEventLog>,
500    mut record: TrustRecord,
501) -> Result<TrustRecord, LogError> {
502    let latest = latest_chain_record(log).await?;
503    record.chain_index = latest
504        .as_ref()
505        .map(|(_, record)| record.chain_index.saturating_add(1).max(1))
506        .unwrap_or(1);
507    record.previous_hash = latest.and_then(|(_, record)| {
508        if record.entry_hash.is_empty() {
509            compute_trust_record_hash(&record).ok()
510        } else {
511            Some(record.entry_hash)
512        }
513    });
514    record.entry_hash.clear();
515    record.entry_hash = compute_trust_record_hash(&record)?;
516    Ok(record)
517}
518
519async fn latest_chain_record(
520    log: &Arc<AnyEventLog>,
521) -> Result<Option<(EventId, TrustRecord)>, LogError> {
522    let (_, records) = preferred_chain_records(log).await?;
523    Ok(records.into_iter().last())
524}
525
526async fn preferred_chain_records(
527    log: &Arc<AnyEventLog>,
528) -> Result<(Topic, Vec<(EventId, TrustRecord)>), LogError> {
529    let canonical = global_topic()?;
530    let canonical_records = read_trust_records_from_topic(log, &canonical).await?;
531    if !canonical_records.is_empty() {
532        return Ok((canonical, canonical_records));
533    }
534    let legacy = legacy_global_topic()?;
535    let legacy_records = read_trust_records_from_topic(log, &legacy).await?;
536    if legacy_records.is_empty() {
537        Ok((canonical, Vec::new()))
538    } else {
539        Ok((legacy, legacy_records))
540    }
541}
542
543async fn read_trust_records_from_topic(
544    log: &Arc<AnyEventLog>,
545    topic: &Topic,
546) -> Result<Vec<(EventId, TrustRecord)>, LogError> {
547    let events = log.read_range(topic, None, usize::MAX).await?;
548    let mut records = Vec::new();
549    let mut seen = HashSet::new();
550    for (event_id, event) in events {
551        if event.kind != TRUST_GRAPH_EVENT_KIND {
552            continue;
553        }
554        let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
555            continue;
556        };
557        if seen.insert(trust_record_dedupe_key(&record)) {
558            records.push((event_id, record));
559        }
560    }
561    Ok(records)
562}
563
564fn trust_record_dedupe_key(record: &TrustRecord) -> String {
565    if !record.entry_hash.is_empty() {
566        return record.entry_hash.clone();
567    }
568    record.record_id.clone()
569}
570
571fn score_from_records(
572    agent: &str,
573    action: Option<&str>,
574    effective_tier: AutonomyTier,
575    records: &[TrustRecord],
576) -> TrustScore {
577    let mut score = TrustScore {
578        agent: agent.to_string(),
579        action: action.map(ToString::to_string),
580        effective_tier,
581        ..TrustScore::default()
582    };
583    for record in records {
584        score.total += 1;
585        match record.outcome {
586            TrustOutcome::Success => score.successes += 1,
587            TrustOutcome::Failure => score.failures += 1,
588            TrustOutcome::Denied => score.denied += 1,
589            TrustOutcome::Timeout => score.timeouts += 1,
590        }
591        score.latest_outcome = Some(record.outcome);
592        score.latest_timestamp = Some(record.timestamp);
593    }
594    score.success_rate = if score.total == 0 {
595        0.0
596    } else {
597        score.successes as f64 / score.total as f64
598    };
599    score.policy = policy_from_score(&score);
600    score
601}
602
603fn policy_from_score(score: &TrustScore) -> CapabilityPolicy {
604    let mut policy = policy_for_autonomy_tier(score.effective_tier);
605    let latest_bad = matches!(
606        score.latest_outcome,
607        Some(TrustOutcome::Denied | TrustOutcome::Failure | TrustOutcome::Timeout)
608    );
609    if latest_bad || (score.total >= 3 && score.success_rate < 0.5) {
610        policy.side_effect_level = Some("read_only".to_string());
611    }
612    policy
613}
614
615pub fn policy_for_autonomy_tier(tier: AutonomyTier) -> CapabilityPolicy {
616    CapabilityPolicy {
617        side_effect_level: Some(
618            match tier {
619                AutonomyTier::Shadow => "none",
620                AutonomyTier::Suggest => "read_only",
621                AutonomyTier::ActWithApproval => "read_only",
622                AutonomyTier::ActAuto => "network",
623            }
624            .to_string(),
625        ),
626        recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
627        ..CapabilityPolicy::default()
628    }
629}
630
631fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
632    let Some(limit) = limit else {
633        return;
634    };
635    if records.len() <= limit {
636        return;
637    }
638    let keep_from = records.len() - limit;
639    records.drain(0..keep_from);
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::event_log::MemoryEventLog;
646    use time::Duration;
647
648    #[tokio::test]
649    async fn append_and_query_round_trip() {
650        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
651        let mut record = TrustRecord::new(
652            "github-triage-bot",
653            "github.issue.opened",
654            Some("reviewer".to_string()),
655            TrustOutcome::Success,
656            "trace-1",
657            AutonomyTier::ActWithApproval,
658        );
659        record.cost_usd = Some(1.25);
660        append_trust_record(&log, &record).await.unwrap();
661
662        let records = query_trust_records(
663            &log,
664            &TrustQueryFilters {
665                agent: Some("github-triage-bot".to_string()),
666                ..TrustQueryFilters::default()
667            },
668        )
669        .await
670        .unwrap();
671
672        assert_eq!(records.len(), 1);
673        assert_eq!(records[0].agent, "github-triage-bot");
674        assert_eq!(records[0].cost_usd, Some(1.25));
675        assert_eq!(records[0].chain_index, 1);
676        assert!(records[0].previous_hash.is_none());
677        assert!(records[0].entry_hash.starts_with("sha256:"));
678    }
679
680    #[tokio::test]
681    async fn verify_chain_detects_hash_tampering() {
682        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
683        let first = append_trust_record(
684            &log,
685            &TrustRecord::new(
686                "bot",
687                "first",
688                None,
689                TrustOutcome::Success,
690                "trace-1",
691                AutonomyTier::Suggest,
692            ),
693        )
694        .await
695        .unwrap();
696        let mut second = append_trust_record(
697            &log,
698            &TrustRecord::new(
699                "bot",
700                "second",
701                None,
702                TrustOutcome::Success,
703                "trace-2",
704                AutonomyTier::Suggest,
705            ),
706        )
707        .await
708        .unwrap();
709
710        let report = verify_trust_chain(&log).await.unwrap();
711        assert!(report.verified);
712        assert_eq!(
713            report.root_hash.as_deref(),
714            Some(second.entry_hash.as_str())
715        );
716        assert_eq!(
717            second.previous_hash.as_deref(),
718            Some(first.entry_hash.as_str())
719        );
720
721        second.previous_hash = Some(
722            "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
723        );
724        second.entry_hash =
725            "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string();
726        log.append(
727            &global_topic().unwrap(),
728            LogEvent::new(
729                TRUST_GRAPH_EVENT_KIND,
730                serde_json::to_value(second).unwrap(),
731            ),
732        )
733        .await
734        .unwrap();
735        let report = verify_trust_chain(&log).await.unwrap();
736        assert!(!report.verified);
737        assert!(report
738            .errors
739            .iter()
740            .any(|error| error.contains("previous_hash mismatch")));
741    }
742
743    #[tokio::test]
744    async fn resolve_autonomy_tier_prefers_latest_control_record() {
745        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
746        append_trust_record(
747            &log,
748            &TrustRecord::new(
749                "bot",
750                "trust.promote",
751                None,
752                TrustOutcome::Success,
753                "trace-1",
754                AutonomyTier::ActWithApproval,
755            ),
756        )
757        .await
758        .unwrap();
759        append_trust_record(
760            &log,
761            &TrustRecord::new(
762                "bot",
763                "trust.demote",
764                None,
765                TrustOutcome::Success,
766                "trace-2",
767                AutonomyTier::Shadow,
768            ),
769        )
770        .await
771        .unwrap();
772
773        let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
774            .await
775            .unwrap();
776        assert_eq!(tier, AutonomyTier::Shadow);
777    }
778
779    #[tokio::test]
780    async fn query_limit_keeps_newest_matching_records() {
781        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
782        let base = OffsetDateTime::now_utc();
783        for (offset, action) in ["first", "second", "third"].into_iter().enumerate() {
784            let mut record = TrustRecord::new(
785                "bot",
786                action,
787                None,
788                TrustOutcome::Success,
789                format!("trace-{action}"),
790                AutonomyTier::ActAuto,
791            );
792            record.timestamp = base + Duration::seconds(offset as i64);
793            append_trust_record(&log, &record).await.unwrap();
794        }
795
796        let records = query_trust_records(
797            &log,
798            &TrustQueryFilters {
799                agent: Some("bot".to_string()),
800                limit: Some(2),
801                ..TrustQueryFilters::default()
802            },
803        )
804        .await
805        .unwrap();
806
807        assert_eq!(records.len(), 2);
808        assert_eq!(records[0].action, "second");
809        assert_eq!(records[1].action, "third");
810    }
811
812    #[test]
813    fn group_by_trace_preserves_chronological_group_order() {
814        let make_record = |trace_id: &str, action: &str| TrustRecord {
815            trace_id: trace_id.to_string(),
816            action: action.to_string(),
817            ..TrustRecord::new(
818                "bot",
819                action,
820                None,
821                TrustOutcome::Success,
822                trace_id,
823                AutonomyTier::ActAuto,
824            )
825        };
826        let grouped = group_trust_records_by_trace(&[
827            make_record("trace-1", "first"),
828            make_record("trace-2", "second"),
829            make_record("trace-1", "third"),
830        ]);
831
832        assert_eq!(grouped.len(), 2);
833        assert_eq!(grouped[0].trace_id, "trace-1");
834        assert_eq!(grouped[0].records.len(), 2);
835        assert_eq!(grouped[0].records[1].action, "third");
836        assert_eq!(grouped[1].trace_id, "trace-2");
837    }
838}