Skip to main content

exo_gatekeeper/
governance_monitor.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Governance Monitor Poisoning defense (T-14).
18//!
19//! Validates governance health attestation payloads before they are persisted
20//! or acted upon, preventing adversarial manipulation of continuous governance
21//! monitoring output. Implements three sub-mitigations:
22//!
23//! 1. **Signed attestation verification** — rejects payloads without a valid
24//!    Ed25519 signature over a domain-separated canonical message binding the
25//!    signer DID to the findings payload digest (sub-threat T-14a).
26//! 2. **Circuit breaker** — auto-pauses self-improvement when >3 Critical
27//!    findings are recorded within a 24-hour window (sub-threat T-14c).
28//! 3. **Human approval gate** — requires human-DID (`SignerType 0x01`)
29//!    approval before self-improvement cycle may begin (sub-threat T-14b).
30//!
31//! This module is a pure validation library — no database or I/O dependency.
32//! The persistence layer is the caller's concern.
33
34use std::collections::BTreeMap;
35
36use exo_core::{Did, Hash256, PublicKey, Signature, Timestamp, crypto, hash::hash_structured};
37use serde::Serialize;
38
39// ---------------------------------------------------------------------------
40// Signed attestation envelope (T-14a)
41// ---------------------------------------------------------------------------
42
43/// A signed governance health attestation.
44///
45/// The signature covers [`governance_attestation_signature_message_digest`],
46/// which binds the signer DID, a governance-monitor domain separator, and the
47/// `findings_digest`. The digest must match the canonical BLAKE3/CBOR digest of
48/// the findings payload passed to [`verify_attestation`].
49#[derive(Debug, Clone)]
50pub struct GovernanceAttestation {
51    /// DID of the entity that produced this attestation.
52    pub signer_did: Did,
53    /// BLAKE3 hash of the canonical findings payload.
54    pub findings_digest: Hash256,
55    /// Ed25519 signature over the governance attestation message digest bytes.
56    pub signature: Signature,
57}
58
59/// Domain separator for governance-monitor attestation signatures.
60pub const GOVERNANCE_ATTESTATION_SIGNATURE_DOMAIN: &str =
61    "exo.gatekeeper.governance-monitor.attestation.v1";
62
63#[derive(Serialize)]
64struct GovernanceAttestationSignaturePayload<'a> {
65    domain: &'static str,
66    signer_did: &'a Did,
67    findings_digest: &'a Hash256,
68}
69
70/// Errors from governance monitor validation.
71#[derive(Debug, Clone, thiserror::Error)]
72pub enum GovernanceMonitorError {
73    /// Attestation signature is missing.
74    #[error("attestation signature is required")]
75    MissingAttestation,
76
77    /// Attestation signature is invalid.
78    #[error("attestation signature verification failed for signer {signer_did}")]
79    InvalidAttestation {
80        /// DID of the claimed signer.
81        signer_did: Did,
82    },
83
84    /// Findings payload could not be canonically encoded for digesting.
85    #[error("findings payload digest encoding failed: {reason}")]
86    FindingsDigestEncodingFailed {
87        /// Encoding failure reason.
88        reason: String,
89    },
90
91    /// Attestation signature message could not be canonically encoded.
92    #[error("attestation signature message encoding failed: {reason}")]
93    AttestationMessageEncodingFailed {
94        /// Encoding failure reason.
95        reason: String,
96    },
97
98    /// Attested digest does not match the actual findings payload.
99    #[error(
100        "attestation findings digest does not match canonical findings payload for signer {signer_did}"
101    )]
102    FindingsDigestMismatch {
103        /// DID of the claimed signer.
104        signer_did: Did,
105    },
106
107    /// Circuit breaker has been tripped — too many Critical findings.
108    #[error(
109        "circuit breaker triggered: {critical_count} Critical findings in 24h (threshold: {threshold})"
110    )]
111    CircuitBreakerTripped {
112        /// Number of Critical findings in the window.
113        critical_count: u64,
114        /// The threshold that was exceeded.
115        threshold: u64,
116    },
117
118    /// Self-improvement trigger requires human approval.
119    #[error("human approval required: run_id={run_id}")]
120    HumanApprovalRequired {
121        /// The run ID that needs approval.
122        run_id: String,
123    },
124
125    /// The approver is not a human DID (SignerType prefix != 0x01).
126    #[error("approver must be a human DID (SignerType 0x01), got AI agent")]
127    ApproverNotHuman,
128}
129
130/// Compute the canonical digest for a governance findings payload.
131///
132/// # Errors
133///
134/// Returns [`GovernanceMonitorError::FindingsDigestEncodingFailed`] if the
135/// payload cannot be encoded with the canonical structured hash format.
136pub fn governance_findings_digest<T: Serialize>(
137    findings_payload: &T,
138) -> Result<Hash256, GovernanceMonitorError> {
139    hash_structured(findings_payload).map_err(|err| {
140        GovernanceMonitorError::FindingsDigestEncodingFailed {
141            reason: err.to_string(),
142        }
143    })
144}
145
146/// Compute the canonical domain-separated message digest that must be signed
147/// for a governance monitor attestation.
148///
149/// # Errors
150///
151/// Returns [`GovernanceMonitorError::AttestationMessageEncodingFailed`] if the
152/// message cannot be encoded with the canonical structured hash format.
153pub fn governance_attestation_signature_message_digest(
154    signer_did: &Did,
155    findings_digest: &Hash256,
156) -> Result<Hash256, GovernanceMonitorError> {
157    let payload = GovernanceAttestationSignaturePayload {
158        domain: GOVERNANCE_ATTESTATION_SIGNATURE_DOMAIN,
159        signer_did,
160        findings_digest,
161    };
162    hash_structured(&payload).map_err(|err| {
163        GovernanceMonitorError::AttestationMessageEncodingFailed {
164            reason: err.to_string(),
165        }
166    })
167}
168
169/// Verify the cryptographic attestation on a governance health payload.
170///
171/// **Security: This MUST be called BEFORE any data is stored or circuit
172/// breaker state is updated.** An attacker injecting unsigned payloads
173/// must never influence the circuit breaker's critical-finding counter.
174///
175/// # Errors
176///
177/// Returns [`GovernanceMonitorError::InvalidAttestation`] if the signature
178/// does not verify against the signer's public key.
179///
180/// Returns [`GovernanceMonitorError::FindingsDigestMismatch`] if the digest
181/// in the attestation is not the canonical digest of `findings_payload`.
182pub fn verify_attestation<T: Serialize>(
183    attestation: &GovernanceAttestation,
184    signer_public_key: &PublicKey,
185    findings_payload: &T,
186) -> Result<(), GovernanceMonitorError> {
187    let computed_digest = governance_findings_digest(findings_payload)?;
188    if computed_digest != attestation.findings_digest {
189        return Err(GovernanceMonitorError::FindingsDigestMismatch {
190            signer_did: attestation.signer_did.clone(),
191        });
192    }
193
194    let message = governance_attestation_signature_message_digest(
195        &attestation.signer_did,
196        &attestation.findings_digest,
197    )?;
198    if crypto::verify(
199        message.as_bytes(),
200        &attestation.signature,
201        signer_public_key,
202    ) {
203        Ok(())
204    } else {
205        Err(GovernanceMonitorError::InvalidAttestation {
206            signer_did: attestation.signer_did.clone(),
207        })
208    }
209}
210
211// ---------------------------------------------------------------------------
212// Circuit breaker (T-14c)
213// ---------------------------------------------------------------------------
214
215/// Circuit breaker threshold: maximum Critical findings in a 24-hour window
216/// before auto-pause triggers.
217pub const CIRCUIT_BREAKER_THRESHOLD: u64 = 3;
218
219/// Duration of the circuit breaker window in milliseconds (24 hours).
220pub const CIRCUIT_BREAKER_WINDOW_MS: u64 = 86_400_000;
221
222/// In-memory circuit breaker tracking Critical finding timestamps.
223///
224/// Callers feed in timestamps of Critical findings; the breaker trips
225/// when more than [`CIRCUIT_BREAKER_THRESHOLD`] Critical findings have
226/// been recorded within [`CIRCUIT_BREAKER_WINDOW_MS`].
227#[derive(Debug, Clone)]
228pub struct GovernanceCircuitBreaker {
229    /// Recent Critical findings grouped by timestamp (physical_ms).
230    critical_timestamps: BTreeMap<u64, u64>,
231    /// The threshold above which the breaker trips.
232    threshold: u64,
233    /// Window duration in milliseconds.
234    window_ms: u64,
235}
236
237impl Default for GovernanceCircuitBreaker {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl GovernanceCircuitBreaker {
244    /// Create a new circuit breaker with default thresholds.
245    #[must_use]
246    pub fn new() -> Self {
247        Self {
248            critical_timestamps: BTreeMap::new(),
249            threshold: CIRCUIT_BREAKER_THRESHOLD,
250            window_ms: CIRCUIT_BREAKER_WINDOW_MS,
251        }
252    }
253
254    /// Create a circuit breaker with custom thresholds (for testing).
255    #[must_use]
256    pub fn with_thresholds(threshold: u64, window_ms: u64) -> Self {
257        Self {
258            critical_timestamps: BTreeMap::new(),
259            threshold,
260            window_ms,
261        }
262    }
263
264    /// Record Critical findings from a scan at the given timestamp.
265    ///
266    /// `critical_count` is the number of Critical-severity findings in
267    /// a single scan run.
268    pub fn record_critical_findings(&mut self, timestamp_ms: u64, critical_count: u64) {
269        if critical_count == 0 {
270            return;
271        }
272
273        let count = self.critical_timestamps.entry(timestamp_ms).or_insert(0);
274        *count = (*count).saturating_add(critical_count);
275    }
276
277    /// Check whether the circuit breaker has tripped.
278    ///
279    /// Counts Critical findings within the window ending at `now_ms`.
280    /// Returns `Ok(count_in_window)` if the breaker is healthy,
281    /// or `Err(CircuitBreakerTripped)` if the threshold is exceeded.
282    pub fn check(&self, now_ms: u64) -> Result<u64, GovernanceMonitorError> {
283        let window_start = now_ms.saturating_sub(self.window_ms);
284        let count = self
285            .critical_timestamps
286            .iter()
287            .filter(|(ts, _)| **ts >= window_start)
288            .fold(0u64, |total, (_, count)| total.saturating_add(*count));
289
290        if count > self.threshold {
291            Err(GovernanceMonitorError::CircuitBreakerTripped {
292                critical_count: count,
293                threshold: self.threshold,
294            })
295        } else {
296            Ok(count)
297        }
298    }
299
300    /// Evict timestamps older than the window (housekeeping).
301    pub fn evict_expired(&mut self, now_ms: u64) {
302        let window_start = now_ms.saturating_sub(self.window_ms);
303        self.critical_timestamps.retain(|&ts, _| ts >= window_start);
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Human approval gate (T-14b)
309// ---------------------------------------------------------------------------
310
311/// Approval status for a self-improvement trigger.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum ApprovalStatus {
314    /// Approval is pending human review.
315    Pending,
316    /// Approved by a human DID.
317    Approved {
318        /// DID of the human approver.
319        approved_by: Did,
320        /// Timestamp of approval.
321        approved_at: Timestamp,
322    },
323    /// Rejected by a human DID.
324    Rejected {
325        /// DID of the human rejector.
326        rejected_by: Did,
327        /// Timestamp of rejection.
328        rejected_at: Timestamp,
329    },
330}
331
332/// A pending approval gate for a self-improvement cycle trigger.
333#[derive(Debug, Clone)]
334pub struct ApprovalGate {
335    /// The run ID that triggered the approval requirement.
336    pub run_id: String,
337    /// Current approval status.
338    pub status: ApprovalStatus,
339}
340
341impl ApprovalGate {
342    /// Create a new pending approval gate.
343    #[must_use]
344    pub fn new(run_id: String) -> Self {
345        Self {
346            run_id,
347            status: ApprovalStatus::Pending,
348        }
349    }
350
351    /// Approve the gate with a human DID.
352    ///
353    /// # Errors
354    ///
355    /// Returns [`GovernanceMonitorError::ApproverNotHuman`] if the
356    /// approver's signer type is not human (prefix 0x01).
357    pub fn approve(
358        &mut self,
359        approver_did: Did,
360        signer_type: &exo_core::SignerType,
361        timestamp: Timestamp,
362    ) -> Result<(), GovernanceMonitorError> {
363        // TNC-02: Only human signers may approve self-improvement triggers
364        if *signer_type != exo_core::SignerType::Human {
365            return Err(GovernanceMonitorError::ApproverNotHuman);
366        }
367
368        self.status = ApprovalStatus::Approved {
369            approved_by: approver_did,
370            approved_at: timestamp,
371        };
372        Ok(())
373    }
374
375    /// Reject the gate with a human DID.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`GovernanceMonitorError::ApproverNotHuman`] if the
380    /// rejector's signer type is not human.
381    pub fn reject(
382        &mut self,
383        rejector_did: Did,
384        signer_type: &exo_core::SignerType,
385        timestamp: Timestamp,
386    ) -> Result<(), GovernanceMonitorError> {
387        if *signer_type != exo_core::SignerType::Human {
388            return Err(GovernanceMonitorError::ApproverNotHuman);
389        }
390
391        self.status = ApprovalStatus::Rejected {
392            rejected_by: rejector_did,
393            rejected_at: timestamp,
394        };
395        Ok(())
396    }
397
398    /// Whether the gate is approved and the cycle may proceed.
399    #[must_use]
400    pub fn is_approved(&self) -> bool {
401        matches!(self.status, ApprovalStatus::Approved { .. })
402    }
403
404    /// Whether the gate is still pending.
405    #[must_use]
406    pub fn is_pending(&self) -> bool {
407        matches!(self.status, ApprovalStatus::Pending)
408    }
409}
410
411/// Determine whether a scan result requires a human approval gate.
412///
413/// Per T-14b: Critical or High findings require human approval before
414/// any self-improvement cycle may begin implementation.
415#[must_use]
416pub fn requires_approval_gate(critical_count: u64, high_count: u64) -> bool {
417    critical_count > 0 || high_count > 0
418}
419
420// ===========================================================================
421// Tests
422// ===========================================================================
423
424#[cfg(test)]
425mod tests {
426    use exo_core::crypto::{generate_keypair, sign};
427
428    use super::*;
429
430    fn test_did(name: &str) -> Did {
431        Did::new(&format!("did:exo:{name}")).expect("valid")
432    }
433
434    fn make_attestation(
435        findings_digest: Hash256,
436        signer_did: Did,
437        secret: &exo_core::SecretKey,
438    ) -> GovernanceAttestation {
439        let message =
440            governance_attestation_signature_message_digest(&signer_did, &findings_digest)
441                .expect("signature message digest");
442        let signature = sign(message.as_bytes(), secret);
443        GovernanceAttestation {
444            signer_did,
445            findings_digest,
446            signature,
447        }
448    }
449
450    fn findings_payload(label: &str, severity: &str) -> serde_json::Value {
451        serde_json::json!([
452            {
453                "id": label,
454                "severity": severity,
455                "title": "governance monitor finding"
456            }
457        ])
458    }
459
460    // ── Attestation verification tests ──────────────────────────────────
461
462    #[test]
463    fn valid_attestation_passes() {
464        let (pk, sk) = generate_keypair();
465        let findings = findings_payload("F-001", "critical");
466        let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
467        let attestation = make_attestation(digest, test_did("scanner"), &sk);
468
469        assert!(verify_attestation(&attestation, &pk, &findings).is_ok());
470    }
471
472    #[test]
473    fn attestation_rejects_signature_replayed_for_different_findings_payload() {
474        let (pk, sk) = generate_keypair();
475        let signed_findings = findings_payload("F-001", "low");
476        let substituted_findings = findings_payload("F-999", "critical");
477        let signed_digest =
478            exo_core::hash::hash_structured(&signed_findings).expect("findings digest");
479        let attestation = make_attestation(signed_digest, test_did("scanner"), &sk);
480
481        let err = verify_attestation(&attestation, &pk, &substituted_findings).unwrap_err();
482        assert!(matches!(
483            err,
484            GovernanceMonitorError::FindingsDigestMismatch { .. }
485        ));
486    }
487
488    #[test]
489    fn attestation_rejects_signature_replayed_with_relabelled_signer_did() {
490        let (pk, sk) = generate_keypair();
491        let findings = findings_payload("F-001", "critical");
492        let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
493        let mut attestation = make_attestation(digest, test_did("scanner"), &sk);
494        attestation.signer_did = test_did("impersonated-scanner");
495
496        let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
497        assert!(matches!(
498            err,
499            GovernanceMonitorError::InvalidAttestation { .. }
500        ));
501    }
502
503    #[test]
504    fn attestation_rejects_digest_only_signature_without_domain_context() {
505        let (pk, sk) = generate_keypair();
506        let findings = findings_payload("F-001", "critical");
507        let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
508        let signature = sign(digest.as_bytes(), &sk);
509        let attestation = GovernanceAttestation {
510            signer_did: test_did("scanner"),
511            findings_digest: digest,
512            signature,
513        };
514
515        let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
516        assert!(matches!(
517            err,
518            GovernanceMonitorError::InvalidAttestation { .. }
519        ));
520    }
521
522    #[test]
523    fn attestation_signature_message_binds_domain_signer_and_findings_digest() {
524        let signer = test_did("scanner");
525        let findings = findings_payload("F-001", "critical");
526        let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
527        let signer_message =
528            governance_attestation_signature_message_digest(&signer, &digest).expect("message");
529        let relabelled_message =
530            governance_attestation_signature_message_digest(&test_did("other-scanner"), &digest)
531                .expect("message");
532        let other_digest = Hash256::digest(b"other findings");
533        let other_findings_message =
534            governance_attestation_signature_message_digest(&signer, &other_digest)
535                .expect("message");
536
537        assert_ne!(
538            signer_message, digest,
539            "signature message must not be the raw findings digest"
540        );
541        assert_ne!(
542            signer_message, relabelled_message,
543            "signature message must bind the signer DID"
544        );
545        assert_ne!(
546            signer_message, other_findings_message,
547            "signature message must bind the findings digest"
548        );
549    }
550
551    #[test]
552    fn wrong_key_attestation_fails() {
553        let (_pk, sk) = generate_keypair();
554        let (wrong_pk, _) = generate_keypair();
555        let findings = findings_payload("F-001", "critical");
556        let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
557        let attestation = make_attestation(digest, test_did("scanner"), &sk);
558
559        let err = verify_attestation(&attestation, &wrong_pk, &findings).unwrap_err();
560        assert!(matches!(
561            err,
562            GovernanceMonitorError::InvalidAttestation { .. }
563        ));
564    }
565
566    #[test]
567    fn tampered_digest_fails() {
568        let (pk, sk) = generate_keypair();
569        let findings = findings_payload("F-001", "critical");
570        let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
571        let mut attestation = make_attestation(digest, test_did("scanner"), &sk);
572
573        // Tamper with the digest after signing
574        attestation.findings_digest = Hash256::digest(b"tampered");
575
576        let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
577        assert!(matches!(
578            err,
579            GovernanceMonitorError::FindingsDigestMismatch { .. }
580        ));
581    }
582
583    // ── Circuit breaker tests ───────────────────────────────────────────
584
585    #[test]
586    fn circuit_breaker_healthy_when_below_threshold() {
587        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
588        cb.record_critical_findings(1000, 2);
589
590        let count = cb.check(2000).expect("should be healthy");
591        assert_eq!(count, 2);
592    }
593
594    #[test]
595    fn circuit_breaker_trips_above_threshold() {
596        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
597        cb.record_critical_findings(1000, 2);
598        cb.record_critical_findings(2000, 2); // total = 4, threshold = 3
599
600        let err = cb.check(3000).unwrap_err();
601        assert!(matches!(
602            err,
603            GovernanceMonitorError::CircuitBreakerTripped {
604                critical_count: 4,
605                threshold: 3
606            }
607        ));
608    }
609
610    #[test]
611    fn circuit_breaker_expired_findings_not_counted() {
612        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 1000); // 1s window
613        cb.record_critical_findings(100, 4); // 4 findings at t=100
614
615        // At t=1200, the window is [200, 1200] — t=100 is outside
616        let count = cb.check(1200).expect("should be healthy after expiry");
617        assert_eq!(count, 0);
618    }
619
620    #[test]
621    fn circuit_breaker_eviction() {
622        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 1000);
623        cb.record_critical_findings(100, 4);
624        cb.evict_expired(1200);
625
626        assert_eq!(cb.critical_timestamps.len(), 0);
627    }
628
629    #[test]
630    fn circuit_breaker_exactly_at_threshold_is_ok() {
631        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
632        cb.record_critical_findings(1000, 3); // exactly 3 = threshold
633
634        // threshold check is > not >=, so exactly at threshold is OK
635        let count = cb.check(2000).expect("exactly at threshold should pass");
636        assert_eq!(count, 3);
637    }
638
639    #[test]
640    fn circuit_breaker_records_many_findings_as_one_timestamp_bucket() {
641        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
642        cb.record_critical_findings(1000, 1024);
643
644        assert_eq!(cb.critical_timestamps.len(), 1);
645        assert!(matches!(
646            cb.check(2000),
647            Err(GovernanceMonitorError::CircuitBreakerTripped {
648                critical_count: 1024,
649                threshold: 3
650            })
651        ));
652    }
653
654    #[test]
655    fn circuit_breaker_coalesces_repeated_timestamp_counts() {
656        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
657        cb.record_critical_findings(1000, 2);
658        cb.record_critical_findings(1000, 3);
659
660        assert_eq!(cb.critical_timestamps.len(), 1);
661        assert!(matches!(
662            cb.check(2000),
663            Err(GovernanceMonitorError::CircuitBreakerTripped {
664                critical_count: 5,
665                threshold: 3
666            })
667        ));
668    }
669
670    #[test]
671    fn circuit_breaker_zero_count_does_not_create_bucket() {
672        let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
673        cb.record_critical_findings(1000, 0);
674
675        assert_eq!(cb.critical_timestamps.len(), 0);
676        assert!(matches!(cb.check(2000), Ok(0)));
677    }
678
679    #[test]
680    fn circuit_breaker_saturates_count_overflow_without_per_finding_allocation() {
681        let mut cb = GovernanceCircuitBreaker::with_thresholds(u64::MAX - 1, 86_400_000);
682        cb.record_critical_findings(1000, u64::MAX);
683        cb.record_critical_findings(2000, 1);
684
685        assert_eq!(cb.critical_timestamps.len(), 2);
686        assert!(matches!(
687            cb.check(3000),
688            Err(GovernanceMonitorError::CircuitBreakerTripped {
689                critical_count: u64::MAX,
690                threshold
691            }) if threshold == u64::MAX - 1
692        ));
693    }
694
695    #[test]
696    fn circuit_breaker_default_thresholds() {
697        let cb = GovernanceCircuitBreaker::new();
698        assert_eq!(cb.threshold, CIRCUIT_BREAKER_THRESHOLD);
699        assert_eq!(cb.window_ms, CIRCUIT_BREAKER_WINDOW_MS);
700    }
701
702    // ── Human approval gate tests ───────────────────────────────────────
703
704    #[test]
705    fn approval_gate_starts_pending() {
706        let gate = ApprovalGate::new("run-001".to_string());
707        assert!(gate.is_pending());
708        assert!(!gate.is_approved());
709    }
710
711    #[test]
712    fn human_can_approve() {
713        let mut gate = ApprovalGate::new("run-001".to_string());
714        let did = test_did("human-operator");
715        let ts = Timestamp::new(5000, 0);
716
717        gate.approve(did, &exo_core::SignerType::Human, ts)
718            .expect("human approval should succeed");
719
720        assert!(gate.is_approved());
721        assert!(!gate.is_pending());
722    }
723
724    #[test]
725    fn ai_cannot_approve() {
726        let mut gate = ApprovalGate::new("run-001".to_string());
727        let did = test_did("ai-agent");
728        let ts = Timestamp::new(5000, 0);
729        let ai_signer = exo_core::SignerType::Ai {
730            delegation_id: Hash256::ZERO,
731        };
732
733        let err = gate.approve(did, &ai_signer, ts).unwrap_err();
734        assert!(matches!(err, GovernanceMonitorError::ApproverNotHuman));
735        assert!(gate.is_pending()); // status unchanged
736    }
737
738    #[test]
739    fn human_can_reject() {
740        let mut gate = ApprovalGate::new("run-001".to_string());
741        let did = test_did("human-operator");
742        let ts = Timestamp::new(5000, 0);
743
744        gate.reject(did, &exo_core::SignerType::Human, ts)
745            .expect("human rejection should succeed");
746
747        assert!(!gate.is_approved());
748        assert!(!gate.is_pending());
749        assert!(matches!(gate.status, ApprovalStatus::Rejected { .. }));
750    }
751
752    #[test]
753    fn ai_cannot_reject() {
754        let mut gate = ApprovalGate::new("run-001".to_string());
755        let did = test_did("ai-agent");
756        let ts = Timestamp::new(5000, 0);
757        let ai_signer = exo_core::SignerType::Ai {
758            delegation_id: Hash256::ZERO,
759        };
760
761        let err = gate.reject(did, &ai_signer, ts).unwrap_err();
762        assert!(matches!(err, GovernanceMonitorError::ApproverNotHuman));
763    }
764
765    // ── Approval gate trigger tests ─────────────────────────────────────
766
767    #[test]
768    fn critical_findings_require_approval() {
769        assert!(requires_approval_gate(1, 0));
770    }
771
772    #[test]
773    fn high_findings_require_approval() {
774        assert!(requires_approval_gate(0, 1));
775    }
776
777    #[test]
778    fn no_critical_or_high_no_approval_needed() {
779        assert!(!requires_approval_gate(0, 0));
780    }
781
782    #[test]
783    fn both_critical_and_high_require_approval() {
784        assert!(requires_approval_gate(2, 3));
785    }
786}