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