Skip to main content

qae_kernel/
certificate.rs

1// SPDX-License-Identifier: BUSL-1.1
2//! Safety certificate — the output of the certification kernel.
3
4use crate::DeterministicHash;
5use crate::KernelResult;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10
11
12/// Domain-agnostic safety zone classification.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum SafetyZone {
15    /// Comfortable headroom — all margins well above thresholds.
16    Safe,
17    /// Caution warranted — some margins approaching thresholds.
18    Caution,
19    /// Near constraint boundary — action is risky.
20    Danger,
21}
22
23/// Certification decision made by the kernel.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub enum CertificationDecision {
26    /// Action is certified — safe to proceed.
27    Certified,
28    /// Action is certified but with warnings.
29    CertifiedWithWarning { warnings: Vec<String> },
30    /// Action requires human review before proceeding.
31    EscalateToHuman { reason: String },
32    /// Action is blocked — too dangerous to proceed.
33    Blocked { reason: String },
34}
35
36/// A safety certificate produced by the certification kernel.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SafetyCertificate {
39    /// Unique certificate identifier.
40    pub certificate_id: String,
41    /// Identifier of the action that was certified.
42    pub action_id: String,
43    /// Identifier of the agent that proposed the action.
44    pub agent_id: String,
45    /// When the certification decision was made.
46    pub decided_at: DateTime<Utc>,
47    /// The certification decision.
48    pub decision: CertificationDecision,
49    /// Safety zone classification.
50    pub zone: SafetyZone,
51    /// Per-channel margins (channel_name -> margin in \[0,1\]).
52    pub margins: BTreeMap<String, f64>,
53    /// Aggregate rate of change (gradient).
54    pub gradient: Option<f64>,
55    /// Name of the binding (tightest) constraint channel.
56    pub binding_constraint: Option<String>,
57    /// Remaining budget before zone transition.
58    pub drift_budget: Option<f64>,
59    /// Deterministic hash for reproducibility verification.
60    pub deterministic_hash: DeterministicHash,
61    /// Opaque domain-specific payload (e.g., full channel breakdown as JSON).
62    pub domain_payload: Option<serde_json::Value>,
63}
64
65/// Builder for assembling safety certificates.
66pub struct SafetyCertificateBuilder {
67    action_id: String,
68    agent_id: String,
69    decided_at: DateTime<Utc>,
70    decision: Option<CertificationDecision>,
71    zone: Option<SafetyZone>,
72    margins: BTreeMap<String, f64>,
73    gradient: Option<f64>,
74    binding_constraint: Option<String>,
75    drift_budget: Option<f64>,
76    domain_payload: Option<serde_json::Value>,
77}
78
79impl SafetyCertificateBuilder {
80    pub fn new(action_id: String, agent_id: String, decided_at: DateTime<Utc>) -> Self {
81        Self {
82            action_id,
83            agent_id,
84            decided_at,
85            decision: None,
86            zone: None,
87            margins: BTreeMap::new(),
88            gradient: None,
89            binding_constraint: None,
90            drift_budget: None,
91            domain_payload: None,
92        }
93    }
94
95    pub fn decision(mut self, decision: CertificationDecision) -> Self {
96        self.decision = Some(decision);
97        self
98    }
99
100    pub fn zone(mut self, zone: SafetyZone) -> Self {
101        self.zone = Some(zone);
102        self
103    }
104
105    pub fn margin(mut self, channel: String, value: f64) -> Self {
106        self.margins.insert(channel, value);
107        self
108    }
109
110    pub fn margins(mut self, margins: BTreeMap<String, f64>) -> Self {
111        self.margins = margins;
112        self
113    }
114
115    pub fn gradient(mut self, gradient: f64) -> Self {
116        self.gradient = Some(gradient);
117        self
118    }
119
120    pub fn binding_constraint(mut self, name: String) -> Self {
121        self.binding_constraint = Some(name);
122        self
123    }
124
125    pub fn drift_budget(mut self, budget: f64) -> Self {
126        self.drift_budget = Some(budget);
127        self
128    }
129
130    pub fn domain_payload(mut self, payload: serde_json::Value) -> Self {
131        self.domain_payload = Some(payload);
132        self
133    }
134
135    /// Build the safety certificate with a deterministic hash.
136    pub fn build(self) -> KernelResult<SafetyCertificate> {
137        let decision = self
138            .decision
139            .unwrap_or(CertificationDecision::Certified);
140        let zone = self.zone.unwrap_or(SafetyZone::Safe);
141
142        // Compute deterministic hash (covers decision + zone for tamper evidence)
143        let hash_input = compute_hash_input(
144            &self.action_id,
145            &self.agent_id,
146            &self.decided_at,
147            &self.margins,
148            &decision,
149            &zone,
150        );
151        let mut hasher = Sha256::new();
152        hasher.update(hash_input.as_bytes());
153        let hash = hex::encode(hasher.finalize());
154
155        // Derive certificate ID deterministically from hash (UUIDv5-style)
156        let certificate_id = format!("cert-{}", &hash[..32]);
157
158        Ok(SafetyCertificate {
159            certificate_id,
160            action_id: self.action_id,
161            agent_id: self.agent_id,
162            decided_at: self.decided_at,
163            decision,
164            zone,
165            margins: self.margins,
166            gradient: self.gradient,
167            binding_constraint: self.binding_constraint,
168            drift_budget: self.drift_budget,
169            deterministic_hash: DeterministicHash(hash),
170            domain_payload: self.domain_payload,
171        })
172    }
173}
174
175/// Compute deterministic hash input from certificate components.
176/// Uses BTreeMap ordering for determinism. Includes decision and zone
177/// so that tampering with either invalidates the hash.
178fn compute_hash_input(
179    action_id: &str,
180    agent_id: &str,
181    decided_at: &DateTime<Utc>,
182    margins: &BTreeMap<String, f64>,
183    decision: &CertificationDecision,
184    zone: &SafetyZone,
185) -> String {
186    let mut parts = Vec::new();
187    parts.push(format!("action:{}", action_id));
188    parts.push(format!("agent:{}", agent_id));
189    parts.push(format!("decided_at:{}", decided_at.to_rfc3339()));
190
191    for (name, margin) in margins {
192        parts.push(format!("channel:{}:{:.16e}", name, margin));
193    }
194
195    parts.push(format!("decision:{:?}", decision));
196    parts.push(format!("zone:{:?}", zone));
197
198    parts.join("|")
199}
200
201/// Verify that a safety certificate's hash matches its content.
202pub fn verify_safety_certificate(cert: &SafetyCertificate) -> bool {
203    let hash_input = compute_hash_input(
204        &cert.action_id,
205        &cert.agent_id,
206        &cert.decided_at,
207        &cert.margins,
208        &cert.decision,
209        &cert.zone,
210    );
211    let mut hasher = Sha256::new();
212    hasher.update(hash_input.as_bytes());
213    let expected = hex::encode(hasher.finalize());
214    cert.deterministic_hash.0 == expected
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn certificate_hash_is_deterministic() {
223        let decided_at = chrono::Utc::now();
224        let cert1 = SafetyCertificateBuilder::new(
225            "act-1".into(),
226            "agent-1".into(),
227            decided_at,
228        )
229        .decision(CertificationDecision::Certified)
230        .zone(SafetyZone::Safe)
231        .margin("channel_a".into(), 0.8)
232        .margin("channel_b".into(), 0.7)
233        .build()
234        .unwrap();
235
236        let cert2 = SafetyCertificateBuilder::new(
237            "act-1".into(),
238            "agent-1".into(),
239            decided_at,
240        )
241        .decision(CertificationDecision::Certified)
242        .zone(SafetyZone::Safe)
243        .margin("channel_a".into(), 0.8)
244        .margin("channel_b".into(), 0.7)
245        .build()
246        .unwrap();
247
248        assert_eq!(cert1.deterministic_hash, cert2.deterministic_hash);
249    }
250
251    #[test]
252    fn certificate_hash_verifies() {
253        let cert = SafetyCertificateBuilder::new(
254            "act-1".into(),
255            "agent-1".into(),
256            chrono::Utc::now(),
257        )
258        .margin("ch1".into(), 0.5)
259        .build()
260        .unwrap();
261
262        assert!(verify_safety_certificate(&cert));
263    }
264
265    #[test]
266    fn certification_decision_serialization() {
267        let decisions = vec![
268            CertificationDecision::Certified,
269            CertificationDecision::CertifiedWithWarning {
270                warnings: vec!["near limit".into()],
271            },
272            CertificationDecision::EscalateToHuman {
273                reason: "regime change".into(),
274            },
275            CertificationDecision::Blocked {
276                reason: "too risky".into(),
277            },
278        ];
279        for decision in &decisions {
280            let json = serde_json::to_string(decision).unwrap();
281            let deserialized: CertificationDecision = serde_json::from_str(&json).unwrap();
282            assert_eq!(&deserialized, decision);
283        }
284    }
285
286    #[test]
287    fn builder_defaults() {
288        let cert = SafetyCertificateBuilder::new(
289            "a".into(),
290            "b".into(),
291            chrono::Utc::now(),
292        )
293        .build()
294        .unwrap();
295
296        assert_eq!(cert.decision, CertificationDecision::Certified);
297        assert_eq!(cert.zone, SafetyZone::Safe);
298        assert!(cert.margins.is_empty());
299        assert!(cert.gradient.is_none());
300        assert!(cert.binding_constraint.is_none());
301        assert!(cert.drift_budget.is_none());
302        assert!(cert.domain_payload.is_none());
303    }
304}