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::event_log::{
10 active_event_log, sanitize_topic_component, AnyEventLog, EventId, EventLog, LogError, LogEvent,
11 Topic,
12};
13use crate::orchestration::{CapabilityPolicy, EffectRecord};
14
15pub const OPENTRUSTGRAPH_SCHEMA_V0: &str = "opentrustgraph/v0";
16pub const OPENTRUSTGRAPH_SCHEMA_V0_1: &str = "opentrustgraph/v0.1";
25pub const OPENTRUSTGRAPH_ACCEPTED_SCHEMAS: &[&str] =
28 &[OPENTRUSTGRAPH_SCHEMA_V0_1, OPENTRUSTGRAPH_SCHEMA_V0];
29pub const OPENTRUSTGRAPH_CHAIN_SCHEMA_V0: &str = "opentrustgraph-chain/v0";
30
31pub const METADATA_KEY_EFFECTS_GRANT: &str = "effects_grant";
34pub const METADATA_KEY_EFFECTS_USED: &str = "effects_used";
38pub const METADATA_KEY_PARENT_RECORD_ID: &str = "parent_record_id";
42pub const TRUST_GRAPH_RECORDS_TOPIC: &str = "trust_graph.records";
43pub const TRUST_GRAPH_GLOBAL_TOPIC: &str = "trust_graph";
44pub const TRUST_GRAPH_LEGACY_GLOBAL_TOPIC: &str = "trust.graph";
45pub const TRUST_GRAPH_TOPIC_PREFIX: &str = "trust_graph.";
46pub const TRUST_GRAPH_LEGACY_TOPIC_PREFIX: &str = "trust.graph.";
47pub const TRUST_GRAPH_EVENT_KIND: &str = "trust_recorded";
48pub const TRUST_ACTION_RELEASE: &str = "release";
49
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum AutonomyTier {
53 Shadow,
54 Suggest,
55 ActWithApproval,
56 #[default]
57 ActAuto,
58}
59
60impl AutonomyTier {
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Self::Shadow => "shadow",
64 Self::Suggest => "suggest",
65 Self::ActWithApproval => "act_with_approval",
66 Self::ActAuto => "act_auto",
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum TrustOutcome {
74 Success,
75 Failure,
76 Denied,
77 Timeout,
78}
79
80impl TrustOutcome {
81 pub fn as_str(self) -> &'static str {
82 match self {
83 Self::Success => "success",
84 Self::Failure => "failure",
85 Self::Denied => "denied",
86 Self::Timeout => "timeout",
87 }
88 }
89}
90
91#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92pub struct TrustRecord {
93 pub schema: String,
94 pub record_id: String,
95 pub agent: String,
96 pub action: String,
97 pub approver: Option<String>,
98 pub outcome: TrustOutcome,
99 pub trace_id: String,
100 pub autonomy_tier: AutonomyTier,
101 #[serde(with = "time::serde::rfc3339")]
102 pub timestamp: OffsetDateTime,
103 pub cost_usd: Option<f64>,
104 #[serde(default)]
105 pub chain_index: u64,
106 #[serde(default)]
107 pub previous_hash: Option<String>,
108 #[serde(default)]
109 pub entry_hash: String,
110 #[serde(default)]
111 pub metadata: BTreeMap<String, serde_json::Value>,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(tag = "kind", rename_all = "snake_case")]
116pub enum TrustRecordActionKind {
117 Release {
118 bundle_hash: String,
119 harn_version: String,
120 parent_trust_record_id: Option<String>,
121 },
122}
123
124impl TrustRecord {
125 pub fn new(
126 agent: impl Into<String>,
127 action: impl Into<String>,
128 approver: Option<String>,
129 outcome: TrustOutcome,
130 trace_id: impl Into<String>,
131 autonomy_tier: AutonomyTier,
132 ) -> Self {
133 Self {
134 schema: OPENTRUSTGRAPH_SCHEMA_V0_1.to_string(),
135 record_id: Uuid::now_v7().to_string(),
136 agent: agent.into(),
137 action: action.into(),
138 approver,
139 outcome,
140 trace_id: trace_id.into(),
141 autonomy_tier,
142 timestamp: OffsetDateTime::now_utc(),
143 cost_usd: None,
144 chain_index: 0,
145 previous_hash: None,
146 entry_hash: String::new(),
147 metadata: BTreeMap::new(),
148 }
149 }
150
151 pub fn release(
152 agent: impl Into<String>,
153 bundle_hash: impl Into<String>,
154 harn_version: impl Into<String>,
155 parent_trust_record_id: Option<String>,
156 trace_id: impl Into<String>,
157 autonomy_tier: AutonomyTier,
158 ) -> Self {
159 let bundle_hash = bundle_hash.into();
160 let harn_version = harn_version.into();
161 let action_kind = TrustRecordActionKind::Release {
162 bundle_hash: bundle_hash.clone(),
163 harn_version: harn_version.clone(),
164 parent_trust_record_id: parent_trust_record_id.clone(),
165 };
166 let mut record = Self::new(
167 agent,
168 TRUST_ACTION_RELEASE,
169 None,
170 TrustOutcome::Success,
171 trace_id,
172 autonomy_tier,
173 );
174 record
175 .metadata
176 .insert("action_kind".to_string(), serde_json::json!(action_kind));
177 record
178 .metadata
179 .insert("bundle_hash".to_string(), serde_json::json!(bundle_hash));
180 record
181 .metadata
182 .insert("harn_version".to_string(), serde_json::json!(harn_version));
183 record.metadata.insert(
184 "parent_trust_record_id".to_string(),
185 parent_trust_record_id
186 .map(serde_json::Value::String)
187 .unwrap_or(serde_json::Value::Null),
188 );
189 record
190 }
191
192 pub fn with_effects_grant(mut self, effects: Vec<EffectRecord>) -> Self {
196 self.set_effects_grant(effects);
197 self
198 }
199
200 pub fn set_effects_grant(&mut self, effects: Vec<EffectRecord>) {
201 if effects.is_empty() {
202 self.metadata.remove(METADATA_KEY_EFFECTS_GRANT);
203 return;
204 }
205 self.metadata.insert(
206 METADATA_KEY_EFFECTS_GRANT.to_string(),
207 serde_json::to_value(effects).expect("EffectRecord is serializable"),
208 );
209 }
210
211 pub fn effects_grant(&self) -> Vec<EffectRecord> {
212 decode_effect_list(self.metadata.get(METADATA_KEY_EFFECTS_GRANT))
213 }
214
215 pub fn with_effects_used(mut self, effects: Vec<EffectRecord>) -> Self {
219 self.set_effects_used(effects);
220 self
221 }
222
223 pub fn set_effects_used(&mut self, effects: Vec<EffectRecord>) {
224 if effects.is_empty() {
225 self.metadata.remove(METADATA_KEY_EFFECTS_USED);
226 return;
227 }
228 self.metadata.insert(
229 METADATA_KEY_EFFECTS_USED.to_string(),
230 serde_json::to_value(effects).expect("EffectRecord is serializable"),
231 );
232 }
233
234 pub fn effects_used(&self) -> Vec<EffectRecord> {
235 decode_effect_list(self.metadata.get(METADATA_KEY_EFFECTS_USED))
236 }
237
238 pub fn with_parent_record_id(mut self, parent_record_id: impl Into<String>) -> Self {
242 self.set_parent_record_id(Some(parent_record_id.into()));
243 self
244 }
245
246 pub fn set_parent_record_id(&mut self, parent_record_id: Option<String>) {
247 match parent_record_id {
248 Some(id) if !id.is_empty() => {
249 self.metadata.insert(
250 METADATA_KEY_PARENT_RECORD_ID.to_string(),
251 serde_json::Value::String(id),
252 );
253 }
254 _ => {
255 self.metadata.remove(METADATA_KEY_PARENT_RECORD_ID);
256 }
257 }
258 }
259
260 pub fn parent_record_id(&self) -> Option<String> {
261 self.metadata
262 .get(METADATA_KEY_PARENT_RECORD_ID)
263 .and_then(|value| value.as_str())
264 .map(str::to_string)
265 }
266}
267
268fn decode_effect_list(value: Option<&serde_json::Value>) -> Vec<EffectRecord> {
269 value
270 .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
271 .unwrap_or_default()
272}
273
274#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
275pub struct TrustGraphRecord {
276 pub actor_id: String,
277 pub action: String,
278 pub approver: Option<String>,
279 pub outcome: TrustOutcome,
280 #[serde(default)]
281 pub evidence_refs: Vec<serde_json::Value>,
282 pub trace_id: String,
283 #[serde(with = "time::serde::rfc3339")]
284 pub timestamp: OffsetDateTime,
285 pub autonomy_tier_at_time: AutonomyTier,
286}
287
288impl TrustGraphRecord {
289 pub fn from_trust_record(record: &TrustRecord) -> Self {
290 Self {
291 actor_id: record.agent.clone(),
292 action: record.action.clone(),
293 approver: record.approver.clone(),
294 outcome: record.outcome,
295 evidence_refs: evidence_refs_from_metadata(&record.metadata),
296 trace_id: record.trace_id.clone(),
297 timestamp: record.timestamp,
298 autonomy_tier_at_time: record.autonomy_tier,
299 }
300 }
301}
302
303#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
304#[serde(default)]
305pub struct TrustQueryFilters {
306 pub agent: Option<String>,
307 pub action: Option<String>,
308 #[serde(with = "time::serde::rfc3339::option")]
309 pub since: Option<OffsetDateTime>,
310 #[serde(with = "time::serde::rfc3339::option")]
311 pub until: Option<OffsetDateTime>,
312 pub tier: Option<AutonomyTier>,
313 pub outcome: Option<TrustOutcome>,
314 pub limit: Option<usize>,
315 pub grouped_by_trace: bool,
316}
317
318#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
319#[serde(default)]
320pub struct TrustTraceGroup {
321 pub trace_id: String,
322 pub records: Vec<TrustRecord>,
323}
324
325#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
326#[serde(default)]
327pub struct TrustAgentSummary {
328 pub agent: String,
329 pub total: u64,
330 pub success_rate: f64,
331 pub mean_cost_usd: Option<f64>,
332 pub tier_distribution: BTreeMap<String, u64>,
333 pub outcome_distribution: BTreeMap<String, u64>,
334}
335
336#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
337#[serde(default)]
338pub struct TrustScore {
339 pub agent: String,
340 pub action: Option<String>,
341 pub total: u64,
342 pub successes: u64,
343 pub failures: u64,
344 pub denied: u64,
345 pub timeouts: u64,
346 pub success_rate: f64,
347 pub latest_outcome: Option<TrustOutcome>,
348 #[serde(with = "time::serde::rfc3339::option")]
349 pub latest_timestamp: Option<OffsetDateTime>,
350 pub effective_tier: AutonomyTier,
351 pub policy: CapabilityPolicy,
352}
353
354#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
355#[serde(default)]
356pub struct TrustChainReport {
357 pub topic: String,
358 pub total: u64,
359 pub verified: bool,
360 pub root_hash: Option<String>,
361 pub broken_at_event_id: Option<EventId>,
362 pub errors: Vec<String>,
363}
364
365#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
366pub struct TrustChainExportProducer {
367 pub name: String,
368 pub version: String,
369}
370
371impl Default for TrustChainExportProducer {
372 fn default() -> Self {
373 Self {
374 name: "harn".to_string(),
375 version: env!("CARGO_PKG_VERSION").to_string(),
376 }
377 }
378}
379
380#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
381pub struct TrustChainExportMetadata {
382 pub topic: String,
383 pub total: u64,
384 pub root_hash: Option<String>,
385 pub verified: bool,
386 #[serde(with = "time::serde::rfc3339")]
387 pub generated_at: OffsetDateTime,
388 pub producer: TrustChainExportProducer,
389}
390
391#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
392pub struct TrustChainExport {
393 pub schema: String,
394 pub chain: TrustChainExportMetadata,
395 pub records: Vec<TrustRecord>,
396}
397
398fn global_topic() -> Result<Topic, LogError> {
399 Topic::new(TRUST_GRAPH_GLOBAL_TOPIC)
400}
401
402fn legacy_global_topic() -> Result<Topic, LogError> {
403 Topic::new(TRUST_GRAPH_LEGACY_GLOBAL_TOPIC)
404}
405
406fn records_topic() -> Result<Topic, LogError> {
407 Topic::new(TRUST_GRAPH_RECORDS_TOPIC)
408}
409
410pub fn topic_for_agent(agent: &str) -> Result<Topic, LogError> {
411 Topic::new(format!(
412 "{TRUST_GRAPH_TOPIC_PREFIX}{}",
413 sanitize_topic_component(agent)
414 ))
415}
416
417pub fn legacy_topic_for_agent(agent: &str) -> Result<Topic, LogError> {
418 Topic::new(format!(
419 "{TRUST_GRAPH_LEGACY_TOPIC_PREFIX}{}",
420 sanitize_topic_component(agent)
421 ))
422}
423
424pub async fn append_trust_record(
425 log: &Arc<AnyEventLog>,
426 record: &TrustRecord,
427) -> Result<TrustRecord, LogError> {
428 let finalized = finalize_trust_record(log, record.clone()).await?;
429 let payload = serde_json::to_value(&finalized)
430 .map_err(|error| LogError::Serde(format!("trust record encode error: {error}")))?;
431 let mut headers = BTreeMap::new();
432 headers.insert("trace_id".to_string(), finalized.trace_id.clone());
433 headers.insert("agent".to_string(), finalized.agent.clone());
434 headers.insert(
435 "autonomy_tier".to_string(),
436 finalized.autonomy_tier.as_str().to_string(),
437 );
438 headers.insert(
439 "outcome".to_string(),
440 finalized.outcome.as_str().to_string(),
441 );
442 headers.insert("entry_hash".to_string(), finalized.entry_hash.clone());
443 let event = LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers);
444 for topic in append_topics_for_record(&finalized)? {
445 log.append(&topic, event.clone()).await?;
446 }
447 append_trust_graph_record_projection(log, &finalized).await?;
448 Ok(finalized)
449}
450
451pub async fn append_active_trust_record(record: &TrustRecord) -> Result<TrustRecord, LogError> {
452 let log = active_event_log()
453 .ok_or_else(|| LogError::Config("trust graph requires an active event log".to_string()))?;
454 append_trust_record(&log, record).await
455}
456
457pub async fn query_trust_records(
458 log: &Arc<AnyEventLog>,
459 filters: &TrustQueryFilters,
460) -> Result<Vec<TrustRecord>, LogError> {
461 let topics = query_topics(filters)?;
462 let mut records = Vec::new();
463 let mut seen = HashSet::new();
464 for topic in topics {
465 for (_, event) in log.read_range(&topic, None, usize::MAX).await? {
466 if event.kind != TRUST_GRAPH_EVENT_KIND {
467 continue;
468 }
469 let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
470 continue;
471 };
472 if !matches_filters(&record, filters) {
473 continue;
474 }
475 let dedupe_key = trust_record_dedupe_key(&record);
476 if seen.insert(dedupe_key) {
477 records.push(record);
478 }
479 }
480 }
481 records.sort_by(|left, right| {
482 left.timestamp
483 .cmp(&right.timestamp)
484 .then(left.chain_index.cmp(&right.chain_index))
485 .then(left.agent.cmp(&right.agent))
486 .then(left.record_id.cmp(&right.record_id))
487 });
488 apply_record_limit(&mut records, filters.limit);
489 Ok(records)
490}
491
492pub async fn query_trust_graph_records(
493 log: &Arc<AnyEventLog>,
494 filters: &TrustQueryFilters,
495) -> Result<Vec<TrustGraphRecord>, LogError> {
496 let mut graph_records = Vec::new();
497 let mut seen = HashSet::new();
498
499 for record in query_trust_records(log, filters).await? {
500 let graph_record = TrustGraphRecord::from_trust_record(&record);
501 let dedupe_key = trust_graph_record_dedupe_key(&graph_record);
502 if seen.insert(dedupe_key) {
503 graph_records.push(graph_record);
504 }
505 }
506
507 for (_, event) in log.read_range(&records_topic()?, None, usize::MAX).await? {
508 if event.kind != TRUST_GRAPH_EVENT_KIND {
509 continue;
510 }
511 let Ok(record) = serde_json::from_value::<TrustGraphRecord>(event.payload) else {
512 continue;
513 };
514 if !matches_graph_filters(&record, filters) {
515 continue;
516 }
517 let dedupe_key = trust_graph_record_dedupe_key(&record);
518 if seen.insert(dedupe_key) {
519 graph_records.push(record);
520 }
521 }
522
523 graph_records.sort_by(|left, right| {
524 left.timestamp
525 .cmp(&right.timestamp)
526 .then(left.actor_id.cmp(&right.actor_id))
527 .then(left.action.cmp(&right.action))
528 .then(left.trace_id.cmp(&right.trace_id))
529 });
530 apply_graph_record_limit(&mut graph_records, filters.limit);
531 Ok(graph_records)
532}
533
534pub async fn trust_score_for(
535 log: &Arc<AnyEventLog>,
536 agent: &str,
537 action: Option<&str>,
538) -> Result<TrustScore, LogError> {
539 let records = query_trust_records(
540 log,
541 &TrustQueryFilters {
542 agent: Some(agent.to_string()),
543 action: action.map(ToString::to_string),
544 ..TrustQueryFilters::default()
545 },
546 )
547 .await?;
548 let effective_tier = resolve_agent_autonomy_tier(log, agent, AutonomyTier::ActAuto).await?;
549 let mut score = score_from_records(agent, action, effective_tier, &records);
550 score.policy =
551 crate::corrections::apply_corrections_to_policy(log, agent, score.policy).await?;
552 Ok(score)
553}
554
555pub async fn policy_for_agent(
556 log: &Arc<AnyEventLog>,
557 agent: &str,
558) -> Result<CapabilityPolicy, LogError> {
559 Ok(trust_score_for(log, agent, None).await?.policy)
560}
561
562pub async fn verify_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainReport, LogError> {
563 let (topic, records) = preferred_chain_records(log).await?;
564 let mut previous_hash: Option<String> = None;
565 let mut errors = Vec::new();
566 let mut broken_at_event_id = None;
567
568 for (position, (event_id, record)) in records.iter().enumerate() {
569 let expected_index = (position as u64) + 1;
570 if record.chain_index != expected_index {
571 errors.push(format!(
572 "event {event_id}: expected chain_index {expected_index}, found {}",
573 record.chain_index
574 ));
575 }
576 if record.previous_hash != previous_hash {
577 errors.push(format!(
578 "event {event_id}: previous_hash mismatch; expected {:?}, found {:?}",
579 previous_hash, record.previous_hash
580 ));
581 }
582 match compute_trust_record_hash(record) {
583 Ok(expected_hash) if expected_hash == record.entry_hash => {}
584 Ok(expected_hash) => errors.push(format!(
585 "event {event_id}: entry_hash mismatch; expected {expected_hash}, found {}",
586 record.entry_hash
587 )),
588 Err(error) => errors.push(format!("event {event_id}: {error}")),
589 }
590 if !errors.is_empty() && broken_at_event_id.is_none() {
591 broken_at_event_id = Some(*event_id);
592 }
593 previous_hash = Some(record.entry_hash.clone());
594 }
595
596 Ok(TrustChainReport {
597 topic: topic.as_str().to_string(),
598 total: records.len() as u64,
599 verified: errors.is_empty(),
600 root_hash: records.last().map(|(_, record)| record.entry_hash.clone()),
601 broken_at_event_id,
602 errors,
603 })
604}
605
606pub async fn export_trust_chain(log: &Arc<AnyEventLog>) -> Result<TrustChainExport, LogError> {
607 let (topic, records_with_ids) = preferred_chain_records(log).await?;
608 let report = verify_trust_chain(log).await?;
609 let records: Vec<TrustRecord> = records_with_ids.into_iter().map(|(_, r)| r).collect();
610 Ok(TrustChainExport {
611 schema: OPENTRUSTGRAPH_CHAIN_SCHEMA_V0.to_string(),
612 chain: TrustChainExportMetadata {
613 topic: topic.as_str().to_string(),
614 total: records.len() as u64,
615 root_hash: records.last().map(|record| record.entry_hash.clone()),
616 verified: report.verified,
617 generated_at: OffsetDateTime::now_utc(),
618 producer: TrustChainExportProducer::default(),
619 },
620 records,
621 })
622}
623
624pub fn compute_trust_record_hash(record: &TrustRecord) -> Result<String, LogError> {
625 let mut value = serde_json::to_value(record)
626 .map_err(|error| LogError::Serde(format!("trust record hash encode error: {error}")))?;
627 if let Some(object) = value.as_object_mut() {
628 object.remove("entry_hash");
629 }
630 let canonical = serde_json::to_string(&value)
631 .map_err(|error| LogError::Serde(format!("trust record canonicalize error: {error}")))?;
632 let digest = Sha256::digest(canonical.as_bytes());
633 Ok(format!("sha256:{}", hex::encode(digest)))
634}
635
636pub fn group_trust_records_by_trace(records: &[TrustRecord]) -> Vec<TrustTraceGroup> {
637 let mut groups: Vec<TrustTraceGroup> = Vec::new();
638 let mut positions: HashMap<String, usize> = HashMap::new();
639 for record in records {
640 if let Some(index) = positions.get(record.trace_id.as_str()).copied() {
641 groups[index].records.push(record.clone());
642 continue;
643 }
644 positions.insert(record.trace_id.clone(), groups.len());
645 groups.push(TrustTraceGroup {
646 trace_id: record.trace_id.clone(),
647 records: vec![record.clone()],
648 });
649 }
650 groups
651}
652
653pub fn summarize_trust_records(records: &[TrustRecord]) -> Vec<TrustAgentSummary> {
654 #[derive(Default)]
655 struct RunningSummary {
656 total: u64,
657 successes: u64,
658 cost_sum: f64,
659 cost_count: u64,
660 tier_distribution: BTreeMap<String, u64>,
661 outcome_distribution: BTreeMap<String, u64>,
662 }
663
664 let mut by_agent: BTreeMap<String, RunningSummary> = BTreeMap::new();
665 for record in records {
666 let entry = by_agent.entry(record.agent.clone()).or_default();
667 entry.total += 1;
668 if record.outcome == TrustOutcome::Success {
669 entry.successes += 1;
670 }
671 if let Some(cost_usd) = record.cost_usd {
672 entry.cost_sum += cost_usd;
673 entry.cost_count += 1;
674 }
675 *entry
676 .tier_distribution
677 .entry(record.autonomy_tier.as_str().to_string())
678 .or_default() += 1;
679 *entry
680 .outcome_distribution
681 .entry(record.outcome.as_str().to_string())
682 .or_default() += 1;
683 }
684
685 by_agent
686 .into_iter()
687 .map(|(agent, summary)| TrustAgentSummary {
688 agent,
689 total: summary.total,
690 success_rate: if summary.total == 0 {
691 0.0
692 } else {
693 summary.successes as f64 / summary.total as f64
694 },
695 mean_cost_usd: (summary.cost_count > 0)
696 .then_some(summary.cost_sum / summary.cost_count as f64),
697 tier_distribution: summary.tier_distribution,
698 outcome_distribution: summary.outcome_distribution,
699 })
700 .collect()
701}
702
703pub async fn resolve_agent_autonomy_tier(
704 log: &Arc<AnyEventLog>,
705 agent: &str,
706 default: AutonomyTier,
707) -> Result<AutonomyTier, LogError> {
708 let records = query_trust_records(
709 log,
710 &TrustQueryFilters {
711 agent: Some(agent.to_string()),
712 ..TrustQueryFilters::default()
713 },
714 )
715 .await?;
716 let mut current = default;
717 for record in records {
718 if matches!(record.action.as_str(), "trust.promote" | "trust.demote")
719 && record.outcome == TrustOutcome::Success
720 {
721 current = record.autonomy_tier;
722 }
723 }
724 Ok(current)
725}
726
727fn matches_filters(record: &TrustRecord, filters: &TrustQueryFilters) -> bool {
728 if let Some(agent) = filters.agent.as_deref() {
729 if record.agent != agent {
730 return false;
731 }
732 }
733 if let Some(action) = filters.action.as_deref() {
734 if record.action != action {
735 return false;
736 }
737 }
738 if let Some(since) = filters.since {
739 if record.timestamp < since {
740 return false;
741 }
742 }
743 if let Some(until) = filters.until {
744 if record.timestamp > until {
745 return false;
746 }
747 }
748 if let Some(tier) = filters.tier {
749 if record.autonomy_tier != tier {
750 return false;
751 }
752 }
753 if let Some(outcome) = filters.outcome {
754 if record.outcome != outcome {
755 return false;
756 }
757 }
758 true
759}
760
761fn matches_graph_filters(record: &TrustGraphRecord, filters: &TrustQueryFilters) -> bool {
762 if let Some(agent) = filters.agent.as_deref() {
763 if record.actor_id != agent {
764 return false;
765 }
766 }
767 if let Some(action) = filters.action.as_deref() {
768 if record.action != action {
769 return false;
770 }
771 }
772 if let Some(since) = filters.since {
773 if record.timestamp < since {
774 return false;
775 }
776 }
777 if let Some(until) = filters.until {
778 if record.timestamp > until {
779 return false;
780 }
781 }
782 if let Some(tier) = filters.tier {
783 if record.autonomy_tier_at_time != tier {
784 return false;
785 }
786 }
787 if let Some(outcome) = filters.outcome {
788 if record.outcome != outcome {
789 return false;
790 }
791 }
792 true
793}
794
795fn query_topics(filters: &TrustQueryFilters) -> Result<Vec<Topic>, LogError> {
796 match filters.agent.as_deref() {
797 Some(agent) => unique_topics(vec![
798 topic_for_agent(agent)?,
799 legacy_topic_for_agent(agent)?,
800 ]),
801 None => unique_topics(vec![global_topic()?, legacy_global_topic()?]),
802 }
803}
804
805fn append_topics_for_record(record: &TrustRecord) -> Result<Vec<Topic>, LogError> {
806 unique_topics(vec![
807 global_topic()?,
808 legacy_global_topic()?,
809 topic_for_agent(&record.agent)?,
810 legacy_topic_for_agent(&record.agent)?,
811 ])
812}
813
814fn unique_topics(topics: Vec<Topic>) -> Result<Vec<Topic>, LogError> {
815 let mut seen = HashSet::new();
816 Ok(topics
817 .into_iter()
818 .filter(|topic| seen.insert(topic.as_str().to_string()))
819 .collect())
820}
821
822async fn append_trust_graph_record_projection(
823 log: &Arc<AnyEventLog>,
824 record: &TrustRecord,
825) -> Result<(), LogError> {
826 let payload = serde_json::to_value(TrustGraphRecord::from_trust_record(record))
827 .map_err(|error| LogError::Serde(format!("trust graph record encode error: {error}")))?;
828 let mut headers = BTreeMap::new();
829 headers.insert("trace_id".to_string(), record.trace_id.clone());
830 headers.insert("actor_id".to_string(), record.agent.clone());
831 headers.insert("action".to_string(), record.action.clone());
832 headers.insert(
833 "autonomy_tier_at_time".to_string(),
834 record.autonomy_tier.as_str().to_string(),
835 );
836 headers.insert("outcome".to_string(), record.outcome.as_str().to_string());
837 log.append(
838 &records_topic()?,
839 LogEvent::new(TRUST_GRAPH_EVENT_KIND, payload).with_headers(headers),
840 )
841 .await?;
842 Ok(())
843}
844
845async fn finalize_trust_record(
846 log: &Arc<AnyEventLog>,
847 mut record: TrustRecord,
848) -> Result<TrustRecord, LogError> {
849 let latest = latest_chain_record(log).await?;
850 record.chain_index = latest
851 .as_ref()
852 .map(|(_, record)| record.chain_index.saturating_add(1).max(1))
853 .unwrap_or(1);
854 record.previous_hash = latest.and_then(|(_, record)| {
855 if record.entry_hash.is_empty() {
856 compute_trust_record_hash(&record).ok()
857 } else {
858 Some(record.entry_hash)
859 }
860 });
861 record.entry_hash.clear();
862 record.entry_hash = compute_trust_record_hash(&record)?;
863 Ok(record)
864}
865
866async fn latest_chain_record(
867 log: &Arc<AnyEventLog>,
868) -> Result<Option<(EventId, TrustRecord)>, LogError> {
869 let (_, records) = preferred_chain_records(log).await?;
870 Ok(records.into_iter().last())
871}
872
873async fn preferred_chain_records(
874 log: &Arc<AnyEventLog>,
875) -> Result<(Topic, Vec<(EventId, TrustRecord)>), LogError> {
876 let canonical = global_topic()?;
877 let canonical_records = read_trust_records_from_topic(log, &canonical).await?;
878 if !canonical_records.is_empty() {
879 return Ok((canonical, canonical_records));
880 }
881 let legacy = legacy_global_topic()?;
882 let legacy_records = read_trust_records_from_topic(log, &legacy).await?;
883 if legacy_records.is_empty() {
884 Ok((canonical, Vec::new()))
885 } else {
886 Ok((legacy, legacy_records))
887 }
888}
889
890async fn read_trust_records_from_topic(
891 log: &Arc<AnyEventLog>,
892 topic: &Topic,
893) -> Result<Vec<(EventId, TrustRecord)>, LogError> {
894 let events = log.read_range(topic, None, usize::MAX).await?;
895 let mut records = Vec::new();
896 let mut seen = HashSet::new();
897 for (event_id, event) in events {
898 if event.kind != TRUST_GRAPH_EVENT_KIND {
899 continue;
900 }
901 let Ok(record) = serde_json::from_value::<TrustRecord>(event.payload) else {
902 continue;
903 };
904 if seen.insert(trust_record_dedupe_key(&record)) {
905 records.push((event_id, record));
906 }
907 }
908 Ok(records)
909}
910
911fn trust_record_dedupe_key(record: &TrustRecord) -> String {
912 if !record.entry_hash.is_empty() {
913 return record.entry_hash.clone();
914 }
915 record.record_id.clone()
916}
917
918fn trust_graph_record_dedupe_key(record: &TrustGraphRecord) -> String {
919 format!(
920 "{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
921 record.actor_id,
922 record.action,
923 record.trace_id,
924 record.timestamp,
925 record.outcome.as_str()
926 )
927}
928
929fn evidence_refs_from_metadata(
930 metadata: &BTreeMap<String, serde_json::Value>,
931) -> Vec<serde_json::Value> {
932 metadata
933 .get("evidence_refs")
934 .or_else(|| metadata.get("evidenceRefs"))
935 .or_else(|| {
936 metadata
937 .get("approval")
938 .and_then(|approval| approval.get("evidence_refs"))
939 })
940 .and_then(|value| value.as_array())
941 .cloned()
942 .unwrap_or_default()
943}
944
945fn score_from_records(
946 agent: &str,
947 action: Option<&str>,
948 effective_tier: AutonomyTier,
949 records: &[TrustRecord],
950) -> TrustScore {
951 let mut score = TrustScore {
952 agent: agent.to_string(),
953 action: action.map(ToString::to_string),
954 effective_tier,
955 ..TrustScore::default()
956 };
957 let recent_cutoff = OffsetDateTime::now_utc() - Duration::days(30);
958 let mut recent_successes = 0;
959 let mut recent_bad_or_rollback = false;
960 for record in records {
961 score.total += 1;
962 match record.outcome {
963 TrustOutcome::Success => score.successes += 1,
964 TrustOutcome::Failure => score.failures += 1,
965 TrustOutcome::Denied => score.denied += 1,
966 TrustOutcome::Timeout => score.timeouts += 1,
967 }
968 if record.timestamp >= recent_cutoff {
969 if record.outcome == TrustOutcome::Success && !is_control_plane_action(&record.action) {
970 recent_successes += 1;
971 } else if record.outcome != TrustOutcome::Success {
972 recent_bad_or_rollback = true;
973 }
974 if record.action.contains("rollback") {
975 recent_bad_or_rollback = true;
976 }
977 }
978 score.latest_outcome = Some(record.outcome);
979 score.latest_timestamp = Some(record.timestamp);
980 }
981 score.success_rate = if score.total == 0 {
982 0.0
983 } else {
984 score.successes as f64 / score.total as f64
985 };
986 score.policy = policy_from_score(&score, recent_successes, recent_bad_or_rollback);
987 score
988}
989
990fn policy_from_score(
991 score: &TrustScore,
992 recent_successes: u64,
993 recent_bad_or_rollback: bool,
994) -> CapabilityPolicy {
995 let mut policy = policy_for_autonomy_tier(score.effective_tier);
996 let latest_bad = matches!(
997 score.latest_outcome,
998 Some(TrustOutcome::Denied | TrustOutcome::Failure | TrustOutcome::Timeout)
999 );
1000 let trusted_recent_track_record = score.effective_tier == AutonomyTier::ActWithApproval
1001 && recent_successes >= 10
1002 && !recent_bad_or_rollback;
1003 if latest_bad || (!trusted_recent_track_record && score.total >= 3 && score.success_rate < 0.5)
1004 {
1005 policy.side_effect_level = Some("read_only".to_string());
1006 } else if trusted_recent_track_record {
1007 policy.side_effect_level = Some("network".to_string());
1008 }
1009 policy
1010}
1011
1012pub fn policy_for_autonomy_tier(tier: AutonomyTier) -> CapabilityPolicy {
1013 CapabilityPolicy {
1014 side_effect_level: Some(
1015 match tier {
1016 AutonomyTier::Shadow => "none",
1017 AutonomyTier::Suggest => "read_only",
1018 AutonomyTier::ActWithApproval => "read_only",
1019 AutonomyTier::ActAuto => "network",
1020 }
1021 .to_string(),
1022 ),
1023 recursion_limit: matches!(tier, AutonomyTier::Shadow).then_some(0),
1024 ..CapabilityPolicy::default()
1025 }
1026}
1027
1028fn apply_record_limit(records: &mut Vec<TrustRecord>, limit: Option<usize>) {
1029 let Some(limit) = limit else {
1030 return;
1031 };
1032 if records.len() <= limit {
1033 return;
1034 }
1035 let keep_from = records.len() - limit;
1036 records.drain(0..keep_from);
1037}
1038
1039fn apply_graph_record_limit(records: &mut Vec<TrustGraphRecord>, limit: Option<usize>) {
1040 let Some(limit) = limit else {
1041 return;
1042 };
1043 if records.len() <= limit {
1044 return;
1045 }
1046 let keep_from = records.len() - limit;
1047 records.drain(0..keep_from);
1048}
1049
1050fn is_control_plane_action(action: &str) -> bool {
1051 matches!(
1052 action,
1053 "trust.promote" | "trust.demote" | "autonomy.tier_transition"
1054 )
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060 use crate::event_log::MemoryEventLog;
1061 use time::Duration;
1062
1063 const RECORD_SCHEMA_JSON: &str =
1064 include_str!("trust_graph/schemas/trust-record.v0.schema.json");
1065 const RECORD_SCHEMA_V0_1_JSON: &str =
1066 include_str!("trust_graph/schemas/trust-record.v0.1.schema.json");
1067 const CHAIN_SCHEMA_JSON: &str = include_str!("trust_graph/schemas/trust-chain.v0.schema.json");
1068 const VALID_DECISION_CHAIN_JSON: &str =
1069 include_str!("trust_graph/fixtures/valid/decision-chain.json");
1070 const VALID_TIER_TRANSITION_JSON: &str =
1071 include_str!("trust_graph/fixtures/valid/tier-transition.json");
1072 const VALID_EFFECT_INHERITANCE_CHAIN_JSON: &str =
1073 include_str!("trust_graph/fixtures/valid/effect-inheritance-chain.json");
1074 const INVALID_TAMPERED_CHAIN_JSON: &str =
1075 include_str!("trust_graph/fixtures/invalid/tampered-chain.json");
1076 const INVALID_MISSING_APPROVAL_JSON: &str =
1077 include_str!("trust_graph/fixtures/invalid/missing-approval.json");
1078
1079 #[derive(Debug, serde::Deserialize)]
1080 struct TrustChainFixture {
1081 schema: String,
1082 chain: TrustChainFixtureMetadata,
1083 records: Vec<TrustRecord>,
1084 }
1085
1086 #[derive(Debug, serde::Deserialize)]
1087 struct TrustChainFixtureMetadata {
1088 topic: String,
1089 total: u64,
1090 root_hash: Option<String>,
1091 verified: bool,
1092 generated_at: String,
1093 producer: BTreeMap<String, serde_json::Value>,
1094 }
1095
1096 #[test]
1097 fn embedded_trust_graph_fixtures_match_workspace_spec_when_available() {
1098 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1099 let spec_dir = manifest_dir.join("../../opentrustgraph-spec");
1100 if !spec_dir.exists() {
1101 return;
1102 }
1103
1104 for (relative, embedded) in [
1105 ("schemas/trust-record.v0.schema.json", RECORD_SCHEMA_JSON),
1106 (
1107 "schemas/trust-record.v0.1.schema.json",
1108 RECORD_SCHEMA_V0_1_JSON,
1109 ),
1110 ("schemas/trust-chain.v0.schema.json", CHAIN_SCHEMA_JSON),
1111 (
1112 "fixtures/valid/decision-chain.json",
1113 VALID_DECISION_CHAIN_JSON,
1114 ),
1115 (
1116 "fixtures/valid/tier-transition.json",
1117 VALID_TIER_TRANSITION_JSON,
1118 ),
1119 (
1120 "fixtures/valid/effect-inheritance-chain.json",
1121 VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1122 ),
1123 (
1124 "fixtures/invalid/tampered-chain.json",
1125 INVALID_TAMPERED_CHAIN_JSON,
1126 ),
1127 (
1128 "fixtures/invalid/missing-approval.json",
1129 INVALID_MISSING_APPROVAL_JSON,
1130 ),
1131 ] {
1132 let source = std::fs::read_to_string(spec_dir.join(relative)).unwrap_or_else(|e| {
1133 panic!("failed to read opentrustgraph fixture {relative}: {e}")
1134 });
1135 assert_eq!(
1136 embedded, source,
1137 "embedded trust graph fixture {relative} drifted from opentrustgraph-spec"
1138 );
1139 }
1140 }
1141
1142 #[tokio::test]
1143 async fn append_and_query_round_trip() {
1144 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1145 let mut record = TrustRecord::new(
1146 "github-triage-bot",
1147 "github.issue.opened",
1148 Some("reviewer".to_string()),
1149 TrustOutcome::Success,
1150 "trace-1",
1151 AutonomyTier::ActWithApproval,
1152 );
1153 record.cost_usd = Some(1.25);
1154 append_trust_record(&log, &record).await.unwrap();
1155
1156 let records = query_trust_records(
1157 &log,
1158 &TrustQueryFilters {
1159 agent: Some("github-triage-bot".to_string()),
1160 ..TrustQueryFilters::default()
1161 },
1162 )
1163 .await
1164 .unwrap();
1165
1166 assert_eq!(records.len(), 1);
1167 assert_eq!(records[0].agent, "github-triage-bot");
1168 assert_eq!(records[0].cost_usd, Some(1.25));
1169 assert_eq!(records[0].chain_index, 1);
1170 assert!(records[0].previous_hash.is_none());
1171 assert!(records[0].entry_hash.starts_with("sha256:"));
1172 }
1173
1174 #[tokio::test]
1175 async fn verify_chain_detects_hash_tampering() {
1176 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1177 let first = append_trust_record(
1178 &log,
1179 &TrustRecord::new(
1180 "bot",
1181 "first",
1182 None,
1183 TrustOutcome::Success,
1184 "trace-1",
1185 AutonomyTier::Suggest,
1186 ),
1187 )
1188 .await
1189 .unwrap();
1190 let mut second = append_trust_record(
1191 &log,
1192 &TrustRecord::new(
1193 "bot",
1194 "second",
1195 None,
1196 TrustOutcome::Success,
1197 "trace-2",
1198 AutonomyTier::Suggest,
1199 ),
1200 )
1201 .await
1202 .unwrap();
1203
1204 let report = verify_trust_chain(&log).await.unwrap();
1205 assert!(report.verified);
1206 assert_eq!(
1207 report.root_hash.as_deref(),
1208 Some(second.entry_hash.as_str())
1209 );
1210 assert_eq!(
1211 second.previous_hash.as_deref(),
1212 Some(first.entry_hash.as_str())
1213 );
1214
1215 second.previous_hash = Some(
1216 "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1217 );
1218 second.entry_hash =
1219 "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1220 log.append(
1221 &global_topic().unwrap(),
1222 LogEvent::new(
1223 TRUST_GRAPH_EVENT_KIND,
1224 serde_json::to_value(second).unwrap(),
1225 ),
1226 )
1227 .await
1228 .unwrap();
1229 let report = verify_trust_chain(&log).await.unwrap();
1230 assert!(!report.verified);
1231 assert!(report
1232 .errors
1233 .iter()
1234 .any(|error| error.contains("previous_hash mismatch")));
1235 }
1236
1237 #[tokio::test]
1238 async fn export_trust_chain_emits_envelope_matching_chain_schema() {
1239 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1240 let first = append_trust_record(
1241 &log,
1242 &TrustRecord::new(
1243 "bot",
1244 "github.issue.opened",
1245 None,
1246 TrustOutcome::Success,
1247 "trace-1",
1248 AutonomyTier::Suggest,
1249 ),
1250 )
1251 .await
1252 .unwrap();
1253 let second = append_trust_record(
1254 &log,
1255 &TrustRecord::new(
1256 "bot",
1257 "trust.promote",
1258 Some("maintainer-1".to_string()),
1259 TrustOutcome::Success,
1260 "trace-2",
1261 AutonomyTier::ActAuto,
1262 ),
1263 )
1264 .await
1265 .unwrap();
1266
1267 let export = export_trust_chain(&log).await.unwrap();
1268 assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1269 assert_eq!(export.chain.topic, TRUST_GRAPH_GLOBAL_TOPIC);
1270 assert_eq!(export.chain.total, 2);
1271 assert!(export.chain.verified);
1272 assert_eq!(
1273 export.chain.root_hash.as_deref(),
1274 Some(second.entry_hash.as_str())
1275 );
1276 assert_eq!(export.records.len(), 2);
1277 assert_eq!(export.records[0].entry_hash, first.entry_hash);
1278 assert_eq!(export.records[1].entry_hash, second.entry_hash);
1279 assert_eq!(export.chain.producer.name, "harn");
1280
1281 let envelope_json = serde_json::to_value(&export).unwrap();
1282 assert_eq!(envelope_json["schema"], OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1283 assert_eq!(envelope_json["chain"]["total"], 2);
1284 assert_eq!(envelope_json["chain"]["verified"], true);
1285 assert!(envelope_json["records"].as_array().unwrap().len() == 2);
1286 }
1287
1288 #[tokio::test]
1289 async fn export_trust_chain_handles_empty_log() {
1290 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1291 let export = export_trust_chain(&log).await.unwrap();
1292 assert_eq!(export.schema, OPENTRUSTGRAPH_CHAIN_SCHEMA_V0);
1293 assert_eq!(export.chain.total, 0);
1294 assert!(export.chain.verified);
1295 assert!(export.chain.root_hash.is_none());
1296 assert!(export.records.is_empty());
1297 }
1298
1299 #[tokio::test]
1300 async fn resolve_autonomy_tier_prefers_latest_control_record() {
1301 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1302 append_trust_record(
1303 &log,
1304 &TrustRecord::new(
1305 "bot",
1306 "trust.promote",
1307 None,
1308 TrustOutcome::Success,
1309 "trace-1",
1310 AutonomyTier::ActWithApproval,
1311 ),
1312 )
1313 .await
1314 .unwrap();
1315 append_trust_record(
1316 &log,
1317 &TrustRecord::new(
1318 "bot",
1319 "trust.demote",
1320 None,
1321 TrustOutcome::Success,
1322 "trace-2",
1323 AutonomyTier::Shadow,
1324 ),
1325 )
1326 .await
1327 .unwrap();
1328
1329 let tier = resolve_agent_autonomy_tier(&log, "bot", AutonomyTier::ActAuto)
1330 .await
1331 .unwrap();
1332 assert_eq!(tier, AutonomyTier::Shadow);
1333 }
1334
1335 #[tokio::test]
1336 async fn query_limit_keeps_newest_matching_records() {
1337 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1338 let base = OffsetDateTime::from_unix_timestamp(1_775_000_000).unwrap();
1339 for (offset, action) in ["first", "second", "third"].into_iter().enumerate() {
1340 let mut record = TrustRecord::new(
1341 "bot",
1342 action,
1343 None,
1344 TrustOutcome::Success,
1345 format!("trace-{action}"),
1346 AutonomyTier::ActAuto,
1347 );
1348 record.timestamp = base + Duration::seconds(offset as i64);
1349 append_trust_record(&log, &record).await.unwrap();
1350 }
1351
1352 let records = query_trust_records(
1353 &log,
1354 &TrustQueryFilters {
1355 agent: Some("bot".to_string()),
1356 limit: Some(2),
1357 ..TrustQueryFilters::default()
1358 },
1359 )
1360 .await
1361 .unwrap();
1362
1363 assert_eq!(records.len(), 2);
1364 assert_eq!(records[0].action, "second");
1365 assert_eq!(records[1].action, "third");
1366 }
1367
1368 #[test]
1369 fn group_by_trace_preserves_chronological_group_order() {
1370 let make_record = |trace_id: &str, action: &str| TrustRecord {
1371 trace_id: trace_id.to_string(),
1372 action: action.to_string(),
1373 ..TrustRecord::new(
1374 "bot",
1375 action,
1376 None,
1377 TrustOutcome::Success,
1378 trace_id,
1379 AutonomyTier::ActAuto,
1380 )
1381 };
1382 let grouped = group_trust_records_by_trace(&[
1383 make_record("trace-1", "first"),
1384 make_record("trace-2", "second"),
1385 make_record("trace-1", "third"),
1386 ]);
1387
1388 assert_eq!(grouped.len(), 2);
1389 assert_eq!(grouped[0].trace_id, "trace-1");
1390 assert_eq!(grouped[0].records.len(), 2);
1391 assert_eq!(grouped[0].records[1].action, "third");
1392 assert_eq!(grouped[1].trace_id, "trace-2");
1393 }
1394
1395 #[test]
1396 fn opentrustgraph_schema_files_are_parseable_and_match_runtime_enums() {
1397 let record_schema: serde_json::Value = serde_json::from_str(RECORD_SCHEMA_JSON).unwrap();
1398 let record_schema_v0_1: serde_json::Value =
1399 serde_json::from_str(RECORD_SCHEMA_V0_1_JSON).unwrap();
1400 let chain_schema: serde_json::Value = serde_json::from_str(CHAIN_SCHEMA_JSON).unwrap();
1401
1402 assert_eq!(
1403 record_schema["properties"]["schema"]["const"],
1404 serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)
1405 );
1406 let v0_1_schema_enum = record_schema_v0_1["properties"]["schema"]["enum"]
1407 .as_array()
1408 .expect("v0.1 record schema declares schema as an enum");
1409 assert!(
1410 v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0_1)),
1411 "v0.1 record schema must accept {OPENTRUSTGRAPH_SCHEMA_V0_1}: {v0_1_schema_enum:?}"
1412 );
1413 assert!(
1414 v0_1_schema_enum.contains(&serde_json::json!(OPENTRUSTGRAPH_SCHEMA_V0)),
1415 "v0.1 record schema must still accept v0 (one-release back-compat): {v0_1_schema_enum:?}"
1416 );
1417 assert_eq!(
1418 chain_schema["properties"]["schema"]["const"],
1419 serde_json::json!("opentrustgraph-chain/v0")
1420 );
1421
1422 let outcomes = record_schema["properties"]["outcome"]["enum"]
1423 .as_array()
1424 .unwrap();
1425 for outcome in [
1426 TrustOutcome::Success,
1427 TrustOutcome::Failure,
1428 TrustOutcome::Denied,
1429 TrustOutcome::Timeout,
1430 ] {
1431 assert!(outcomes.contains(&serde_json::json!(outcome.as_str())));
1432 }
1433
1434 let tiers = record_schema["properties"]["autonomy_tier"]["enum"]
1435 .as_array()
1436 .unwrap();
1437 for tier in [
1438 AutonomyTier::Shadow,
1439 AutonomyTier::Suggest,
1440 AutonomyTier::ActWithApproval,
1441 AutonomyTier::ActAuto,
1442 ] {
1443 assert!(tiers.contains(&serde_json::json!(tier.as_str())));
1444 }
1445 }
1446
1447 #[test]
1448 fn opentrustgraph_valid_fixtures_match_runtime_contract() {
1449 for (name, fixture) in [
1450 ("decision-chain", VALID_DECISION_CHAIN_JSON),
1451 ("tier-transition", VALID_TIER_TRANSITION_JSON),
1452 (
1453 "effect-inheritance-chain",
1454 VALID_EFFECT_INHERITANCE_CHAIN_JSON,
1455 ),
1456 ] {
1457 let fixture = parse_chain_fixture(fixture);
1458 let errors = validate_chain_fixture(&fixture);
1459 assert!(errors.is_empty(), "{name} errors: {errors:?}");
1460 }
1461 }
1462
1463 #[test]
1464 fn opentrustgraph_invalid_fixtures_exercise_expected_failures() {
1465 let tampered = parse_chain_fixture(INVALID_TAMPERED_CHAIN_JSON);
1466 let tampered_errors = validate_chain_fixture(&tampered);
1467 assert!(
1468 tampered_errors
1469 .iter()
1470 .any(|error| error.contains("previous_hash mismatch")),
1471 "tampered-chain errors: {tampered_errors:?}"
1472 );
1473 assert!(
1474 !tampered_errors
1475 .iter()
1476 .any(|error| error.contains("entry_hash mismatch")),
1477 "tampered-chain should isolate hash-link tampering: {tampered_errors:?}"
1478 );
1479
1480 let missing_approval = parse_chain_fixture(INVALID_MISSING_APPROVAL_JSON);
1481 let missing_errors = validate_chain_fixture(&missing_approval);
1482 assert!(
1483 missing_errors
1484 .iter()
1485 .any(|error| error.contains("approval required")),
1486 "missing-approval errors: {missing_errors:?}"
1487 );
1488 }
1489
1490 fn parse_chain_fixture(input: &str) -> TrustChainFixture {
1491 serde_json::from_str(input).unwrap()
1492 }
1493
1494 fn validate_chain_fixture(fixture: &TrustChainFixture) -> Vec<String> {
1495 let mut errors = Vec::new();
1496 if fixture.schema != OPENTRUSTGRAPH_CHAIN_SCHEMA_V0 {
1497 errors.push(format!("unsupported chain schema {}", fixture.schema));
1498 }
1499 if fixture.chain.topic.trim().is_empty() {
1500 errors.push("chain topic is empty".to_string());
1501 }
1502 if fixture.chain.total != fixture.records.len() as u64 {
1503 errors.push(format!(
1504 "chain total mismatch; expected {}, found {}",
1505 fixture.records.len(),
1506 fixture.chain.total
1507 ));
1508 }
1509 if fixture
1510 .chain
1511 .producer
1512 .get("name")
1513 .and_then(|value| value.as_str())
1514 .unwrap_or_default()
1515 .trim()
1516 .is_empty()
1517 {
1518 errors.push("chain producer.name is empty".to_string());
1519 }
1520 if OffsetDateTime::parse(
1521 &fixture.chain.generated_at,
1522 &time::format_description::well_known::Rfc3339,
1523 )
1524 .is_err()
1525 {
1526 errors.push("chain generated_at is not RFC3339".to_string());
1527 }
1528
1529 for (index, record) in fixture.records.iter().enumerate() {
1530 errors.extend(validate_fixture_record_contract(index, record));
1531 }
1532 errors.extend(validate_fixture_hash_chain(fixture));
1533
1534 let expected_verified = errors.is_empty();
1535 if fixture.chain.verified != expected_verified {
1536 errors.push(format!(
1537 "chain verified flag mismatch; expected {expected_verified}, found {}",
1538 fixture.chain.verified
1539 ));
1540 }
1541 errors
1542 }
1543
1544 fn validate_fixture_record_contract(index: usize, record: &TrustRecord) -> Vec<String> {
1545 let mut errors = Vec::new();
1546 let label = format!("record {index}");
1547 if !OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&record.schema.as_str()) {
1548 errors.push(format!("{label}: unsupported schema {}", record.schema));
1549 }
1550 if record.record_id.trim().is_empty() {
1551 errors.push(format!("{label}: record_id is empty"));
1552 }
1553 if record.agent.trim().is_empty() {
1554 errors.push(format!("{label}: agent is empty"));
1555 }
1556 if record.action.trim().is_empty() {
1557 errors.push(format!("{label}: action is empty"));
1558 }
1559 if record.trace_id.trim().is_empty() {
1560 errors.push(format!("{label}: trace_id is empty"));
1561 }
1562 if !record.entry_hash.starts_with("sha256:") {
1563 errors.push(format!("{label}: entry_hash is not sha256-prefixed"));
1564 }
1565 if let Some(cost_usd) = record.cost_usd {
1566 if cost_usd < 0.0 {
1567 errors.push(format!("{label}: cost_usd is negative"));
1568 }
1569 }
1570
1571 if record.outcome == TrustOutcome::Success
1572 && record.autonomy_tier == AutonomyTier::ActWithApproval
1573 && approval_required(record)
1574 {
1575 if record
1576 .approver
1577 .as_deref()
1578 .unwrap_or_default()
1579 .trim()
1580 .is_empty()
1581 {
1582 errors.push(format!("{label}: approval required but approver is empty"));
1583 }
1584 if approval_signature_count(record) == 0 {
1585 errors.push(format!(
1586 "{label}: approval required but signatures are empty"
1587 ));
1588 }
1589 }
1590
1591 errors
1592 }
1593
1594 fn validate_fixture_hash_chain(fixture: &TrustChainFixture) -> Vec<String> {
1595 let mut errors = Vec::new();
1596 let mut previous_hash: Option<String> = None;
1597
1598 for (position, record) in fixture.records.iter().enumerate() {
1599 let expected_index = position as u64 + 1;
1600 if record.chain_index != expected_index {
1601 errors.push(format!(
1602 "record {position}: expected chain_index {expected_index}, found {}",
1603 record.chain_index
1604 ));
1605 }
1606 if record.previous_hash != previous_hash {
1607 errors.push(format!(
1608 "record {position}: previous_hash mismatch; expected {:?}, found {:?}",
1609 previous_hash, record.previous_hash
1610 ));
1611 }
1612 let expected_hash = compute_trust_record_hash(record).unwrap();
1613 if expected_hash != record.entry_hash {
1614 errors.push(format!(
1615 "record {position}: entry_hash mismatch; expected {expected_hash}, found {}",
1616 record.entry_hash
1617 ));
1618 }
1619 previous_hash = Some(record.entry_hash.clone());
1620 }
1621
1622 if fixture.chain.root_hash != previous_hash {
1623 errors.push(format!(
1624 "chain root_hash mismatch; expected {:?}, found {:?}",
1625 previous_hash, fixture.chain.root_hash
1626 ));
1627 }
1628 errors
1629 }
1630
1631 fn approval_required(record: &TrustRecord) -> bool {
1632 record
1633 .metadata
1634 .get("approval")
1635 .and_then(|approval| approval.get("required"))
1636 .and_then(|required| required.as_bool())
1637 .unwrap_or(false)
1638 }
1639
1640 fn approval_signature_count(record: &TrustRecord) -> usize {
1641 record
1642 .metadata
1643 .get("approval")
1644 .and_then(|approval| approval.get("signatures"))
1645 .and_then(|signatures| signatures.as_array())
1646 .map(Vec::len)
1647 .unwrap_or(0)
1648 }
1649
1650 use crate::orchestration::{EffectKind, EffectScope};
1653
1654 #[test]
1655 fn new_trust_record_defaults_to_v0_1_schema() {
1656 let record = TrustRecord::new(
1657 "agent",
1658 "deploy.preview",
1659 None,
1660 TrustOutcome::Success,
1661 "trace-1",
1662 AutonomyTier::Suggest,
1663 );
1664 assert_eq!(record.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1665 }
1666
1667 #[test]
1668 fn v0_records_still_parse_for_backward_compat() {
1669 let record_v0 = serde_json::json!({
1670 "schema": "opentrustgraph/v0",
1671 "record_id": "01966f4c-0f31-7b5d-b44b-f7f8e7e1d384",
1672 "agent": "legacy-bot",
1673 "action": "github.issue.opened",
1674 "approver": null,
1675 "outcome": "success",
1676 "trace_id": "trace-legacy",
1677 "autonomy_tier": "suggest",
1678 "timestamp": "2026-04-19T18:42:11Z",
1679 "cost_usd": null,
1680 "chain_index": 1,
1681 "previous_hash": null,
1682 "entry_hash": "sha256:84facae7d56fd304e040ea18d80bd019e274ad86ddd5a4d732f3ac3d984c48ec",
1683 "metadata": {"provider": "github"}
1684 });
1685 let decoded: TrustRecord = serde_json::from_value(record_v0).unwrap();
1686 assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0);
1687 assert!(OPENTRUSTGRAPH_ACCEPTED_SCHEMAS.contains(&decoded.schema.as_str()));
1688 assert!(decoded.effects_grant().is_empty());
1689 assert!(decoded.effects_used().is_empty());
1690 assert!(decoded.parent_record_id().is_none());
1691 }
1692
1693 #[test]
1694 fn v0_1_effect_metadata_round_trips_through_json() {
1695 let grant = vec![
1696 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1697 .with_resource("https://api.example"),
1698 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1699 ];
1700 let used =
1701 vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
1702 .with_resource("/workspace/src")];
1703 let record = TrustRecord::new(
1704 "child-agent",
1705 "fs.read",
1706 None,
1707 TrustOutcome::Success,
1708 "trace-effects-1",
1709 AutonomyTier::ActAuto,
1710 )
1711 .with_effects_grant(grant.clone())
1712 .with_effects_used(used.clone())
1713 .with_parent_record_id("parent-record-001");
1714
1715 let encoded = serde_json::to_string(&record).unwrap();
1716 let decoded: TrustRecord = serde_json::from_str(&encoded).unwrap();
1717 assert_eq!(decoded.schema, OPENTRUSTGRAPH_SCHEMA_V0_1);
1718 assert_eq!(decoded.effects_grant(), grant);
1719 assert_eq!(decoded.effects_used(), used);
1720 assert_eq!(
1721 decoded.parent_record_id().as_deref(),
1722 Some("parent-record-001")
1723 );
1724 }
1725
1726 #[test]
1727 fn effect_helpers_remove_keys_on_empty_input() {
1728 let mut record = TrustRecord::new(
1729 "agent",
1730 "noop",
1731 None,
1732 TrustOutcome::Success,
1733 "trace-1",
1734 AutonomyTier::Suggest,
1735 )
1736 .with_effects_grant(vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)])
1737 .with_parent_record_id("parent-1");
1738 assert!(record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
1739 assert!(record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
1740
1741 record.set_effects_grant(Vec::new());
1742 record.set_parent_record_id(None);
1743 assert!(!record.metadata.contains_key(METADATA_KEY_EFFECTS_GRANT));
1744 assert!(!record.metadata.contains_key(METADATA_KEY_PARENT_RECORD_ID));
1745 }
1746
1747 #[tokio::test]
1748 async fn three_agent_chain_proves_effects_subset_inheritance() {
1749 let log: Arc<AnyEventLog> = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1750
1751 let parent_grant = vec![
1752 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1753 .with_resource("https://api.example"),
1754 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1755 EffectRecord::new(EffectKind::Fs, EffectScope::Write).with_resource("/workspace/tmp"),
1756 ];
1757 let parent = append_trust_record(
1758 &log,
1759 &TrustRecord::new(
1760 "parent",
1761 "agent.spawn",
1762 None,
1763 TrustOutcome::Success,
1764 "trace-parent",
1765 AutonomyTier::ActAuto,
1766 )
1767 .with_effects_grant(parent_grant.clone()),
1768 )
1769 .await
1770 .unwrap();
1771
1772 let child_grant = vec![
1773 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1774 .with_resource("https://api.example"),
1775 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1776 ];
1777 let child = append_trust_record(
1778 &log,
1779 &TrustRecord::new(
1780 "child",
1781 "agent.spawn",
1782 None,
1783 TrustOutcome::Success,
1784 "trace-child",
1785 AutonomyTier::ActAuto,
1786 )
1787 .with_effects_grant(child_grant.clone())
1788 .with_parent_record_id(parent.record_id.clone()),
1789 )
1790 .await
1791 .unwrap();
1792
1793 let grandchild_used =
1794 vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)
1795 .with_resource("/workspace/src")];
1796 let grandchild = append_trust_record(
1797 &log,
1798 &TrustRecord::new(
1799 "grandchild",
1800 "fs.read",
1801 None,
1802 TrustOutcome::Success,
1803 "trace-grandchild",
1804 AutonomyTier::ActAuto,
1805 )
1806 .with_effects_used(grandchild_used.clone())
1807 .with_parent_record_id(child.record_id.clone()),
1808 )
1809 .await
1810 .unwrap();
1811
1812 for effect in &grandchild_used {
1814 assert!(
1815 child_grant.contains(effect),
1816 "grandchild used {effect:?} not in child grant"
1817 );
1818 }
1819 for effect in &child_grant {
1821 assert!(
1822 parent_grant.contains(effect),
1823 "child grant {effect:?} not in parent grant"
1824 );
1825 }
1826
1827 assert_eq!(
1828 grandchild.parent_record_id().as_deref(),
1829 Some(child.record_id.as_str())
1830 );
1831 assert_eq!(
1832 child.parent_record_id().as_deref(),
1833 Some(parent.record_id.as_str())
1834 );
1835 assert!(parent.parent_record_id().is_none());
1836
1837 let report = verify_trust_chain(&log).await.unwrap();
1839 assert!(report.verified, "verification errors: {:?}", report.errors);
1840 assert_eq!(report.total, 3);
1841 }
1842}