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}