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";
17pub const OPENTRUSTGRAPH_SCHEMA_V0_1: &str = "opentrustgraph/v0.1";
25pub const OPENTRUSTGRAPH_ACCEPTED_SCHEMAS: &[&str] =
27 &[OPENTRUSTGRAPH_SCHEMA_V0_1, OPENTRUSTGRAPH_SCHEMA_V0];
28pub const OPENTRUSTGRAPH_CHAIN_SCHEMA_V0: &str = "opentrustgraph-chain/v0";
29
30pub const METADATA_KEY_EFFECTS_GRANT: &str = "effects_grant";
33pub const METADATA_KEY_EFFECTS_USED: &str = "effects_used";
36pub const METADATA_KEY_PARENT_RECORD_ID: &str = "parent_record_id";
40pub const METADATA_KEY_ACTOR_CHAIN: &str = "actor_chain";
44pub 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 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 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 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 pub fn with_actor_chain(mut self, actor_chain: ActorChain) -> Self {
274 self.set_actor_chain(Some(actor_chain));
275 self
276 }
277
278 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 pub fn actor_chain(&self) -> Option<ActorChain> {
296 self.try_actor_chain().ok().flatten()
297 }
298
299 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 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 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 for effect in &grandchild_used {
2215 assert!(
2216 child_grant.contains(effect),
2217 "grandchild used {effect:?} not in child grant"
2218 );
2219 }
2220 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 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}