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