use std::collections::BTreeMap;
use exo_core::{Did, Hash256, PublicKey, Signature, Timestamp, crypto, hash::hash_structured};
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct GovernanceAttestation {
pub signer_did: Did,
pub findings_digest: Hash256,
pub signature: Signature,
}
pub const GOVERNANCE_ATTESTATION_SIGNATURE_DOMAIN: &str =
"exo.gatekeeper.governance-monitor.attestation.v1";
#[derive(Serialize)]
struct GovernanceAttestationSignaturePayload<'a> {
domain: &'static str,
signer_did: &'a Did,
findings_digest: &'a Hash256,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum GovernanceMonitorError {
#[error("attestation signature is required")]
MissingAttestation,
#[error("attestation signature verification failed for signer {signer_did}")]
InvalidAttestation {
signer_did: Did,
},
#[error("findings payload digest encoding failed: {reason}")]
FindingsDigestEncodingFailed {
reason: String,
},
#[error("attestation signature message encoding failed: {reason}")]
AttestationMessageEncodingFailed {
reason: String,
},
#[error(
"attestation findings digest does not match canonical findings payload for signer {signer_did}"
)]
FindingsDigestMismatch {
signer_did: Did,
},
#[error(
"circuit breaker triggered: {critical_count} Critical findings in 24h (threshold: {threshold})"
)]
CircuitBreakerTripped {
critical_count: u64,
threshold: u64,
},
#[error("human approval required: run_id={run_id}")]
HumanApprovalRequired {
run_id: String,
},
#[error("approver must be a human DID (SignerType 0x01), got AI agent")]
ApproverNotHuman,
}
pub fn governance_findings_digest<T: Serialize>(
findings_payload: &T,
) -> Result<Hash256, GovernanceMonitorError> {
hash_structured(findings_payload).map_err(|err| {
GovernanceMonitorError::FindingsDigestEncodingFailed {
reason: err.to_string(),
}
})
}
pub fn governance_attestation_signature_message_digest(
signer_did: &Did,
findings_digest: &Hash256,
) -> Result<Hash256, GovernanceMonitorError> {
let payload = GovernanceAttestationSignaturePayload {
domain: GOVERNANCE_ATTESTATION_SIGNATURE_DOMAIN,
signer_did,
findings_digest,
};
hash_structured(&payload).map_err(|err| {
GovernanceMonitorError::AttestationMessageEncodingFailed {
reason: err.to_string(),
}
})
}
pub fn verify_attestation<T: Serialize>(
attestation: &GovernanceAttestation,
signer_public_key: &PublicKey,
findings_payload: &T,
) -> Result<(), GovernanceMonitorError> {
let computed_digest = governance_findings_digest(findings_payload)?;
if computed_digest != attestation.findings_digest {
return Err(GovernanceMonitorError::FindingsDigestMismatch {
signer_did: attestation.signer_did.clone(),
});
}
let message = governance_attestation_signature_message_digest(
&attestation.signer_did,
&attestation.findings_digest,
)?;
if crypto::verify(
message.as_bytes(),
&attestation.signature,
signer_public_key,
) {
Ok(())
} else {
Err(GovernanceMonitorError::InvalidAttestation {
signer_did: attestation.signer_did.clone(),
})
}
}
pub const CIRCUIT_BREAKER_THRESHOLD: u64 = 3;
pub const CIRCUIT_BREAKER_WINDOW_MS: u64 = 86_400_000;
#[derive(Debug, Clone)]
pub struct GovernanceCircuitBreaker {
critical_timestamps: BTreeMap<u64, u64>,
threshold: u64,
window_ms: u64,
}
impl Default for GovernanceCircuitBreaker {
fn default() -> Self {
Self::new()
}
}
impl GovernanceCircuitBreaker {
#[must_use]
pub fn new() -> Self {
Self {
critical_timestamps: BTreeMap::new(),
threshold: CIRCUIT_BREAKER_THRESHOLD,
window_ms: CIRCUIT_BREAKER_WINDOW_MS,
}
}
#[must_use]
pub fn with_thresholds(threshold: u64, window_ms: u64) -> Self {
Self {
critical_timestamps: BTreeMap::new(),
threshold,
window_ms,
}
}
pub fn record_critical_findings(&mut self, timestamp_ms: u64, critical_count: u64) {
if critical_count == 0 {
return;
}
let count = self.critical_timestamps.entry(timestamp_ms).or_insert(0);
*count = (*count).saturating_add(critical_count);
}
pub fn check(&self, now_ms: u64) -> Result<u64, GovernanceMonitorError> {
let window_start = now_ms.saturating_sub(self.window_ms);
let count = self
.critical_timestamps
.iter()
.filter(|(ts, _)| **ts >= window_start)
.fold(0u64, |total, (_, count)| total.saturating_add(*count));
if count > self.threshold {
Err(GovernanceMonitorError::CircuitBreakerTripped {
critical_count: count,
threshold: self.threshold,
})
} else {
Ok(count)
}
}
pub fn evict_expired(&mut self, now_ms: u64) {
let window_start = now_ms.saturating_sub(self.window_ms);
self.critical_timestamps.retain(|&ts, _| ts >= window_start);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalStatus {
Pending,
Approved {
approved_by: Did,
approved_at: Timestamp,
},
Rejected {
rejected_by: Did,
rejected_at: Timestamp,
},
}
#[derive(Debug, Clone)]
pub struct ApprovalGate {
pub run_id: String,
pub status: ApprovalStatus,
}
impl ApprovalGate {
#[must_use]
pub fn new(run_id: String) -> Self {
Self {
run_id,
status: ApprovalStatus::Pending,
}
}
pub fn approve(
&mut self,
approver_did: Did,
signer_type: &exo_core::SignerType,
timestamp: Timestamp,
) -> Result<(), GovernanceMonitorError> {
if *signer_type != exo_core::SignerType::Human {
return Err(GovernanceMonitorError::ApproverNotHuman);
}
self.status = ApprovalStatus::Approved {
approved_by: approver_did,
approved_at: timestamp,
};
Ok(())
}
pub fn reject(
&mut self,
rejector_did: Did,
signer_type: &exo_core::SignerType,
timestamp: Timestamp,
) -> Result<(), GovernanceMonitorError> {
if *signer_type != exo_core::SignerType::Human {
return Err(GovernanceMonitorError::ApproverNotHuman);
}
self.status = ApprovalStatus::Rejected {
rejected_by: rejector_did,
rejected_at: timestamp,
};
Ok(())
}
#[must_use]
pub fn is_approved(&self) -> bool {
matches!(self.status, ApprovalStatus::Approved { .. })
}
#[must_use]
pub fn is_pending(&self) -> bool {
matches!(self.status, ApprovalStatus::Pending)
}
}
#[must_use]
pub fn requires_approval_gate(critical_count: u64, high_count: u64) -> bool {
critical_count > 0 || high_count > 0
}
#[cfg(test)]
mod tests {
use exo_core::crypto::{generate_keypair, sign};
use super::*;
fn test_did(name: &str) -> Did {
Did::new(&format!("did:exo:{name}")).expect("valid")
}
fn make_attestation(
findings_digest: Hash256,
signer_did: Did,
secret: &exo_core::SecretKey,
) -> GovernanceAttestation {
let message =
governance_attestation_signature_message_digest(&signer_did, &findings_digest)
.expect("signature message digest");
let signature = sign(message.as_bytes(), secret);
GovernanceAttestation {
signer_did,
findings_digest,
signature,
}
}
fn findings_payload(label: &str, severity: &str) -> serde_json::Value {
serde_json::json!([
{
"id": label,
"severity": severity,
"title": "governance monitor finding"
}
])
}
#[test]
fn valid_attestation_passes() {
let (pk, sk) = generate_keypair();
let findings = findings_payload("F-001", "critical");
let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
let attestation = make_attestation(digest, test_did("scanner"), &sk);
assert!(verify_attestation(&attestation, &pk, &findings).is_ok());
}
#[test]
fn attestation_rejects_signature_replayed_for_different_findings_payload() {
let (pk, sk) = generate_keypair();
let signed_findings = findings_payload("F-001", "low");
let substituted_findings = findings_payload("F-999", "critical");
let signed_digest =
exo_core::hash::hash_structured(&signed_findings).expect("findings digest");
let attestation = make_attestation(signed_digest, test_did("scanner"), &sk);
let err = verify_attestation(&attestation, &pk, &substituted_findings).unwrap_err();
assert!(matches!(
err,
GovernanceMonitorError::FindingsDigestMismatch { .. }
));
}
#[test]
fn attestation_rejects_signature_replayed_with_relabelled_signer_did() {
let (pk, sk) = generate_keypair();
let findings = findings_payload("F-001", "critical");
let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
let mut attestation = make_attestation(digest, test_did("scanner"), &sk);
attestation.signer_did = test_did("impersonated-scanner");
let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
assert!(matches!(
err,
GovernanceMonitorError::InvalidAttestation { .. }
));
}
#[test]
fn attestation_rejects_digest_only_signature_without_domain_context() {
let (pk, sk) = generate_keypair();
let findings = findings_payload("F-001", "critical");
let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
let signature = sign(digest.as_bytes(), &sk);
let attestation = GovernanceAttestation {
signer_did: test_did("scanner"),
findings_digest: digest,
signature,
};
let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
assert!(matches!(
err,
GovernanceMonitorError::InvalidAttestation { .. }
));
}
#[test]
fn attestation_signature_message_binds_domain_signer_and_findings_digest() {
let signer = test_did("scanner");
let findings = findings_payload("F-001", "critical");
let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
let signer_message =
governance_attestation_signature_message_digest(&signer, &digest).expect("message");
let relabelled_message =
governance_attestation_signature_message_digest(&test_did("other-scanner"), &digest)
.expect("message");
let other_digest = Hash256::digest(b"other findings");
let other_findings_message =
governance_attestation_signature_message_digest(&signer, &other_digest)
.expect("message");
assert_ne!(
signer_message, digest,
"signature message must not be the raw findings digest"
);
assert_ne!(
signer_message, relabelled_message,
"signature message must bind the signer DID"
);
assert_ne!(
signer_message, other_findings_message,
"signature message must bind the findings digest"
);
}
#[test]
fn wrong_key_attestation_fails() {
let (_pk, sk) = generate_keypair();
let (wrong_pk, _) = generate_keypair();
let findings = findings_payload("F-001", "critical");
let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
let attestation = make_attestation(digest, test_did("scanner"), &sk);
let err = verify_attestation(&attestation, &wrong_pk, &findings).unwrap_err();
assert!(matches!(
err,
GovernanceMonitorError::InvalidAttestation { .. }
));
}
#[test]
fn tampered_digest_fails() {
let (pk, sk) = generate_keypair();
let findings = findings_payload("F-001", "critical");
let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
let mut attestation = make_attestation(digest, test_did("scanner"), &sk);
attestation.findings_digest = Hash256::digest(b"tampered");
let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
assert!(matches!(
err,
GovernanceMonitorError::FindingsDigestMismatch { .. }
));
}
#[test]
fn circuit_breaker_healthy_when_below_threshold() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
cb.record_critical_findings(1000, 2);
let count = cb.check(2000).expect("should be healthy");
assert_eq!(count, 2);
}
#[test]
fn circuit_breaker_trips_above_threshold() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
cb.record_critical_findings(1000, 2);
cb.record_critical_findings(2000, 2);
let err = cb.check(3000).unwrap_err();
assert!(matches!(
err,
GovernanceMonitorError::CircuitBreakerTripped {
critical_count: 4,
threshold: 3
}
));
}
#[test]
fn circuit_breaker_expired_findings_not_counted() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 1000); cb.record_critical_findings(100, 4);
let count = cb.check(1200).expect("should be healthy after expiry");
assert_eq!(count, 0);
}
#[test]
fn circuit_breaker_eviction() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 1000);
cb.record_critical_findings(100, 4);
cb.evict_expired(1200);
assert_eq!(cb.critical_timestamps.len(), 0);
}
#[test]
fn circuit_breaker_exactly_at_threshold_is_ok() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
cb.record_critical_findings(1000, 3);
let count = cb.check(2000).expect("exactly at threshold should pass");
assert_eq!(count, 3);
}
#[test]
fn circuit_breaker_records_many_findings_as_one_timestamp_bucket() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
cb.record_critical_findings(1000, 1024);
assert_eq!(cb.critical_timestamps.len(), 1);
assert!(matches!(
cb.check(2000),
Err(GovernanceMonitorError::CircuitBreakerTripped {
critical_count: 1024,
threshold: 3
})
));
}
#[test]
fn circuit_breaker_coalesces_repeated_timestamp_counts() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
cb.record_critical_findings(1000, 2);
cb.record_critical_findings(1000, 3);
assert_eq!(cb.critical_timestamps.len(), 1);
assert!(matches!(
cb.check(2000),
Err(GovernanceMonitorError::CircuitBreakerTripped {
critical_count: 5,
threshold: 3
})
));
}
#[test]
fn circuit_breaker_zero_count_does_not_create_bucket() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
cb.record_critical_findings(1000, 0);
assert_eq!(cb.critical_timestamps.len(), 0);
assert!(matches!(cb.check(2000), Ok(0)));
}
#[test]
fn circuit_breaker_saturates_count_overflow_without_per_finding_allocation() {
let mut cb = GovernanceCircuitBreaker::with_thresholds(u64::MAX - 1, 86_400_000);
cb.record_critical_findings(1000, u64::MAX);
cb.record_critical_findings(2000, 1);
assert_eq!(cb.critical_timestamps.len(), 2);
assert!(matches!(
cb.check(3000),
Err(GovernanceMonitorError::CircuitBreakerTripped {
critical_count: u64::MAX,
threshold
}) if threshold == u64::MAX - 1
));
}
#[test]
fn circuit_breaker_default_thresholds() {
let cb = GovernanceCircuitBreaker::new();
assert_eq!(cb.threshold, CIRCUIT_BREAKER_THRESHOLD);
assert_eq!(cb.window_ms, CIRCUIT_BREAKER_WINDOW_MS);
}
#[test]
fn approval_gate_starts_pending() {
let gate = ApprovalGate::new("run-001".to_string());
assert!(gate.is_pending());
assert!(!gate.is_approved());
}
#[test]
fn human_can_approve() {
let mut gate = ApprovalGate::new("run-001".to_string());
let did = test_did("human-operator");
let ts = Timestamp::new(5000, 0);
gate.approve(did, &exo_core::SignerType::Human, ts)
.expect("human approval should succeed");
assert!(gate.is_approved());
assert!(!gate.is_pending());
}
#[test]
fn ai_cannot_approve() {
let mut gate = ApprovalGate::new("run-001".to_string());
let did = test_did("ai-agent");
let ts = Timestamp::new(5000, 0);
let ai_signer = exo_core::SignerType::Ai {
delegation_id: Hash256::ZERO,
};
let err = gate.approve(did, &ai_signer, ts).unwrap_err();
assert!(matches!(err, GovernanceMonitorError::ApproverNotHuman));
assert!(gate.is_pending()); }
#[test]
fn human_can_reject() {
let mut gate = ApprovalGate::new("run-001".to_string());
let did = test_did("human-operator");
let ts = Timestamp::new(5000, 0);
gate.reject(did, &exo_core::SignerType::Human, ts)
.expect("human rejection should succeed");
assert!(!gate.is_approved());
assert!(!gate.is_pending());
assert!(matches!(gate.status, ApprovalStatus::Rejected { .. }));
}
#[test]
fn ai_cannot_reject() {
let mut gate = ApprovalGate::new("run-001".to_string());
let did = test_did("ai-agent");
let ts = Timestamp::new(5000, 0);
let ai_signer = exo_core::SignerType::Ai {
delegation_id: Hash256::ZERO,
};
let err = gate.reject(did, &ai_signer, ts).unwrap_err();
assert!(matches!(err, GovernanceMonitorError::ApproverNotHuman));
}
#[test]
fn critical_findings_require_approval() {
assert!(requires_approval_gate(1, 0));
}
#[test]
fn high_findings_require_approval() {
assert!(requires_approval_gate(0, 1));
}
#[test]
fn no_critical_or_high_no_approval_needed() {
assert!(!requires_approval_gate(0, 0));
}
#[test]
fn both_critical_and_high_require_approval() {
assert!(requires_approval_gate(2, 3));
}
}