Skip to main content

harn_vm/
trust_graph.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::{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}