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::actor_chain::ActorChain;
10use crate::event_log::{
11    active_event_log, sanitize_topic_component, AnyEventLog, EventId, EventLog, LogError, LogEvent,
12    Topic,
13};
14use crate::orchestration::{CapabilityPolicy, EffectRecord};
15
16pub const OPENTRUSTGRAPH_SCHEMA_V0: &str = "opentrustgraph/v0";
17/// OpenTrustGraph v0.1: additive metadata schema. Reserves lineage keys under
18/// `TrustRecord.metadata` so chain validators can prove that child-agent
19/// effects, actors, and actor-chain policy alerts stay inside the parent chain.
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.
26pub const OPENTRUSTGRAPH_ACCEPTED_SCHEMAS: &[&str] =
27    &[OPENTRUSTGRAPH_SCHEMA_V0_1, OPENTRUSTGRAPH_SCHEMA_V0];
28pub const OPENTRUSTGRAPH_CHAIN_SCHEMA_V0: &str = "opentrustgraph-chain/v0";
29
30/// Reserved metadata key for the effect grant attached to a record by its
31/// spawning parent.
32pub const METADATA_KEY_EFFECTS_GRANT: &str = "effects_grant";
33/// Reserved metadata key for the effects the recorded action actually
34/// exercised. Must be a subset of the parent's `effects_grant`.
35pub const METADATA_KEY_EFFECTS_USED: &str = "effects_used";
36/// Reserved metadata key pointing at the parent record's `record_id`.
37/// Lets verifiers reconstruct the agent chain without scanning the whole
38/// stream.
39pub const METADATA_KEY_PARENT_RECORD_ID: &str = "parent_record_id";
40/// Reserved metadata key carrying the RFC 8693 actor chain for the record.
41/// When paired with `parent_record_id`, the nested `act` chain must extend
42/// the parent's actor chain by exactly one hop.
43pub const METADATA_KEY_ACTOR_CHAIN: &str = "actor_chain";
44/// Reserved metadata key for actor-chain policy alerts.
45pub const METADATA_KEY_ACTOR_CHAIN_ALERT: &str = "actor_chain_alert";
46pub const TRUST_GRAPH_RECORDS_TOPIC: &str = "trust_graph.records";
47pub const TRUST_GRAPH_GLOBAL_TOPIC: &str = "trust_graph";
48pub const TRUST_GRAPH_LEGACY_GLOBAL_TOPIC: &str = "trust.graph";
49pub const TRUST_GRAPH_TOPIC_PREFIX: &str = "trust_graph.";
50pub const TRUST_GRAPH_LEGACY_TOPIC_PREFIX: &str = "trust.graph.";
51pub const TRUST_GRAPH_EVENT_KIND: &str = "trust_recorded";
52pub const TRUST_ACTION_RELEASE: &str = "release";
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum AutonomyTier {
57    Shadow,
58    Suggest,
59    ActWithApproval,
60    #[default]
61    ActAuto,
62}
63
64impl AutonomyTier {
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Self::Shadow => "shadow",
68            Self::Suggest => "suggest",
69            Self::ActWithApproval => "act_with_approval",
70            Self::ActAuto => "act_auto",
71        }
72    }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum TrustOutcome {
78    Success,
79    Failure,
80    Denied,
81    Timeout,
82}
83
84impl TrustOutcome {
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Success => "success",
88            Self::Failure => "failure",
89            Self::Denied => "denied",
90            Self::Timeout => "timeout",
91        }
92    }
93}
94
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
96pub struct TrustRecord {
97    pub schema: String,
98    pub record_id: String,
99    pub agent: String,
100    pub action: String,
101    pub approver: Option<String>,
102    pub outcome: TrustOutcome,
103    pub trace_id: String,
104    pub autonomy_tier: AutonomyTier,
105    #[serde(with = "time::serde::rfc3339")]
106    pub timestamp: OffsetDateTime,
107    pub cost_usd: Option<f64>,
108    #[serde(default)]
109    pub chain_index: u64,
110    #[serde(default)]
111    pub previous_hash: Option<String>,
112    #[serde(default)]
113    pub entry_hash: String,
114    #[serde(default)]
115    pub metadata: BTreeMap<String, serde_json::Value>,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "kind", rename_all = "snake_case")]
120pub enum TrustRecordActionKind {
121    Release {
122        bundle_hash: String,
123        harn_version: String,
124        parent_trust_record_id: Option<String>,
125    },
126}
127
128impl TrustRecord {
129    pub fn new(
130        agent: impl Into<String>,
131        action: impl Into<String>,
132        approver: Option<String>,
133        outcome: TrustOutcome,
134        trace_id: impl Into<String>,
135        autonomy_tier: AutonomyTier,
136    ) -> Self {
137        Self {
138            schema: OPENTRUSTGRAPH_SCHEMA_V0_1.to_string(),
139            record_id: Uuid::now_v7().to_string(),
140            agent: agent.into(),
141            action: action.into(),
142            approver,
143            outcome,
144            trace_id: trace_id.into(),
145            autonomy_tier,
146            timestamp: OffsetDateTime::now_utc(),
147            cost_usd: None,
148            chain_index: 0,
149            previous_hash: None,
150            entry_hash: String::new(),
151            metadata: BTreeMap::new(),
152        }
153    }
154
155    pub fn release(
156        agent: impl Into<String>,
157        bundle_hash: impl Into<String>,
158        harn_version: impl Into<String>,
159        parent_trust_record_id: Option<String>,
160        trace_id: impl Into<String>,
161        autonomy_tier: AutonomyTier,
162    ) -> Self {
163        let bundle_hash = bundle_hash.into();
164        let harn_version = harn_version.into();
165        let action_kind = TrustRecordActionKind::Release {
166            bundle_hash: bundle_hash.clone(),
167            harn_version: harn_version.clone(),
168            parent_trust_record_id: parent_trust_record_id.clone(),
169        };
170        let mut record = Self::new(
171            agent,
172            TRUST_ACTION_RELEASE,
173            None,
174            TrustOutcome::Success,
175            trace_id,
176            autonomy_tier,
177        );
178        record
179            .metadata
180            .insert("action_kind".to_string(), serde_json::json!(action_kind));
181        record
182            .metadata
183            .insert("bundle_hash".to_string(), serde_json::json!(bundle_hash));
184        record
185            .metadata
186            .insert("harn_version".to_string(), serde_json::json!(harn_version));
187        record.metadata.insert(
188            "parent_trust_record_id".to_string(),
189            parent_trust_record_id
190                .map(serde_json::Value::String)
191                .unwrap_or(serde_json::Value::Null),
192        );
193        record
194    }
195
196    /// Attach the typed effect grant a parent extended to this record.
197    /// Empty grants are skipped so records stay compact when there is
198    /// nothing to prove.
199    pub fn with_effects_grant(mut self, effects: Vec<EffectRecord>) -> Self {
200        self.set_effects_grant(effects);
201        self
202    }
203
204    pub fn set_effects_grant(&mut self, effects: Vec<EffectRecord>) {
205        if effects.is_empty() {
206            self.metadata.remove(METADATA_KEY_EFFECTS_GRANT);
207            return;
208        }
209        self.metadata.insert(
210            METADATA_KEY_EFFECTS_GRANT.to_string(),
211            serde_json::to_value(effects).expect("EffectRecord is serializable"),
212        );
213    }
214
215    pub fn effects_grant(&self) -> Vec<EffectRecord> {
216        decode_effect_list(self.metadata.get(METADATA_KEY_EFFECTS_GRANT))
217    }
218
219    /// Attach the typed effect set the action actually exercised.
220    /// Verifiers must check `effects_used ⊆ effects_grant` through the
221    /// parent chain.
222    pub fn with_effects_used(mut self, effects: Vec<EffectRecord>) -> Self {
223        self.set_effects_used(effects);
224        self
225    }
226
227    pub fn set_effects_used(&mut self, effects: Vec<EffectRecord>) {
228        if effects.is_empty() {
229            self.metadata.remove(METADATA_KEY_EFFECTS_USED);
230            return;
231        }
232        self.metadata.insert(
233            METADATA_KEY_EFFECTS_USED.to_string(),
234            serde_json::to_value(effects).expect("EffectRecord is serializable"),
235        );
236    }
237
238    pub fn effects_used(&self) -> Vec<EffectRecord> {
239        decode_effect_list(self.metadata.get(METADATA_KEY_EFFECTS_USED))
240    }
241
242    /// Point this record at its parent's `record_id`. The existing
243    /// release-record key (`parent_trust_record_id`) is retained for the
244    /// release flow; this is the generic spawn-lineage pointer.
245    pub fn with_parent_record_id(mut self, parent_record_id: impl Into<String>) -> Self {
246        self.set_parent_record_id(Some(parent_record_id.into()));
247        self
248    }
249
250    pub fn set_parent_record_id(&mut self, parent_record_id: Option<String>) {
251        match parent_record_id {
252            Some(id) if !id.is_empty() => {
253                self.metadata.insert(
254                    METADATA_KEY_PARENT_RECORD_ID.to_string(),
255                    serde_json::Value::String(id),
256                );
257            }
258            _ => {
259                self.metadata.remove(METADATA_KEY_PARENT_RECORD_ID);
260            }
261        }
262    }
263
264    pub fn parent_record_id(&self) -> Option<String> {
265        self.metadata
266            .get(METADATA_KEY_PARENT_RECORD_ID)
267            .and_then(|value| value.as_str())
268            .map(str::to_string)
269    }
270
271    /// Attach the RFC 8693 actor chain for the principal that caused this
272    /// record.
273    pub fn with_actor_chain(mut self, actor_chain: ActorChain) -> Self {
274        self.set_actor_chain(Some(actor_chain));
275        self
276    }
277
278    /// Set or clear the reserved `actor_chain` metadata entry.
279    pub fn set_actor_chain(&mut self, actor_chain: Option<ActorChain>) {
280        match actor_chain {
281            Some(actor_chain) => {
282                self.metadata.insert(
283                    METADATA_KEY_ACTOR_CHAIN.to_string(),
284                    actor_chain.to_json_value(),
285                );
286            }
287            None => {
288                self.metadata.remove(METADATA_KEY_ACTOR_CHAIN);
289            }
290        }
291    }
292
293    /// Decode the reserved actor-chain metadata entry, dropping malformed
294    /// values for callers that only need best-effort display data.
295    pub fn actor_chain(&self) -> Option<ActorChain> {
296        self.try_actor_chain().ok().flatten()
297    }
298
299    /// Decode the reserved actor-chain metadata entry and report malformed
300    /// RFC 8693 claim shapes to strict validators.
301    pub fn try_actor_chain(&self) -> Result<Option<ActorChain>, crate::ActorChainError> {
302        self.metadata
303            .get(METADATA_KEY_ACTOR_CHAIN)
304            .map(ActorChain::from_json_value)
305            .transpose()
306    }
307
308    pub fn with_actor_chain_alert(mut self, alert: serde_json::Value) -> Self {
309        self.set_actor_chain_alert(Some(alert));
310        self
311    }
312
313    pub fn set_actor_chain_alert(&mut self, alert: Option<serde_json::Value>) {
314        match alert {
315            Some(alert) => {
316                self.metadata
317                    .insert(METADATA_KEY_ACTOR_CHAIN_ALERT.to_string(), alert);
318            }
319            None => {
320                self.metadata.remove(METADATA_KEY_ACTOR_CHAIN_ALERT);
321            }
322        }
323    }
324
325    pub fn actor_chain_alert(&self) -> Option<&serde_json::Value> {
326        self.metadata.get(METADATA_KEY_ACTOR_CHAIN_ALERT)
327    }
328}
329
330fn decode_effect_list(value: Option<&serde_json::Value>) -> Vec<EffectRecord> {
331    value
332        .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
333        .unwrap_or_default()
334}
335
336#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
337pub struct TrustGraphRecord {
338    pub actor_id: String,
339    pub action: String,
340    pub approver: Option<String>,
341    pub outcome: TrustOutcome,
342    #[serde(default)]
343    pub evidence_refs: Vec<serde_json::Value>,
344    pub trace_id: String,
345    #[serde(with = "time::serde::rfc3339")]
346    pub timestamp: OffsetDateTime,
347    pub autonomy_tier_at_time: AutonomyTier,
348}
349
350impl TrustGraphRecord {
351    pub fn from_trust_record(record: &TrustRecord) -> Self {
352        Self {
353            actor_id: record.agent.clone(),
354            action: record.action.clone(),
355            approver: record.approver.clone(),
356            outcome: record.outcome,
357            evidence_refs: evidence_refs_from_metadata(&record.metadata),
358            trace_id: record.trace_id.clone(),
359            timestamp: record.timestamp,
360            autonomy_tier_at_time: record.autonomy_tier,
361        }
362    }
363}
364
365#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(default)]
367pub struct TrustQueryFilters {
368    pub agent: Option<String>,
369    pub action: Option<String>,
370    #[serde(with = "time::serde::rfc3339::option")]
371    pub since: Option<OffsetDateTime>,
372    #[serde(with = "time::serde::rfc3339::option")]
373    pub until: Option<OffsetDateTime>,
374    pub tier: Option<AutonomyTier>,
375    pub outcome: Option<TrustOutcome>,
376    pub limit: Option<usize>,
377    pub grouped_by_trace: bool,
378}
379
380#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
381#[serde(default)]
382pub struct TrustTraceGroup {
383    pub trace_id: String,
384    pub records: Vec<TrustRecord>,
385}
386
387#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
388#[serde(default)]
389pub struct TrustAgentSummary {
390    pub agent: String,
391    pub total: u64,
392    pub success_rate: f64,
393    pub mean_cost_usd: Option<f64>,
394    pub tier_distribution: BTreeMap<String, u64>,
395    pub outcome_distribution: BTreeMap<String, u64>,
396}
397
398#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
399#[serde(default)]
400pub struct TrustScore {
401    pub agent: String,
402    pub action: Option<String>,
403    pub total: u64,
404    pub successes: u64,
405    pub failures: u64,
406    pub denied: u64,
407    pub timeouts: u64,
408    pub success_rate: f64,
409    pub latest_outcome: Option<TrustOutcome>,
410    #[serde(with = "time::serde::rfc3339::option")]
411    pub latest_timestamp: Option<OffsetDateTime>,
412    pub effective_tier: AutonomyTier,
413    pub policy: CapabilityPolicy,
414}
415
416#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(default)]
418pub struct TrustChainReport {
419    pub topic: String,
420    pub total: u64,
421    pub verified: bool,
422    pub root_hash: Option<String>,
423    pub broken_at_event_id: Option<EventId>,
424    pub errors: Vec<String>,
425}
426
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct TrustChainExportProducer {
429    pub name: String,
430    pub version: String,
431}
432
433impl Default for TrustChainExportProducer {
434    fn default() -> Self {
435        Self {
436            name: "harn".to_string(),
437            version: env!("CARGO_PKG_VERSION").to_string(),
438        }
439    }
440}
441
442#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
443pub struct TrustChainExportMetadata {
444    pub topic: String,
445    pub total: u64,
446    pub root_hash: Option<String>,
447    pub verified: bool,
448    #[serde(with = "time::serde::rfc3339")]
449    pub generated_at: OffsetDateTime,
450    pub producer: TrustChainExportProducer,
451}
452
453#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
454pub struct TrustChainExport {
455    pub schema: String,
456    pub chain: TrustChainExportMetadata,
457    pub records: Vec<TrustRecord>,
458}
459
460fn global_topic() -> Result<Topic, LogError> {
461    Topic::new(TRUST_GRAPH_GLOBAL_TOPIC)
462}
463
464fn legacy_global_topic() -> Result<Topic, LogError> {
465    Topic::new(TRUST_GRAPH_LEGACY_GLOBAL_TOPIC)
466}
467
468fn records_topic() -> Result<Topic, LogError> {
469    Topic::new(TRUST_GRAPH_RECORDS_TOPIC)
470}
471
472pub fn topic_for_agent(agent: &str) -> Result<Topic, LogError> {
473    Topic::new(format!(
474        "{TRUST_GRAPH_TOPIC_PREFIX}{}",
475        sanitize_topic_component(agent)
476    ))
477}
478
479pub fn legacy_topic_for_agent(agent: &str) -> Result<Topic, LogError> {
480    Topic::new(format!(
481        "{TRUST_GRAPH_LEGACY_TOPIC_PREFIX}{}",
482        sanitize_topic_component(agent)
483    ))
484}
485
486pub async fn append_trust_record(
487    log: &Arc<AnyEventLog>,
488    record: &TrustRecord,
489) -> Result<TrustRecord, LogError> {
490    let finalized = finalize_trust_record(log, record.clone()).await?;
491    let payload = serde_json::to_value(&finalized)
492        .map_err(|error| LogError::Serde(format!("trust record encode error: {error}")))?;
493    let mut headers = BTreeMap::new();
494    headers.insert("trace_id".to_string(), finalized.trace_id.clone());
495    headers.insert("agent".to_string(), finalized.agent.clone());
496    headers.insert(
497        "autonomy_tier".to_string(),
498        finalized.autonomy_tier.as_str().to_string(),
499    );
500    headers.insert(
501        "outcome".to_string(),
502        finalized.outcome.as_str().to_string(),
503    );
504    headers.insert("entry_hash".to_string(), finalized.entry_hash.clone());
505    let event = LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers);
506    for topic in append_topics_for_record(&finalized)? {
507        log.append(&topic, event.clone()).await?;
508    }
509    append_trust_graph_record_projection(log, &finalized).await?;
510    Ok(finalized)
511}
512
513pub async fn append_active_trust_record(record: &TrustRecord) -> Result<TrustRecord, LogError> {
514    let log = active_event_log()
515        .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
516    append_trust_record(&log, record).await
517}
518
519pub async fn append_scope_attenuation_alert(
520    log: &Arc<AnyEventLog>,
521    actor_chain: &crate::ActorChain,
522    violation: &crate::ScopeAttenuationViolation,
523    trace_id: impl Into<String>,
524) -> Result<TrustRecord, LogError> {
525    let record = TrustRecord::new(
526        violation.child_subject(),
527        "identity.scope_attenuation",
528        None,
529        TrustOutcome::Denied,
530        trace_id,
531        AutonomyTier::ActAuto,
532    )
533    .with_actor_chain(actor_chain.clone())
534    .with_actor_chain_alert(violation.to_json_value());
535    append_trust_record(log, &record).await
536}
537
538pub async fn append_active_scope_attenuation_alert(
539    actor_chain: &crate::ActorChain,
540    violation: &crate::ScopeAttenuationViolation,
541    trace_id: impl Into<String>,
542) -> Result<TrustRecord, LogError> {
543    let log = active_event_log()
544        .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
545    append_scope_attenuation_alert(&log, actor_chain, violation, trace_id).await
546}
547
548pub async fn query_trust_records(
549    log: &Arc<AnyEventLog>,
550    filters: &TrustQueryFilters,
551) -> Result<Vec<TrustRecord>, LogError> {
552    let topics = query_topics(filters)?;
553    let mut records = Vec::new();
554    let mut seen = HashSet::new();
555    for topic in topics {
556        for (_, event) in log.read_range(&topic, None, usize::MAX).await? {
557            if event.kind != TRUST_GRAPH_EVENT_KIND {
558                continue;
559            }
560            let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
561                continue;
562            };
563            if !matches_filters(&record, filters) {
564                continue;
565            }
566            let dedupe_key = trust_record_dedupe_key(&record);
567            if seen.insert(dedupe_key) {
568                records.push(record);
569            }
570        }
571    }
572    records.sort_by(|left, right| {
573        left.timestamp
574            .cmp(&right.timestamp)
575            .then(left.chain_index.cmp(&right.chain_index))
576            .then(left.agent.cmp(&right.agent))
577            .then(left.record_id.cmp(&right.record_id))
578    });
579    apply_record_limit(&mut records, filters.limit);
580    Ok(records)
581}
582
583pub async fn query_trust_graph_records(
584    log: &Arc<AnyEventLog>,
585    filters: &TrustQueryFilters,
586) -> Result<Vec<TrustGraphRecord>, LogError> {
587    let mut graph_records = Vec::new();
588    let mut seen = HashSet::new();
589
590    for record in query_trust_records(log, filters).await? {
591        let graph_record = TrustGraphRecord::from_trust_record(&record);
592        let dedupe_key = trust_graph_record_dedupe_key(&graph_record);
593        if seen.insert(dedupe_key) {
594            graph_records.push(graph_record);
595        }
596    }
597
598    for (_, event) in log.read_range(&records_topic()?, None, usize::MAX).await? {
599        if event.kind != TRUST_GRAPH_EVENT_KIND {
600            continue;
601        }
602        let Ok(record) = serde_json::from_value::<TrustGraphRecord>(event.payload) else {
603            continue;
604        };
605        if !matches_graph_filters(&record, filters) {
606            continue;
607        }
608        let dedupe_key = trust_graph_record_dedupe_key(&record);
609        if seen.insert(dedupe_key) {
610            graph_records.push(record);
611        }
612    }
613
614    graph_records.sort_by(|left, right| {
615        left.timestamp
616            .cmp(&right.timestamp)
617            .then(left.actor_id.cmp(&right.actor_id))
618            .then(left.action.cmp(&right.action))
619            .then(left.trace_id.cmp(&right.trace_id))
620    });
621    apply_graph_record_limit(&mut graph_records, filters.limit);
622    Ok(graph_records)
623}
624
625pub async fn trust_score_for(
626    log: &Arc<AnyEventLog>,
627    agent: &str,
628    action: Option<&str>,
629) -> Result<TrustScore, LogError> {
630    let records = query_trust_records(
631        log,
632        &TrustQueryFilters {
633            agent: Some(agent.to_string()),
634            action: action.map(ToString::to_string),
635            ..TrustQueryFilters::default()
636        },
637    )
638    .await?;
639    let effective_tier = resolve_agent_autonomy_tier(log, agent, AutonomyTier::ActAuto).await?;
640    let mut score = score_from_records(agent, action, effective_tier, &records);
641    score.policy =
642        crate::corrections::apply_corrections_to_policy(log, agent, score.policy).await?;
643    Ok(score)
644}
645
646pub async fn policy_for_agent(
647    log: &Arc<AnyEventLog>,
648    agent: &str,
649) -> Result<CapabilityPolicy, LogError> {
650    Ok(trust_score_for(log, agent, None).await?.policy)
651}
652
653pub async fn verify_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainReport, LogError> {
654    let (topic, records) = preferred_chain_records(log).await?;
655    let mut previous_hash: Option<String> = None;
656    let mut errors = Vec::new();
657    let mut broken_at_event_id = None;
658
659    for (position, (event_id, record)) in records.iter().enumerate() {
660        let expected_index = (position as u64) + 1;
661        if record.chain_index != expected_index {
662            errors.push(format!(
663                "event {event_id}: expected chain_index {expected_index}, found {}",
664                record.chain_index
665            ));
666        }
667        if record.previous_hash != previous_hash {
668            errors.push(format!(
669                "event {event_id}: previous_hash mismatch; expected {:?}, found {:?}",
670                previous_hash, record.previous_hash
671            ));
672        }
673        match compute_trust_record_hash(record) {
674            Ok(expected_hash) if expected_hash == record.entry_hash => {}
675            Ok(expected_hash) => errors.push(format!(
676                "event {event_id}: entry_hash mismatch; expected {expected_hash}, found {}",
677                record.entry_hash
678            )),
679            Err(error) => errors.push(format!("event {event_id}: {error}")),
680        }
681        if !errors.is_empty() && broken_at_event_id.is_none() {
682            broken_at_event_id = Some(*event_id);
683        }
684        previous_hash = Some(record.entry_hash.clone());
685    }
686    let lineage_errors = validate_lineage_invariants(
687        records
688            .iter()
689            .map(|(event_id, record)| (format!("event {event_id}"), Some(*event_id), record)),
690    );
691    if broken_at_event_id.is_none() {
692        broken_at_event_id = lineage_errors.iter().find_map(|error| error.event_id);
693    }
694    errors.extend(lineage_errors.into_iter().map(|error| error.message));
695
696    Ok(TrustChainReport {
697        topic: topic.as_str().to_string(),
698        total: records.len() as u64,
699        verified: errors.is_empty(),
700        root_hash: records.last().map(|(_, record)| record.entry_hash.clone()),
701        broken_at_event_id,
702        errors,
703    })
704}
705
706pub async fn export_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainExport, LogError> {
707    let (topic, records_with_ids) = preferred_chain_records(log).await?;
708    let report = verify_trust_chain(log).await?;
709    let records: Vec<TrustRecord> = records_with_ids.into_iter().map(|(_, r)| r).collect();
710    Ok(TrustChainExport {
711        schema: OPENTRUSTGRAPH_CHAIN_SCHEMA_V0.to_string(),
712        chain: TrustChainExportMetadata {
713            topic: topic.as_str().to_string(),
714            total: records.len() as u64,
715            root_hash: records.last().map(|record| record.entry_hash.clone()),
716            verified: report.verified,
717            generated_at: OffsetDateTime::now_utc(),
718            producer: TrustChainExportProducer::default(),
719        },
720        records,
721    })
722}
723
724pub fn compute_trust_record_hash(record: &TrustRecord) -> Result<String, LogError> {
725    let mut value = serde_json::to_value(record)
726        .map_err(|error| LogError::Serde(format!("trust record hash encode error: {error}")))?;
727    if let Some(object) = value.as_object_mut() {
728        object.remove("entry_hash");
729    }
730    let canonical = crate::canonical_json::to_string(&value);
731    let digest = Sha256::digest(canonical.as_bytes());
732    Ok(format!("sha256:{}", hex::encode(digest)))
733}
734
735struct LineageInvariantError {
736    event_id: Option<EventId>,
737    message: String,
738}
739
740impl LineageInvariantError {
741    fn new(event_id: Option<EventId>, message: String) -> Self {
742        Self { event_id, message }
743    }
744}
745
746fn validate_lineage_invariants<'a, I>(records: I) -> Vec<LineageInvariantError>
747where
748    I: IntoIterator<Item = (String, Option<EventId>, &'a TrustRecord)>,
749{
750    let mut errors = Vec::new();
751    let mut by_id: HashMap<&'a str, &'a TrustRecord> = HashMap::new();
752
753    for (label, event_id, record) in records {
754        let actor_chain = match record.try_actor_chain() {
755            Ok(actor_chain) => actor_chain,
756            Err(error) => {
757                errors.push(LineageInvariantError::new(
758                    event_id,
759                    format!("{label}: actor_chain invalid: {error}"),
760                ));
761                None
762            }
763        };
764        let effects_used = record.effects_used();
765        if let Some(parent_id) = record.parent_record_id() {
766            let parent = by_id.get(parent_id.as_str()).copied();
767            if parent.is_none() && (!effects_used.is_empty() || actor_chain.is_some()) {
768                errors.push(LineageInvariantError::new(
769                    event_id,
770                    format!("{label}: parent_record_id {parent_id:?} not found in chain"),
771                ));
772            }
773            if let Some(parent) = parent {
774                validate_effect_lineage(
775                    &mut errors,
776                    &label,
777                    event_id,
778                    &parent_id,
779                    parent,
780                    &effects_used,
781                );
782                validate_actor_lineage(
783                    &mut errors,
784                    &label,
785                    event_id,
786                    &parent_id,
787                    parent,
788                    actor_chain,
789                );
790            }
791        }
792
793        if !record.record_id.is_empty() {
794            by_id.insert(record.record_id.as_str(), record);
795        }
796    }
797
798    errors
799}
800
801fn validate_effect_lineage(
802    errors: &mut Vec<LineageInvariantError>,
803    label: &str,
804    event_id: Option<EventId>,
805    parent_id: &str,
806    parent: &TrustRecord,
807    effects_used: &[EffectRecord],
808) {
809    if effects_used.is_empty() {
810        return;
811    }
812    let parent_grant = parent.effects_grant();
813    for effect in effects_used {
814        if !parent_grant.contains(effect) {
815            errors.push(LineageInvariantError::new(
816                event_id,
817                format!(
818                    "{label}: effects_used escaped grant from parent {parent_id:?}: {effect:?}"
819                ),
820            ));
821        }
822    }
823}
824
825fn validate_actor_lineage(
826    errors: &mut Vec<LineageInvariantError>,
827    label: &str,
828    event_id: Option<EventId>,
829    parent_id: &str,
830    parent: &TrustRecord,
831    actor_chain: Option<ActorChain>,
832) {
833    let Some(actor_chain) = actor_chain else {
834        return;
835    };
836    let parent_actor_chain = match parent.try_actor_chain() {
837        Ok(Some(parent_actor_chain)) => parent_actor_chain,
838        Ok(None) => {
839            errors.push(LineageInvariantError::new(
840                event_id,
841                format!("{label}: actor_chain parent {parent_id:?} missing actor_chain"),
842            ));
843            return;
844        }
845        Err(error) => {
846            errors.push(LineageInvariantError::new(
847                event_id,
848                format!("{label}: parent actor_chain invalid: {error}"),
849            ));
850            return;
851        }
852    };
853    if !actor_chain_extends_parent(&actor_chain, &parent_actor_chain) {
854        errors.push(LineageInvariantError::new(
855            event_id,
856            format!("{label}: actor_chain escaped parentage from parent {parent_id:?}"),
857        ));
858    }
859}
860
861fn actor_chain_extends_parent(child: &ActorChain, parent: &ActorChain) -> bool {
862    if child.origin() != parent.origin() {
863        return false;
864    }
865    let child_actors: Vec<&str> = child.actors().collect();
866    let parent_actors: Vec<&str> = parent.actors().collect();
867    child_actors.len() == parent_actors.len() + 1 && child_actors[1..] == parent_actors[..]
868}
869
870pub fn group_trust_records_by_trace(records: &[TrustRecord]) -> Vec<TrustTraceGroup> {
871    let mut groups: Vec<TrustTraceGroup> = Vec::new();
872    let mut positions: HashMap<String, usize> = HashMap::new();
873    for record in records {
874        if let Some(index) = positions.get(record.trace_id.as_str()).copied() {
875            groups[index].records.push(record.clone());
876            continue;
877        }
878        positions.insert(record.trace_id.clone(), groups.len());
879        groups.push(TrustTraceGroup {
880            trace_id: record.trace_id.clone(),
881            records: vec![record.clone()],
882        });
883    }
884    groups
885}
886
887pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
888    #[derive(Default)]
889    struct RunningSummary {
890        total: u64,
891        successes: u64,
892        cost_sum: f64,
893        cost_count: u64,
894        tier_distribution: BTreeMap<String, u64>,
895        outcome_distribution: BTreeMap<String, u64>,
896    }
897
898    let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
899    for record in records {
900        let entry = by_agent.entry(record.agent.clone()).or_default();
901        entry.total += 1;
902        if record.outcome == TrustOutcome::Success {
903            entry.successes += 1;
904        }
905        if let Some(cost_usd) = record.cost_usd {
906            entry.cost_sum += cost_usd;
907            entry.cost_count += 1;
908        }
909        *entry
910            .tier_distribution
911            .entry(record.autonomy_tier.as_str().to_string())
912            .or_default() += 1;
913        *entry
914            .outcome_distribution
915            .entry(record.outcome.as_str().to_string())
916            .or_default() += 1;
917    }
918
919    by_agent
920        .into_iter()
921        .map(|(agent, summary)| TrustAgentSummary {
922            agent,
923            total: summary.total,
924            success_rate: if summary.total == 0 {
925                0.0
926            } else {
927                summary.successes as f64 / summary.total as f64
928            },
929            mean_cost_usd: (summary.cost_count > 0)
930                .then_some(summary.cost_sum / summary.cost_count as f64),
931            tier_distribution: summary.tier_distribution,
932            outcome_distribution: summary.outcome_distribution,
933        })
934        .collect()
935}
936
937pub async fn resolve_agent_autonomy_tier(
938    log: &Arc<AnyEventLog>,
939    agent: &str,
940    default: AutonomyTier,
941) -> Result<AutonomyTier, LogError> {
942    let records = query_trust_records(
943        log,
944        &TrustQueryFilters {
945            agent: Some(agent.to_string()),
946            ..TrustQueryFilters::default()
947        },
948    )
949    .await?;
950    let mut current = default;
951    for record in records {
952        if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
953            && record.outcome == TrustOutcome::Success
954        {
955            current = record.autonomy_tier;
956        }
957    }
958    Ok(current)
959}
960
961fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
962    if let Some(agent) = filters.agent.as_deref() {
963        if record.agent != agent {
964            return false;
965        }
966    }
967    if let Some(action) = filters.action.as_deref() {
968        if record.action != action {
969            return false;
970        }
971    }
972    if let Some(since) = filters.since {
973        if record.timestamp < since {
974            return false;
975        }
976    }
977    if let Some(until) = filters.until {
978        if record.timestamp > until {
979            return false;
980        }
981    }
982    if let Some(tier) = filters.tier {
983        if record.autonomy_tier != tier {
984            return false;
985        }
986    }
987    if let Some(outcome) = filters.outcome {
988        if record.outcome != outcome {
989            return false;
990        }
991    }
992    true
993}
994
995fn matches_graph_filters(record: &TrustGraphRecord, filters: &TrustQueryFilters) -> bool {
996    if let Some(agent) = filters.agent.as_deref() {
997        if record.actor_id != agent {
998            return false;
999        }
1000    }
1001    if let Some(action) = filters.action.as_deref() {
1002        if record.action != action {
1003            return false;
1004        }
1005    }
1006    if let Some(since) = filters.since {
1007        if record.timestamp < since {
1008            return false;
1009        }
1010    }
1011    if let Some(until) = filters.until {
1012        if record.timestamp > until {
1013            return false;
1014        }
1015    }
1016    if let Some(tier) = filters.tier {
1017        if record.autonomy_tier_at_time != tier {
1018            return false;
1019        }
1020    }
1021    if let Some(outcome) = filters.outcome {
1022        if record.outcome != outcome {
1023            return false;
1024        }
1025    }
1026    true
1027}
1028
1029fn query_topics(filters: &TrustQueryFilters) -> Result<Vec<Topic>, LogError> {
1030    match filters.agent.as_deref() {
1031        Some(agent) => unique_topics(vec![
1032            topic_for_agent(agent)?,
1033            legacy_topic_for_agent(agent)?,
1034        ]),
1035        None => unique_topics(vec![global_topic()?, legacy_global_topic()?]),
1036    }
1037}
1038
1039fn append_topics_for_record(record: &TrustRecord) -> Result<Vec<Topic>, LogError> {
1040    unique_topics(vec![
1041        global_topic()?,
1042        legacy_global_topic()?,
1043        topic_for_agent(&record.agent)?,
1044        legacy_topic_for_agent(&record.agent)?,
1045    ])
1046}
1047
1048fn unique_topics(topics: Vec<Topic>) -> Result<Vec<Topic>, LogError> {
1049    let mut seen = HashSet::new();
1050    Ok(topics
1051        .into_iter()
1052        .filter(|topic| seen.insert(topic.as_str().to_string()))
1053        .collect())
1054}
1055
1056async fn append_trust_graph_record_projection(
1057    log: &Arc<AnyEventLog>,
1058    record: &TrustRecord,
1059) -> Result<(), LogError> {
1060    let payload = serde_json::to_value(TrustGraphRecord::from_trust_record(record))
1061        .map_err(|error| LogError::Serde(format!("trust graph record encode error: {error}")))?;
1062    let mut headers = BTreeMap::new();
1063    headers.insert("trace_id".to_string(), record.trace_id.clone());
1064    headers.insert("actor_id".to_string(), record.agent.clone());
1065    headers.insert("action".to_string(), record.action.clone());
1066    headers.insert(
1067        "autonomy_tier_at_time".to_string(),
1068        record.autonomy_tier.as_str().to_string(),
1069    );
1070    headers.insert("outcome".to_string(), record.outcome.as_str().to_string());
1071    log.append(
1072        &records_topic()?,
1073        LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers),
1074    )
1075    .await?;
1076    Ok(())
1077}
1078
1079async fn finalize_trust_record(
1080    log: &Arc<AnyEventLog>,
1081    mut record: TrustRecord,
1082) -> Result<TrustRecord, LogError> {
1083    attach_current_actor_chain(&mut record);
1084    let latest = latest_chain_record(log).await?;
1085    record.chain_index = latest
1086        .as_ref()
1087        .map(|(_, record)| record.chain_index.saturating_add(1).max(1))
1088        .unwrap_or(1);
1089    record.previous_hash = latest.and_then(|(_, record)| {
1090        if record.entry_hash.is_empty() {
1091            compute_trust_record_hash(&record).ok()
1092        } else {
1093            Some(record.entry_hash)
1094        }
1095    });
1096    record.entry_hash.clear();
1097    record.entry_hash = compute_trust_record_hash(&record)?;
1098    Ok(record)
1099}
1100
1101fn attach_current_actor_chain(record: &mut TrustRecord) {
1102    if record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN) {
1103        return;
1104    }
1105    if let Some(actor_chain) = crate::agent_sessions::current_actor_chain() {
1106        record.set_actor_chain(Some(actor_chain));
1107    }
1108}
1109
1110async fn latest_chain_record(
1111    log: &Arc<AnyEventLog>,
1112) -> Result<Option<(EventId, TrustRecord)>, LogError> {
1113    let (_, records) = preferred_chain_records(log).await?;
1114    Ok(records.into_iter().last())
1115}
1116
1117async fn preferred_chain_records(
1118    log: &Arc<AnyEventLog>,
1119) -> Result<(Topic, Vec<(EventId, TrustRecord)>), LogError> {
1120    let canonical = global_topic()?;
1121    let canonical_records = read_trust_records_from_topic(log, &canonical).await?;
1122    if !canonical_records.is_empty() {
1123        return Ok((canonical, canonical_records));
1124    }
1125    let legacy = legacy_global_topic()?;
1126    let legacy_records = read_trust_records_from_topic(log, &legacy).await?;
1127    if legacy_records.is_empty() {
1128        Ok((canonical, Vec::new()))
1129    } else {
1130        Ok((legacy, legacy_records))
1131    }
1132}
1133
1134async fn read_trust_records_from_topic(
1135    log: &Arc<AnyEventLog>,
1136    topic: &Topic,
1137) -> Result<Vec<(EventId, TrustRecord)>, LogError> {
1138    let events = log.read_range(topic, None, usize::MAX).await?;
1139    let mut records = Vec::new();
1140    let mut seen = HashSet::new();
1141    for (event_id, event) in events {
1142        if event.kind != TRUST_GRAPH_EVENT_KIND {
1143            continue;
1144        }
1145        let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
1146            continue;
1147        };
1148        if seen.insert(trust_record_dedupe_key(&record)) {
1149            records.push((event_id, record));
1150        }
1151    }
1152    Ok(records)
1153}
1154
1155fn trust_record_dedupe_key(record: &TrustRecord) -> String {
1156    if !record.entry_hash.is_empty() {
1157        return record.entry_hash.clone();
1158    }
1159    record.record_id.clone()
1160}
1161
1162fn trust_graph_record_dedupe_key(record: &TrustGraphRecord) -> String {
1163    format!(
1164        "{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
1165        record.actor_id,
1166        record.action,
1167        record.trace_id,
1168        record.timestamp,
1169        record.outcome.as_str()
1170    )
1171}
1172
1173fn evidence_refs_from_metadata(
1174    metadata: &BTreeMap<String, serde_json::Value>,
1175) -> Vec<serde_json::Value> {
1176    metadata
1177        .get("evidence_refs")
1178        .or_else(|| metadata.get("evidenceRefs"))
1179        .or_else(|| {
1180            metadata
1181                .get("approval")
1182                .and_then(|approval| approval.get("evidence_refs"))
1183        })
1184        .and_then(|value| value.as_array())
1185        .cloned()
1186        .unwrap_or_default()
1187}
1188
1189fn score_from_records(
1190    agent: &str,
1191    action: Option<&str>,
1192    effective_tier: AutonomyTier,
1193    records: &[TrustRecord],
1194) -> TrustScore {
1195    let mut score = TrustScore {
1196        agent: agent.to_string(),
1197        action: action.map(ToString::to_string),
1198        effective_tier,
1199        ..TrustScore::default()
1200    };
1201    let recent_cutoff = OffsetDateTime::now_utc() - Duration::days(30);
1202    let mut recent_successes = 0;
1203    let mut recent_bad_or_rollback = false;
1204    for record in records {
1205        score.total += 1;
1206        match record.outcome {
1207            TrustOutcome::Success => score.successes += 1,
1208            TrustOutcome::Failure => score.failures += 1,
1209            TrustOutcome::Denied => score.denied += 1,
1210            TrustOutcome::Timeout => score.timeouts += 1,
1211        }
1212        if record.timestamp >= recent_cutoff {
1213            if record.outcome == TrustOutcome::Success && !is_control_plane_action(&record.action) {
1214                recent_successes += 1;
1215            } else if record.outcome != TrustOutcome::Success {
1216                recent_bad_or_rollback = true;
1217            }
1218            if record.action.contains("rollback") {
1219                recent_bad_or_rollback = true;
1220            }
1221        }
1222        score.latest_outcome = Some(record.outcome);
1223        score.latest_timestamp = Some(record.timestamp);
1224    }
1225    score.success_rate = if score.total == 0 {
1226        0.0
1227    } else {
1228        score.successes as f64 / score.total as f64
1229    };
1230    score.policy = policy_from_score(&score, recent_successes, recent_bad_or_rollback);
1231    score
1232}
1233
1234fn policy_from_score(
1235    score: &TrustScore,
1236    recent_successes: u64,
1237    recent_bad_or_rollback: bool,
1238) -> CapabilityPolicy {
1239    let mut policy = policy_for_autonomy_tier(score.effective_tier);
1240    let latest_bad = matches!(
1241        score.latest_outcome,
1242        Some(TrustOutcome::Denied | TrustOutcome::Failure | TrustOutcome::Timeout)
1243    );
1244    let trusted_recent_track_record = score.effective_tier == AutonomyTier::ActWithApproval
1245        && recent_successes >= 10
1246        && !recent_bad_or_rollback;
1247    if latest_bad || (!trusted_recent_track_record && score.total >= 3 && score.success_rate < 0.5)
1248    {
1249        policy.side_effect_level = Some("read_only".to_string());
1250    } else if trusted_recent_track_record {
1251        policy.side_effect_level = Some("network".to_string());
1252    }
1253    policy
1254}
1255
1256pub fn policy_for_autonomy_tier(tier: AutonomyTier) -> CapabilityPolicy {
1257    use crate::tool_annotations::SideEffectLevel;
1258    let level = match tier {
1259        // Shadow handlers must be able to inspect inputs and compute a
1260        // proposal; only their mutations are suppressed by the autonomy
1261        // decision engine. A `none` ceiling would reject even `exists` and
1262        // other observations before the engine could record the proposal.
1263        AutonomyTier::Shadow => SideEffectLevel::ReadOnly,
1264        AutonomyTier::Suggest => SideEffectLevel::ReadOnly,
1265        AutonomyTier::ActWithApproval => SideEffectLevel::ReadOnly,
1266        // Full autonomy carries the outermost ceiling — the TOP of the ladder,
1267        // not a hardcoded level. This must track the ladder so a newly-added
1268        // most-invasive level (e.g. `desktop_control`, added above `network`) is
1269        // not silently capped out of the fully-autonomous tier.
1270        AutonomyTier::ActAuto => SideEffectLevel::MAX,
1271    };
1272    // An autonomy tier bounds how much a handler may *do*, not where it may
1273    // read and write, so this is an overlay on whatever confinement the run
1274    // already has. Built on `default()` it would carry `SandboxProfile::
1275    // Worktree` and confine handlers dispatched from an unsandboxed run.
1276    CapabilityPolicy {
1277        side_effect_level: Some(level.as_str().to_string()),
1278        recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
1279        ..CapabilityPolicy::neutral()
1280    }
1281}
1282
1283fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
1284    let Some(limit) = limit else {
1285        return;
1286    };
1287    if records.len() <= limit {
1288        return;
1289    }
1290    let keep_from = records.len() - limit;
1291    records.drain(0..keep_from);
1292}
1293
1294fn apply_graph_record_limit(records: &mut Vec<TrustGraphRecord>, limit: Option<usize>) {
1295    let Some(limit) = limit else {
1296        return;
1297    };
1298    if records.len() <= limit {
1299        return;
1300    }
1301    let keep_from = records.len() - limit;
1302    records.drain(0..keep_from);
1303}
1304
1305fn is_control_plane_action(action: &str) -> bool {
1306    matches!(
1307        action,
1308        "trust.promote" | "trust.demote" | "autonomy.tier_transition"
1309    )
1310}
1311
1312#[cfg(test)]
1313mod tests;