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, 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 = serde_json::to_string(&value)
731        .map_err(|error| LogError::Serde(format!("trust record canonicalize error: {error}")))?;
732    let digest = Sha256::digest(canonical.as_bytes());
733    Ok(format!("sha256:{}", hex::encode(digest)))
734}
735
736struct LineageInvariantError {
737    event_id: Option<EventId>,
738    message: String,
739}
740
741impl LineageInvariantError {
742    fn new(event_id: Option<EventId>, message: String) -> Self {
743        Self { event_id, message }
744    }
745}
746
747fn validate_lineage_invariants<'a, I>(records: I) -> Vec<LineageInvariantError>
748where
749    I: IntoIterator<Item = (String, Option<EventId>, &'a TrustRecord)>,
750{
751    let mut errors = Vec::new();
752    let mut by_id: HashMap<&'a str, &'a TrustRecord> = HashMap::new();
753
754    for (label, event_id, record) in records {
755        let actor_chain = match record.try_actor_chain() {
756            Ok(actor_chain) => actor_chain,
757            Err(error) => {
758                errors.push(LineageInvariantError::new(
759                    event_id,
760                    format!("{label}: actor_chain invalid: {error}"),
761                ));
762                None
763            }
764        };
765        let effects_used = record.effects_used();
766        if let Some(parent_id) = record.parent_record_id() {
767            let parent = by_id.get(parent_id.as_str()).copied();
768            if parent.is_none() && (!effects_used.is_empty() || actor_chain.is_some()) {
769                errors.push(LineageInvariantError::new(
770                    event_id,
771                    format!("{label}: parent_record_id {parent_id:?} not found in chain"),
772                ));
773            }
774            if let Some(parent) = parent {
775                validate_effect_lineage(
776                    &mut errors,
777                    &label,
778                    event_id,
779                    &parent_id,
780                    parent,
781                    &effects_used,
782                );
783                validate_actor_lineage(
784                    &mut errors,
785                    &label,
786                    event_id,
787                    &parent_id,
788                    parent,
789                    actor_chain,
790                );
791            }
792        }
793
794        if !record.record_id.is_empty() {
795            by_id.insert(record.record_id.as_str(), record);
796        }
797    }
798
799    errors
800}
801
802fn validate_effect_lineage(
803    errors: &mut Vec<LineageInvariantError>,
804    label: &str,
805    event_id: Option<EventId>,
806    parent_id: &str,
807    parent: &TrustRecord,
808    effects_used: &[EffectRecord],
809) {
810    if effects_used.is_empty() {
811        return;
812    }
813    let parent_grant = parent.effects_grant();
814    for effect in effects_used {
815        if !parent_grant.contains(effect) {
816            errors.push(LineageInvariantError::new(
817                event_id,
818                format!(
819                    "{label}: effects_used escaped grant from parent {parent_id:?}: {effect:?}"
820                ),
821            ));
822        }
823    }
824}
825
826fn validate_actor_lineage(
827    errors: &mut Vec<LineageInvariantError>,
828    label: &str,
829    event_id: Option<EventId>,
830    parent_id: &str,
831    parent: &TrustRecord,
832    actor_chain: Option<ActorChain>,
833) {
834    let Some(actor_chain) = actor_chain else {
835        return;
836    };
837    let parent_actor_chain = match parent.try_actor_chain() {
838        Ok(Some(parent_actor_chain)) => parent_actor_chain,
839        Ok(None) => {
840            errors.push(LineageInvariantError::new(
841                event_id,
842                format!("{label}: actor_chain parent {parent_id:?} missing actor_chain"),
843            ));
844            return;
845        }
846        Err(error) => {
847            errors.push(LineageInvariantError::new(
848                event_id,
849                format!("{label}: parent actor_chain invalid: {error}"),
850            ));
851            return;
852        }
853    };
854    if !actor_chain_extends_parent(&actor_chain, &parent_actor_chain) {
855        errors.push(LineageInvariantError::new(
856            event_id,
857            format!("{label}: actor_chain escaped parentage from parent {parent_id:?}"),
858        ));
859    }
860}
861
862fn actor_chain_extends_parent(child: &ActorChain, parent: &ActorChain) -> bool {
863    if child.origin() != parent.origin() {
864        return false;
865    }
866    let child_actors: Vec<&str> = child.actors().collect();
867    let parent_actors: Vec<&str> = parent.actors().collect();
868    child_actors.len() == parent_actors.len() + 1 && child_actors[1..] == parent_actors[..]
869}
870
871pub fn group_trust_records_by_trace(records: &[TrustRecord]) -> Vec<TrustTraceGroup> {
872    let mut groups: Vec<TrustTraceGroup> = Vec::new();
873    let mut positions: HashMap<String, usize> = HashMap::new();
874    for record in records {
875        if let Some(index) = positions.get(record.trace_id.as_str()).copied() {
876            groups[index].records.push(record.clone());
877            continue;
878        }
879        positions.insert(record.trace_id.clone(), groups.len());
880        groups.push(TrustTraceGroup {
881            trace_id: record.trace_id.clone(),
882            records: vec![record.clone()],
883        });
884    }
885    groups
886}
887
888pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
889    #[derive(Default)]
890    struct RunningSummary {
891        total: u64,
892        successes: u64,
893        cost_sum: f64,
894        cost_count: u64,
895        tier_distribution: BTreeMap<String, u64>,
896        outcome_distribution: BTreeMap<String, u64>,
897    }
898
899    let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
900    for record in records {
901        let entry = by_agent.entry(record.agent.clone()).or_default();
902        entry.total += 1;
903        if record.outcome == TrustOutcome::Success {
904            entry.successes += 1;
905        }
906        if let Some(cost_usd) = record.cost_usd {
907            entry.cost_sum += cost_usd;
908            entry.cost_count += 1;
909        }
910        *entry
911            .tier_distribution
912            .entry(record.autonomy_tier.as_str().to_string())
913            .or_default() += 1;
914        *entry
915            .outcome_distribution
916            .entry(record.outcome.as_str().to_string())
917            .or_default() += 1;
918    }
919
920    by_agent
921        .into_iter()
922        .map(|(agent, summary)| TrustAgentSummary {
923            agent,
924            total: summary.total,
925            success_rate: if summary.total == 0 {
926                0.0
927            } else {
928                summary.successes as f64 / summary.total as f64
929            },
930            mean_cost_usd: (summary.cost_count > 0)
931                .then_some(summary.cost_sum / summary.cost_count as f64),
932            tier_distribution: summary.tier_distribution,
933            outcome_distribution: summary.outcome_distribution,
934        })
935        .collect()
936}
937
938pub async fn resolve_agent_autonomy_tier(
939    log: &Arc<AnyEventLog>,
940    agent: &str,
941    default: AutonomyTier,
942) -> Result<AutonomyTier, LogError> {
943    let records = query_trust_records(
944        log,
945        &TrustQueryFilters {
946            agent: Some(agent.to_string()),
947            ..TrustQueryFilters::default()
948        },
949    )
950    .await?;
951    let mut current = default;
952    for record in records {
953        if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
954            && record.outcome == TrustOutcome::Success
955        {
956            current = record.autonomy_tier;
957        }
958    }
959    Ok(current)
960}
961
962fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
963    if let Some(agent) = filters.agent.as_deref() {
964        if record.agent != agent {
965            return false;
966        }
967    }
968    if let Some(action) = filters.action.as_deref() {
969        if record.action != action {
970            return false;
971        }
972    }
973    if let Some(since) = filters.since {
974        if record.timestamp < since {
975            return false;
976        }
977    }
978    if let Some(until) = filters.until {
979        if record.timestamp > until {
980            return false;
981        }
982    }
983    if let Some(tier) = filters.tier {
984        if record.autonomy_tier != tier {
985            return false;
986        }
987    }
988    if let Some(outcome) = filters.outcome {
989        if record.outcome != outcome {
990            return false;
991        }
992    }
993    true
994}
995
996fn matches_graph_filters(record: &TrustGraphRecord, filters: &TrustQueryFilters) -> bool {
997    if let Some(agent) = filters.agent.as_deref() {
998        if record.actor_id != agent {
999            return false;
1000        }
1001    }
1002    if let Some(action) = filters.action.as_deref() {
1003        if record.action != action {
1004            return false;
1005        }
1006    }
1007    if let Some(since) = filters.since {
1008        if record.timestamp < since {
1009            return false;
1010        }
1011    }
1012    if let Some(until) = filters.until {
1013        if record.timestamp > until {
1014            return false;
1015        }
1016    }
1017    if let Some(tier) = filters.tier {
1018        if record.autonomy_tier_at_time != tier {
1019            return false;
1020        }
1021    }
1022    if let Some(outcome) = filters.outcome {
1023        if record.outcome != outcome {
1024            return false;
1025        }
1026    }
1027    true
1028}
1029
1030fn query_topics(filters: &TrustQueryFilters) -> Result<Vec<Topic>, LogError> {
1031    match filters.agent.as_deref() {
1032        Some(agent) => unique_topics(vec![
1033            topic_for_agent(agent)?,
1034            legacy_topic_for_agent(agent)?,
1035        ]),
1036        None => unique_topics(vec![global_topic()?, legacy_global_topic()?]),
1037    }
1038}
1039
1040fn append_topics_for_record(record: &TrustRecord) -> Result<Vec<Topic>, LogError> {
1041    unique_topics(vec![
1042        global_topic()?,
1043        legacy_global_topic()?,
1044        topic_for_agent(&record.agent)?,
1045        legacy_topic_for_agent(&record.agent)?,
1046    ])
1047}
1048
1049fn unique_topics(topics: Vec<Topic>) -> Result<Vec<Topic>, LogError> {
1050    let mut seen = HashSet::new();
1051    Ok(topics
1052        .into_iter()
1053        .filter(|topic| seen.insert(topic.as_str().to_string()))
1054        .collect())
1055}
1056
1057async fn append_trust_graph_record_projection(
1058    log: &Arc<AnyEventLog>,
1059    record: &TrustRecord,
1060) -> Result<(), LogError> {
1061    let payload = serde_json::to_value(TrustGraphRecord::from_trust_record(record))
1062        .map_err(|error| LogError::Serde(format!("trust graph record encode error: {error}")))?;
1063    let mut headers = BTreeMap::new();
1064    headers.insert("trace_id".to_string(), record.trace_id.clone());
1065    headers.insert("actor_id".to_string(), record.agent.clone());
1066    headers.insert("action".to_string(), record.action.clone());
1067    headers.insert(
1068        "autonomy_tier_at_time".to_string(),
1069        record.autonomy_tier.as_str().to_string(),
1070    );
1071    headers.insert("outcome".to_string(), record.outcome.as_str().to_string());
1072    log.append(
1073        &records_topic()?,
1074        LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers),
1075    )
1076    .await?;
1077    Ok(())
1078}
1079
1080async fn finalize_trust_record(
1081    log: &Arc<AnyEventLog>,
1082    mut record: TrustRecord,
1083) -> Result<TrustRecord, LogError> {
1084    attach_current_actor_chain(&mut record);
1085    let latest = latest_chain_record(log).await?;
1086    record.chain_index = latest
1087        .as_ref()
1088        .map(|(_, record)| record.chain_index.saturating_add(1).max(1))
1089        .unwrap_or(1);
1090    record.previous_hash = latest.and_then(|(_, record)| {
1091        if record.entry_hash.is_empty() {
1092            compute_trust_record_hash(&record).ok()
1093        } else {
1094            Some(record.entry_hash)
1095        }
1096    });
1097    record.entry_hash.clear();
1098    record.entry_hash = compute_trust_record_hash(&record)?;
1099    Ok(record)
1100}
1101
1102fn attach_current_actor_chain(record: &mut TrustRecord) {
1103    if record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN) {
1104        return;
1105    }
1106    if let Some(actor_chain) = crate::agent_sessions::current_actor_chain() {
1107        record.set_actor_chain(Some(actor_chain));
1108    }
1109}
1110
1111async fn latest_chain_record(
1112    log: &Arc<AnyEventLog>,
1113) -> Result<Option<(EventId, TrustRecord)>, LogError> {
1114    let (_, records) = preferred_chain_records(log).await?;
1115    Ok(records.into_iter().last())
1116}
1117
1118async fn preferred_chain_records(
1119    log: &Arc<AnyEventLog>,
1120) -> Result<(Topic, Vec<(EventId, TrustRecord)>), LogError> {
1121    let canonical = global_topic()?;
1122    let canonical_records = read_trust_records_from_topic(log, &canonical).await?;
1123    if !canonical_records.is_empty() {
1124        return Ok((canonical, canonical_records));
1125    }
1126    let legacy = legacy_global_topic()?;
1127    let legacy_records = read_trust_records_from_topic(log, &legacy).await?;
1128    if legacy_records.is_empty() {
1129        Ok((canonical, Vec::new()))
1130    } else {
1131        Ok((legacy, legacy_records))
1132    }
1133}
1134
1135async fn read_trust_records_from_topic(
1136    log: &Arc<AnyEventLog>,
1137    topic: &Topic,
1138) -> Result<Vec<(EventId, TrustRecord)>, LogError> {
1139    let events = log.read_range(topic, None, usize::MAX).await?;
1140    let mut records = Vec::new();
1141    let mut seen = HashSet::new();
1142    for (event_id, event) in events {
1143        if event.kind != TRUST_GRAPH_EVENT_KIND {
1144            continue;
1145        }
1146        let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
1147            continue;
1148        };
1149        if seen.insert(trust_record_dedupe_key(&record)) {
1150            records.push((event_id, record));
1151        }
1152    }
1153    Ok(records)
1154}
1155
1156fn trust_record_dedupe_key(record: &TrustRecord) -> String {
1157    if !record.entry_hash.is_empty() {
1158        return record.entry_hash.clone();
1159    }
1160    record.record_id.clone()
1161}
1162
1163fn trust_graph_record_dedupe_key(record: &TrustGraphRecord) -> String {
1164    format!(
1165        "{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
1166        record.actor_id,
1167        record.action,
1168        record.trace_id,
1169        record.timestamp,
1170        record.outcome.as_str()
1171    )
1172}
1173
1174fn evidence_refs_from_metadata(
1175    metadata: &BTreeMap<String, serde_json::Value>,
1176) -> Vec<serde_json::Value> {
1177    metadata
1178        .get("evidence_refs")
1179        .or_else(|| metadata.get("evidenceRefs"))
1180        .or_else(|| {
1181            metadata
1182                .get("approval")
1183                .and_then(|approval| approval.get("evidence_refs"))
1184        })
1185        .and_then(|value| value.as_array())
1186        .cloned()
1187        .unwrap_or_default()
1188}
1189
1190fn score_from_records(
1191    agent: &str,
1192    action: Option<&str>,
1193    effective_tier: AutonomyTier,
1194    records: &[TrustRecord],
1195) -> TrustScore {
1196    let mut score = TrustScore {
1197        agent: agent.to_string(),
1198        action: action.map(ToString::to_string),
1199        effective_tier,
1200        ..TrustScore::default()
1201    };
1202    let recent_cutoff = OffsetDateTime::now_utc() - Duration::days(30);
1203    let mut recent_successes = 0;
1204    let mut recent_bad_or_rollback = false;
1205    for record in records {
1206        score.total += 1;
1207        match record.outcome {
1208            TrustOutcome::Success => score.successes += 1,
1209            TrustOutcome::Failure => score.failures += 1,
1210            TrustOutcome::Denied => score.denied += 1,
1211            TrustOutcome::Timeout => score.timeouts += 1,
1212        }
1213        if record.timestamp >= recent_cutoff {
1214            if record.outcome == TrustOutcome::Success && !is_control_plane_action(&record.action) {
1215                recent_successes += 1;
1216            } else if record.outcome != TrustOutcome::Success {
1217                recent_bad_or_rollback = true;
1218            }
1219            if record.action.contains("rollback") {
1220                recent_bad_or_rollback = true;
1221            }
1222        }
1223        score.latest_outcome = Some(record.outcome);
1224        score.latest_timestamp = Some(record.timestamp);
1225    }
1226    score.success_rate = if score.total == 0 {
1227        0.0
1228    } else {
1229        score.successes as f64 / score.total as f64
1230    };
1231    score.policy = policy_from_score(&score, recent_successes, recent_bad_or_rollback);
1232    score
1233}
1234
1235fn policy_from_score(
1236    score: &TrustScore,
1237    recent_successes: u64,
1238    recent_bad_or_rollback: bool,
1239) -> CapabilityPolicy {
1240    let mut policy = policy_for_autonomy_tier(score.effective_tier);
1241    let latest_bad = matches!(
1242        score.latest_outcome,
1243        Some(TrustOutcome::Denied | TrustOutcome::Failure | TrustOutcome::Timeout)
1244    );
1245    let trusted_recent_track_record = score.effective_tier == AutonomyTier::ActWithApproval
1246        && recent_successes >= 10
1247        && !recent_bad_or_rollback;
1248    if latest_bad || (!trusted_recent_track_record && score.total >= 3 && score.success_rate < 0.5)
1249    {
1250        policy.side_effect_level = Some("read_only".to_string());
1251    } else if trusted_recent_track_record {
1252        policy.side_effect_level = Some("network".to_string());
1253    }
1254    policy
1255}
1256
1257pub fn policy_for_autonomy_tier(tier: AutonomyTier) -> CapabilityPolicy {
1258    use crate::tool_annotations::SideEffectLevel;
1259    let level = match tier {
1260        AutonomyTier::Shadow => SideEffectLevel::None,
1261        AutonomyTier::Suggest => SideEffectLevel::ReadOnly,
1262        AutonomyTier::ActWithApproval => SideEffectLevel::ReadOnly,
1263        // Full autonomy carries the outermost ceiling โ€” the TOP of the ladder,
1264        // not a hardcoded level. This must track the ladder so a newly-added
1265        // most-invasive level (e.g. `desktop_control`, added above `network`) is
1266        // not silently capped out of the fully-autonomous tier.
1267        AutonomyTier::ActAuto => SideEffectLevel::MAX,
1268    };
1269    CapabilityPolicy {
1270        side_effect_level: Some(level.as_str().to_string()),
1271        recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
1272        ..CapabilityPolicy::default()
1273    }
1274}
1275
1276fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
1277    let Some(limit) = limit else {
1278        return;
1279    };
1280    if records.len() <= limit {
1281        return;
1282    }
1283    let keep_from = records.len() - limit;
1284    records.drain(0..keep_from);
1285}
1286
1287fn apply_graph_record_limit(records: &mut Vec<TrustGraphRecord>, limit: Option<usize>) {
1288    let Some(limit) = limit else {
1289        return;
1290    };
1291    if records.len() <= limit {
1292        return;
1293    }
1294    let keep_from = records.len() - limit;
1295    records.drain(0..keep_from);
1296}
1297
1298fn is_control_plane_action(action: &str) -> bool {
1299    matches!(
1300        action,
1301        "trust.promote" | "trust.demote" | "autonomy.tier_transition"
1302    )
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308    use crate::event_log::MemoryEventLog;
1309    use time::Duration;
1310
1311    const RECORD_SCHEMA_JSON: &str =
1312        include_str!("trust_graph/schemas/trust-record.v0.schema.json");
1313    const RECORD_SCHEMA_V0_1_JSON: &str =
1314        include_str!("trust_graph/schemas/trust-record.v0.1.schema.json");
1315    const CHAIN_SCHEMA_JSON: &str = include_str!("trust_graph/schemas/trust-chain.v0.schema.json");
1316    const VALID_DECISION_CHAIN_JSON: &str =
1317        include_str!("trust_graph/fixtures/valid/decision-chain.json");
1318    const VALID_TIER_TRANSITION_JSON: &str =
1319        include_str!("trust_graph/fixtures/valid/tier-transition.json");
1320    const VALID_EFFECT_INHERITANCE_CHAIN_JSON: &str =
1321        include_str!("trust_graph/fixtures/valid/effect-inheritance-chain.json");
1322    const INVALID_TAMPERED_CHAIN_JSON: &str =
1323        include_str!("trust_graph/fixtures/invalid/tampered-chain.json");
1324    const INVALID_MISSING_APPROVAL_JSON: &str =
1325        include_str!("trust_graph/fixtures/invalid/missing-approval.json");
1326    const INVALID_ACTOR_CHAIN_PARENTAGE_JSON: &str =
1327        include_str!("trust_graph/fixtures/invalid/actor-chain-parentage.json");
1328
1329    #[derive(Debug, serde::Deserialize)]
1330    struct TrustChainFixture {
1331        schema: String,
1332        chain: TrustChainFixtureMetadata,
1333        records: Vec<TrustRecord>,
1334    }
1335
1336    #[derive(Debug, serde::Deserialize)]
1337    struct TrustChainFixtureMetadata {
1338        topic: String,
1339        total: u64,
1340        root_hash: Option<String>,
1341        verified: bool,
1342        generated_at: String,
1343        producer: BTreeMap<String, serde_json::Value>,
1344    }
1345
1346    #[test]
1347    fn embedded_trust_graph_fixtures_match_workspace_spec_when_available() {
1348        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1349        let spec_dir = manifest_dir.join("../../opentrustgraph-spec");
1350        if !spec_dir.exists() {
1351            return;
1352        }
1353
1354        for (relative, embedded) in [
1355            ("schemas/trust-record.v0.schema.json", RECORD_SCHEMA_JSON),
1356            (
1357                "schemas/trust-record.v0.1.schema.json",
1358                RECORD_SCHEMA_V0_1_JSON,
1359            ),
1360            ("schemas/trust-chain.v0.schema.json", CHAIN_SCHEMA_JSON),
1361            (
1362                "fixtures/valid/decision-chain.json",
1363                VALID_DECISION_CHAIN_JSON,
1364            ),
1365            (
1366                "fixtures/valid/tier-transition.json",
1367                VALID_TIER_TRANSITION_JSON,
1368            ),
1369            (
1370                "fixtures/valid/effect-inheritance-chain.json",
1371                VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1372            ),
1373            (
1374                "fixtures/invalid/tampered-chain.json",
1375                INVALID_TAMPERED_CHAIN_JSON,
1376            ),
1377            (
1378                "fixtures/invalid/missing-approval.json",
1379                INVALID_MISSING_APPROVAL_JSON,
1380            ),
1381            (
1382                "fixtures/invalid/actor-chain-parentage.json",
1383                INVALID_ACTOR_CHAIN_PARENTAGE_JSON,
1384            ),
1385        ] {
1386            let source = std::fs::read_to_string(spec_dir.join(relative)).unwrap_or_else(|e| {
1387                panic!("failed to read opentrustgraph fixture {relative}: {e}")
1388            });
1389            assert_eq!(
1390                embedded, source,
1391                "embedded trust graph fixture {relative} drifted from opentrustgraph-spec"
1392            );
1393        }
1394    }
1395
1396    #[tokio::test]
1397    async fn append_and_query_round_trip() {
1398        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1399        let mut record = TrustRecord::new(
1400            "github-triage-bot",
1401            "github.issue.opened",
1402            Some("reviewer".to_string()),
1403            TrustOutcome::Success,
1404            "trace-1",
1405            AutonomyTier::ActWithApproval,
1406        );
1407        record.cost_usd = Some(1.25);
1408        append_trust_record(&log, &record).await.unwrap();
1409
1410        let records = query_trust_records(
1411            &log,
1412            &TrustQueryFilters {
1413                agent: Some("github-triage-bot".to_string()),
1414                ..TrustQueryFilters::default()
1415            },
1416        )
1417        .await
1418        .unwrap();
1419
1420        assert_eq!(records.len(), 1);
1421        assert_eq!(records[0].agent, "github-triage-bot");
1422        assert_eq!(records[0].cost_usd, Some(1.25));
1423        assert_eq!(records[0].chain_index, 1);
1424        assert!(records[0].previous_hash.is_none());
1425        assert!(records[0].entry_hash.starts_with("sha256:"));
1426    }
1427
1428    #[tokio::test]
1429    async fn verify_chain_detects_hash_tampering() {
1430        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1431        let first = append_trust_record(
1432            &log,
1433            &TrustRecord::new(
1434                "bot",
1435                "first",
1436                None,
1437                TrustOutcome::Success,
1438                "trace-1",
1439                AutonomyTier::Suggest,
1440            ),
1441        )
1442        .await
1443        .unwrap();
1444        let mut second = append_trust_record(
1445            &log,
1446            &TrustRecord::new(
1447                "bot",
1448                "second",
1449                None,
1450                TrustOutcome::Success,
1451                "trace-2",
1452                AutonomyTier::Suggest,
1453            ),
1454        )
1455        .await
1456        .unwrap();
1457
1458        let report = verify_trust_chain(&log).await.unwrap();
1459        assert!(report.verified);
1460        assert_eq!(
1461            report.root_hash.as_deref(),
1462            Some(second.entry_hash.as_str())
1463        );
1464        assert_eq!(
1465            second.previous_hash.as_deref(),
1466            Some(first.entry_hash.as_str())
1467        );
1468
1469        second.previous_hash = Some(
1470            "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1471        );
1472        second.entry_hash =
1473            "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1474        log.append(
1475            &global_topic().unwrap(),
1476            LogEvent::new(
1477                TRUST_GRAPH_EVENT_KIND,
1478                serde_json::to_value(second).unwrap(),
1479            ),
1480        )
1481        .await
1482        .unwrap();
1483        let report = verify_trust_chain(&log).await.unwrap();
1484        assert!(!report.verified);
1485        assert!(report
1486            .errors
1487            .iter()
1488            .any(|error| error.contains("previous_hash mismatch")));
1489    }
1490
1491    #[tokio::test]
1492    async fn export_trust_chain_emits_envelope_matching_chain_schema() {
1493        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1494        let first = append_trust_record(
1495            &log,
1496            &TrustRecord::new(
1497                "bot",
1498                "github.issue.opened",
1499                None,
1500                TrustOutcome::Success,
1501                "trace-1",
1502                AutonomyTier::Suggest,
1503            ),
1504        )
1505        .await
1506        .unwrap();
1507        let second = append_trust_record(
1508            &log,
1509            &TrustRecord::new(
1510                "bot",
1511                "trust.promote",
1512                Some("maintainer-1".to_string()),
1513                TrustOutcome::Success,
1514                "trace-2",
1515                AutonomyTier::ActAuto,
1516            ),
1517        )
1518        .await
1519        .unwrap();
1520
1521        let export = export_trust_chain(&log).await.unwrap();
1522        assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1523        assert_eq!(export.chain.topic, TRUST_GRAPH_GLOBAL_TOPIC);
1524        assert_eq!(export.chain.total, 2);
1525        assert!(export.chain.verified);
1526        assert_eq!(
1527            export.chain.root_hash.as_deref(),
1528            Some(second.entry_hash.as_str())
1529        );
1530        assert_eq!(export.records.len(), 2);
1531        assert_eq!(export.records[0].entry_hash, first.entry_hash);
1532        assert_eq!(export.records[1].entry_hash, second.entry_hash);
1533        assert_eq!(export.chain.producer.name, "harn");
1534
1535        let envelope_json = serde_json::to_value(&export).unwrap();
1536        assert_eq!(envelope_json["schema"], OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1537        assert_eq!(envelope_json["chain"]["total"], 2);
1538        assert_eq!(envelope_json["chain"]["verified"], true);
1539        assert!(envelope_json["records"].as_array().unwrap().len() == 2);
1540    }
1541
1542    #[tokio::test]
1543    async fn export_trust_chain_handles_empty_log() {
1544        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1545        let export = export_trust_chain(&log).await.unwrap();
1546        assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1547        assert_eq!(export.chain.total, 0);
1548        assert!(export.chain.verified);
1549        assert!(export.chain.root_hash.is_none());
1550        assert!(export.records.is_empty());
1551    }
1552
1553    #[tokio::test]
1554    async fn resolve_autonomy_tier_prefers_latest_control_record() {
1555        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1556        append_trust_record(
1557            &log,
1558            &TrustRecord::new(
1559                "bot",
1560                "trust.promote",
1561                None,
1562                TrustOutcome::Success,
1563                "trace-1",
1564                AutonomyTier::ActWithApproval,
1565            ),
1566        )
1567        .await
1568        .unwrap();
1569        append_trust_record(
1570            &log,
1571            &TrustRecord::new(
1572                "bot",
1573                "trust.demote",
1574                None,
1575                TrustOutcome::Success,
1576                "trace-2",
1577                AutonomyTier::Shadow,
1578            ),
1579        )
1580        .await
1581        .unwrap();
1582
1583        let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
1584            .await
1585            .unwrap();
1586        assert_eq!(tier, AutonomyTier::Shadow);
1587    }
1588
1589    #[tokio::test]
1590    async fn query_limit_keeps_newest_matching_records() {
1591        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1592        let base = OffsetDateTime::from_unix_timestamp(1_775_000_000).unwrap();
1593        for (offset, action) in ["first", "second", "third"].into_iter().enumerate() {
1594            let mut record = TrustRecord::new(
1595                "bot",
1596                action,
1597                None,
1598                TrustOutcome::Success,
1599                format!("trace-{action}"),
1600                AutonomyTier::ActAuto,
1601            );
1602            record.timestamp = base + Duration::seconds(offset as i64);
1603            append_trust_record(&log, &record).await.unwrap();
1604        }
1605
1606        let records = query_trust_records(
1607            &log,
1608            &TrustQueryFilters {
1609                agent: Some("bot".to_string()),
1610                limit: Some(2),
1611                ..TrustQueryFilters::default()
1612            },
1613        )
1614        .await
1615        .unwrap();
1616
1617        assert_eq!(records.len(), 2);
1618        assert_eq!(records[0].action, "second");
1619        assert_eq!(records[1].action, "third");
1620    }
1621
1622    #[test]
1623    fn group_by_trace_preserves_chronological_group_order() {
1624        let make_record = |trace_id: &str, action: &str| TrustRecord {
1625            trace_id: trace_id.to_string(),
1626            action: action.to_string(),
1627            ..TrustRecord::new(
1628                "bot",
1629                action,
1630                None,
1631                TrustOutcome::Success,
1632                trace_id,
1633                AutonomyTier::ActAuto,
1634            )
1635        };
1636        let grouped = group_trust_records_by_trace(&[
1637            make_record("trace-1", "first"),
1638            make_record("trace-2", "second"),
1639            make_record("trace-1", "third"),
1640        ]);
1641
1642        assert_eq!(grouped.len(), 2);
1643        assert_eq!(grouped[0].trace_id, "trace-1");
1644        assert_eq!(grouped[0].records.len(), 2);
1645        assert_eq!(grouped[0].records[1].action, "third");
1646        assert_eq!(grouped[1].trace_id, "trace-2");
1647    }
1648
1649    #[test]
1650    fn opentrustgraph_schema_files_are_parseable_and_match_runtime_enums() {
1651        let record_schema: serde_json::Value = serde_json::from_str(RECORD_SCHEMA_JSON).unwrap();
1652        let record_schema_v0_1: serde_json::Value =
1653            serde_json::from_str(RECORD_SCHEMA_V0_1_JSON).unwrap();
1654        let chain_schema: serde_json::Value = serde_json::from_str(CHAIN_SCHEMA_JSON).unwrap();
1655
1656        assert_eq!(
1657            record_schema["properties"]["schema"]["const"],
1658            serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)
1659        );
1660        let v0_1_schema_enum = record_schema_v0_1["properties"]["schema"]["enum"]
1661            .as_array()
1662            .expect("v0.1 record schema declares schema as an enum");
1663        assert!(
1664            v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0_1)),
1665            "v0.1 record schema must accept {OPENTRUSTGRAPH_SCHEMA_V0_1}: {v0_1_schema_enum:?}"
1666        );
1667        assert!(
1668            v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)),
1669            "v0.1 record schema must still accept v0 (one-release back-compat): {v0_1_schema_enum:?}"
1670        );
1671        assert_eq!(
1672            chain_schema["properties"]["schema"]["const"],
1673            serde_json::json!("opentrustgraph-chain/v0")
1674        );
1675
1676        let outcomes = record_schema["properties"]["outcome"]["enum"]
1677            .as_array()
1678            .unwrap();
1679        for outcome in [
1680            TrustOutcome::Success,
1681            TrustOutcome::Failure,
1682            TrustOutcome::Denied,
1683            TrustOutcome::Timeout,
1684        ] {
1685            assert!(outcomes.contains(&serde_json::json!(outcome.as_str())));
1686        }
1687
1688        let tiers = record_schema["properties"]["autonomy_tier"]["enum"]
1689            .as_array()
1690            .unwrap();
1691        for tier in [
1692            AutonomyTier::Shadow,
1693            AutonomyTier::Suggest,
1694            AutonomyTier::ActWithApproval,
1695            AutonomyTier::ActAuto,
1696        ] {
1697            assert!(tiers.contains(&serde_json::json!(tier.as_str())));
1698        }
1699    }
1700
1701    #[test]
1702    fn opentrustgraph_valid_fixtures_match_runtime_contract() {
1703        for (name, fixture) in [
1704            ("decision-chain", VALID_DECISION_CHAIN_JSON),
1705            ("tier-transition", VALID_TIER_TRANSITION_JSON),
1706            (
1707                "effect-inheritance-chain",
1708                VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1709            ),
1710        ] {
1711            let fixture = parse_chain_fixture(fixture);
1712            let errors = validate_chain_fixture(&fixture);
1713            assert!(errors.is_empty(), "{name} errors: {errors:?}");
1714        }
1715    }
1716
1717    #[test]
1718    fn opentrustgraph_invalid_fixtures_exercise_expected_failures() {
1719        let tampered = parse_chain_fixture(INVALID_TAMPERED_CHAIN_JSON);
1720        let tampered_errors = validate_chain_fixture(&tampered);
1721        assert!(
1722            tampered_errors
1723                .iter()
1724                .any(|error| error.contains("previous_hash mismatch")),
1725            "tampered-chain errors: {tampered_errors:?}"
1726        );
1727        assert!(
1728            !tampered_errors
1729                .iter()
1730                .any(|error| error.contains("entry_hash mismatch")),
1731            "tampered-chain should isolate hash-link tampering: {tampered_errors:?}"
1732        );
1733
1734        let missing_approval = parse_chain_fixture(INVALID_MISSING_APPROVAL_JSON);
1735        let missing_errors = validate_chain_fixture(&missing_approval);
1736        assert!(
1737            missing_errors
1738                .iter()
1739                .any(|error| error.contains("approval required")),
1740            "missing-approval errors: {missing_errors:?}"
1741        );
1742
1743        let actor_parentage = parse_chain_fixture(INVALID_ACTOR_CHAIN_PARENTAGE_JSON);
1744        let actor_errors = validate_chain_fixture(&actor_parentage);
1745        assert!(
1746            actor_errors
1747                .iter()
1748                .any(|error| error.contains("actor_chain escaped parentage")),
1749            "actor-chain-parentage errors: {actor_errors:?}"
1750        );
1751    }
1752
1753    fn parse_chain_fixture(input: &str) -> TrustChainFixture {
1754        serde_json::from_str(input).unwrap()
1755    }
1756
1757    fn validate_chain_fixture(fixture: &TrustChainFixture) -> Vec<String> {
1758        let mut errors = Vec::new();
1759        if fixture.schema != OPENTRUSTGRAPH_CHAIN_SCHEMA_V0 {
1760            errors.push(format!("unsupported chain schema {}", fixture.schema));
1761        }
1762        if fixture.chain.topic.trim().is_empty() {
1763            errors.push("chain topic is empty".to_string());
1764        }
1765        if fixture.chain.total != fixture.records.len() as u64 {
1766            errors.push(format!(
1767                "chain total mismatch; expected {}, found {}",
1768                fixture.records.len(),
1769                fixture.chain.total
1770            ));
1771        }
1772        if fixture
1773            .chain
1774            .producer
1775            .get("name")
1776            .and_then(|value| value.as_str())
1777            .unwrap_or_default()
1778            .trim()
1779            .is_empty()
1780        {
1781            errors.push("chain producer.name is empty".to_string());
1782        }
1783        if OffsetDateTime::parse(
1784            &fixture.chain.generated_at,
1785            &time::format_description::well_known::Rfc3339,
1786        )
1787        .is_err()
1788        {
1789            errors.push("chain generated_at is not RFC3339".to_string());
1790        }
1791
1792        for (index, record) in fixture.records.iter().enumerate() {
1793            errors.extend(validate_fixture_record_contract(index, record));
1794        }
1795        errors.extend(validate_fixture_hash_chain(fixture));
1796        errors.extend(
1797            validate_lineage_invariants(
1798                fixture
1799                    .records
1800                    .iter()
1801                    .enumerate()
1802                    .map(|(index, record)| (format!("record {index}"), None, record)),
1803            )
1804            .into_iter()
1805            .map(|error| error.message),
1806        );
1807
1808        let expected_verified = errors.is_empty();
1809        if fixture.chain.verified != expected_verified {
1810            errors.push(format!(
1811                "chain verified flag mismatch; expected {expected_verified}, found {}",
1812                fixture.chain.verified
1813            ));
1814        }
1815        errors
1816    }
1817
1818    fn validate_fixture_record_contract(index: usize, record: &TrustRecord) -> Vec<String> {
1819        let mut errors = Vec::new();
1820        let label = format!("record {index}");
1821        if !OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&record.schema.as_str()) {
1822            errors.push(format!("{label}: unsupported schema {}", record.schema));
1823        }
1824        if record.record_id.trim().is_empty() {
1825            errors.push(format!("{label}: record_id is empty"));
1826        }
1827        if record.agent.trim().is_empty() {
1828            errors.push(format!("{label}: agent is empty"));
1829        }
1830        if record.action.trim().is_empty() {
1831            errors.push(format!("{label}: action is empty"));
1832        }
1833        if record.trace_id.trim().is_empty() {
1834            errors.push(format!("{label}: trace_id is empty"));
1835        }
1836        if !record.entry_hash.starts_with("sha256:") {
1837            errors.push(format!("{label}: entry_hash is not sha256-prefixed"));
1838        }
1839        if let Some(cost_usd) = record.cost_usd {
1840            if cost_usd < 0.0 {
1841                errors.push(format!("{label}: cost_usd is negative"));
1842            }
1843        }
1844
1845        if record.outcome == TrustOutcome::Success
1846            && record.autonomy_tier == AutonomyTier::ActWithApproval
1847            && approval_required(record)
1848        {
1849            if record
1850                .approver
1851                .as_deref()
1852                .unwrap_or_default()
1853                .trim()
1854                .is_empty()
1855            {
1856                errors.push(format!("{label}: approval required but approver is empty"));
1857            }
1858            if approval_signature_count(record) == 0 {
1859                errors.push(format!(
1860                    "{label}: approval required but signatures are empty"
1861                ));
1862            }
1863        }
1864
1865        errors
1866    }
1867
1868    fn validate_fixture_hash_chain(fixture: &TrustChainFixture) -> Vec<String> {
1869        let mut errors = Vec::new();
1870        let mut previous_hash: Option<String> = None;
1871
1872        for (position, record) in fixture.records.iter().enumerate() {
1873            let expected_index = position as u64 + 1;
1874            if record.chain_index != expected_index {
1875                errors.push(format!(
1876                    "record {position}: expected chain_index {expected_index}, found {}",
1877                    record.chain_index
1878                ));
1879            }
1880            if record.previous_hash != previous_hash {
1881                errors.push(format!(
1882                    "record {position}: previous_hash mismatch; expected {:?}, found {:?}",
1883                    previous_hash, record.previous_hash
1884                ));
1885            }
1886            let expected_hash = compute_trust_record_hash(record).unwrap();
1887            if expected_hash != record.entry_hash {
1888                errors.push(format!(
1889                    "record {position}: entry_hash mismatch; expected {expected_hash}, found {}",
1890                    record.entry_hash
1891                ));
1892            }
1893            previous_hash = Some(record.entry_hash.clone());
1894        }
1895
1896        if fixture.chain.root_hash != previous_hash {
1897            errors.push(format!(
1898                "chain root_hash mismatch; expected {:?}, found {:?}",
1899                previous_hash, fixture.chain.root_hash
1900            ));
1901        }
1902        errors
1903    }
1904
1905    fn approval_required(record: &TrustRecord) -> bool {
1906        record
1907            .metadata
1908            .get("approval")
1909            .and_then(|approval| approval.get("required"))
1910            .and_then(|required| required.as_bool())
1911            .unwrap_or(false)
1912    }
1913
1914    fn approval_signature_count(record: &TrustRecord) -> usize {
1915        record
1916            .metadata
1917            .get("approval")
1918            .and_then(|approval| approval.get("signatures"))
1919            .and_then(|signatures| signatures.as_array())
1920            .map(Vec::len)
1921            .unwrap_or(0)
1922    }
1923
1924    // ----- OpenTrustGraph v0.1 schema and lineage metadata -----
1925
1926    use crate::orchestration::{EffectKind, EffectScope};
1927
1928    #[test]
1929    fn new_trust_record_defaults_to_v0_1_schema() {
1930        let record = TrustRecord::new(
1931            "agent",
1932            "deploy.preview",
1933            None,
1934            TrustOutcome::Success,
1935            "trace-1",
1936            AutonomyTier::Suggest,
1937        );
1938        assert_eq!(record.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1939    }
1940
1941    #[test]
1942    fn v0_records_still_parse_for_backward_compat() {
1943        let record_v0 = serde_json::json!({
1944            "schema": "opentrustgraph/v0",
1945            "record_id": "01966f4c-0f31-7b5d-b44b-f7f8e7e1d384",
1946            "agent": "legacy-bot",
1947            "action": "github.issue.opened",
1948            "approver": null,
1949            "outcome": "success",
1950            "trace_id": "trace-legacy",
1951            "autonomy_tier": "suggest",
1952            "timestamp": "2026-04-19T18:42:11Z",
1953            "cost_usd": null,
1954            "chain_index": 1,
1955            "previous_hash": null,
1956            "entry_hash": "sha256:84facae7d56fd304e040ea18d80bd019e274ad86ddd5a4d732f3ac3d984c48ec",
1957            "metadata": {"provider": "github"}
1958        });
1959        let decoded: TrustRecord = serde_json::from_value(record_v0).unwrap();
1960        assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0);
1961        assert!(OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&decoded.schema.as_str()));
1962        assert!(decoded.effects_grant().is_empty());
1963        assert!(decoded.effects_used().is_empty());
1964        assert!(decoded.parent_record_id().is_none());
1965        assert!(decoded.actor_chain().is_none());
1966    }
1967
1968    #[test]
1969    fn v0_1_lineage_metadata_round_trips_through_json() {
1970        let grant = vec![
1971            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1972                .with_resource("https://api.example"),
1973            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1974        ];
1975        let used =
1976            vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
1977                .with_resource("/workspace/src")];
1978        let actor_chain = ActorChain::new("user:kenneth")
1979            .pushed("agent:parent")
1980            .pushed("agent:child");
1981        let record = TrustRecord::new(
1982            "child-agent",
1983            "fs.read",
1984            None,
1985            TrustOutcome::Success,
1986            "trace-effects-1",
1987            AutonomyTier::ActAuto,
1988        )
1989        .with_effects_grant(grant.clone())
1990        .with_effects_used(used.clone())
1991        .with_parent_record_id("parent-record-001")
1992        .with_actor_chain(actor_chain.clone());
1993
1994        let encoded = serde_json::to_string(&record).unwrap();
1995        let decoded: TrustRecord = serde_json::from_str(&encoded).unwrap();
1996        assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1997        assert_eq!(decoded.effects_grant(), grant);
1998        assert_eq!(decoded.effects_used(), used);
1999        assert_eq!(
2000            decoded.parent_record_id().as_deref(),
2001            Some("parent-record-001")
2002        );
2003        assert_eq!(decoded.actor_chain(), Some(actor_chain));
2004    }
2005
2006    #[test]
2007    fn v0_1_actor_chain_metadata_round_trips_through_json() {
2008        let chain = crate::ActorChain::new_with_scopes("user:kenneth", ["repo:read", "repo:write"])
2009            .pushed_with_scopes("agent:burin", ["repo:read"])
2010            .pushed_with_scopes("agent:merge-captain", ["repo:read"]);
2011        let alert = serde_json::json!({
2012            "kind": "scope_attenuation_violation",
2013            "mode": "non-increasing",
2014            "parent_subject": "agent:burin",
2015            "child_subject": "agent:merge-captain",
2016            "parent_scopes": ["repo:read"],
2017            "child_scopes": ["repo:read", "repo:write"],
2018            "extra_scopes": ["repo:write"]
2019        });
2020        let record = TrustRecord::new(
2021            "agent:merge-captain",
2022            "identity.scope_attenuation",
2023            None,
2024            TrustOutcome::Denied,
2025            "trace-actor-chain-1",
2026            AutonomyTier::ActAuto,
2027        )
2028        .with_actor_chain(chain.clone())
2029        .with_actor_chain_alert(alert.clone());
2030
2031        let encoded = serde_json::to_string(&record).unwrap();
2032        let decoded: TrustRecord = serde_json::from_str(&encoded).unwrap();
2033        assert_eq!(decoded.actor_chain(), Some(chain));
2034        assert_eq!(decoded.actor_chain_alert(), Some(&alert));
2035    }
2036
2037    #[tokio::test]
2038    async fn scope_attenuation_alert_appends_denied_actor_chain_record() {
2039        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2040        let chain = crate::ActorChain::new_with_scopes("user:owner", ["repo:read"])
2041            .pushed_with_scopes("agent:writer", ["repo:read", "repo:write"]);
2042        let violation = chain
2043            .validate_scope_attenuation(&crate::ScopeAttenuationPolicy::default())
2044            .unwrap_err();
2045
2046        let record = append_scope_attenuation_alert(&log, &chain, &violation, "trace-scope-alert")
2047            .await
2048            .unwrap();
2049
2050        assert_eq!(record.agent, "agent:writer");
2051        assert_eq!(record.action, "identity.scope_attenuation");
2052        assert_eq!(record.outcome, TrustOutcome::Denied);
2053        assert_eq!(record.trace_id, "trace-scope-alert");
2054        assert_eq!(record.actor_chain(), Some(chain));
2055        assert_eq!(
2056            record
2057                .actor_chain_alert()
2058                .and_then(|value| value.get("kind"))
2059                .and_then(serde_json::Value::as_str),
2060            Some("scope_attenuation_violation")
2061        );
2062
2063        let records = query_trust_records(
2064            &log,
2065            &TrustQueryFilters {
2066                agent: Some("agent:writer".to_string()),
2067                action: Some("identity.scope_attenuation".to_string()),
2068                outcome: Some(TrustOutcome::Denied),
2069                ..TrustQueryFilters::default()
2070            },
2071        )
2072        .await
2073        .unwrap();
2074        assert_eq!(records.len(), 1);
2075        let report = verify_trust_chain(&log).await.unwrap();
2076        assert!(report.verified, "verification errors: {:?}", report.errors);
2077    }
2078
2079    #[test]
2080    fn lineage_helpers_remove_keys_on_empty_input() {
2081        let mut record = TrustRecord::new(
2082            "agent",
2083            "noop",
2084            None,
2085            TrustOutcome::Success,
2086            "trace-1",
2087            AutonomyTier::Suggest,
2088        )
2089        .with_effects_grant(vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)])
2090        .with_parent_record_id("parent-1")
2091        .with_actor_chain(ActorChain::new("user:kenneth").pushed("agent:agent"))
2092        .with_actor_chain_alert(serde_json::json!({"kind": "test_alert"}));
2093        assert!(record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
2094        assert!(record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
2095        assert!(record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN));
2096        assert!(record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN_ALERT));
2097
2098        record.set_effects_grant(Vec::new());
2099        record.set_parent_record_id(None);
2100        record.set_actor_chain(None);
2101        record.set_actor_chain_alert(None);
2102        assert!(!record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
2103        assert!(!record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
2104        assert!(!record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN));
2105        assert!(!record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN_ALERT));
2106    }
2107
2108    #[tokio::test]
2109    async fn append_attaches_current_session_actor_chain() {
2110        crate::reset_thread_local_state();
2111        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2112        let actor_chain = ActorChain::new("user:kenneth").pushed("agent:reviewer");
2113        let session_id = crate::agent_sessions::open_or_create_with_actor_chain(
2114            Some("trust-actor-session".to_string()),
2115            Some(actor_chain.clone()),
2116        );
2117        let _session = crate::agent_sessions::enter_current_session(session_id);
2118
2119        let appended = append_trust_record(
2120            &log,
2121            &TrustRecord::new(
2122                "reviewer",
2123                "fs.read",
2124                None,
2125                TrustOutcome::Success,
2126                "trace-actor-session",
2127                AutonomyTier::ActAuto,
2128            ),
2129        )
2130        .await
2131        .unwrap();
2132
2133        assert_eq!(appended.actor_chain(), Some(actor_chain));
2134    }
2135
2136    #[tokio::test]
2137    async fn three_agent_chain_proves_effects_subset_inheritance() {
2138        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2139
2140        let parent_grant = vec![
2141            EffectRecord::new(EffectKind::Net, EffectScope::Write)
2142                .with_resource("https://api.example"),
2143            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
2144            EffectRecord::new(EffectKind::Fs, EffectScope::Write).with_resource("/workspace/tmp"),
2145        ];
2146        let parent = append_trust_record(
2147            &log,
2148            &TrustRecord::new(
2149                "parent",
2150                "agent.spawn",
2151                None,
2152                TrustOutcome::Success,
2153                "trace-parent",
2154                AutonomyTier::ActAuto,
2155            )
2156            .with_effects_grant(parent_grant.clone())
2157            .with_actor_chain(ActorChain::new("user:kenneth").pushed("agent:parent")),
2158        )
2159        .await
2160        .unwrap();
2161
2162        let child_grant = vec![
2163            EffectRecord::new(EffectKind::Net, EffectScope::Write)
2164                .with_resource("https://api.example"),
2165            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
2166        ];
2167        let child = append_trust_record(
2168            &log,
2169            &TrustRecord::new(
2170                "child",
2171                "agent.spawn",
2172                None,
2173                TrustOutcome::Success,
2174                "trace-child",
2175                AutonomyTier::ActAuto,
2176            )
2177            .with_effects_grant(child_grant.clone())
2178            .with_parent_record_id(parent.record_id.clone())
2179            .with_actor_chain(
2180                ActorChain::new("user:kenneth")
2181                    .pushed("agent:parent")
2182                    .pushed("agent:child"),
2183            ),
2184        )
2185        .await
2186        .unwrap();
2187
2188        let grandchild_used =
2189            vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
2190                .with_resource("/workspace/src")];
2191        let grandchild = append_trust_record(
2192            &log,
2193            &TrustRecord::new(
2194                "grandchild",
2195                "fs.read",
2196                None,
2197                TrustOutcome::Success,
2198                "trace-grandchild",
2199                AutonomyTier::ActAuto,
2200            )
2201            .with_effects_used(grandchild_used.clone())
2202            .with_parent_record_id(child.record_id.clone())
2203            .with_actor_chain(
2204                ActorChain::new("user:kenneth")
2205                    .pushed("agent:parent")
2206                    .pushed("agent:child")
2207                    .pushed("agent:grandchild"),
2208            ),
2209        )
2210        .await
2211        .unwrap();
2212
2213        // grandchild.effects_used โІ child.effects_grant
2214        for effect in &grandchild_used {
2215            assert!(
2216                child_grant.contains(effect),
2217                "grandchild used {effect:?} not in child grant"
2218            );
2219        }
2220        // child.effects_grant โІ parent.effects_grant
2221        for effect in &child_grant {
2222            assert!(
2223                parent_grant.contains(effect),
2224                "child grant {effect:?} not in parent grant"
2225            );
2226        }
2227
2228        assert_eq!(
2229            grandchild.parent_record_id().as_deref(),
2230            Some(child.record_id.as_str())
2231        );
2232        assert_eq!(
2233            child.parent_record_id().as_deref(),
2234            Some(parent.record_id.as_str())
2235        );
2236        assert!(parent.parent_record_id().is_none());
2237
2238        // The chain still verifies cleanly (additive metadata change).
2239        let report = verify_trust_chain(&log).await.unwrap();
2240        assert!(report.verified, "verification errors: {:?}", report.errors);
2241        assert_eq!(report.total, 3);
2242    }
2243
2244    #[tokio::test]
2245    async fn trust_chain_verifies_actor_chain_parentage() {
2246        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2247        let parent_chain = ActorChain::new("user:kenneth").pushed("agent:burin");
2248        let child_chain = parent_chain.clone().pushed("agent:merge-captain");
2249
2250        let parent = append_trust_record(
2251            &log,
2252            &TrustRecord::new(
2253                "agent:burin",
2254                "agent.spawn",
2255                None,
2256                TrustOutcome::Success,
2257                "trace-parent",
2258                AutonomyTier::ActAuto,
2259            )
2260            .with_actor_chain(parent_chain),
2261        )
2262        .await
2263        .unwrap();
2264        append_trust_record(
2265            &log,
2266            &TrustRecord::new(
2267                "agent:merge-captain",
2268                "agent.spawn",
2269                None,
2270                TrustOutcome::Success,
2271                "trace-child",
2272                AutonomyTier::ActAuto,
2273            )
2274            .with_actor_chain(child_chain)
2275            .with_parent_record_id(parent.record_id),
2276        )
2277        .await
2278        .unwrap();
2279
2280        let report = verify_trust_chain(&log).await.unwrap();
2281        assert!(report.verified, "verification errors: {:?}", report.errors);
2282    }
2283
2284    #[tokio::test]
2285    async fn verify_chain_rejects_actor_chain_that_escapes_parentage() {
2286        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2287        let parent = append_trust_record(
2288            &log,
2289            &TrustRecord::new(
2290                "parent",
2291                "agent.spawn",
2292                None,
2293                TrustOutcome::Success,
2294                "trace-parent",
2295                AutonomyTier::ActAuto,
2296            )
2297            .with_actor_chain(ActorChain::new("user:kenneth").pushed("agent:parent")),
2298        )
2299        .await
2300        .unwrap();
2301        append_trust_record(
2302            &log,
2303            &TrustRecord::new(
2304                "child",
2305                "agent.spawn",
2306                None,
2307                TrustOutcome::Success,
2308                "trace-child",
2309                AutonomyTier::ActAuto,
2310            )
2311            .with_parent_record_id(parent.record_id)
2312            .with_actor_chain(
2313                ActorChain::new("user:kenneth")
2314                    .pushed("agent:other-parent")
2315                    .pushed("agent:child"),
2316            ),
2317        )
2318        .await
2319        .unwrap();
2320
2321        let report = verify_trust_chain(&log).await.unwrap();
2322        assert!(!report.verified);
2323        assert!(report
2324            .errors
2325            .iter()
2326            .any(|error| error.contains("actor_chain escaped parentage")));
2327    }
2328}