1use crate::DeterministicHash;
5use crate::KernelResult;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum SafetyZone {
15 Safe,
17 Caution,
19 Danger,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub enum CertificationDecision {
26 Certified,
28 CertifiedWithWarning { warnings: Vec<String> },
30 EscalateToHuman { reason: String },
32 Blocked { reason: String },
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SafetyCertificate {
39 pub certificate_id: String,
41 pub action_id: String,
43 pub agent_id: String,
45 pub decided_at: DateTime<Utc>,
47 pub decision: CertificationDecision,
49 pub zone: SafetyZone,
51 pub margins: BTreeMap<String, f64>,
53 pub gradient: Option<f64>,
55 pub binding_constraint: Option<String>,
57 pub drift_budget: Option<f64>,
59 pub deterministic_hash: DeterministicHash,
61 pub domain_payload: Option<serde_json::Value>,
63}
64
65pub 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 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 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 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
175fn 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
201pub 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}