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    CapabilityPolicy {
1259        side_effect_level: Some(
1260            match tier {
1261                AutonomyTier::Shadow => "none",
1262                AutonomyTier::Suggest => "read_only",
1263                AutonomyTier::ActWithApproval => "read_only",
1264                AutonomyTier::ActAuto => "network",
1265            }
1266            .to_string(),
1267        ),
1268        recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
1269        ..CapabilityPolicy::default()
1270    }
1271}
1272
1273fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
1274    let Some(limit) = limit else {
1275        return;
1276    };
1277    if records.len() <= limit {
1278        return;
1279    }
1280    let keep_from = records.len() - limit;
1281    records.drain(0..keep_from);
1282}
1283
1284fn apply_graph_record_limit(records: &mut Vec<TrustGraphRecord>, limit: Option<usize>) {
1285    let Some(limit) = limit else {
1286        return;
1287    };
1288    if records.len() <= limit {
1289        return;
1290    }
1291    let keep_from = records.len() - limit;
1292    records.drain(0..keep_from);
1293}
1294
1295fn is_control_plane_action(action: &str) -> bool {
1296    matches!(
1297        action,
1298        "trust.promote" | "trust.demote" | "autonomy.tier_transition"
1299    )
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304    use super::*;
1305    use crate::event_log::MemoryEventLog;
1306    use time::Duration;
1307
1308    const RECORD_SCHEMA_JSON: &str =
1309        include_str!("trust_graph/schemas/trust-record.v0.schema.json");
1310    const RECORD_SCHEMA_V0_1_JSON: &str =
1311        include_str!("trust_graph/schemas/trust-record.v0.1.schema.json");
1312    const CHAIN_SCHEMA_JSON: &str = include_str!("trust_graph/schemas/trust-chain.v0.schema.json");
1313    const VALID_DECISION_CHAIN_JSON: &str =
1314        include_str!("trust_graph/fixtures/valid/decision-chain.json");
1315    const VALID_TIER_TRANSITION_JSON: &str =
1316        include_str!("trust_graph/fixtures/valid/tier-transition.json");
1317    const VALID_EFFECT_INHERITANCE_CHAIN_JSON: &str =
1318        include_str!("trust_graph/fixtures/valid/effect-inheritance-chain.json");
1319    const INVALID_TAMPERED_CHAIN_JSON: &str =
1320        include_str!("trust_graph/fixtures/invalid/tampered-chain.json");
1321    const INVALID_MISSING_APPROVAL_JSON: &str =
1322        include_str!("trust_graph/fixtures/invalid/missing-approval.json");
1323    const INVALID_ACTOR_CHAIN_PARENTAGE_JSON: &str =
1324        include_str!("trust_graph/fixtures/invalid/actor-chain-parentage.json");
1325
1326    #[derive(Debug, serde::Deserialize)]
1327    struct TrustChainFixture {
1328        schema: String,
1329        chain: TrustChainFixtureMetadata,
1330        records: Vec<TrustRecord>,
1331    }
1332
1333    #[derive(Debug, serde::Deserialize)]
1334    struct TrustChainFixtureMetadata {
1335        topic: String,
1336        total: u64,
1337        root_hash: Option<String>,
1338        verified: bool,
1339        generated_at: String,
1340        producer: BTreeMap<String, serde_json::Value>,
1341    }
1342
1343    #[test]
1344    fn embedded_trust_graph_fixtures_match_workspace_spec_when_available() {
1345        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1346        let spec_dir = manifest_dir.join("../../opentrustgraph-spec");
1347        if !spec_dir.exists() {
1348            return;
1349        }
1350
1351        for (relative, embedded) in [
1352            ("schemas/trust-record.v0.schema.json", RECORD_SCHEMA_JSON),
1353            (
1354                "schemas/trust-record.v0.1.schema.json",
1355                RECORD_SCHEMA_V0_1_JSON,
1356            ),
1357            ("schemas/trust-chain.v0.schema.json", CHAIN_SCHEMA_JSON),
1358            (
1359                "fixtures/valid/decision-chain.json",
1360                VALID_DECISION_CHAIN_JSON,
1361            ),
1362            (
1363                "fixtures/valid/tier-transition.json",
1364                VALID_TIER_TRANSITION_JSON,
1365            ),
1366            (
1367                "fixtures/valid/effect-inheritance-chain.json",
1368                VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1369            ),
1370            (
1371                "fixtures/invalid/tampered-chain.json",
1372                INVALID_TAMPERED_CHAIN_JSON,
1373            ),
1374            (
1375                "fixtures/invalid/missing-approval.json",
1376                INVALID_MISSING_APPROVAL_JSON,
1377            ),
1378            (
1379                "fixtures/invalid/actor-chain-parentage.json",
1380                INVALID_ACTOR_CHAIN_PARENTAGE_JSON,
1381            ),
1382        ] {
1383            let source = std::fs::read_to_string(spec_dir.join(relative)).unwrap_or_else(|e| {
1384                panic!("failed to read opentrustgraph fixture {relative}: {e}")
1385            });
1386            assert_eq!(
1387                embedded, source,
1388                "embedded trust graph fixture {relative} drifted from opentrustgraph-spec"
1389            );
1390        }
1391    }
1392
1393    #[tokio::test]
1394    async fn append_and_query_round_trip() {
1395        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1396        let mut record = TrustRecord::new(
1397            "github-triage-bot",
1398            "github.issue.opened",
1399            Some("reviewer".to_string()),
1400            TrustOutcome::Success,
1401            "trace-1",
1402            AutonomyTier::ActWithApproval,
1403        );
1404        record.cost_usd = Some(1.25);
1405        append_trust_record(&log, &record).await.unwrap();
1406
1407        let records = query_trust_records(
1408            &log,
1409            &TrustQueryFilters {
1410                agent: Some("github-triage-bot".to_string()),
1411                ..TrustQueryFilters::default()
1412            },
1413        )
1414        .await
1415        .unwrap();
1416
1417        assert_eq!(records.len(), 1);
1418        assert_eq!(records[0].agent, "github-triage-bot");
1419        assert_eq!(records[0].cost_usd, Some(1.25));
1420        assert_eq!(records[0].chain_index, 1);
1421        assert!(records[0].previous_hash.is_none());
1422        assert!(records[0].entry_hash.starts_with("sha256:"));
1423    }
1424
1425    #[tokio::test]
1426    async fn verify_chain_detects_hash_tampering() {
1427        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1428        let first = append_trust_record(
1429            &log,
1430            &TrustRecord::new(
1431                "bot",
1432                "first",
1433                None,
1434                TrustOutcome::Success,
1435                "trace-1",
1436                AutonomyTier::Suggest,
1437            ),
1438        )
1439        .await
1440        .unwrap();
1441        let mut second = append_trust_record(
1442            &log,
1443            &TrustRecord::new(
1444                "bot",
1445                "second",
1446                None,
1447                TrustOutcome::Success,
1448                "trace-2",
1449                AutonomyTier::Suggest,
1450            ),
1451        )
1452        .await
1453        .unwrap();
1454
1455        let report = verify_trust_chain(&log).await.unwrap();
1456        assert!(report.verified);
1457        assert_eq!(
1458            report.root_hash.as_deref(),
1459            Some(second.entry_hash.as_str())
1460        );
1461        assert_eq!(
1462            second.previous_hash.as_deref(),
1463            Some(first.entry_hash.as_str())
1464        );
1465
1466        second.previous_hash = Some(
1467            "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1468        );
1469        second.entry_hash =
1470            "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1471        log.append(
1472            &global_topic().unwrap(),
1473            LogEvent::new(
1474                TRUST_GRAPH_EVENT_KIND,
1475                serde_json::to_value(second).unwrap(),
1476            ),
1477        )
1478        .await
1479        .unwrap();
1480        let report = verify_trust_chain(&log).await.unwrap();
1481        assert!(!report.verified);
1482        assert!(report
1483            .errors
1484            .iter()
1485            .any(|error| error.contains("previous_hash mismatch")));
1486    }
1487
1488    #[tokio::test]
1489    async fn export_trust_chain_emits_envelope_matching_chain_schema() {
1490        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1491        let first = append_trust_record(
1492            &log,
1493            &TrustRecord::new(
1494                "bot",
1495                "github.issue.opened",
1496                None,
1497                TrustOutcome::Success,
1498                "trace-1",
1499                AutonomyTier::Suggest,
1500            ),
1501        )
1502        .await
1503        .unwrap();
1504        let second = append_trust_record(
1505            &log,
1506            &TrustRecord::new(
1507                "bot",
1508                "trust.promote",
1509                Some("maintainer-1".to_string()),
1510                TrustOutcome::Success,
1511                "trace-2",
1512                AutonomyTier::ActAuto,
1513            ),
1514        )
1515        .await
1516        .unwrap();
1517
1518        let export = export_trust_chain(&log).await.unwrap();
1519        assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1520        assert_eq!(export.chain.topic, TRUST_GRAPH_GLOBAL_TOPIC);
1521        assert_eq!(export.chain.total, 2);
1522        assert!(export.chain.verified);
1523        assert_eq!(
1524            export.chain.root_hash.as_deref(),
1525            Some(second.entry_hash.as_str())
1526        );
1527        assert_eq!(export.records.len(), 2);
1528        assert_eq!(export.records[0].entry_hash, first.entry_hash);
1529        assert_eq!(export.records[1].entry_hash, second.entry_hash);
1530        assert_eq!(export.chain.producer.name, "harn");
1531
1532        let envelope_json = serde_json::to_value(&export).unwrap();
1533        assert_eq!(envelope_json["schema"], OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1534        assert_eq!(envelope_json["chain"]["total"], 2);
1535        assert_eq!(envelope_json["chain"]["verified"], true);
1536        assert!(envelope_json["records"].as_array().unwrap().len() == 2);
1537    }
1538
1539    #[tokio::test]
1540    async fn export_trust_chain_handles_empty_log() {
1541        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1542        let export = export_trust_chain(&log).await.unwrap();
1543        assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1544        assert_eq!(export.chain.total, 0);
1545        assert!(export.chain.verified);
1546        assert!(export.chain.root_hash.is_none());
1547        assert!(export.records.is_empty());
1548    }
1549
1550    #[tokio::test]
1551    async fn resolve_autonomy_tier_prefers_latest_control_record() {
1552        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1553        append_trust_record(
1554            &log,
1555            &TrustRecord::new(
1556                "bot",
1557                "trust.promote",
1558                None,
1559                TrustOutcome::Success,
1560                "trace-1",
1561                AutonomyTier::ActWithApproval,
1562            ),
1563        )
1564        .await
1565        .unwrap();
1566        append_trust_record(
1567            &log,
1568            &TrustRecord::new(
1569                "bot",
1570                "trust.demote",
1571                None,
1572                TrustOutcome::Success,
1573                "trace-2",
1574                AutonomyTier::Shadow,
1575            ),
1576        )
1577        .await
1578        .unwrap();
1579
1580        let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
1581            .await
1582            .unwrap();
1583        assert_eq!(tier, AutonomyTier::Shadow);
1584    }
1585
1586    #[tokio::test]
1587    async fn query_limit_keeps_newest_matching_records() {
1588        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1589        let base = OffsetDateTime::from_unix_timestamp(1_775_000_000).unwrap();
1590        for (offset, action) in ["first", "second", "third"].into_iter().enumerate() {
1591            let mut record = TrustRecord::new(
1592                "bot",
1593                action,
1594                None,
1595                TrustOutcome::Success,
1596                format!("trace-{action}"),
1597                AutonomyTier::ActAuto,
1598            );
1599            record.timestamp = base + Duration::seconds(offset as i64);
1600            append_trust_record(&log, &record).await.unwrap();
1601        }
1602
1603        let records = query_trust_records(
1604            &log,
1605            &TrustQueryFilters {
1606                agent: Some("bot".to_string()),
1607                limit: Some(2),
1608                ..TrustQueryFilters::default()
1609            },
1610        )
1611        .await
1612        .unwrap();
1613
1614        assert_eq!(records.len(), 2);
1615        assert_eq!(records[0].action, "second");
1616        assert_eq!(records[1].action, "third");
1617    }
1618
1619    #[test]
1620    fn group_by_trace_preserves_chronological_group_order() {
1621        let make_record = |trace_id: &str, action: &str| TrustRecord {
1622            trace_id: trace_id.to_string(),
1623            action: action.to_string(),
1624            ..TrustRecord::new(
1625                "bot",
1626                action,
1627                None,
1628                TrustOutcome::Success,
1629                trace_id,
1630                AutonomyTier::ActAuto,
1631            )
1632        };
1633        let grouped = group_trust_records_by_trace(&[
1634            make_record("trace-1", "first"),
1635            make_record("trace-2", "second"),
1636            make_record("trace-1", "third"),
1637        ]);
1638
1639        assert_eq!(grouped.len(), 2);
1640        assert_eq!(grouped[0].trace_id, "trace-1");
1641        assert_eq!(grouped[0].records.len(), 2);
1642        assert_eq!(grouped[0].records[1].action, "third");
1643        assert_eq!(grouped[1].trace_id, "trace-2");
1644    }
1645
1646    #[test]
1647    fn opentrustgraph_schema_files_are_parseable_and_match_runtime_enums() {
1648        let record_schema: serde_json::Value = serde_json::from_str(RECORD_SCHEMA_JSON).unwrap();
1649        let record_schema_v0_1: serde_json::Value =
1650            serde_json::from_str(RECORD_SCHEMA_V0_1_JSON).unwrap();
1651        let chain_schema: serde_json::Value = serde_json::from_str(CHAIN_SCHEMA_JSON).unwrap();
1652
1653        assert_eq!(
1654            record_schema["properties"]["schema"]["const"],
1655            serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)
1656        );
1657        let v0_1_schema_enum = record_schema_v0_1["properties"]["schema"]["enum"]
1658            .as_array()
1659            .expect("v0.1 record schema declares schema as an enum");
1660        assert!(
1661            v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0_1)),
1662            "v0.1 record schema must accept {OPENTRUSTGRAPH_SCHEMA_V0_1}: {v0_1_schema_enum:?}"
1663        );
1664        assert!(
1665            v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)),
1666            "v0.1 record schema must still accept v0 (one-release back-compat): {v0_1_schema_enum:?}"
1667        );
1668        assert_eq!(
1669            chain_schema["properties"]["schema"]["const"],
1670            serde_json::json!("opentrustgraph-chain/v0")
1671        );
1672
1673        let outcomes = record_schema["properties"]["outcome"]["enum"]
1674            .as_array()
1675            .unwrap();
1676        for outcome in [
1677            TrustOutcome::Success,
1678            TrustOutcome::Failure,
1679            TrustOutcome::Denied,
1680            TrustOutcome::Timeout,
1681        ] {
1682            assert!(outcomes.contains(&serde_json::json!(outcome.as_str())));
1683        }
1684
1685        let tiers = record_schema["properties"]["autonomy_tier"]["enum"]
1686            .as_array()
1687            .unwrap();
1688        for tier in [
1689            AutonomyTier::Shadow,
1690            AutonomyTier::Suggest,
1691            AutonomyTier::ActWithApproval,
1692            AutonomyTier::ActAuto,
1693        ] {
1694            assert!(tiers.contains(&serde_json::json!(tier.as_str())));
1695        }
1696    }
1697
1698    #[test]
1699    fn opentrustgraph_valid_fixtures_match_runtime_contract() {
1700        for (name, fixture) in [
1701            ("decision-chain", VALID_DECISION_CHAIN_JSON),
1702            ("tier-transition", VALID_TIER_TRANSITION_JSON),
1703            (
1704                "effect-inheritance-chain",
1705                VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1706            ),
1707        ] {
1708            let fixture = parse_chain_fixture(fixture);
1709            let errors = validate_chain_fixture(&fixture);
1710            assert!(errors.is_empty(), "{name} errors: {errors:?}");
1711        }
1712    }
1713
1714    #[test]
1715    fn opentrustgraph_invalid_fixtures_exercise_expected_failures() {
1716        let tampered = parse_chain_fixture(INVALID_TAMPERED_CHAIN_JSON);
1717        let tampered_errors = validate_chain_fixture(&tampered);
1718        assert!(
1719            tampered_errors
1720                .iter()
1721                .any(|error| error.contains("previous_hash mismatch")),
1722            "tampered-chain errors: {tampered_errors:?}"
1723        );
1724        assert!(
1725            !tampered_errors
1726                .iter()
1727                .any(|error| error.contains("entry_hash mismatch")),
1728            "tampered-chain should isolate hash-link tampering: {tampered_errors:?}"
1729        );
1730
1731        let missing_approval = parse_chain_fixture(INVALID_MISSING_APPROVAL_JSON);
1732        let missing_errors = validate_chain_fixture(&missing_approval);
1733        assert!(
1734            missing_errors
1735                .iter()
1736                .any(|error| error.contains("approval required")),
1737            "missing-approval errors: {missing_errors:?}"
1738        );
1739
1740        let actor_parentage = parse_chain_fixture(INVALID_ACTOR_CHAIN_PARENTAGE_JSON);
1741        let actor_errors = validate_chain_fixture(&actor_parentage);
1742        assert!(
1743            actor_errors
1744                .iter()
1745                .any(|error| error.contains("actor_chain escaped parentage")),
1746            "actor-chain-parentage errors: {actor_errors:?}"
1747        );
1748    }
1749
1750    fn parse_chain_fixture(input: &str) -> TrustChainFixture {
1751        serde_json::from_str(input).unwrap()
1752    }
1753
1754    fn validate_chain_fixture(fixture: &TrustChainFixture) -> Vec<String> {
1755        let mut errors = Vec::new();
1756        if fixture.schema != OPENTRUSTGRAPH_CHAIN_SCHEMA_V0 {
1757            errors.push(format!("unsupported chain schema {}", fixture.schema));
1758        }
1759        if fixture.chain.topic.trim().is_empty() {
1760            errors.push("chain topic is empty".to_string());
1761        }
1762        if fixture.chain.total != fixture.records.len() as u64 {
1763            errors.push(format!(
1764                "chain total mismatch; expected {}, found {}",
1765                fixture.records.len(),
1766                fixture.chain.total
1767            ));
1768        }
1769        if fixture
1770            .chain
1771            .producer
1772            .get("name")
1773            .and_then(|value| value.as_str())
1774            .unwrap_or_default()
1775            .trim()
1776            .is_empty()
1777        {
1778            errors.push("chain producer.name is empty".to_string());
1779        }
1780        if OffsetDateTime::parse(
1781            &fixture.chain.generated_at,
1782            &time::format_description::well_known::Rfc3339,
1783        )
1784        .is_err()
1785        {
1786            errors.push("chain generated_at is not RFC3339".to_string());
1787        }
1788
1789        for (index, record) in fixture.records.iter().enumerate() {
1790            errors.extend(validate_fixture_record_contract(index, record));
1791        }
1792        errors.extend(validate_fixture_hash_chain(fixture));
1793        errors.extend(
1794            validate_lineage_invariants(
1795                fixture
1796                    .records
1797                    .iter()
1798                    .enumerate()
1799                    .map(|(index, record)| (format!("record {index}"), None, record)),
1800            )
1801            .into_iter()
1802            .map(|error| error.message),
1803        );
1804
1805        let expected_verified = errors.is_empty();
1806        if fixture.chain.verified != expected_verified {
1807            errors.push(format!(
1808                "chain verified flag mismatch; expected {expected_verified}, found {}",
1809                fixture.chain.verified
1810            ));
1811        }
1812        errors
1813    }
1814
1815    fn validate_fixture_record_contract(index: usize, record: &TrustRecord) -> Vec<String> {
1816        let mut errors = Vec::new();
1817        let label = format!("record {index}");
1818        if !OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&record.schema.as_str()) {
1819            errors.push(format!("{label}: unsupported schema {}", record.schema));
1820        }
1821        if record.record_id.trim().is_empty() {
1822            errors.push(format!("{label}: record_id is empty"));
1823        }
1824        if record.agent.trim().is_empty() {
1825            errors.push(format!("{label}: agent is empty"));
1826        }
1827        if record.action.trim().is_empty() {
1828            errors.push(format!("{label}: action is empty"));
1829        }
1830        if record.trace_id.trim().is_empty() {
1831            errors.push(format!("{label}: trace_id is empty"));
1832        }
1833        if !record.entry_hash.starts_with("sha256:") {
1834            errors.push(format!("{label}: entry_hash is not sha256-prefixed"));
1835        }
1836        if let Some(cost_usd) = record.cost_usd {
1837            if cost_usd < 0.0 {
1838                errors.push(format!("{label}: cost_usd is negative"));
1839            }
1840        }
1841
1842        if record.outcome == TrustOutcome::Success
1843            && record.autonomy_tier == AutonomyTier::ActWithApproval
1844            && approval_required(record)
1845        {
1846            if record
1847                .approver
1848                .as_deref()
1849                .unwrap_or_default()
1850                .trim()
1851                .is_empty()
1852            {
1853                errors.push(format!("{label}: approval required but approver is empty"));
1854            }
1855            if approval_signature_count(record) == 0 {
1856                errors.push(format!(
1857                    "{label}: approval required but signatures are empty"
1858                ));
1859            }
1860        }
1861
1862        errors
1863    }
1864
1865    fn validate_fixture_hash_chain(fixture: &TrustChainFixture) -> Vec<String> {
1866        let mut errors = Vec::new();
1867        let mut previous_hash: Option<String> = None;
1868
1869        for (position, record) in fixture.records.iter().enumerate() {
1870            let expected_index = position as u64 + 1;
1871            if record.chain_index != expected_index {
1872                errors.push(format!(
1873                    "record {position}: expected chain_index {expected_index}, found {}",
1874                    record.chain_index
1875                ));
1876            }
1877            if record.previous_hash != previous_hash {
1878                errors.push(format!(
1879                    "record {position}: previous_hash mismatch; expected {:?}, found {:?}",
1880                    previous_hash, record.previous_hash
1881                ));
1882            }
1883            let expected_hash = compute_trust_record_hash(record).unwrap();
1884            if expected_hash != record.entry_hash {
1885                errors.push(format!(
1886                    "record {position}: entry_hash mismatch; expected {expected_hash}, found {}",
1887                    record.entry_hash
1888                ));
1889            }
1890            previous_hash = Some(record.entry_hash.clone());
1891        }
1892
1893        if fixture.chain.root_hash != previous_hash {
1894            errors.push(format!(
1895                "chain root_hash mismatch; expected {:?}, found {:?}",
1896                previous_hash, fixture.chain.root_hash
1897            ));
1898        }
1899        errors
1900    }
1901
1902    fn approval_required(record: &TrustRecord) -> bool {
1903        record
1904            .metadata
1905            .get("approval")
1906            .and_then(|approval| approval.get("required"))
1907            .and_then(|required| required.as_bool())
1908            .unwrap_or(false)
1909    }
1910
1911    fn approval_signature_count(record: &TrustRecord) -> usize {
1912        record
1913            .metadata
1914            .get("approval")
1915            .and_then(|approval| approval.get("signatures"))
1916            .and_then(|signatures| signatures.as_array())
1917            .map(Vec::len)
1918            .unwrap_or(0)
1919    }
1920
1921    // ----- OpenTrustGraph v0.1 schema and lineage metadata -----
1922
1923    use crate::orchestration::{EffectKind, EffectScope};
1924
1925    #[test]
1926    fn new_trust_record_defaults_to_v0_1_schema() {
1927        let record = TrustRecord::new(
1928            "agent",
1929            "deploy.preview",
1930            None,
1931            TrustOutcome::Success,
1932            "trace-1",
1933            AutonomyTier::Suggest,
1934        );
1935        assert_eq!(record.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1936    }
1937
1938    #[test]
1939    fn v0_records_still_parse_for_backward_compat() {
1940        let record_v0 = serde_json::json!({
1941            "schema": "opentrustgraph/v0",
1942            "record_id": "01966f4c-0f31-7b5d-b44b-f7f8e7e1d384",
1943            "agent": "legacy-bot",
1944            "action": "github.issue.opened",
1945            "approver": null,
1946            "outcome": "success",
1947            "trace_id": "trace-legacy",
1948            "autonomy_tier": "suggest",
1949            "timestamp": "2026-04-19T18:42:11Z",
1950            "cost_usd": null,
1951            "chain_index": 1,
1952            "previous_hash": null,
1953            "entry_hash": "sha256:84facae7d56fd304e040ea18d80bd019e274ad86ddd5a4d732f3ac3d984c48ec",
1954            "metadata": {"provider": "github"}
1955        });
1956        let decoded: TrustRecord = serde_json::from_value(record_v0).unwrap();
1957        assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0);
1958        assert!(OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&decoded.schema.as_str()));
1959        assert!(decoded.effects_grant().is_empty());
1960        assert!(decoded.effects_used().is_empty());
1961        assert!(decoded.parent_record_id().is_none());
1962        assert!(decoded.actor_chain().is_none());
1963    }
1964
1965    #[test]
1966    fn v0_1_lineage_metadata_round_trips_through_json() {
1967        let grant = vec![
1968            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1969                .with_resource("https://api.example"),
1970            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1971        ];
1972        let used =
1973            vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
1974                .with_resource("/workspace/src")];
1975        let actor_chain = ActorChain::new("user:kenneth")
1976            .pushed("agent:parent")
1977            .pushed("agent:child");
1978        let record = TrustRecord::new(
1979            "child-agent",
1980            "fs.read",
1981            None,
1982            TrustOutcome::Success,
1983            "trace-effects-1",
1984            AutonomyTier::ActAuto,
1985        )
1986        .with_effects_grant(grant.clone())
1987        .with_effects_used(used.clone())
1988        .with_parent_record_id("parent-record-001")
1989        .with_actor_chain(actor_chain.clone());
1990
1991        let encoded = serde_json::to_string(&record).unwrap();
1992        let decoded: TrustRecord = serde_json::from_str(&encoded).unwrap();
1993        assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1994        assert_eq!(decoded.effects_grant(), grant);
1995        assert_eq!(decoded.effects_used(), used);
1996        assert_eq!(
1997            decoded.parent_record_id().as_deref(),
1998            Some("parent-record-001")
1999        );
2000        assert_eq!(decoded.actor_chain(), Some(actor_chain));
2001    }
2002
2003    #[test]
2004    fn v0_1_actor_chain_metadata_round_trips_through_json() {
2005        let chain = crate::ActorChain::new_with_scopes("user:kenneth", ["repo:read", "repo:write"])
2006            .pushed_with_scopes("agent:burin", ["repo:read"])
2007            .pushed_with_scopes("agent:merge-captain", ["repo:read"]);
2008        let alert = serde_json::json!({
2009            "kind": "scope_attenuation_violation",
2010            "mode": "non-increasing",
2011            "parent_subject": "agent:burin",
2012            "child_subject": "agent:merge-captain",
2013            "parent_scopes": ["repo:read"],
2014            "child_scopes": ["repo:read", "repo:write"],
2015            "extra_scopes": ["repo:write"]
2016        });
2017        let record = TrustRecord::new(
2018            "agent:merge-captain",
2019            "identity.scope_attenuation",
2020            None,
2021            TrustOutcome::Denied,
2022            "trace-actor-chain-1",
2023            AutonomyTier::ActAuto,
2024        )
2025        .with_actor_chain(chain.clone())
2026        .with_actor_chain_alert(alert.clone());
2027
2028        let encoded = serde_json::to_string(&record).unwrap();
2029        let decoded: TrustRecord = serde_json::from_str(&encoded).unwrap();
2030        assert_eq!(decoded.actor_chain(), Some(chain));
2031        assert_eq!(decoded.actor_chain_alert(), Some(&alert));
2032    }
2033
2034    #[tokio::test]
2035    async fn scope_attenuation_alert_appends_denied_actor_chain_record() {
2036        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2037        let chain = crate::ActorChain::new_with_scopes("user:owner", ["repo:read"])
2038            .pushed_with_scopes("agent:writer", ["repo:read", "repo:write"]);
2039        let violation = chain
2040            .validate_scope_attenuation(&crate::ScopeAttenuationPolicy::default())
2041            .unwrap_err();
2042
2043        let record = append_scope_attenuation_alert(&log, &chain, &violation, "trace-scope-alert")
2044            .await
2045            .unwrap();
2046
2047        assert_eq!(record.agent, "agent:writer");
2048        assert_eq!(record.action, "identity.scope_attenuation");
2049        assert_eq!(record.outcome, TrustOutcome::Denied);
2050        assert_eq!(record.trace_id, "trace-scope-alert");
2051        assert_eq!(record.actor_chain(), Some(chain));
2052        assert_eq!(
2053            record
2054                .actor_chain_alert()
2055                .and_then(|value| value.get("kind"))
2056                .and_then(serde_json::Value::as_str),
2057            Some("scope_attenuation_violation")
2058        );
2059
2060        let records = query_trust_records(
2061            &log,
2062            &TrustQueryFilters {
2063                agent: Some("agent:writer".to_string()),
2064                action: Some("identity.scope_attenuation".to_string()),
2065                outcome: Some(TrustOutcome::Denied),
2066                ..TrustQueryFilters::default()
2067            },
2068        )
2069        .await
2070        .unwrap();
2071        assert_eq!(records.len(), 1);
2072        let report = verify_trust_chain(&log).await.unwrap();
2073        assert!(report.verified, "verification errors: {:?}", report.errors);
2074    }
2075
2076    #[test]
2077    fn lineage_helpers_remove_keys_on_empty_input() {
2078        let mut record = TrustRecord::new(
2079            "agent",
2080            "noop",
2081            None,
2082            TrustOutcome::Success,
2083            "trace-1",
2084            AutonomyTier::Suggest,
2085        )
2086        .with_effects_grant(vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)])
2087        .with_parent_record_id("parent-1")
2088        .with_actor_chain(ActorChain::new("user:kenneth").pushed("agent:agent"))
2089        .with_actor_chain_alert(serde_json::json!({"kind": "test_alert"}));
2090        assert!(record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
2091        assert!(record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
2092        assert!(record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN));
2093        assert!(record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN_ALERT));
2094
2095        record.set_effects_grant(Vec::new());
2096        record.set_parent_record_id(None);
2097        record.set_actor_chain(None);
2098        record.set_actor_chain_alert(None);
2099        assert!(!record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
2100        assert!(!record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
2101        assert!(!record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN));
2102        assert!(!record.metadata.contains_key(METADATA_KEY_ACTOR_CHAIN_ALERT));
2103    }
2104
2105    #[tokio::test]
2106    async fn append_attaches_current_session_actor_chain() {
2107        crate::reset_thread_local_state();
2108        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2109        let actor_chain = ActorChain::new("user:kenneth").pushed("agent:reviewer");
2110        let session_id = crate::agent_sessions::open_or_create_with_actor_chain(
2111            Some("trust-actor-session".to_string()),
2112            Some(actor_chain.clone()),
2113        );
2114        let _session = crate::agent_sessions::enter_current_session(session_id);
2115
2116        let appended = append_trust_record(
2117            &log,
2118            &TrustRecord::new(
2119                "reviewer",
2120                "fs.read",
2121                None,
2122                TrustOutcome::Success,
2123                "trace-actor-session",
2124                AutonomyTier::ActAuto,
2125            ),
2126        )
2127        .await
2128        .unwrap();
2129
2130        assert_eq!(appended.actor_chain(), Some(actor_chain));
2131    }
2132
2133    #[tokio::test]
2134    async fn three_agent_chain_proves_effects_subset_inheritance() {
2135        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2136
2137        let parent_grant = vec![
2138            EffectRecord::new(EffectKind::Net, EffectScope::Write)
2139                .with_resource("https://api.example"),
2140            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
2141            EffectRecord::new(EffectKind::Fs, EffectScope::Write).with_resource("/workspace/tmp"),
2142        ];
2143        let parent = append_trust_record(
2144            &log,
2145            &TrustRecord::new(
2146                "parent",
2147                "agent.spawn",
2148                None,
2149                TrustOutcome::Success,
2150                "trace-parent",
2151                AutonomyTier::ActAuto,
2152            )
2153            .with_effects_grant(parent_grant.clone())
2154            .with_actor_chain(ActorChain::new("user:kenneth").pushed("agent:parent")),
2155        )
2156        .await
2157        .unwrap();
2158
2159        let child_grant = vec![
2160            EffectRecord::new(EffectKind::Net, EffectScope::Write)
2161                .with_resource("https://api.example"),
2162            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
2163        ];
2164        let child = append_trust_record(
2165            &log,
2166            &TrustRecord::new(
2167                "child",
2168                "agent.spawn",
2169                None,
2170                TrustOutcome::Success,
2171                "trace-child",
2172                AutonomyTier::ActAuto,
2173            )
2174            .with_effects_grant(child_grant.clone())
2175            .with_parent_record_id(parent.record_id.clone())
2176            .with_actor_chain(
2177                ActorChain::new("user:kenneth")
2178                    .pushed("agent:parent")
2179                    .pushed("agent:child"),
2180            ),
2181        )
2182        .await
2183        .unwrap();
2184
2185        let grandchild_used =
2186            vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
2187                .with_resource("/workspace/src")];
2188        let grandchild = append_trust_record(
2189            &log,
2190            &TrustRecord::new(
2191                "grandchild",
2192                "fs.read",
2193                None,
2194                TrustOutcome::Success,
2195                "trace-grandchild",
2196                AutonomyTier::ActAuto,
2197            )
2198            .with_effects_used(grandchild_used.clone())
2199            .with_parent_record_id(child.record_id.clone())
2200            .with_actor_chain(
2201                ActorChain::new("user:kenneth")
2202                    .pushed("agent:parent")
2203                    .pushed("agent:child")
2204                    .pushed("agent:grandchild"),
2205            ),
2206        )
2207        .await
2208        .unwrap();
2209
2210        // grandchild.effects_used โІ child.effects_grant
2211        for effect in &grandchild_used {
2212            assert!(
2213                child_grant.contains(effect),
2214                "grandchild used {effect:?} not in child grant"
2215            );
2216        }
2217        // child.effects_grant โІ parent.effects_grant
2218        for effect in &child_grant {
2219            assert!(
2220                parent_grant.contains(effect),
2221                "child grant {effect:?} not in parent grant"
2222            );
2223        }
2224
2225        assert_eq!(
2226            grandchild.parent_record_id().as_deref(),
2227            Some(child.record_id.as_str())
2228        );
2229        assert_eq!(
2230            child.parent_record_id().as_deref(),
2231            Some(parent.record_id.as_str())
2232        );
2233        assert!(parent.parent_record_id().is_none());
2234
2235        // The chain still verifies cleanly (additive metadata change).
2236        let report = verify_trust_chain(&log).await.unwrap();
2237        assert!(report.verified, "verification errors: {:?}", report.errors);
2238        assert_eq!(report.total, 3);
2239    }
2240
2241    #[tokio::test]
2242    async fn trust_chain_verifies_actor_chain_parentage() {
2243        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2244        let parent_chain = ActorChain::new("user:kenneth").pushed("agent:burin");
2245        let child_chain = parent_chain.clone().pushed("agent:merge-captain");
2246
2247        let parent = append_trust_record(
2248            &log,
2249            &TrustRecord::new(
2250                "agent:burin",
2251                "agent.spawn",
2252                None,
2253                TrustOutcome::Success,
2254                "trace-parent",
2255                AutonomyTier::ActAuto,
2256            )
2257            .with_actor_chain(parent_chain),
2258        )
2259        .await
2260        .unwrap();
2261        append_trust_record(
2262            &log,
2263            &TrustRecord::new(
2264                "agent:merge-captain",
2265                "agent.spawn",
2266                None,
2267                TrustOutcome::Success,
2268                "trace-child",
2269                AutonomyTier::ActAuto,
2270            )
2271            .with_actor_chain(child_chain)
2272            .with_parent_record_id(parent.record_id),
2273        )
2274        .await
2275        .unwrap();
2276
2277        let report = verify_trust_chain(&log).await.unwrap();
2278        assert!(report.verified, "verification errors: {:?}", report.errors);
2279    }
2280
2281    #[tokio::test]
2282    async fn verify_chain_rejects_actor_chain_that_escapes_parentage() {
2283        let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
2284        let parent = append_trust_record(
2285            &log,
2286            &TrustRecord::new(
2287                "parent",
2288                "agent.spawn",
2289                None,
2290                TrustOutcome::Success,
2291                "trace-parent",
2292                AutonomyTier::ActAuto,
2293            )
2294            .with_actor_chain(ActorChain::new("user:kenneth").pushed("agent:parent")),
2295        )
2296        .await
2297        .unwrap();
2298        append_trust_record(
2299            &log,
2300            &TrustRecord::new(
2301                "child",
2302                "agent.spawn",
2303                None,
2304                TrustOutcome::Success,
2305                "trace-child",
2306                AutonomyTier::ActAuto,
2307            )
2308            .with_parent_record_id(parent.record_id)
2309            .with_actor_chain(
2310                ActorChain::new("user:kenneth")
2311                    .pushed("agent:other-parent")
2312                    .pushed("agent:child"),
2313            ),
2314        )
2315        .await
2316        .unwrap();
2317
2318        let report = verify_trust_chain(&log).await.unwrap();
2319        assert!(!report.verified);
2320        assert!(report
2321            .errors
2322            .iter()
2323            .any(|error| error.contains("actor_chain escaped parentage")));
2324    }
2325}