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, EffectRecord};
14
15pub const OPENTRUSTGRAPH_SCHEMA_V0: &str = "opentrustgraph/v0";
16/// OpenTrustGraph v0.1: additive metadata schema (#1778). Adds the
17/// `effects_grant`, `effects_used`, and `parent_record_id` reserved keys
18/// under `TrustRecord.metadata` so chain validators can prove that a
19/// child agent's `effects_used ⊆ parent.effects_grant` (E5.5).
20///
21/// Backwards compatible: v0 records are still accepted (the new keys are
22/// optional). One patch release window after this bump, v0 will be
23/// dropped per `opentrustgraph-spec/CONFORMANCE.md` §5.
24pub const OPENTRUSTGRAPH_SCHEMA_V0_1: &str = "opentrustgraph/v0.1";
25/// Set of schema discriminators accepted by the v0.1 validator. v0 stays
26/// here for one patch release window before being retired.
27pub const OPENTRUSTGRAPH_ACCEPTED_SCHEMAS: &[&str] =
28    &[OPENTRUSTGRAPH_SCHEMA_V0_1, OPENTRUSTGRAPH_SCHEMA_V0];
29pub const OPENTRUSTGRAPH_CHAIN_SCHEMA_V0: &str = "opentrustgraph-chain/v0";
30
31/// Reserved metadata key for the effect grant attached to a record by its
32/// spawning parent. v0.1 addition (#1778).
33pub const METADATA_KEY_EFFECTS_GRANT: &str = "effects_grant";
34/// Reserved metadata key for the effects the recorded action actually
35/// exercised. Must be a subset of the parent's `effects_grant`. v0.1
36/// addition (#1778).
37pub const METADATA_KEY_EFFECTS_USED: &str = "effects_used";
38/// Reserved metadata key pointing at the parent record's `record_id`.
39/// Lets verifiers reconstruct the agent chain without scanning the whole
40/// stream. v0.1 addition (#1778).
41pub const METADATA_KEY_PARENT_RECORD_ID: &str = "parent_record_id";
42pub const TRUST_GRAPH_RECORDS_TOPIC: &str = "trust_graph.records";
43pub const TRUST_GRAPH_GLOBAL_TOPIC: &str = "trust_graph";
44pub const TRUST_GRAPH_LEGACY_GLOBAL_TOPIC: &str = "trust.graph";
45pub const TRUST_GRAPH_TOPIC_PREFIX: &str = "trust_graph.";
46pub const TRUST_GRAPH_LEGACY_TOPIC_PREFIX: &str = "trust.graph.";
47pub const TRUST_GRAPH_EVENT_KIND: &str = "trust_recorded";
48pub const TRUST_ACTION_RELEASE: &str = "release";
49
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum AutonomyTier {
53    Shadow,
54    Suggest,
55    ActWithApproval,
56    #[default]
57    ActAuto,
58}
59
60impl AutonomyTier {
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Shadow => "shadow",
64            Self::Suggest => "suggest",
65            Self::ActWithApproval => "act_with_approval",
66            Self::ActAuto => "act_auto",
67        }
68    }
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum TrustOutcome {
74    Success,
75    Failure,
76    Denied,
77    Timeout,
78}
79
80impl TrustOutcome {
81    pub fn as_str(self) -> &'static str {
82        match self {
83            Self::Success => "success",
84            Self::Failure => "failure",
85            Self::Denied => "denied",
86            Self::Timeout => "timeout",
87        }
88    }
89}
90
91#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92pub struct TrustRecord {
93    pub schema: String,
94    pub record_id: String,
95    pub agent: String,
96    pub action: String,
97    pub approver: Option<String>,
98    pub outcome: TrustOutcome,
99    pub trace_id: String,
100    pub autonomy_tier: AutonomyTier,
101    #[serde(with = "time::serde::rfc3339")]
102    pub timestamp: OffsetDateTime,
103    pub cost_usd: Option<f64>,
104    #[serde(default)]
105    pub chain_index: u64,
106    #[serde(default)]
107    pub previous_hash: Option<String>,
108    #[serde(default)]
109    pub entry_hash: String,
110    #[serde(default)]
111    pub metadata: BTreeMap<String, serde_json::Value>,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(tag = "kind", rename_all = "snake_case")]
116pub enum TrustRecordActionKind {
117    Release {
118        bundle_hash: String,
119        harn_version: String,
120        parent_trust_record_id: Option<String>,
121    },
122}
123
124impl TrustRecord {
125    pub fn new(
126        agent: impl Into<String>,
127        action: impl Into<String>,
128        approver: Option<String>,
129        outcome: TrustOutcome,
130        trace_id: impl Into<String>,
131        autonomy_tier: AutonomyTier,
132    ) -> Self {
133        Self {
134            schema: OPENTRUSTGRAPH_SCHEMA_V0_1.to_string(),
135            record_id: Uuid::now_v7().to_string(),
136            agent: agent.into(),
137            action: action.into(),
138            approver,
139            outcome,
140            trace_id: trace_id.into(),
141            autonomy_tier,
142            timestamp: OffsetDateTime::now_utc(),
143            cost_usd: None,
144            chain_index: 0,
145            previous_hash: None,
146            entry_hash: String::new(),
147            metadata: BTreeMap::new(),
148        }
149    }
150
151    pub fn release(
152        agent: impl Into<String>,
153        bundle_hash: impl Into<String>,
154        harn_version: impl Into<String>,
155        parent_trust_record_id: Option<String>,
156        trace_id: impl Into<String>,
157        autonomy_tier: AutonomyTier,
158    ) -> Self {
159        let bundle_hash = bundle_hash.into();
160        let harn_version = harn_version.into();
161        let action_kind = TrustRecordActionKind::Release {
162            bundle_hash: bundle_hash.clone(),
163            harn_version: harn_version.clone(),
164            parent_trust_record_id: parent_trust_record_id.clone(),
165        };
166        let mut record = Self::new(
167            agent,
168            TRUST_ACTION_RELEASE,
169            None,
170            TrustOutcome::Success,
171            trace_id,
172            autonomy_tier,
173        );
174        record
175            .metadata
176            .insert("action_kind".to_string(), serde_json::json!(action_kind));
177        record
178            .metadata
179            .insert("bundle_hash".to_string(), serde_json::json!(bundle_hash));
180        record
181            .metadata
182            .insert("harn_version".to_string(), serde_json::json!(harn_version));
183        record.metadata.insert(
184            "parent_trust_record_id".to_string(),
185            parent_trust_record_id
186                .map(serde_json::Value::String)
187                .unwrap_or(serde_json::Value::Null),
188        );
189        record
190    }
191
192    /// Attach the typed effect grant a parent extended to this record. v0.1
193    /// (#1778). Empty grants are skipped so legacy `metadata` shape is
194    /// preserved when there is nothing to record.
195    pub fn with_effects_grant(mut self, effects: Vec<EffectRecord>) -> Self {
196        self.set_effects_grant(effects);
197        self
198    }
199
200    pub fn set_effects_grant(&mut self, effects: Vec<EffectRecord>) {
201        if effects.is_empty() {
202            self.metadata.remove(METADATA_KEY_EFFECTS_GRANT);
203            return;
204        }
205        self.metadata.insert(
206            METADATA_KEY_EFFECTS_GRANT.to_string(),
207            serde_json::to_value(effects).expect("EffectRecord is serializable"),
208        );
209    }
210
211    pub fn effects_grant(&self) -> Vec<EffectRecord> {
212        decode_effect_list(self.metadata.get(METADATA_KEY_EFFECTS_GRANT))
213    }
214
215    /// Attach the typed effect set the action actually exercised. v0.1
216    /// (#1778). Verifiers must check `effects_used ⊆ effects_grant` (and
217    /// transitively up the parent chain).
218    pub fn with_effects_used(mut self, effects: Vec<EffectRecord>) -> Self {
219        self.set_effects_used(effects);
220        self
221    }
222
223    pub fn set_effects_used(&mut self, effects: Vec<EffectRecord>) {
224        if effects.is_empty() {
225            self.metadata.remove(METADATA_KEY_EFFECTS_USED);
226            return;
227        }
228        self.metadata.insert(
229            METADATA_KEY_EFFECTS_USED.to_string(),
230            serde_json::to_value(effects).expect("EffectRecord is serializable"),
231        );
232    }
233
234    pub fn effects_used(&self) -> Vec<EffectRecord> {
235        decode_effect_list(self.metadata.get(METADATA_KEY_EFFECTS_USED))
236    }
237
238    /// Point this record at its parent's `record_id`. v0.1 (#1778). The
239    /// existing release-record key (`parent_trust_record_id`) is retained
240    /// for the release flow; this is the generic spawn-lineage pointer.
241    pub fn with_parent_record_id(mut self, parent_record_id: impl Into<String>) -> Self {
242        self.set_parent_record_id(Some(parent_record_id.into()));
243        self
244    }
245
246    pub fn set_parent_record_id(&mut self, parent_record_id: Option<String>) {
247        match parent_record_id {
248            Some(id) if !id.is_empty() => {
249                self.metadata.insert(
250                    METADATA_KEY_PARENT_RECORD_ID.to_string(),
251                    serde_json::Value::String(id),
252                );
253            }
254            _ => {
255                self.metadata.remove(METADATA_KEY_PARENT_RECORD_ID);
256            }
257        }
258    }
259
260    pub fn parent_record_id(&self) -> Option<String> {
261        self.metadata
262            .get(METADATA_KEY_PARENT_RECORD_ID)
263            .and_then(|value| value.as_str())
264            .map(str::to_string)
265    }
266}
267
268fn decode_effect_list(value: Option<&serde_json::Value>) -> Vec<EffectRecord> {
269    value
270        .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
271        .unwrap_or_default()
272}
273
274#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
275pub struct TrustGraphRecord {
276    pub actor_id: String,
277    pub action: String,
278    pub approver: Option<String>,
279    pub outcome: TrustOutcome,
280    #[serde(default)]
281    pub evidence_refs: Vec<serde_json::Value>,
282    pub trace_id: String,
283    #[serde(with = "time::serde::rfc3339")]
284    pub timestamp: OffsetDateTime,
285    pub autonomy_tier_at_time: AutonomyTier,
286}
287
288impl TrustGraphRecord {
289    pub fn from_trust_record(record: &TrustRecord) -> Self {
290        Self {
291            actor_id: record.agent.clone(),
292            action: record.action.clone(),
293            approver: record.approver.clone(),
294            outcome: record.outcome,
295            evidence_refs: evidence_refs_from_metadata(&record.metadata),
296            trace_id: record.trace_id.clone(),
297            timestamp: record.timestamp,
298            autonomy_tier_at_time: record.autonomy_tier,
299        }
300    }
301}
302
303#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
304#[serde(default)]
305pub struct TrustQueryFilters {
306    pub agent: Option<String>,
307    pub action: Option<String>,
308    #[serde(with = "time::serde::rfc3339::option")]
309    pub since: Option<OffsetDateTime>,
310    #[serde(with = "time::serde::rfc3339::option")]
311    pub until: Option<OffsetDateTime>,
312    pub tier: Option<AutonomyTier>,
313    pub outcome: Option<TrustOutcome>,
314    pub limit: Option<usize>,
315    pub grouped_by_trace: bool,
316}
317
318#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
319#[serde(default)]
320pub struct TrustTraceGroup {
321    pub trace_id: String,
322    pub records: Vec<TrustRecord>,
323}
324
325#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
326#[serde(default)]
327pub struct TrustAgentSummary {
328    pub agent: String,
329    pub total: u64,
330    pub success_rate: f64,
331    pub mean_cost_usd: Option<f64>,
332    pub tier_distribution: BTreeMap<String, u64>,
333    pub outcome_distribution: BTreeMap<String, u64>,
334}
335
336#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
337#[serde(default)]
338pub struct TrustScore {
339    pub agent: String,
340    pub action: Option<String>,
341    pub total: u64,
342    pub successes: u64,
343    pub failures: u64,
344    pub denied: u64,
345    pub timeouts: u64,
346    pub success_rate: f64,
347    pub latest_outcome: Option<TrustOutcome>,
348    #[serde(with = "time::serde::rfc3339::option")]
349    pub latest_timestamp: Option<OffsetDateTime>,
350    pub effective_tier: AutonomyTier,
351    pub policy: CapabilityPolicy,
352}
353
354#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
355#[serde(default)]
356pub struct TrustChainReport {
357    pub topic: String,
358    pub total: u64,
359    pub verified: bool,
360    pub root_hash: Option<String>,
361    pub broken_at_event_id: Option<EventId>,
362    pub errors: Vec<String>,
363}
364
365#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
366pub struct TrustChainExportProducer {
367    pub name: String,
368    pub version: String,
369}
370
371impl Default for TrustChainExportProducer {
372    fn default() -> Self {
373        Self {
374            name: "harn".to_string(),
375            version: env!("CARGO_PKG_VERSION").to_string(),
376        }
377    }
378}
379
380#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
381pub struct TrustChainExportMetadata {
382    pub topic: String,
383    pub total: u64,
384    pub root_hash: Option<String>,
385    pub verified: bool,
386    #[serde(with = "time::serde::rfc3339")]
387    pub generated_at: OffsetDateTime,
388    pub producer: TrustChainExportProducer,
389}
390
391#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
392pub struct TrustChainExport {
393    pub schema: String,
394    pub chain: TrustChainExportMetadata,
395    pub records: Vec<TrustRecord>,
396}
397
398fn global_topic() -> Result<Topic, LogError> {
399    Topic::new(TRUST_GRAPH_GLOBAL_TOPIC)
400}
401
402fn legacy_global_topic() -> Result<Topic, LogError> {
403    Topic::new(TRUST_GRAPH_LEGACY_GLOBAL_TOPIC)
404}
405
406fn records_topic() -> Result<Topic, LogError> {
407    Topic::new(TRUST_GRAPH_RECORDS_TOPIC)
408}
409
410pub fn topic_for_agent(agent: &str) -> Result<Topic, LogError> {
411    Topic::new(format!(
412        "{TRUST_GRAPH_TOPIC_PREFIX}{}",
413        sanitize_topic_component(agent)
414    ))
415}
416
417pub fn legacy_topic_for_agent(agent: &str) -> Result<Topic, LogError> {
418    Topic::new(format!(
419        "{TRUST_GRAPH_LEGACY_TOPIC_PREFIX}{}",
420        sanitize_topic_component(agent)
421    ))
422}
423
424pub async fn append_trust_record(
425    log: &Arc<AnyEventLog>,
426    record: &TrustRecord,
427) -> Result<TrustRecord, LogError> {
428    let finalized = finalize_trust_record(log, record.clone()).await?;
429    let payload = serde_json::to_value(&finalized)
430        .map_err(|error| LogError::Serde(format!("trust record encode error: {error}")))?;
431    let mut headers = BTreeMap::new();
432    headers.insert("trace_id".to_string(), finalized.trace_id.clone());
433    headers.insert("agent".to_string(), finalized.agent.clone());
434    headers.insert(
435        "autonomy_tier".to_string(),
436        finalized.autonomy_tier.as_str().to_string(),
437    );
438    headers.insert(
439        "outcome".to_string(),
440        finalized.outcome.as_str().to_string(),
441    );
442    headers.insert("entry_hash".to_string(), finalized.entry_hash.clone());
443    let event = LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers);
444    for topic in append_topics_for_record(&finalized)? {
445        log.append(&topic, event.clone()).await?;
446    }
447    append_trust_graph_record_projection(log, &finalized).await?;
448    Ok(finalized)
449}
450
451pub async fn append_active_trust_record(record: &TrustRecord) -> Result<TrustRecord, LogError> {
452    let log = active_event_log()
453        .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
454    append_trust_record(&log, record).await
455}
456
457pub async fn query_trust_records(
458    log: &Arc<AnyEventLog>,
459    filters: &TrustQueryFilters,
460) -> Result<Vec<TrustRecord>, LogError> {
461    let topics = query_topics(filters)?;
462    let mut records = Vec::new();
463    let mut seen = HashSet::new();
464    for topic in topics {
465        for (_, event) in log.read_range(&topic, None, usize::MAX).await? {
466            if event.kind != TRUST_GRAPH_EVENT_KIND {
467                continue;
468            }
469            let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
470                continue;
471            };
472            if !matches_filters(&record, filters) {
473                continue;
474            }
475            let dedupe_key = trust_record_dedupe_key(&record);
476            if seen.insert(dedupe_key) {
477                records.push(record);
478            }
479        }
480    }
481    records.sort_by(|left, right| {
482        left.timestamp
483            .cmp(&right.timestamp)
484            .then(left.chain_index.cmp(&right.chain_index))
485            .then(left.agent.cmp(&right.agent))
486            .then(left.record_id.cmp(&right.record_id))
487    });
488    apply_record_limit(&mut records, filters.limit);
489    Ok(records)
490}
491
492pub async fn query_trust_graph_records(
493    log: &Arc<AnyEventLog>,
494    filters: &TrustQueryFilters,
495) -> Result<Vec<TrustGraphRecord>, LogError> {
496    let mut graph_records = Vec::new();
497    let mut seen = HashSet::new();
498
499    for record in query_trust_records(log, filters).await? {
500        let graph_record = TrustGraphRecord::from_trust_record(&record);
501        let dedupe_key = trust_graph_record_dedupe_key(&graph_record);
502        if seen.insert(dedupe_key) {
503            graph_records.push(graph_record);
504        }
505    }
506
507    for (_, event) in log.read_range(&records_topic()?, None, usize::MAX).await? {
508        if event.kind != TRUST_GRAPH_EVENT_KIND {
509            continue;
510        }
511        let Ok(record) = serde_json::from_value::<TrustGraphRecord>(event.payload) else {
512            continue;
513        };
514        if !matches_graph_filters(&record, filters) {
515            continue;
516        }
517        let dedupe_key = trust_graph_record_dedupe_key(&record);
518        if seen.insert(dedupe_key) {
519            graph_records.push(record);
520        }
521    }
522
523    graph_records.sort_by(|left, right| {
524        left.timestamp
525            .cmp(&right.timestamp)
526            .then(left.actor_id.cmp(&right.actor_id))
527            .then(left.action.cmp(&right.action))
528            .then(left.trace_id.cmp(&right.trace_id))
529    });
530    apply_graph_record_limit(&mut graph_records, filters.limit);
531    Ok(graph_records)
532}
533
534pub async fn trust_score_for(
535    log: &Arc<AnyEventLog>,
536    agent: &str,
537    action: Option<&str>,
538) -> Result<TrustScore, LogError> {
539    let records = query_trust_records(
540        log,
541        &TrustQueryFilters {
542            agent: Some(agent.to_string()),
543            action: action.map(ToString::to_string),
544            ..TrustQueryFilters::default()
545        },
546    )
547    .await?;
548    let effective_tier = resolve_agent_autonomy_tier(log, agent, AutonomyTier::ActAuto).await?;
549    let mut score = score_from_records(agent, action, effective_tier, &records);
550    score.policy =
551        crate::corrections::apply_corrections_to_policy(log, agent, score.policy).await?;
552    Ok(score)
553}
554
555pub async fn policy_for_agent(
556    log: &Arc<AnyEventLog>,
557    agent: &str,
558) -> Result<CapabilityPolicy, LogError> {
559    Ok(trust_score_for(log, agent, None).await?.policy)
560}
561
562pub async fn verify_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainReport, LogError> {
563    let (topic, records) = preferred_chain_records(log).await?;
564    let mut previous_hash: Option<String> = None;
565    let mut errors = Vec::new();
566    let mut broken_at_event_id = None;
567
568    for (position, (event_id, record)) in records.iter().enumerate() {
569        let expected_index = (position as u64) + 1;
570        if record.chain_index != expected_index {
571            errors.push(format!(
572                "event {event_id}: expected chain_index {expected_index}, found {}",
573                record.chain_index
574            ));
575        }
576        if record.previous_hash != previous_hash {
577            errors.push(format!(
578                "event {event_id}: previous_hash mismatch; expected {:?}, found {:?}",
579                previous_hash, record.previous_hash
580            ));
581        }
582        match compute_trust_record_hash(record) {
583            Ok(expected_hash) if expected_hash == record.entry_hash => {}
584            Ok(expected_hash) => errors.push(format!(
585                "event {event_id}: entry_hash mismatch; expected {expected_hash}, found {}",
586                record.entry_hash
587            )),
588            Err(error) => errors.push(format!("event {event_id}: {error}")),
589        }
590        if !errors.is_empty() && broken_at_event_id.is_none() {
591            broken_at_event_id = Some(*event_id);
592        }
593        previous_hash = Some(record.entry_hash.clone());
594    }
595
596    Ok(TrustChainReport {
597        topic: topic.as_str().to_string(),
598        total: records.len() as u64,
599        verified: errors.is_empty(),
600        root_hash: records.last().map(|(_, record)| record.entry_hash.clone()),
601        broken_at_event_id,
602        errors,
603    })
604}
605
606pub async fn export_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainExport, LogError> {
607    let (topic, records_with_ids) = preferred_chain_records(log).await?;
608    let report = verify_trust_chain(log).await?;
609    let records: Vec<TrustRecord> = records_with_ids.into_iter().map(|(_, r)| r).collect();
610    Ok(TrustChainExport {
611        schema: OPENTRUSTGRAPH_CHAIN_SCHEMA_V0.to_string(),
612        chain: TrustChainExportMetadata {
613            topic: topic.as_str().to_string(),
614            total: records.len() as u64,
615            root_hash: records.last().map(|record| record.entry_hash.clone()),
616            verified: report.verified,
617            generated_at: OffsetDateTime::now_utc(),
618            producer: TrustChainExportProducer::default(),
619        },
620        records,
621    })
622}
623
624pub fn compute_trust_record_hash(record: &TrustRecord) -> Result<String, LogError> {
625    let mut value = serde_json::to_value(record)
626        .map_err(|error| LogError::Serde(format!("trust record hash encode error: {error}")))?;
627    if let Some(object) = value.as_object_mut() {
628        object.remove("entry_hash");
629    }
630    let canonical = serde_json::to_string(&value)
631        .map_err(|error| LogError::Serde(format!("trust record canonicalize error: {error}")))?;
632    let digest = Sha256::digest(canonical.as_bytes());
633    Ok(format!("sha256:{}", hex::encode(digest)))
634}
635
636pub fn group_trust_records_by_trace(records: &[TrustRecord]) -> Vec<TrustTraceGroup> {
637    let mut groups: Vec<TrustTraceGroup> = Vec::new();
638    let mut positions: HashMap<String, usize> = HashMap::new();
639    for record in records {
640        if let Some(index) = positions.get(record.trace_id.as_str()).copied() {
641            groups[index].records.push(record.clone());
642            continue;
643        }
644        positions.insert(record.trace_id.clone(), groups.len());
645        groups.push(TrustTraceGroup {
646            trace_id: record.trace_id.clone(),
647            records: vec![record.clone()],
648        });
649    }
650    groups
651}
652
653pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
654    #[derive(Default)]
655    struct RunningSummary {
656        total: u64,
657        successes: u64,
658        cost_sum: f64,
659        cost_count: u64,
660        tier_distribution: BTreeMap<String, u64>,
661        outcome_distribution: BTreeMap<String, u64>,
662    }
663
664    let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
665    for record in records {
666        let entry = by_agent.entry(record.agent.clone()).or_default();
667        entry.total += 1;
668        if record.outcome == TrustOutcome::Success {
669            entry.successes += 1;
670        }
671        if let Some(cost_usd) = record.cost_usd {
672            entry.cost_sum += cost_usd;
673            entry.cost_count += 1;
674        }
675        *entry
676            .tier_distribution
677            .entry(record.autonomy_tier.as_str().to_string())
678            .or_default() += 1;
679        *entry
680            .outcome_distribution
681            .entry(record.outcome.as_str().to_string())
682            .or_default() += 1;
683    }
684
685    by_agent
686        .into_iter()
687        .map(|(agent, summary)| TrustAgentSummary {
688            agent,
689            total: summary.total,
690            success_rate: if summary.total == 0 {
691                0.0
692            } else {
693                summary.successes as f64 / summary.total as f64
694            },
695            mean_cost_usd: (summary.cost_count > 0)
696                .then_some(summary.cost_sum / summary.cost_count as f64),
697            tier_distribution: summary.tier_distribution,
698            outcome_distribution: summary.outcome_distribution,
699        })
700        .collect()
701}
702
703pub async fn resolve_agent_autonomy_tier(
704    log: &Arc<AnyEventLog>,
705    agent: &str,
706    default: AutonomyTier,
707) -> Result<AutonomyTier, LogError> {
708    let records = query_trust_records(
709        log,
710        &TrustQueryFilters {
711            agent: Some(agent.to_string()),
712            ..TrustQueryFilters::default()
713        },
714    )
715    .await?;
716    let mut current = default;
717    for record in records {
718        if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
719            && record.outcome == TrustOutcome::Success
720        {
721            current = record.autonomy_tier;
722        }
723    }
724    Ok(current)
725}
726
727fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
728    if let Some(agent) = filters.agent.as_deref() {
729        if record.agent != agent {
730            return false;
731        }
732    }
733    if let Some(action) = filters.action.as_deref() {
734        if record.action != action {
735            return false;
736        }
737    }
738    if let Some(since) = filters.since {
739        if record.timestamp < since {
740            return false;
741        }
742    }
743    if let Some(until) = filters.until {
744        if record.timestamp > until {
745            return false;
746        }
747    }
748    if let Some(tier) = filters.tier {
749        if record.autonomy_tier != tier {
750            return false;
751        }
752    }
753    if let Some(outcome) = filters.outcome {
754        if record.outcome != outcome {
755            return false;
756        }
757    }
758    true
759}
760
761fn matches_graph_filters(record: &TrustGraphRecord, filters: &TrustQueryFilters) -> bool {
762    if let Some(agent) = filters.agent.as_deref() {
763        if record.actor_id != agent {
764            return false;
765        }
766    }
767    if let Some(action) = filters.action.as_deref() {
768        if record.action != action {
769            return false;
770        }
771    }
772    if let Some(since) = filters.since {
773        if record.timestamp < since {
774            return false;
775        }
776    }
777    if let Some(until) = filters.until {
778        if record.timestamp > until {
779            return false;
780        }
781    }
782    if let Some(tier) = filters.tier {
783        if record.autonomy_tier_at_time != tier {
784            return false;
785        }
786    }
787    if let Some(outcome) = filters.outcome {
788        if record.outcome != outcome {
789            return false;
790        }
791    }
792    true
793}
794
795fn query_topics(filters: &TrustQueryFilters) -> Result<Vec<Topic>, LogError> {
796    match filters.agent.as_deref() {
797        Some(agent) => unique_topics(vec![
798            topic_for_agent(agent)?,
799            legacy_topic_for_agent(agent)?,
800        ]),
801        None => unique_topics(vec![global_topic()?, legacy_global_topic()?]),
802    }
803}
804
805fn append_topics_for_record(record: &TrustRecord) -> Result<Vec<Topic>, LogError> {
806    unique_topics(vec![
807        global_topic()?,
808        legacy_global_topic()?,
809        topic_for_agent(&record.agent)?,
810        legacy_topic_for_agent(&record.agent)?,
811    ])
812}
813
814fn unique_topics(topics: Vec<Topic>) -> Result<Vec<Topic>, LogError> {
815    let mut seen = HashSet::new();
816    Ok(topics
817        .into_iter()
818        .filter(|topic| seen.insert(topic.as_str().to_string()))
819        .collect())
820}
821
822async fn append_trust_graph_record_projection(
823    log: &Arc<AnyEventLog>,
824    record: &TrustRecord,
825) -> Result<(), LogError> {
826    let payload = serde_json::to_value(TrustGraphRecord::from_trust_record(record))
827        .map_err(|error| LogError::Serde(format!("trust graph record encode error: {error}")))?;
828    let mut headers = BTreeMap::new();
829    headers.insert("trace_id".to_string(), record.trace_id.clone());
830    headers.insert("actor_id".to_string(), record.agent.clone());
831    headers.insert("action".to_string(), record.action.clone());
832    headers.insert(
833        "autonomy_tier_at_time".to_string(),
834        record.autonomy_tier.as_str().to_string(),
835    );
836    headers.insert("outcome".to_string(), record.outcome.as_str().to_string());
837    log.append(
838        &records_topic()?,
839        LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers),
840    )
841    .await?;
842    Ok(())
843}
844
845async fn finalize_trust_record(
846    log: &Arc<AnyEventLog>,
847    mut record: TrustRecord,
848) -> Result<TrustRecord, LogError> {
849    let latest = latest_chain_record(log).await?;
850    record.chain_index = latest
851        .as_ref()
852        .map(|(_, record)| record.chain_index.saturating_add(1).max(1))
853        .unwrap_or(1);
854    record.previous_hash = latest.and_then(|(_, record)| {
855        if record.entry_hash.is_empty() {
856            compute_trust_record_hash(&record).ok()
857        } else {
858            Some(record.entry_hash)
859        }
860    });
861    record.entry_hash.clear();
862    record.entry_hash = compute_trust_record_hash(&record)?;
863    Ok(record)
864}
865
866async fn latest_chain_record(
867    log: &Arc<AnyEventLog>,
868) -> Result<Option<(EventId, TrustRecord)>, LogError> {
869    let (_, records) = preferred_chain_records(log).await?;
870    Ok(records.into_iter().last())
871}
872
873async fn preferred_chain_records(
874    log: &Arc<AnyEventLog>,
875) -> Result<(Topic, Vec<(EventId, TrustRecord)>), LogError> {
876    let canonical = global_topic()?;
877    let canonical_records = read_trust_records_from_topic(log, &canonical).await?;
878    if !canonical_records.is_empty() {
879        return Ok((canonical, canonical_records));
880    }
881    let legacy = legacy_global_topic()?;
882    let legacy_records = read_trust_records_from_topic(log, &legacy).await?;
883    if legacy_records.is_empty() {
884        Ok((canonical, Vec::new()))
885    } else {
886        Ok((legacy, legacy_records))
887    }
888}
889
890async fn read_trust_records_from_topic(
891    log: &Arc<AnyEventLog>,
892    topic: &Topic,
893) -> Result<Vec<(EventId, TrustRecord)>, LogError> {
894    let events = log.read_range(topic, None, usize::MAX).await?;
895    let mut records = Vec::new();
896    let mut seen = HashSet::new();
897    for (event_id, event) in events {
898        if event.kind != TRUST_GRAPH_EVENT_KIND {
899            continue;
900        }
901        let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
902            continue;
903        };
904        if seen.insert(trust_record_dedupe_key(&record)) {
905            records.push((event_id, record));
906        }
907    }
908    Ok(records)
909}
910
911fn trust_record_dedupe_key(record: &TrustRecord) -> String {
912    if !record.entry_hash.is_empty() {
913        return record.entry_hash.clone();
914    }
915    record.record_id.clone()
916}
917
918fn trust_graph_record_dedupe_key(record: &TrustGraphRecord) -> String {
919    format!(
920        "{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
921        record.actor_id,
922        record.action,
923        record.trace_id,
924        record.timestamp,
925        record.outcome.as_str()
926    )
927}
928
929fn evidence_refs_from_metadata(
930    metadata: &BTreeMap<String, serde_json::Value>,
931) -> Vec<serde_json::Value> {
932    metadata
933        .get("evidence_refs")
934        .or_else(|| metadata.get("evidenceRefs"))
935        .or_else(|| {
936            metadata
937                .get("approval")
938                .and_then(|approval| approval.get("evidence_refs"))
939        })
940        .and_then(|value| value.as_array())
941        .cloned()
942        .unwrap_or_default()
943}
944
945fn score_from_records(
946    agent: &str,
947    action: Option<&str>,
948    effective_tier: AutonomyTier,
949    records: &[TrustRecord],
950) -> TrustScore {
951    let mut score = TrustScore {
952        agent: agent.to_string(),
953        action: action.map(ToString::to_string),
954        effective_tier,
955        ..TrustScore::default()
956    };
957    let recent_cutoff = OffsetDateTime::now_utc() - Duration::days(30);
958    let mut recent_successes = 0;
959    let mut recent_bad_or_rollback = false;
960    for record in records {
961        score.total += 1;
962        match record.outcome {
963            TrustOutcome::Success => score.successes += 1,
964            TrustOutcome::Failure => score.failures += 1,
965            TrustOutcome::Denied => score.denied += 1,
966            TrustOutcome::Timeout => score.timeouts += 1,
967        }
968        if record.timestamp >= recent_cutoff {
969            if record.outcome == TrustOutcome::Success && !is_control_plane_action(&record.action) {
970                recent_successes += 1;
971            } else if record.outcome != TrustOutcome::Success {
972                recent_bad_or_rollback = true;
973            }
974            if record.action.contains("rollback") {
975                recent_bad_or_rollback = true;
976            }
977        }
978        score.latest_outcome = Some(record.outcome);
979        score.latest_timestamp = Some(record.timestamp);
980    }
981    score.success_rate = if score.total == 0 {
982        0.0
983    } else {
984        score.successes as f64 / score.total as f64
985    };
986    score.policy = policy_from_score(&score, recent_successes, recent_bad_or_rollback);
987    score
988}
989
990fn policy_from_score(
991    score: &TrustScore,
992    recent_successes: u64,
993    recent_bad_or_rollback: bool,
994) -> CapabilityPolicy {
995    let mut policy = policy_for_autonomy_tier(score.effective_tier);
996    let latest_bad = matches!(
997        score.latest_outcome,
998        Some(TrustOutcome::Denied | TrustOutcome::Failure | TrustOutcome::Timeout)
999    );
1000    let trusted_recent_track_record = score.effective_tier == AutonomyTier::ActWithApproval
1001        && recent_successes >= 10
1002        && !recent_bad_or_rollback;
1003    if latest_bad || (!trusted_recent_track_record && score.total >= 3 && score.success_rate < 0.5)
1004    {
1005        policy.side_effect_level = Some("read_only".to_string());
1006    } else if trusted_recent_track_record {
1007        policy.side_effect_level = Some("network".to_string());
1008    }
1009    policy
1010}
1011
1012pub fn policy_for_autonomy_tier(tier: AutonomyTier) -> CapabilityPolicy {
1013    CapabilityPolicy {
1014        side_effect_level: Some(
1015            match tier {
1016                AutonomyTier::Shadow => "none",
1017                AutonomyTier::Suggest => "read_only",
1018                AutonomyTier::ActWithApproval => "read_only",
1019                AutonomyTier::ActAuto => "network",
1020            }
1021            .to_string(),
1022        ),
1023        recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
1024        ..CapabilityPolicy::default()
1025    }
1026}
1027
1028fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
1029    let Some(limit) = limit else {
1030        return;
1031    };
1032    if records.len() <= limit {
1033        return;
1034    }
1035    let keep_from = records.len() - limit;
1036    records.drain(0..keep_from);
1037}
1038
1039fn apply_graph_record_limit(records: &mut Vec<TrustGraphRecord>, limit: Option<usize>) {
1040    let Some(limit) = limit else {
1041        return;
1042    };
1043    if records.len() <= limit {
1044        return;
1045    }
1046    let keep_from = records.len() - limit;
1047    records.drain(0..keep_from);
1048}
1049
1050fn is_control_plane_action(action: &str) -> bool {
1051    matches!(
1052        action,
1053        "trust.promote" | "trust.demote" | "autonomy.tier_transition"
1054    )
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060    use crate::event_log::MemoryEventLog;
1061    use time::Duration;
1062
1063    const RECORD_SCHEMA_JSON: &str =
1064        include_str!("trust_graph/schemas/trust-record.v0.schema.json");
1065    const RECORD_SCHEMA_V0_1_JSON: &str =
1066        include_str!("trust_graph/schemas/trust-record.v0.1.schema.json");
1067    const CHAIN_SCHEMA_JSON: &str = include_str!("trust_graph/schemas/trust-chain.v0.schema.json");
1068    const VALID_DECISION_CHAIN_JSON: &str =
1069        include_str!("trust_graph/fixtures/valid/decision-chain.json");
1070    const VALID_TIER_TRANSITION_JSON: &str =
1071        include_str!("trust_graph/fixtures/valid/tier-transition.json");
1072    const VALID_EFFECT_INHERITANCE_CHAIN_JSON: &str =
1073        include_str!("trust_graph/fixtures/valid/effect-inheritance-chain.json");
1074    const INVALID_TAMPERED_CHAIN_JSON: &str =
1075        include_str!("trust_graph/fixtures/invalid/tampered-chain.json");
1076    const INVALID_MISSING_APPROVAL_JSON: &str =
1077        include_str!("trust_graph/fixtures/invalid/missing-approval.json");
1078
1079    #[derive(Debug, serde::Deserialize)]
1080    struct TrustChainFixture {
1081        schema: String,
1082        chain: TrustChainFixtureMetadata,
1083        records: Vec<TrustRecord>,
1084    }
1085
1086    #[derive(Debug, serde::Deserialize)]
1087    struct TrustChainFixtureMetadata {
1088        topic: String,
1089        total: u64,
1090        root_hash: Option<String>,
1091        verified: bool,
1092        generated_at: String,
1093        producer: BTreeMap<String, serde_json::Value>,
1094    }
1095
1096    #[test]
1097    fn embedded_trust_graph_fixtures_match_workspace_spec_when_available() {
1098        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1099        let spec_dir = manifest_dir.join("../../opentrustgraph-spec");
1100        if !spec_dir.exists() {
1101            return;
1102        }
1103
1104        for (relative, embedded) in [
1105            ("schemas/trust-record.v0.schema.json", RECORD_SCHEMA_JSON),
1106            (
1107                "schemas/trust-record.v0.1.schema.json",
1108                RECORD_SCHEMA_V0_1_JSON,
1109            ),
1110            ("schemas/trust-chain.v0.schema.json", CHAIN_SCHEMA_JSON),
1111            (
1112                "fixtures/valid/decision-chain.json",
1113                VALID_DECISION_CHAIN_JSON,
1114            ),
1115            (
1116                "fixtures/valid/tier-transition.json",
1117                VALID_TIER_TRANSITION_JSON,
1118            ),
1119            (
1120                "fixtures/valid/effect-inheritance-chain.json",
1121                VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1122            ),
1123            (
1124                "fixtures/invalid/tampered-chain.json",
1125                INVALID_TAMPERED_CHAIN_JSON,
1126            ),
1127            (
1128                "fixtures/invalid/missing-approval.json",
1129                INVALID_MISSING_APPROVAL_JSON,
1130            ),
1131        ] {
1132            let source = std::fs::read_to_string(spec_dir.join(relative)).unwrap_or_else(|e| {
1133                panic!("failed to read opentrustgraph fixture {relative}: {e}")
1134            });
1135            assert_eq!(
1136                embedded, source,
1137                "embedded trust graph fixture {relative} drifted from opentrustgraph-spec"
1138            );
1139        }
1140    }
1141
1142    #[tokio::test]
1143    async fn append_and_query_round_trip() {
1144        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1145        let mut record = TrustRecord::new(
1146            "github-triage-bot",
1147            "github.issue.opened",
1148            Some("reviewer".to_string()),
1149            TrustOutcome::Success,
1150            "trace-1",
1151            AutonomyTier::ActWithApproval,
1152        );
1153        record.cost_usd = Some(1.25);
1154        append_trust_record(&log, &record).await.unwrap();
1155
1156        let records = query_trust_records(
1157            &log,
1158            &TrustQueryFilters {
1159                agent: Some("github-triage-bot".to_string()),
1160                ..TrustQueryFilters::default()
1161            },
1162        )
1163        .await
1164        .unwrap();
1165
1166        assert_eq!(records.len(), 1);
1167        assert_eq!(records[0].agent, "github-triage-bot");
1168        assert_eq!(records[0].cost_usd, Some(1.25));
1169        assert_eq!(records[0].chain_index, 1);
1170        assert!(records[0].previous_hash.is_none());
1171        assert!(records[0].entry_hash.starts_with("sha256:"));
1172    }
1173
1174    #[tokio::test]
1175    async fn verify_chain_detects_hash_tampering() {
1176        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1177        let first = append_trust_record(
1178            &log,
1179            &TrustRecord::new(
1180                "bot",
1181                "first",
1182                None,
1183                TrustOutcome::Success,
1184                "trace-1",
1185                AutonomyTier::Suggest,
1186            ),
1187        )
1188        .await
1189        .unwrap();
1190        let mut second = append_trust_record(
1191            &log,
1192            &TrustRecord::new(
1193                "bot",
1194                "second",
1195                None,
1196                TrustOutcome::Success,
1197                "trace-2",
1198                AutonomyTier::Suggest,
1199            ),
1200        )
1201        .await
1202        .unwrap();
1203
1204        let report = verify_trust_chain(&log).await.unwrap();
1205        assert!(report.verified);
1206        assert_eq!(
1207            report.root_hash.as_deref(),
1208            Some(second.entry_hash.as_str())
1209        );
1210        assert_eq!(
1211            second.previous_hash.as_deref(),
1212            Some(first.entry_hash.as_str())
1213        );
1214
1215        second.previous_hash = Some(
1216            "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1217        );
1218        second.entry_hash =
1219            "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1220        log.append(
1221            &global_topic().unwrap(),
1222            LogEvent::new(
1223                TRUST_GRAPH_EVENT_KIND,
1224                serde_json::to_value(second).unwrap(),
1225            ),
1226        )
1227        .await
1228        .unwrap();
1229        let report = verify_trust_chain(&log).await.unwrap();
1230        assert!(!report.verified);
1231        assert!(report
1232            .errors
1233            .iter()
1234            .any(|error| error.contains("previous_hash mismatch")));
1235    }
1236
1237    #[tokio::test]
1238    async fn export_trust_chain_emits_envelope_matching_chain_schema() {
1239        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1240        let first = append_trust_record(
1241            &log,
1242            &TrustRecord::new(
1243                "bot",
1244                "github.issue.opened",
1245                None,
1246                TrustOutcome::Success,
1247                "trace-1",
1248                AutonomyTier::Suggest,
1249            ),
1250        )
1251        .await
1252        .unwrap();
1253        let second = append_trust_record(
1254            &log,
1255            &TrustRecord::new(
1256                "bot",
1257                "trust.promote",
1258                Some("maintainer-1".to_string()),
1259                TrustOutcome::Success,
1260                "trace-2",
1261                AutonomyTier::ActAuto,
1262            ),
1263        )
1264        .await
1265        .unwrap();
1266
1267        let export = export_trust_chain(&log).await.unwrap();
1268        assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1269        assert_eq!(export.chain.topic, TRUST_GRAPH_GLOBAL_TOPIC);
1270        assert_eq!(export.chain.total, 2);
1271        assert!(export.chain.verified);
1272        assert_eq!(
1273            export.chain.root_hash.as_deref(),
1274            Some(second.entry_hash.as_str())
1275        );
1276        assert_eq!(export.records.len(), 2);
1277        assert_eq!(export.records[0].entry_hash, first.entry_hash);
1278        assert_eq!(export.records[1].entry_hash, second.entry_hash);
1279        assert_eq!(export.chain.producer.name, "harn");
1280
1281        let envelope_json = serde_json::to_value(&export).unwrap();
1282        assert_eq!(envelope_json["schema"], OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1283        assert_eq!(envelope_json["chain"]["total"], 2);
1284        assert_eq!(envelope_json["chain"]["verified"], true);
1285        assert!(envelope_json["records"].as_array().unwrap().len() == 2);
1286    }
1287
1288    #[tokio::test]
1289    async fn export_trust_chain_handles_empty_log() {
1290        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1291        let export = export_trust_chain(&log).await.unwrap();
1292        assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1293        assert_eq!(export.chain.total, 0);
1294        assert!(export.chain.verified);
1295        assert!(export.chain.root_hash.is_none());
1296        assert!(export.records.is_empty());
1297    }
1298
1299    #[tokio::test]
1300    async fn resolve_autonomy_tier_prefers_latest_control_record() {
1301        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1302        append_trust_record(
1303            &log,
1304            &TrustRecord::new(
1305                "bot",
1306                "trust.promote",
1307                None,
1308                TrustOutcome::Success,
1309                "trace-1",
1310                AutonomyTier::ActWithApproval,
1311            ),
1312        )
1313        .await
1314        .unwrap();
1315        append_trust_record(
1316            &log,
1317            &TrustRecord::new(
1318                "bot",
1319                "trust.demote",
1320                None,
1321                TrustOutcome::Success,
1322                "trace-2",
1323                AutonomyTier::Shadow,
1324            ),
1325        )
1326        .await
1327        .unwrap();
1328
1329        let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
1330            .await
1331            .unwrap();
1332        assert_eq!(tier, AutonomyTier::Shadow);
1333    }
1334
1335    #[tokio::test]
1336    async fn query_limit_keeps_newest_matching_records() {
1337        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1338        let base = OffsetDateTime::from_unix_timestamp(1_775_000_000).unwrap();
1339        for (offset, action) in ["first", "second", "third"].into_iter().enumerate() {
1340            let mut record = TrustRecord::new(
1341                "bot",
1342                action,
1343                None,
1344                TrustOutcome::Success,
1345                format!("trace-{action}"),
1346                AutonomyTier::ActAuto,
1347            );
1348            record.timestamp = base + Duration::seconds(offset as i64);
1349            append_trust_record(&log, &record).await.unwrap();
1350        }
1351
1352        let records = query_trust_records(
1353            &log,
1354            &TrustQueryFilters {
1355                agent: Some("bot".to_string()),
1356                limit: Some(2),
1357                ..TrustQueryFilters::default()
1358            },
1359        )
1360        .await
1361        .unwrap();
1362
1363        assert_eq!(records.len(), 2);
1364        assert_eq!(records[0].action, "second");
1365        assert_eq!(records[1].action, "third");
1366    }
1367
1368    #[test]
1369    fn group_by_trace_preserves_chronological_group_order() {
1370        let make_record = |trace_id: &str, action: &str| TrustRecord {
1371            trace_id: trace_id.to_string(),
1372            action: action.to_string(),
1373            ..TrustRecord::new(
1374                "bot",
1375                action,
1376                None,
1377                TrustOutcome::Success,
1378                trace_id,
1379                AutonomyTier::ActAuto,
1380            )
1381        };
1382        let grouped = group_trust_records_by_trace(&[
1383            make_record("trace-1", "first"),
1384            make_record("trace-2", "second"),
1385            make_record("trace-1", "third"),
1386        ]);
1387
1388        assert_eq!(grouped.len(), 2);
1389        assert_eq!(grouped[0].trace_id, "trace-1");
1390        assert_eq!(grouped[0].records.len(), 2);
1391        assert_eq!(grouped[0].records[1].action, "third");
1392        assert_eq!(grouped[1].trace_id, "trace-2");
1393    }
1394
1395    #[test]
1396    fn opentrustgraph_schema_files_are_parseable_and_match_runtime_enums() {
1397        let record_schema: serde_json::Value = serde_json::from_str(RECORD_SCHEMA_JSON).unwrap();
1398        let record_schema_v0_1: serde_json::Value =
1399            serde_json::from_str(RECORD_SCHEMA_V0_1_JSON).unwrap();
1400        let chain_schema: serde_json::Value = serde_json::from_str(CHAIN_SCHEMA_JSON).unwrap();
1401
1402        assert_eq!(
1403            record_schema["properties"]["schema"]["const"],
1404            serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)
1405        );
1406        let v0_1_schema_enum = record_schema_v0_1["properties"]["schema"]["enum"]
1407            .as_array()
1408            .expect("v0.1 record schema declares schema as an enum");
1409        assert!(
1410            v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0_1)),
1411            "v0.1 record schema must accept {OPENTRUSTGRAPH_SCHEMA_V0_1}: {v0_1_schema_enum:?}"
1412        );
1413        assert!(
1414            v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)),
1415            "v0.1 record schema must still accept v0 (one-release back-compat): {v0_1_schema_enum:?}"
1416        );
1417        assert_eq!(
1418            chain_schema["properties"]["schema"]["const"],
1419            serde_json::json!("opentrustgraph-chain/v0")
1420        );
1421
1422        let outcomes = record_schema["properties"]["outcome"]["enum"]
1423            .as_array()
1424            .unwrap();
1425        for outcome in [
1426            TrustOutcome::Success,
1427            TrustOutcome::Failure,
1428            TrustOutcome::Denied,
1429            TrustOutcome::Timeout,
1430        ] {
1431            assert!(outcomes.contains(&serde_json::json!(outcome.as_str())));
1432        }
1433
1434        let tiers = record_schema["properties"]["autonomy_tier"]["enum"]
1435            .as_array()
1436            .unwrap();
1437        for tier in [
1438            AutonomyTier::Shadow,
1439            AutonomyTier::Suggest,
1440            AutonomyTier::ActWithApproval,
1441            AutonomyTier::ActAuto,
1442        ] {
1443            assert!(tiers.contains(&serde_json::json!(tier.as_str())));
1444        }
1445    }
1446
1447    #[test]
1448    fn opentrustgraph_valid_fixtures_match_runtime_contract() {
1449        for (name, fixture) in [
1450            ("decision-chain", VALID_DECISION_CHAIN_JSON),
1451            ("tier-transition", VALID_TIER_TRANSITION_JSON),
1452            (
1453                "effect-inheritance-chain",
1454                VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1455            ),
1456        ] {
1457            let fixture = parse_chain_fixture(fixture);
1458            let errors = validate_chain_fixture(&fixture);
1459            assert!(errors.is_empty(), "{name} errors: {errors:?}");
1460        }
1461    }
1462
1463    #[test]
1464    fn opentrustgraph_invalid_fixtures_exercise_expected_failures() {
1465        let tampered = parse_chain_fixture(INVALID_TAMPERED_CHAIN_JSON);
1466        let tampered_errors = validate_chain_fixture(&tampered);
1467        assert!(
1468            tampered_errors
1469                .iter()
1470                .any(|error| error.contains("previous_hash mismatch")),
1471            "tampered-chain errors: {tampered_errors:?}"
1472        );
1473        assert!(
1474            !tampered_errors
1475                .iter()
1476                .any(|error| error.contains("entry_hash mismatch")),
1477            "tampered-chain should isolate hash-link tampering: {tampered_errors:?}"
1478        );
1479
1480        let missing_approval = parse_chain_fixture(INVALID_MISSING_APPROVAL_JSON);
1481        let missing_errors = validate_chain_fixture(&missing_approval);
1482        assert!(
1483            missing_errors
1484                .iter()
1485                .any(|error| error.contains("approval required")),
1486            "missing-approval errors: {missing_errors:?}"
1487        );
1488    }
1489
1490    fn parse_chain_fixture(input: &str) -> TrustChainFixture {
1491        serde_json::from_str(input).unwrap()
1492    }
1493
1494    fn validate_chain_fixture(fixture: &TrustChainFixture) -> Vec<String> {
1495        let mut errors = Vec::new();
1496        if fixture.schema != OPENTRUSTGRAPH_CHAIN_SCHEMA_V0 {
1497            errors.push(format!("unsupported chain schema {}", fixture.schema));
1498        }
1499        if fixture.chain.topic.trim().is_empty() {
1500            errors.push("chain topic is empty".to_string());
1501        }
1502        if fixture.chain.total != fixture.records.len() as u64 {
1503            errors.push(format!(
1504                "chain total mismatch; expected {}, found {}",
1505                fixture.records.len(),
1506                fixture.chain.total
1507            ));
1508        }
1509        if fixture
1510            .chain
1511            .producer
1512            .get("name")
1513            .and_then(|value| value.as_str())
1514            .unwrap_or_default()
1515            .trim()
1516            .is_empty()
1517        {
1518            errors.push("chain producer.name is empty".to_string());
1519        }
1520        if OffsetDateTime::parse(
1521            &fixture.chain.generated_at,
1522            &time::format_description::well_known::Rfc3339,
1523        )
1524        .is_err()
1525        {
1526            errors.push("chain generated_at is not RFC3339".to_string());
1527        }
1528
1529        for (index, record) in fixture.records.iter().enumerate() {
1530            errors.extend(validate_fixture_record_contract(index, record));
1531        }
1532        errors.extend(validate_fixture_hash_chain(fixture));
1533
1534        let expected_verified = errors.is_empty();
1535        if fixture.chain.verified != expected_verified {
1536            errors.push(format!(
1537                "chain verified flag mismatch; expected {expected_verified}, found {}",
1538                fixture.chain.verified
1539            ));
1540        }
1541        errors
1542    }
1543
1544    fn validate_fixture_record_contract(index: usize, record: &TrustRecord) -> Vec<String> {
1545        let mut errors = Vec::new();
1546        let label = format!("record {index}");
1547        if !OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&record.schema.as_str()) {
1548            errors.push(format!("{label}: unsupported schema {}", record.schema));
1549        }
1550        if record.record_id.trim().is_empty() {
1551            errors.push(format!("{label}: record_id is empty"));
1552        }
1553        if record.agent.trim().is_empty() {
1554            errors.push(format!("{label}: agent is empty"));
1555        }
1556        if record.action.trim().is_empty() {
1557            errors.push(format!("{label}: action is empty"));
1558        }
1559        if record.trace_id.trim().is_empty() {
1560            errors.push(format!("{label}: trace_id is empty"));
1561        }
1562        if !record.entry_hash.starts_with("sha256:") {
1563            errors.push(format!("{label}: entry_hash is not sha256-prefixed"));
1564        }
1565        if let Some(cost_usd) = record.cost_usd {
1566            if cost_usd < 0.0 {
1567                errors.push(format!("{label}: cost_usd is negative"));
1568            }
1569        }
1570
1571        if record.outcome == TrustOutcome::Success
1572            && record.autonomy_tier == AutonomyTier::ActWithApproval
1573            && approval_required(record)
1574        {
1575            if record
1576                .approver
1577                .as_deref()
1578                .unwrap_or_default()
1579                .trim()
1580                .is_empty()
1581            {
1582                errors.push(format!("{label}: approval required but approver is empty"));
1583            }
1584            if approval_signature_count(record) == 0 {
1585                errors.push(format!(
1586                    "{label}: approval required but signatures are empty"
1587                ));
1588            }
1589        }
1590
1591        errors
1592    }
1593
1594    fn validate_fixture_hash_chain(fixture: &TrustChainFixture) -> Vec<String> {
1595        let mut errors = Vec::new();
1596        let mut previous_hash: Option<String> = None;
1597
1598        for (position, record) in fixture.records.iter().enumerate() {
1599            let expected_index = position as u64 + 1;
1600            if record.chain_index != expected_index {
1601                errors.push(format!(
1602                    "record {position}: expected chain_index {expected_index}, found {}",
1603                    record.chain_index
1604                ));
1605            }
1606            if record.previous_hash != previous_hash {
1607                errors.push(format!(
1608                    "record {position}: previous_hash mismatch; expected {:?}, found {:?}",
1609                    previous_hash, record.previous_hash
1610                ));
1611            }
1612            let expected_hash = compute_trust_record_hash(record).unwrap();
1613            if expected_hash != record.entry_hash {
1614                errors.push(format!(
1615                    "record {position}: entry_hash mismatch; expected {expected_hash}, found {}",
1616                    record.entry_hash
1617                ));
1618            }
1619            previous_hash = Some(record.entry_hash.clone());
1620        }
1621
1622        if fixture.chain.root_hash != previous_hash {
1623            errors.push(format!(
1624                "chain root_hash mismatch; expected {:?}, found {:?}",
1625                previous_hash, fixture.chain.root_hash
1626            ));
1627        }
1628        errors
1629    }
1630
1631    fn approval_required(record: &TrustRecord) -> bool {
1632        record
1633            .metadata
1634            .get("approval")
1635            .and_then(|approval| approval.get("required"))
1636            .and_then(|required| required.as_bool())
1637            .unwrap_or(false)
1638    }
1639
1640    fn approval_signature_count(record: &TrustRecord) -> usize {
1641        record
1642            .metadata
1643            .get("approval")
1644            .and_then(|approval| approval.get("signatures"))
1645            .and_then(|signatures| signatures.as_array())
1646            .map(Vec::len)
1647            .unwrap_or(0)
1648    }
1649
1650    // ----- OpenTrustGraph v0.1 schema bump (#1778) -----
1651
1652    use crate::orchestration::{EffectKind, EffectScope};
1653
1654    #[test]
1655    fn new_trust_record_defaults_to_v0_1_schema() {
1656        let record = TrustRecord::new(
1657            "agent",
1658            "deploy.preview",
1659            None,
1660            TrustOutcome::Success,
1661            "trace-1",
1662            AutonomyTier::Suggest,
1663        );
1664        assert_eq!(record.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1665    }
1666
1667    #[test]
1668    fn v0_records_still_parse_for_backward_compat() {
1669        let record_v0 = serde_json::json!({
1670            "schema": "opentrustgraph/v0",
1671            "record_id": "01966f4c-0f31-7b5d-b44b-f7f8e7e1d384",
1672            "agent": "legacy-bot",
1673            "action": "github.issue.opened",
1674            "approver": null,
1675            "outcome": "success",
1676            "trace_id": "trace-legacy",
1677            "autonomy_tier": "suggest",
1678            "timestamp": "2026-04-19T18:42:11Z",
1679            "cost_usd": null,
1680            "chain_index": 1,
1681            "previous_hash": null,
1682            "entry_hash": "sha256:84facae7d56fd304e040ea18d80bd019e274ad86ddd5a4d732f3ac3d984c48ec",
1683            "metadata": {"provider": "github"}
1684        });
1685        let decoded: TrustRecord = serde_json::from_value(record_v0).unwrap();
1686        assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0);
1687        assert!(OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&decoded.schema.as_str()));
1688        assert!(decoded.effects_grant().is_empty());
1689        assert!(decoded.effects_used().is_empty());
1690        assert!(decoded.parent_record_id().is_none());
1691    }
1692
1693    #[test]
1694    fn v0_1_effect_metadata_round_trips_through_json() {
1695        let grant = vec![
1696            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1697                .with_resource("https://api.example"),
1698            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1699        ];
1700        let used =
1701            vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
1702                .with_resource("/workspace/src")];
1703        let record = TrustRecord::new(
1704            "child-agent",
1705            "fs.read",
1706            None,
1707            TrustOutcome::Success,
1708            "trace-effects-1",
1709            AutonomyTier::ActAuto,
1710        )
1711        .with_effects_grant(grant.clone())
1712        .with_effects_used(used.clone())
1713        .with_parent_record_id("parent-record-001");
1714
1715        let encoded = serde_json::to_string(&record).unwrap();
1716        let decoded: TrustRecord = serde_json::from_str(&encoded).unwrap();
1717        assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1718        assert_eq!(decoded.effects_grant(), grant);
1719        assert_eq!(decoded.effects_used(), used);
1720        assert_eq!(
1721            decoded.parent_record_id().as_deref(),
1722            Some("parent-record-001")
1723        );
1724    }
1725
1726    #[test]
1727    fn effect_helpers_remove_keys_on_empty_input() {
1728        let mut record = TrustRecord::new(
1729            "agent",
1730            "noop",
1731            None,
1732            TrustOutcome::Success,
1733            "trace-1",
1734            AutonomyTier::Suggest,
1735        )
1736        .with_effects_grant(vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)])
1737        .with_parent_record_id("parent-1");
1738        assert!(record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
1739        assert!(record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
1740
1741        record.set_effects_grant(Vec::new());
1742        record.set_parent_record_id(None);
1743        assert!(!record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
1744        assert!(!record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
1745    }
1746
1747    #[tokio::test]
1748    async fn three_agent_chain_proves_effects_subset_inheritance() {
1749        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1750
1751        let parent_grant = vec![
1752            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1753                .with_resource("https://api.example"),
1754            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1755            EffectRecord::new(EffectKind::Fs, EffectScope::Write).with_resource("/workspace/tmp"),
1756        ];
1757        let parent = append_trust_record(
1758            &log,
1759            &TrustRecord::new(
1760                "parent",
1761                "agent.spawn",
1762                None,
1763                TrustOutcome::Success,
1764                "trace-parent",
1765                AutonomyTier::ActAuto,
1766            )
1767            .with_effects_grant(parent_grant.clone()),
1768        )
1769        .await
1770        .unwrap();
1771
1772        let child_grant = vec![
1773            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1774                .with_resource("https://api.example"),
1775            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1776        ];
1777        let child = append_trust_record(
1778            &log,
1779            &TrustRecord::new(
1780                "child",
1781                "agent.spawn",
1782                None,
1783                TrustOutcome::Success,
1784                "trace-child",
1785                AutonomyTier::ActAuto,
1786            )
1787            .with_effects_grant(child_grant.clone())
1788            .with_parent_record_id(parent.record_id.clone()),
1789        )
1790        .await
1791        .unwrap();
1792
1793        let grandchild_used =
1794            vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
1795                .with_resource("/workspace/src")];
1796        let grandchild = append_trust_record(
1797            &log,
1798            &TrustRecord::new(
1799                "grandchild",
1800                "fs.read",
1801                None,
1802                TrustOutcome::Success,
1803                "trace-grandchild",
1804                AutonomyTier::ActAuto,
1805            )
1806            .with_effects_used(grandchild_used.clone())
1807            .with_parent_record_id(child.record_id.clone()),
1808        )
1809        .await
1810        .unwrap();
1811
1812        // grandchild.effects_used ⊆ child.effects_grant
1813        for effect in &grandchild_used {
1814            assert!(
1815                child_grant.contains(effect),
1816                "grandchild used {effect:?} not in child grant"
1817            );
1818        }
1819        // child.effects_grant ⊆ parent.effects_grant
1820        for effect in &child_grant {
1821            assert!(
1822                parent_grant.contains(effect),
1823                "child grant {effect:?} not in parent grant"
1824            );
1825        }
1826
1827        assert_eq!(
1828            grandchild.parent_record_id().as_deref(),
1829            Some(child.record_id.as_str())
1830        );
1831        assert_eq!(
1832            child.parent_record_id().as_deref(),
1833            Some(parent.record_id.as_str())
1834        );
1835        assert!(parent.parent_record_id().is_none());
1836
1837        // The chain still verifies cleanly (additive metadata change).
1838        let report = verify_trust_chain(&log).await.unwrap();
1839        assert!(report.verified, "verification errors: {:?}", report.errors);
1840        assert_eq!(report.total, 3);
1841    }
1842}