1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::{Duration, 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 OPENTRUSTGRAPH_CHAIN_SCHEMA_V0: &str = "opentrustgraph-chain/v0";
17pub const TRUST_GRAPH_RECORDS_TOPIC: &str = "trust_graph.records";
18pub const TRUST_GRAPH_GLOBAL_TOPIC: &str = "trust_graph";
19pub const TRUST_GRAPH_LEGACY_GLOBAL_TOPIC: &str = "trust.graph";
20pub const TRUST_GRAPH_TOPIC_PREFIX: &str = "trust_graph.";
21pub const TRUST_GRAPH_LEGACY_TOPIC_PREFIX: &str = "trust.graph.";
22pub const TRUST_GRAPH_EVENT_KIND: &str = "trust_recorded";
23pub const TRUST_ACTION_RELEASE: &str = "release";
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AutonomyTier {
28 Shadow,
29 Suggest,
30 ActWithApproval,
31 #[default]
32 ActAuto,
33}
34
35impl AutonomyTier {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Shadow => "shadow",
39 Self::Suggest => "suggest",
40 Self::ActWithApproval => "act_with_approval",
41 Self::ActAuto => "act_auto",
42 }
43 }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum TrustOutcome {
49 Success,
50 Failure,
51 Denied,
52 Timeout,
53}
54
55impl TrustOutcome {
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Success => "success",
59 Self::Failure => "failure",
60 Self::Denied => "denied",
61 Self::Timeout => "timeout",
62 }
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67pub struct TrustRecord {
68 pub schema: String,
69 pub record_id: String,
70 pub agent: String,
71 pub action: String,
72 pub approver: Option<String>,
73 pub outcome: TrustOutcome,
74 pub trace_id: String,
75 pub autonomy_tier: AutonomyTier,
76 #[serde(with = "time::serde::rfc3339")]
77 pub timestamp: OffsetDateTime,
78 pub cost_usd: Option<f64>,
79 #[serde(default)]
80 pub chain_index: u64,
81 #[serde(default)]
82 pub previous_hash: Option<String>,
83 #[serde(default)]
84 pub entry_hash: String,
85 #[serde(default)]
86 pub metadata: BTreeMap<String, serde_json::Value>,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(tag = "kind", rename_all = "snake_case")]
91pub enum TrustRecordActionKind {
92 Release {
93 bundle_hash: String,
94 harn_version: String,
95 parent_trust_record_id: Option<String>,
96 },
97}
98
99impl TrustRecord {
100 pub fn new(
101 agent: impl Into<String>,
102 action: impl Into<String>,
103 approver: Option<String>,
104 outcome: TrustOutcome,
105 trace_id: impl Into<String>,
106 autonomy_tier: AutonomyTier,
107 ) -> Self {
108 Self {
109 schema: OPENTRUSTGRAPH_SCHEMA_V0.to_string(),
110 record_id: Uuid::now_v7().to_string(),
111 agent: agent.into(),
112 action: action.into(),
113 approver,
114 outcome,
115 trace_id: trace_id.into(),
116 autonomy_tier,
117 timestamp: OffsetDateTime::now_utc(),
118 cost_usd: None,
119 chain_index: 0,
120 previous_hash: None,
121 entry_hash: String::new(),
122 metadata: BTreeMap::new(),
123 }
124 }
125
126 pub fn release(
127 agent: impl Into<String>,
128 bundle_hash: impl Into<String>,
129 harn_version: impl Into<String>,
130 parent_trust_record_id: Option<String>,
131 trace_id: impl Into<String>,
132 autonomy_tier: AutonomyTier,
133 ) -> Self {
134 let bundle_hash = bundle_hash.into();
135 let harn_version = harn_version.into();
136 let action_kind = TrustRecordActionKind::Release {
137 bundle_hash: bundle_hash.clone(),
138 harn_version: harn_version.clone(),
139 parent_trust_record_id: parent_trust_record_id.clone(),
140 };
141 let mut record = Self::new(
142 agent,
143 TRUST_ACTION_RELEASE,
144 None,
145 TrustOutcome::Success,
146 trace_id,
147 autonomy_tier,
148 );
149 record
150 .metadata
151 .insert("action_kind".to_string(), serde_json::json!(action_kind));
152 record
153 .metadata
154 .insert("bundle_hash".to_string(), serde_json::json!(bundle_hash));
155 record
156 .metadata
157 .insert("harn_version".to_string(), serde_json::json!(harn_version));
158 record.metadata.insert(
159 "parent_trust_record_id".to_string(),
160 parent_trust_record_id
161 .map(serde_json::Value::String)
162 .unwrap_or(serde_json::Value::Null),
163 );
164 record
165 }
166}
167
168#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
169pub struct TrustGraphRecord {
170 pub actor_id: String,
171 pub action: String,
172 pub approver: Option<String>,
173 pub outcome: TrustOutcome,
174 #[serde(default)]
175 pub evidence_refs: Vec<serde_json::Value>,
176 pub trace_id: String,
177 #[serde(with = "time::serde::rfc3339")]
178 pub timestamp: OffsetDateTime,
179 pub autonomy_tier_at_time: AutonomyTier,
180}
181
182impl TrustGraphRecord {
183 pub fn from_trust_record(record: &TrustRecord) -> Self {
184 Self {
185 actor_id: record.agent.clone(),
186 action: record.action.clone(),
187 approver: record.approver.clone(),
188 outcome: record.outcome,
189 evidence_refs: evidence_refs_from_metadata(&record.metadata),
190 trace_id: record.trace_id.clone(),
191 timestamp: record.timestamp,
192 autonomy_tier_at_time: record.autonomy_tier,
193 }
194 }
195}
196
197#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
198#[serde(default)]
199pub struct TrustQueryFilters {
200 pub agent: Option<String>,
201 pub action: Option<String>,
202 #[serde(with = "time::serde::rfc3339::option")]
203 pub since: Option<OffsetDateTime>,
204 #[serde(with = "time::serde::rfc3339::option")]
205 pub until: Option<OffsetDateTime>,
206 pub tier: Option<AutonomyTier>,
207 pub outcome: Option<TrustOutcome>,
208 pub limit: Option<usize>,
209 pub grouped_by_trace: bool,
210}
211
212#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
213#[serde(default)]
214pub struct TrustTraceGroup {
215 pub trace_id: String,
216 pub records: Vec<TrustRecord>,
217}
218
219#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
220#[serde(default)]
221pub struct TrustAgentSummary {
222 pub agent: String,
223 pub total: u64,
224 pub success_rate: f64,
225 pub mean_cost_usd: Option<f64>,
226 pub tier_distribution: BTreeMap<String, u64>,
227 pub outcome_distribution: BTreeMap<String, u64>,
228}
229
230#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
231#[serde(default)]
232pub struct TrustScore {
233 pub agent: String,
234 pub action: Option<String>,
235 pub total: u64,
236 pub successes: u64,
237 pub failures: u64,
238 pub denied: u64,
239 pub timeouts: u64,
240 pub success_rate: f64,
241 pub latest_outcome: Option<TrustOutcome>,
242 #[serde(with = "time::serde::rfc3339::option")]
243 pub latest_timestamp: Option<OffsetDateTime>,
244 pub effective_tier: AutonomyTier,
245 pub policy: CapabilityPolicy,
246}
247
248#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
249#[serde(default)]
250pub struct TrustChainReport {
251 pub topic: String,
252 pub total: u64,
253 pub verified: bool,
254 pub root_hash: Option<String>,
255 pub broken_at_event_id: Option<EventId>,
256 pub errors: Vec<String>,
257}
258
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260pub struct TrustChainExportProducer {
261 pub name: String,
262 pub version: String,
263}
264
265impl Default for TrustChainExportProducer {
266 fn default() -> Self {
267 Self {
268 name: "harn".to_string(),
269 version: env!("CARGO_PKG_VERSION").to_string(),
270 }
271 }
272}
273
274#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
275pub struct TrustChainExportMetadata {
276 pub topic: String,
277 pub total: u64,
278 pub root_hash: Option<String>,
279 pub verified: bool,
280 #[serde(with = "time::serde::rfc3339")]
281 pub generated_at: OffsetDateTime,
282 pub producer: TrustChainExportProducer,
283}
284
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
286pub struct TrustChainExport {
287 pub schema: String,
288 pub chain: TrustChainExportMetadata,
289 pub records: Vec<TrustRecord>,
290}
291
292fn global_topic() -> Result<Topic, LogError> {
293 Topic::new(TRUST_GRAPH_GLOBAL_TOPIC)
294}
295
296fn legacy_global_topic() -> Result<Topic, LogError> {
297 Topic::new(TRUST_GRAPH_LEGACY_GLOBAL_TOPIC)
298}
299
300fn records_topic() -> Result<Topic, LogError> {
301 Topic::new(TRUST_GRAPH_RECORDS_TOPIC)
302}
303
304pub fn topic_for_agent(agent: &str) -> Result<Topic, LogError> {
305 Topic::new(format!(
306 "{TRUST_GRAPH_TOPIC_PREFIX}{}",
307 sanitize_topic_component(agent)
308 ))
309}
310
311pub fn legacy_topic_for_agent(agent: &str) -> Result<Topic, LogError> {
312 Topic::new(format!(
313 "{TRUST_GRAPH_LEGACY_TOPIC_PREFIX}{}",
314 sanitize_topic_component(agent)
315 ))
316}
317
318pub async fn append_trust_record(
319 log: &Arc<AnyEventLog>,
320 record: &TrustRecord,
321) -> Result<TrustRecord, LogError> {
322 let finalized = finalize_trust_record(log, record.clone()).await?;
323 let payload = serde_json::to_value(&finalized)
324 .map_err(|error| LogError::Serde(format!("trust record encode error: {error}")))?;
325 let mut headers = BTreeMap::new();
326 headers.insert("trace_id".to_string(), finalized.trace_id.clone());
327 headers.insert("agent".to_string(), finalized.agent.clone());
328 headers.insert(
329 "autonomy_tier".to_string(),
330 finalized.autonomy_tier.as_str().to_string(),
331 );
332 headers.insert(
333 "outcome".to_string(),
334 finalized.outcome.as_str().to_string(),
335 );
336 headers.insert("entry_hash".to_string(), finalized.entry_hash.clone());
337 let event = LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers);
338 for topic in append_topics_for_record(&finalized)? {
339 log.append(&topic, event.clone()).await?;
340 }
341 append_trust_graph_record_projection(log, &finalized).await?;
342 Ok(finalized)
343}
344
345pub async fn append_active_trust_record(record: &TrustRecord) -> Result<TrustRecord, LogError> {
346 let log = active_event_log()
347 .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
348 append_trust_record(&log, record).await
349}
350
351pub async fn query_trust_records(
352 log: &Arc<AnyEventLog>,
353 filters: &TrustQueryFilters,
354) -> Result<Vec<TrustRecord>, LogError> {
355 let topics = query_topics(filters)?;
356 let mut records = Vec::new();
357 let mut seen = HashSet::new();
358 for topic in topics {
359 for (_, event) in log.read_range(&topic, None, usize::MAX).await? {
360 if event.kind != TRUST_GRAPH_EVENT_KIND {
361 continue;
362 }
363 let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
364 continue;
365 };
366 if !matches_filters(&record, filters) {
367 continue;
368 }
369 let dedupe_key = trust_record_dedupe_key(&record);
370 if seen.insert(dedupe_key) {
371 records.push(record);
372 }
373 }
374 }
375 records.sort_by(|left, right| {
376 left.timestamp
377 .cmp(&right.timestamp)
378 .then(left.chain_index.cmp(&right.chain_index))
379 .then(left.agent.cmp(&right.agent))
380 .then(left.record_id.cmp(&right.record_id))
381 });
382 apply_record_limit(&mut records, filters.limit);
383 Ok(records)
384}
385
386pub async fn query_trust_graph_records(
387 log: &Arc<AnyEventLog>,
388 filters: &TrustQueryFilters,
389) -> Result<Vec<TrustGraphRecord>, LogError> {
390 let mut graph_records = Vec::new();
391 let mut seen = HashSet::new();
392
393 for record in query_trust_records(log, filters).await? {
394 let graph_record = TrustGraphRecord::from_trust_record(&record);
395 let dedupe_key = trust_graph_record_dedupe_key(&graph_record);
396 if seen.insert(dedupe_key) {
397 graph_records.push(graph_record);
398 }
399 }
400
401 for (_, event) in log.read_range(&records_topic()?, None, usize::MAX).await? {
402 if event.kind != TRUST_GRAPH_EVENT_KIND {
403 continue;
404 }
405 let Ok(record) = serde_json::from_value::<TrustGraphRecord>(event.payload) else {
406 continue;
407 };
408 if !matches_graph_filters(&record, filters) {
409 continue;
410 }
411 let dedupe_key = trust_graph_record_dedupe_key(&record);
412 if seen.insert(dedupe_key) {
413 graph_records.push(record);
414 }
415 }
416
417 graph_records.sort_by(|left, right| {
418 left.timestamp
419 .cmp(&right.timestamp)
420 .then(left.actor_id.cmp(&right.actor_id))
421 .then(left.action.cmp(&right.action))
422 .then(left.trace_id.cmp(&right.trace_id))
423 });
424 apply_graph_record_limit(&mut graph_records, filters.limit);
425 Ok(graph_records)
426}
427
428pub async fn trust_score_for(
429 log: &Arc<AnyEventLog>,
430 agent: &str,
431 action: Option<&str>,
432) -> Result<TrustScore, LogError> {
433 let records = query_trust_records(
434 log,
435 &TrustQueryFilters {
436 agent: Some(agent.to_string()),
437 action: action.map(ToString::to_string),
438 ..TrustQueryFilters::default()
439 },
440 )
441 .await?;
442 let effective_tier = resolve_agent_autonomy_tier(log, agent, AutonomyTier::ActAuto).await?;
443 let mut score = score_from_records(agent, action, effective_tier, &records);
444 score.policy =
445 crate::corrections::apply_corrections_to_policy(log, agent, score.policy).await?;
446 Ok(score)
447}
448
449pub async fn policy_for_agent(
450 log: &Arc<AnyEventLog>,
451 agent: &str,
452) -> Result<CapabilityPolicy, LogError> {
453 Ok(trust_score_for(log, agent, None).await?.policy)
454}
455
456pub async fn verify_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainReport, LogError> {
457 let (topic, records) = preferred_chain_records(log).await?;
458 let mut previous_hash: Option<String> = None;
459 let mut errors = Vec::new();
460 let mut broken_at_event_id = None;
461
462 for (position, (event_id, record)) in records.iter().enumerate() {
463 let expected_index = (position as u64) + 1;
464 if record.chain_index != expected_index {
465 errors.push(format!(
466 "event {event_id}: expected chain_index {expected_index}, found {}",
467 record.chain_index
468 ));
469 }
470 if record.previous_hash != previous_hash {
471 errors.push(format!(
472 "event {event_id}: previous_hash mismatch; expected {:?}, found {:?}",
473 previous_hash, record.previous_hash
474 ));
475 }
476 match compute_trust_record_hash(record) {
477 Ok(expected_hash) if expected_hash == record.entry_hash => {}
478 Ok(expected_hash) => errors.push(format!(
479 "event {event_id}: entry_hash mismatch; expected {expected_hash}, found {}",
480 record.entry_hash
481 )),
482 Err(error) => errors.push(format!("event {event_id}: {error}")),
483 }
484 if !errors.is_empty() && broken_at_event_id.is_none() {
485 broken_at_event_id = Some(*event_id);
486 }
487 previous_hash = Some(record.entry_hash.clone());
488 }
489
490 Ok(TrustChainReport {
491 topic: topic.as_str().to_string(),
492 total: records.len() as u64,
493 verified: errors.is_empty(),
494 root_hash: records.last().map(|(_, record)| record.entry_hash.clone()),
495 broken_at_event_id,
496 errors,
497 })
498}
499
500pub async fn export_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainExport, LogError> {
501 let (topic, records_with_ids) = preferred_chain_records(log).await?;
502 let report = verify_trust_chain(log).await?;
503 let records: Vec<TrustRecord> = records_with_ids.into_iter().map(|(_, r)| r).collect();
504 Ok(TrustChainExport {
505 schema: OPENTRUSTGRAPH_CHAIN_SCHEMA_V0.to_string(),
506 chain: TrustChainExportMetadata {
507 topic: topic.as_str().to_string(),
508 total: records.len() as u64,
509 root_hash: records.last().map(|record| record.entry_hash.clone()),
510 verified: report.verified,
511 generated_at: OffsetDateTime::now_utc(),
512 producer: TrustChainExportProducer::default(),
513 },
514 records,
515 })
516}
517
518pub fn compute_trust_record_hash(record: &TrustRecord) -> Result<String, LogError> {
519 let mut value = serde_json::to_value(record)
520 .map_err(|error| LogError::Serde(format!("trust record hash encode error: {error}")))?;
521 if let Some(object) = value.as_object_mut() {
522 object.remove("entry_hash");
523 }
524 let canonical = serde_json::to_string(&value)
525 .map_err(|error| LogError::Serde(format!("trust record canonicalize error: {error}")))?;
526 let digest = Sha256::digest(canonical.as_bytes());
527 Ok(format!("sha256:{}", hex::encode(digest)))
528}
529
530pub fn group_trust_records_by_trace(records: &[TrustRecord]) -> Vec<TrustTraceGroup> {
531 let mut groups: Vec<TrustTraceGroup> = Vec::new();
532 let mut positions: HashMap<String, usize> = HashMap::new();
533 for record in records {
534 if let Some(index) = positions.get(record.trace_id.as_str()).copied() {
535 groups[index].records.push(record.clone());
536 continue;
537 }
538 positions.insert(record.trace_id.clone(), groups.len());
539 groups.push(TrustTraceGroup {
540 trace_id: record.trace_id.clone(),
541 records: vec![record.clone()],
542 });
543 }
544 groups
545}
546
547pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
548 #[derive(Default)]
549 struct RunningSummary {
550 total: u64,
551 successes: u64,
552 cost_sum: f64,
553 cost_count: u64,
554 tier_distribution: BTreeMap<String, u64>,
555 outcome_distribution: BTreeMap<String, u64>,
556 }
557
558 let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
559 for record in records {
560 let entry = by_agent.entry(record.agent.clone()).or_default();
561 entry.total += 1;
562 if record.outcome == TrustOutcome::Success {
563 entry.successes += 1;
564 }
565 if let Some(cost_usd) = record.cost_usd {
566 entry.cost_sum += cost_usd;
567 entry.cost_count += 1;
568 }
569 *entry
570 .tier_distribution
571 .entry(record.autonomy_tier.as_str().to_string())
572 .or_default() += 1;
573 *entry
574 .outcome_distribution
575 .entry(record.outcome.as_str().to_string())
576 .or_default() += 1;
577 }
578
579 by_agent
580 .into_iter()
581 .map(|(agent, summary)| TrustAgentSummary {
582 agent,
583 total: summary.total,
584 success_rate: if summary.total == 0 {
585 0.0
586 } else {
587 summary.successes as f64 / summary.total as f64
588 },
589 mean_cost_usd: (summary.cost_count > 0)
590 .then_some(summary.cost_sum / summary.cost_count as f64),
591 tier_distribution: summary.tier_distribution,
592 outcome_distribution: summary.outcome_distribution,
593 })
594 .collect()
595}
596
597pub async fn resolve_agent_autonomy_tier(
598 log: &Arc<AnyEventLog>,
599 agent: &str,
600 default: AutonomyTier,
601) -> Result<AutonomyTier, LogError> {
602 let records = query_trust_records(
603 log,
604 &TrustQueryFilters {
605 agent: Some(agent.to_string()),
606 ..TrustQueryFilters::default()
607 },
608 )
609 .await?;
610 let mut current = default;
611 for record in records {
612 if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
613 && record.outcome == TrustOutcome::Success
614 {
615 current = record.autonomy_tier;
616 }
617 }
618 Ok(current)
619}
620
621fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
622 if let Some(agent) = filters.agent.as_deref() {
623 if record.agent != agent {
624 return false;
625 }
626 }
627 if let Some(action) = filters.action.as_deref() {
628 if record.action != action {
629 return false;
630 }
631 }
632 if let Some(since) = filters.since {
633 if record.timestamp < since {
634 return false;
635 }
636 }
637 if let Some(until) = filters.until {
638 if record.timestamp > until {
639 return false;
640 }
641 }
642 if let Some(tier) = filters.tier {
643 if record.autonomy_tier != tier {
644 return false;
645 }
646 }
647 if let Some(outcome) = filters.outcome {
648 if record.outcome != outcome {
649 return false;
650 }
651 }
652 true
653}
654
655fn matches_graph_filters(record: &TrustGraphRecord, filters: &TrustQueryFilters) -> bool {
656 if let Some(agent) = filters.agent.as_deref() {
657 if record.actor_id != agent {
658 return false;
659 }
660 }
661 if let Some(action) = filters.action.as_deref() {
662 if record.action != action {
663 return false;
664 }
665 }
666 if let Some(since) = filters.since {
667 if record.timestamp < since {
668 return false;
669 }
670 }
671 if let Some(until) = filters.until {
672 if record.timestamp > until {
673 return false;
674 }
675 }
676 if let Some(tier) = filters.tier {
677 if record.autonomy_tier_at_time != tier {
678 return false;
679 }
680 }
681 if let Some(outcome) = filters.outcome {
682 if record.outcome != outcome {
683 return false;
684 }
685 }
686 true
687}
688
689fn query_topics(filters: &TrustQueryFilters) -> Result<Vec<Topic>, LogError> {
690 match filters.agent.as_deref() {
691 Some(agent) => unique_topics(vec![
692 topic_for_agent(agent)?,
693 legacy_topic_for_agent(agent)?,
694 ]),
695 None => unique_topics(vec![global_topic()?, legacy_global_topic()?]),
696 }
697}
698
699fn append_topics_for_record(record: &TrustRecord) -> Result<Vec<Topic>, LogError> {
700 unique_topics(vec![
701 global_topic()?,
702 legacy_global_topic()?,
703 topic_for_agent(&record.agent)?,
704 legacy_topic_for_agent(&record.agent)?,
705 ])
706}
707
708fn unique_topics(topics: Vec<Topic>) -> Result<Vec<Topic>, LogError> {
709 let mut seen = HashSet::new();
710 Ok(topics
711 .into_iter()
712 .filter(|topic| seen.insert(topic.as_str().to_string()))
713 .collect())
714}
715
716async fn append_trust_graph_record_projection(
717 log: &Arc<AnyEventLog>,
718 record: &TrustRecord,
719) -> Result<(), LogError> {
720 let payload = serde_json::to_value(TrustGraphRecord::from_trust_record(record))
721 .map_err(|error| LogError::Serde(format!("trust graph record encode error: {error}")))?;
722 let mut headers = BTreeMap::new();
723 headers.insert("trace_id".to_string(), record.trace_id.clone());
724 headers.insert("actor_id".to_string(), record.agent.clone());
725 headers.insert("action".to_string(), record.action.clone());
726 headers.insert(
727 "autonomy_tier_at_time".to_string(),
728 record.autonomy_tier.as_str().to_string(),
729 );
730 headers.insert("outcome".to_string(), record.outcome.as_str().to_string());
731 log.append(
732 &records_topic()?,
733 LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers),
734 )
735 .await?;
736 Ok(())
737}
738
739async fn finalize_trust_record(
740 log: &Arc<AnyEventLog>,
741 mut record: TrustRecord,
742) -> Result<TrustRecord, LogError> {
743 let latest = latest_chain_record(log).await?;
744 record.chain_index = latest
745 .as_ref()
746 .map(|(_, record)| record.chain_index.saturating_add(1).max(1))
747 .unwrap_or(1);
748 record.previous_hash = latest.and_then(|(_, record)| {
749 if record.entry_hash.is_empty() {
750 compute_trust_record_hash(&record).ok()
751 } else {
752 Some(record.entry_hash)
753 }
754 });
755 record.entry_hash.clear();
756 record.entry_hash = compute_trust_record_hash(&record)?;
757 Ok(record)
758}
759
760async fn latest_chain_record(
761 log: &Arc<AnyEventLog>,
762) -> Result<Option<(EventId, TrustRecord)>, LogError> {
763 let (_, records) = preferred_chain_records(log).await?;
764 Ok(records.into_iter().last())
765}
766
767async fn preferred_chain_records(
768 log: &Arc<AnyEventLog>,
769) -> Result<(Topic, Vec<(EventId, TrustRecord)>), LogError> {
770 let canonical = global_topic()?;
771 let canonical_records = read_trust_records_from_topic(log, &canonical).await?;
772 if !canonical_records.is_empty() {
773 return Ok((canonical, canonical_records));
774 }
775 let legacy = legacy_global_topic()?;
776 let legacy_records = read_trust_records_from_topic(log, &legacy).await?;
777 if legacy_records.is_empty() {
778 Ok((canonical, Vec::new()))
779 } else {
780 Ok((legacy, legacy_records))
781 }
782}
783
784async fn read_trust_records_from_topic(
785 log: &Arc<AnyEventLog>,
786 topic: &Topic,
787) -> Result<Vec<(EventId, TrustRecord)>, LogError> {
788 let events = log.read_range(topic, None, usize::MAX).await?;
789 let mut records = Vec::new();
790 let mut seen = HashSet::new();
791 for (event_id, event) in events {
792 if event.kind != TRUST_GRAPH_EVENT_KIND {
793 continue;
794 }
795 let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
796 continue;
797 };
798 if seen.insert(trust_record_dedupe_key(&record)) {
799 records.push((event_id, record));
800 }
801 }
802 Ok(records)
803}
804
805fn trust_record_dedupe_key(record: &TrustRecord) -> String {
806 if !record.entry_hash.is_empty() {
807 return record.entry_hash.clone();
808 }
809 record.record_id.clone()
810}
811
812fn trust_graph_record_dedupe_key(record: &TrustGraphRecord) -> String {
813 format!(
814 "{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
815 record.actor_id,
816 record.action,
817 record.trace_id,
818 record.timestamp,
819 record.outcome.as_str()
820 )
821}
822
823fn evidence_refs_from_metadata(
824 metadata: &BTreeMap<String, serde_json::Value>,
825) -> Vec<serde_json::Value> {
826 metadata
827 .get("evidence_refs")
828 .or_else(|| metadata.get("evidenceRefs"))
829 .or_else(|| {
830 metadata
831 .get("approval")
832 .and_then(|approval| approval.get("evidence_refs"))
833 })
834 .and_then(|value| value.as_array())
835 .cloned()
836 .unwrap_or_default()
837}
838
839fn score_from_records(
840 agent: &str,
841 action: Option<&str>,
842 effective_tier: AutonomyTier,
843 records: &[TrustRecord],
844) -> TrustScore {
845 let mut score = TrustScore {
846 agent: agent.to_string(),
847 action: action.map(ToString::to_string),
848 effective_tier,
849 ..TrustScore::default()
850 };
851 let recent_cutoff = OffsetDateTime::now_utc() - Duration::days(30);
852 let mut recent_successes = 0;
853 let mut recent_bad_or_rollback = false;
854 for record in records {
855 score.total += 1;
856 match record.outcome {
857 TrustOutcome::Success => score.successes += 1,
858 TrustOutcome::Failure => score.failures += 1,
859 TrustOutcome::Denied => score.denied += 1,
860 TrustOutcome::Timeout => score.timeouts += 1,
861 }
862 if record.timestamp >= recent_cutoff {
863 if record.outcome == TrustOutcome::Success && !is_control_plane_action(&record.action) {
864 recent_successes += 1;
865 } else if record.outcome != TrustOutcome::Success {
866 recent_bad_or_rollback = true;
867 }
868 if record.action.contains("rollback") {
869 recent_bad_or_rollback = true;
870 }
871 }
872 score.latest_outcome = Some(record.outcome);
873 score.latest_timestamp = Some(record.timestamp);
874 }
875 score.success_rate = if score.total == 0 {
876 0.0
877 } else {
878 score.successes as f64 / score.total as f64
879 };
880 score.policy = policy_from_score(&score, recent_successes, recent_bad_or_rollback);
881 score
882}
883
884fn policy_from_score(
885 score: &TrustScore,
886 recent_successes: u64,
887 recent_bad_or_rollback: bool,
888) -> CapabilityPolicy {
889 let mut policy = policy_for_autonomy_tier(score.effective_tier);
890 let latest_bad = matches!(
891 score.latest_outcome,
892 Some(TrustOutcome::Denied | TrustOutcome::Failure | TrustOutcome::Timeout)
893 );
894 let trusted_recent_track_record = score.effective_tier == AutonomyTier::ActWithApproval
895 && recent_successes >= 10
896 && !recent_bad_or_rollback;
897 if latest_bad || (!trusted_recent_track_record && score.total >= 3 && score.success_rate < 0.5)
898 {
899 policy.side_effect_level = Some("read_only".to_string());
900 } else if trusted_recent_track_record {
901 policy.side_effect_level = Some("network".to_string());
902 }
903 policy
904}
905
906pub fn policy_for_autonomy_tier(tier: AutonomyTier) -> CapabilityPolicy {
907 CapabilityPolicy {
908 side_effect_level: Some(
909 match tier {
910 AutonomyTier::Shadow => "none",
911 AutonomyTier::Suggest => "read_only",
912 AutonomyTier::ActWithApproval => "read_only",
913 AutonomyTier::ActAuto => "network",
914 }
915 .to_string(),
916 ),
917 recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
918 ..CapabilityPolicy::default()
919 }
920}
921
922fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
923 let Some(limit) = limit else {
924 return;
925 };
926 if records.len() <= limit {
927 return;
928 }
929 let keep_from = records.len() - limit;
930 records.drain(0..keep_from);
931}
932
933fn apply_graph_record_limit(records: &mut Vec<TrustGraphRecord>, limit: Option<usize>) {
934 let Some(limit) = limit else {
935 return;
936 };
937 if records.len() <= limit {
938 return;
939 }
940 let keep_from = records.len() - limit;
941 records.drain(0..keep_from);
942}
943
944fn is_control_plane_action(action: &str) -> bool {
945 matches!(
946 action,
947 "trust.promote" | "trust.demote" | "autonomy.tier_transition"
948 )
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use crate::event_log::MemoryEventLog;
955 use time::Duration;
956
957 const RECORD_SCHEMA_JSON: &str =
958 include_str!("trust_graph/schemas/trust-record.v0.schema.json");
959 const CHAIN_SCHEMA_JSON: &str = include_str!("trust_graph/schemas/trust-chain.v0.schema.json");
960 const VALID_DECISION_CHAIN_JSON: &str =
961 include_str!("trust_graph/fixtures/valid/decision-chain.json");
962 const VALID_TIER_TRANSITION_JSON: &str =
963 include_str!("trust_graph/fixtures/valid/tier-transition.json");
964 const INVALID_TAMPERED_CHAIN_JSON: &str =
965 include_str!("trust_graph/fixtures/invalid/tampered-chain.json");
966 const INVALID_MISSING_APPROVAL_JSON: &str =
967 include_str!("trust_graph/fixtures/invalid/missing-approval.json");
968
969 #[derive(Debug, serde::Deserialize)]
970 struct TrustChainFixture {
971 schema: String,
972 chain: TrustChainFixtureMetadata,
973 records: Vec<TrustRecord>,
974 }
975
976 #[derive(Debug, serde::Deserialize)]
977 struct TrustChainFixtureMetadata {
978 topic: String,
979 total: u64,
980 root_hash: Option<String>,
981 verified: bool,
982 generated_at: String,
983 producer: BTreeMap<String, serde_json::Value>,
984 }
985
986 #[test]
987 fn embedded_trust_graph_fixtures_match_workspace_spec_when_available() {
988 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
989 let spec_dir = manifest_dir.join("../../opentrustgraph-spec");
990 if !spec_dir.exists() {
991 return;
992 }
993
994 for (relative, embedded) in [
995 ("schemas/trust-record.v0.schema.json", RECORD_SCHEMA_JSON),
996 ("schemas/trust-chain.v0.schema.json", CHAIN_SCHEMA_JSON),
997 (
998 "fixtures/valid/decision-chain.json",
999 VALID_DECISION_CHAIN_JSON,
1000 ),
1001 (
1002 "fixtures/valid/tier-transition.json",
1003 VALID_TIER_TRANSITION_JSON,
1004 ),
1005 (
1006 "fixtures/invalid/tampered-chain.json",
1007 INVALID_TAMPERED_CHAIN_JSON,
1008 ),
1009 (
1010 "fixtures/invalid/missing-approval.json",
1011 INVALID_MISSING_APPROVAL_JSON,
1012 ),
1013 ] {
1014 let source = std::fs::read_to_string(spec_dir.join(relative)).unwrap_or_else(|e| {
1015 panic!("failed to read opentrustgraph fixture {relative}: {e}")
1016 });
1017 assert_eq!(
1018 embedded, source,
1019 "embedded trust graph fixture {relative} drifted from opentrustgraph-spec"
1020 );
1021 }
1022 }
1023
1024 #[tokio::test]
1025 async fn append_and_query_round_trip() {
1026 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1027 let mut record = TrustRecord::new(
1028 "github-triage-bot",
1029 "github.issue.opened",
1030 Some("reviewer".to_string()),
1031 TrustOutcome::Success,
1032 "trace-1",
1033 AutonomyTier::ActWithApproval,
1034 );
1035 record.cost_usd = Some(1.25);
1036 append_trust_record(&log, &record).await.unwrap();
1037
1038 let records = query_trust_records(
1039 &log,
1040 &TrustQueryFilters {
1041 agent: Some("github-triage-bot".to_string()),
1042 ..TrustQueryFilters::default()
1043 },
1044 )
1045 .await
1046 .unwrap();
1047
1048 assert_eq!(records.len(), 1);
1049 assert_eq!(records[0].agent, "github-triage-bot");
1050 assert_eq!(records[0].cost_usd, Some(1.25));
1051 assert_eq!(records[0].chain_index, 1);
1052 assert!(records[0].previous_hash.is_none());
1053 assert!(records[0].entry_hash.starts_with("sha256:"));
1054 }
1055
1056 #[tokio::test]
1057 async fn verify_chain_detects_hash_tampering() {
1058 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1059 let first = append_trust_record(
1060 &log,
1061 &TrustRecord::new(
1062 "bot",
1063 "first",
1064 None,
1065 TrustOutcome::Success,
1066 "trace-1",
1067 AutonomyTier::Suggest,
1068 ),
1069 )
1070 .await
1071 .unwrap();
1072 let mut second = append_trust_record(
1073 &log,
1074 &TrustRecord::new(
1075 "bot",
1076 "second",
1077 None,
1078 TrustOutcome::Success,
1079 "trace-2",
1080 AutonomyTier::Suggest,
1081 ),
1082 )
1083 .await
1084 .unwrap();
1085
1086 let report = verify_trust_chain(&log).await.unwrap();
1087 assert!(report.verified);
1088 assert_eq!(
1089 report.root_hash.as_deref(),
1090 Some(second.entry_hash.as_str())
1091 );
1092 assert_eq!(
1093 second.previous_hash.as_deref(),
1094 Some(first.entry_hash.as_str())
1095 );
1096
1097 second.previous_hash = Some(
1098 "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1099 );
1100 second.entry_hash =
1101 "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1102 log.append(
1103 &global_topic().unwrap(),
1104 LogEvent::new(
1105 TRUST_GRAPH_EVENT_KIND,
1106 serde_json::to_value(second).unwrap(),
1107 ),
1108 )
1109 .await
1110 .unwrap();
1111 let report = verify_trust_chain(&log).await.unwrap();
1112 assert!(!report.verified);
1113 assert!(report
1114 .errors
1115 .iter()
1116 .any(|error| error.contains("previous_hash mismatch")));
1117 }
1118
1119 #[tokio::test]
1120 async fn export_trust_chain_emits_envelope_matching_chain_schema() {
1121 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1122 let first = append_trust_record(
1123 &log,
1124 &TrustRecord::new(
1125 "bot",
1126 "github.issue.opened",
1127 None,
1128 TrustOutcome::Success,
1129 "trace-1",
1130 AutonomyTier::Suggest,
1131 ),
1132 )
1133 .await
1134 .unwrap();
1135 let second = append_trust_record(
1136 &log,
1137 &TrustRecord::new(
1138 "bot",
1139 "trust.promote",
1140 Some("maintainer-1".to_string()),
1141 TrustOutcome::Success,
1142 "trace-2",
1143 AutonomyTier::ActAuto,
1144 ),
1145 )
1146 .await
1147 .unwrap();
1148
1149 let export = export_trust_chain(&log).await.unwrap();
1150 assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1151 assert_eq!(export.chain.topic, TRUST_GRAPH_GLOBAL_TOPIC);
1152 assert_eq!(export.chain.total, 2);
1153 assert!(export.chain.verified);
1154 assert_eq!(
1155 export.chain.root_hash.as_deref(),
1156 Some(second.entry_hash.as_str())
1157 );
1158 assert_eq!(export.records.len(), 2);
1159 assert_eq!(export.records[0].entry_hash, first.entry_hash);
1160 assert_eq!(export.records[1].entry_hash, second.entry_hash);
1161 assert_eq!(export.chain.producer.name, "harn");
1162
1163 let envelope_json = serde_json::to_value(&export).unwrap();
1164 assert_eq!(envelope_json["schema"], OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1165 assert_eq!(envelope_json["chain"]["total"], 2);
1166 assert_eq!(envelope_json["chain"]["verified"], true);
1167 assert!(envelope_json["records"].as_array().unwrap().len() == 2);
1168 }
1169
1170 #[tokio::test]
1171 async fn export_trust_chain_handles_empty_log() {
1172 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1173 let export = export_trust_chain(&log).await.unwrap();
1174 assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1175 assert_eq!(export.chain.total, 0);
1176 assert!(export.chain.verified);
1177 assert!(export.chain.root_hash.is_none());
1178 assert!(export.records.is_empty());
1179 }
1180
1181 #[tokio::test]
1182 async fn resolve_autonomy_tier_prefers_latest_control_record() {
1183 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1184 append_trust_record(
1185 &log,
1186 &TrustRecord::new(
1187 "bot",
1188 "trust.promote",
1189 None,
1190 TrustOutcome::Success,
1191 "trace-1",
1192 AutonomyTier::ActWithApproval,
1193 ),
1194 )
1195 .await
1196 .unwrap();
1197 append_trust_record(
1198 &log,
1199 &TrustRecord::new(
1200 "bot",
1201 "trust.demote",
1202 None,
1203 TrustOutcome::Success,
1204 "trace-2",
1205 AutonomyTier::Shadow,
1206 ),
1207 )
1208 .await
1209 .unwrap();
1210
1211 let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
1212 .await
1213 .unwrap();
1214 assert_eq!(tier, AutonomyTier::Shadow);
1215 }
1216
1217 #[tokio::test]
1218 async fn query_limit_keeps_newest_matching_records() {
1219 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1220 let base = OffsetDateTime::from_unix_timestamp(1_775_000_000).unwrap();
1221 for (offset, action) in ["first", "second", "third"].into_iter().enumerate() {
1222 let mut record = TrustRecord::new(
1223 "bot",
1224 action,
1225 None,
1226 TrustOutcome::Success,
1227 format!("trace-{action}"),
1228 AutonomyTier::ActAuto,
1229 );
1230 record.timestamp = base + Duration::seconds(offset as i64);
1231 append_trust_record(&log, &record).await.unwrap();
1232 }
1233
1234 let records = query_trust_records(
1235 &log,
1236 &TrustQueryFilters {
1237 agent: Some("bot".to_string()),
1238 limit: Some(2),
1239 ..TrustQueryFilters::default()
1240 },
1241 )
1242 .await
1243 .unwrap();
1244
1245 assert_eq!(records.len(), 2);
1246 assert_eq!(records[0].action, "second");
1247 assert_eq!(records[1].action, "third");
1248 }
1249
1250 #[test]
1251 fn group_by_trace_preserves_chronological_group_order() {
1252 let make_record = |trace_id: &str, action: &str| TrustRecord {
1253 trace_id: trace_id.to_string(),
1254 action: action.to_string(),
1255 ..TrustRecord::new(
1256 "bot",
1257 action,
1258 None,
1259 TrustOutcome::Success,
1260 trace_id,
1261 AutonomyTier::ActAuto,
1262 )
1263 };
1264 let grouped = group_trust_records_by_trace(&[
1265 make_record("trace-1", "first"),
1266 make_record("trace-2", "second"),
1267 make_record("trace-1", "third"),
1268 ]);
1269
1270 assert_eq!(grouped.len(), 2);
1271 assert_eq!(grouped[0].trace_id, "trace-1");
1272 assert_eq!(grouped[0].records.len(), 2);
1273 assert_eq!(grouped[0].records[1].action, "third");
1274 assert_eq!(grouped[1].trace_id, "trace-2");
1275 }
1276
1277 #[test]
1278 fn opentrustgraph_schema_files_are_parseable_and_match_runtime_enums() {
1279 let record_schema: serde_json::Value = serde_json::from_str(RECORD_SCHEMA_JSON).unwrap();
1280 let chain_schema: serde_json::Value = serde_json::from_str(CHAIN_SCHEMA_JSON).unwrap();
1281
1282 assert_eq!(
1283 record_schema["properties"]["schema"]["const"],
1284 serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)
1285 );
1286 assert_eq!(
1287 chain_schema["properties"]["schema"]["const"],
1288 serde_json::json!("opentrustgraph-chain/v0")
1289 );
1290
1291 let outcomes = record_schema["properties"]["outcome"]["enum"]
1292 .as_array()
1293 .unwrap();
1294 for outcome in [
1295 TrustOutcome::Success,
1296 TrustOutcome::Failure,
1297 TrustOutcome::Denied,
1298 TrustOutcome::Timeout,
1299 ] {
1300 assert!(outcomes.contains(&serde_json::json!(outcome.as_str())));
1301 }
1302
1303 let tiers = record_schema["properties"]["autonomy_tier"]["enum"]
1304 .as_array()
1305 .unwrap();
1306 for tier in [
1307 AutonomyTier::Shadow,
1308 AutonomyTier::Suggest,
1309 AutonomyTier::ActWithApproval,
1310 AutonomyTier::ActAuto,
1311 ] {
1312 assert!(tiers.contains(&serde_json::json!(tier.as_str())));
1313 }
1314 }
1315
1316 #[test]
1317 fn opentrustgraph_valid_fixtures_match_runtime_contract() {
1318 for (name, fixture) in [
1319 ("decision-chain", VALID_DECISION_CHAIN_JSON),
1320 ("tier-transition", VALID_TIER_TRANSITION_JSON),
1321 ] {
1322 let fixture = parse_chain_fixture(fixture);
1323 let errors = validate_chain_fixture(&fixture);
1324 assert!(errors.is_empty(), "{name} errors: {errors:?}");
1325 }
1326 }
1327
1328 #[test]
1329 fn opentrustgraph_invalid_fixtures_exercise_expected_failures() {
1330 let tampered = parse_chain_fixture(INVALID_TAMPERED_CHAIN_JSON);
1331 let tampered_errors = validate_chain_fixture(&tampered);
1332 assert!(
1333 tampered_errors
1334 .iter()
1335 .any(|error| error.contains("previous_hash mismatch")),
1336 "tampered-chain errors: {tampered_errors:?}"
1337 );
1338 assert!(
1339 !tampered_errors
1340 .iter()
1341 .any(|error| error.contains("entry_hash mismatch")),
1342 "tampered-chain should isolate hash-link tampering: {tampered_errors:?}"
1343 );
1344
1345 let missing_approval = parse_chain_fixture(INVALID_MISSING_APPROVAL_JSON);
1346 let missing_errors = validate_chain_fixture(&missing_approval);
1347 assert!(
1348 missing_errors
1349 .iter()
1350 .any(|error| error.contains("approval required")),
1351 "missing-approval errors: {missing_errors:?}"
1352 );
1353 }
1354
1355 fn parse_chain_fixture(input: &str) -> TrustChainFixture {
1356 serde_json::from_str(input).unwrap()
1357 }
1358
1359 fn validate_chain_fixture(fixture: &TrustChainFixture) -> Vec<String> {
1360 let mut errors = Vec::new();
1361 if fixture.schema != "opentrustgraph-chain/v0" {
1362 errors.push(format!("unsupported chain schema {}", fixture.schema));
1363 }
1364 if fixture.chain.topic.trim().is_empty() {
1365 errors.push("chain topic is empty".to_string());
1366 }
1367 if fixture.chain.total != fixture.records.len() as u64 {
1368 errors.push(format!(
1369 "chain total mismatch; expected {}, found {}",
1370 fixture.records.len(),
1371 fixture.chain.total
1372 ));
1373 }
1374 if fixture
1375 .chain
1376 .producer
1377 .get("name")
1378 .and_then(|value| value.as_str())
1379 .unwrap_or_default()
1380 .trim()
1381 .is_empty()
1382 {
1383 errors.push("chain producer.name is empty".to_string());
1384 }
1385 if OffsetDateTime::parse(
1386 &fixture.chain.generated_at,
1387 &time::format_description::well_known::Rfc3339,
1388 )
1389 .is_err()
1390 {
1391 errors.push("chain generated_at is not RFC3339".to_string());
1392 }
1393
1394 for (index, record) in fixture.records.iter().enumerate() {
1395 errors.extend(validate_fixture_record_contract(index, record));
1396 }
1397 errors.extend(validate_fixture_hash_chain(fixture));
1398
1399 let expected_verified = errors.is_empty();
1400 if fixture.chain.verified != expected_verified {
1401 errors.push(format!(
1402 "chain verified flag mismatch; expected {expected_verified}, found {}",
1403 fixture.chain.verified
1404 ));
1405 }
1406 errors
1407 }
1408
1409 fn validate_fixture_record_contract(index: usize, record: &TrustRecord) -> Vec<String> {
1410 let mut errors = Vec::new();
1411 let label = format!("record {index}");
1412 if record.schema != OPENTRUSTGRAPH_SCHEMA_V0 {
1413 errors.push(format!("{label}: unsupported schema {}", record.schema));
1414 }
1415 if record.record_id.trim().is_empty() {
1416 errors.push(format!("{label}: record_id is empty"));
1417 }
1418 if record.agent.trim().is_empty() {
1419 errors.push(format!("{label}: agent is empty"));
1420 }
1421 if record.action.trim().is_empty() {
1422 errors.push(format!("{label}: action is empty"));
1423 }
1424 if record.trace_id.trim().is_empty() {
1425 errors.push(format!("{label}: trace_id is empty"));
1426 }
1427 if !record.entry_hash.starts_with("sha256:") {
1428 errors.push(format!("{label}: entry_hash is not sha256-prefixed"));
1429 }
1430 if let Some(cost_usd) = record.cost_usd {
1431 if cost_usd < 0.0 {
1432 errors.push(format!("{label}: cost_usd is negative"));
1433 }
1434 }
1435
1436 if record.outcome == TrustOutcome::Success
1437 && record.autonomy_tier == AutonomyTier::ActWithApproval
1438 && approval_required(record)
1439 {
1440 if record
1441 .approver
1442 .as_deref()
1443 .unwrap_or_default()
1444 .trim()
1445 .is_empty()
1446 {
1447 errors.push(format!("{label}: approval required but approver is empty"));
1448 }
1449 if approval_signature_count(record) == 0 {
1450 errors.push(format!(
1451 "{label}: approval required but signatures are empty"
1452 ));
1453 }
1454 }
1455
1456 errors
1457 }
1458
1459 fn validate_fixture_hash_chain(fixture: &TrustChainFixture) -> Vec<String> {
1460 let mut errors = Vec::new();
1461 let mut previous_hash: Option<String> = None;
1462
1463 for (position, record) in fixture.records.iter().enumerate() {
1464 let expected_index = position as u64 + 1;
1465 if record.chain_index != expected_index {
1466 errors.push(format!(
1467 "record {position}: expected chain_index {expected_index}, found {}",
1468 record.chain_index
1469 ));
1470 }
1471 if record.previous_hash != previous_hash {
1472 errors.push(format!(
1473 "record {position}: previous_hash mismatch; expected {:?}, found {:?}",
1474 previous_hash, record.previous_hash
1475 ));
1476 }
1477 let expected_hash = compute_trust_record_hash(record).unwrap();
1478 if expected_hash != record.entry_hash {
1479 errors.push(format!(
1480 "record {position}: entry_hash mismatch; expected {expected_hash}, found {}",
1481 record.entry_hash
1482 ));
1483 }
1484 previous_hash = Some(record.entry_hash.clone());
1485 }
1486
1487 if fixture.chain.root_hash != previous_hash {
1488 errors.push(format!(
1489 "chain root_hash mismatch; expected {:?}, found {:?}",
1490 previous_hash, fixture.chain.root_hash
1491 ));
1492 }
1493 errors
1494 }
1495
1496 fn approval_required(record: &TrustRecord) -> bool {
1497 record
1498 .metadata
1499 .get("approval")
1500 .and_then(|approval| approval.get("required"))
1501 .and_then(|required| required.as_bool())
1502 .unwrap_or(false)
1503 }
1504
1505 fn approval_signature_count(record: &TrustRecord) -> usize {
1506 record
1507 .metadata
1508 .get("approval")
1509 .and_then(|approval| approval.get("signatures"))
1510 .and_then(|signatures| signatures.as_array())
1511 .map(Vec::len)
1512 .unwrap_or(0)
1513 }
1514}