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 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 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 for effect in &grandchild_used {
2212 assert!(
2213 child_grant.contains(effect),
2214 "grandchild used {effect:?} not in child grant"
2215 );
2216 }
2217 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 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}