1use std::collections::HashMap;
17#[cfg(feature = "exochain")]
18use std::sync::Arc;
19
20use dashmap::DashMap;
21use serde::{Deserialize, Serialize};
22use tracing::debug;
23
24pub type EnvironmentId = String;
26
27#[non_exhaustive]
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub enum EnvironmentClass {
31 Development,
34
35 Staging,
38
39 Production,
42
43 Custom {
45 name: String,
47 risk_threshold: f64,
49 },
50}
51
52impl EnvironmentClass {
53 pub fn risk_threshold(&self) -> f64 {
55 match self {
56 EnvironmentClass::Development => 0.9,
57 EnvironmentClass::Staging => 0.6,
58 EnvironmentClass::Production => 0.3,
59 EnvironmentClass::Custom { risk_threshold, .. } => *risk_threshold,
60 }
61 }
62}
63
64impl std::fmt::Display for EnvironmentClass {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 EnvironmentClass::Development => write!(f, "development"),
68 EnvironmentClass::Staging => write!(f, "staging"),
69 EnvironmentClass::Production => write!(f, "production"),
70 EnvironmentClass::Custom { name, .. } => write!(f, "custom({name})"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct GovernanceScope {
78 #[serde(default = "default_risk_threshold")]
80 pub risk_threshold: f64,
81
82 #[serde(default)]
84 pub human_approval_required: bool,
85
86 #[serde(default)]
88 pub active_branches: GovernanceBranches,
89
90 #[serde(default)]
92 pub audit_level: AuditLevel,
93
94 #[serde(default)]
96 pub learning_mode: LearningMode,
97
98 #[serde(default = "default_max_effect")]
100 pub max_effect_magnitude: f64,
101}
102
103fn default_risk_threshold() -> f64 {
104 0.6
105}
106
107fn default_max_effect() -> f64 {
108 1.0
109}
110
111impl Default for GovernanceScope {
112 fn default() -> Self {
113 Self {
114 risk_threshold: default_risk_threshold(),
115 human_approval_required: false,
116 active_branches: GovernanceBranches::default(),
117 audit_level: AuditLevel::default(),
118 learning_mode: LearningMode::default(),
119 max_effect_magnitude: default_max_effect(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct GovernanceBranches {
127 #[serde(default = "default_true")]
129 pub legislative: bool,
130
131 #[serde(default = "default_true")]
133 pub executive: bool,
134
135 #[serde(default)]
137 pub judicial: bool,
138}
139
140fn default_true() -> bool {
141 true
142}
143
144impl Default for GovernanceBranches {
145 fn default() -> Self {
146 Self {
147 legislative: true,
148 executive: true,
149 judicial: false,
150 }
151 }
152}
153
154#[non_exhaustive]
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub enum AuditLevel {
158 #[default]
160 SessionSummary,
161 PerAction,
163 PerActionWithEffects,
165}
166
167#[non_exhaustive]
169#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub enum LearningMode {
171 #[default]
174 Explore,
175 Validate,
178 Exploit,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Environment {
186 pub id: EnvironmentId,
188
189 pub name: String,
191
192 pub class: EnvironmentClass,
194
195 #[serde(default)]
197 pub governance: GovernanceScope,
198
199 #[serde(default)]
201 pub labels: HashMap<String, String>,
202}
203
204#[non_exhaustive]
206#[derive(Debug, thiserror::Error)]
207pub enum EnvironmentError {
208 #[error("environment already exists: '{id}'")]
210 AlreadyExists {
211 id: EnvironmentId,
213 },
214
215 #[error("environment not found: '{id}'")]
217 NotFound {
218 id: EnvironmentId,
220 },
221
222 #[error("invalid risk threshold {value}: must be between 0.0 and 1.0")]
224 InvalidRiskThreshold {
225 value: f64,
227 },
228
229 #[error("governance denied environment operation: {reason}")]
231 GovernanceDenied {
232 reason: String,
234 },
235}
236
237pub struct EnvironmentManager {
243 environments: DashMap<EnvironmentId, Environment>,
244 active: std::sync::RwLock<Option<EnvironmentId>>,
245 #[cfg(feature = "exochain")]
247 chain: Option<Arc<crate::chain::ChainManager>>,
248 #[cfg(feature = "exochain")]
250 gate: Option<Arc<crate::gate::GovernanceGate>>,
251}
252
253impl EnvironmentManager {
254 pub fn new() -> Self {
256 Self {
257 environments: DashMap::new(),
258 active: std::sync::RwLock::new(None),
259 #[cfg(feature = "exochain")]
260 chain: None,
261 #[cfg(feature = "exochain")]
262 gate: None,
263 }
264 }
265
266 #[cfg(feature = "exochain")]
268 pub fn with_chain(mut self, cm: Arc<crate::chain::ChainManager>) -> Self {
269 self.chain = Some(cm);
270 self
271 }
272
273 #[cfg(feature = "exochain")]
275 pub fn with_gate(mut self, gate: Arc<crate::gate::GovernanceGate>) -> Self {
276 self.gate = Some(gate);
277 self
278 }
279
280 pub fn register(&self, env: Environment) -> Result<(), EnvironmentError> {
282 if env.governance.risk_threshold < 0.0 || env.governance.risk_threshold > 1.0 {
283 return Err(EnvironmentError::InvalidRiskThreshold {
284 value: env.governance.risk_threshold,
285 });
286 }
287
288 if self.environments.contains_key(&env.id) {
289 return Err(EnvironmentError::AlreadyExists { id: env.id });
290 }
291
292 #[cfg(feature = "exochain")]
294 if let Some(ref cm) = self.chain {
295 cm.append(
296 "environment",
297 crate::chain::EVENT_KIND_ENV_REGISTER,
298 Some(serde_json::json!({
299 "id": env.id,
300 "name": env.name,
301 "class": env.class.to_string(),
302 "risk_threshold": env.governance.risk_threshold,
303 })),
304 );
305 }
306
307 debug!(id = %env.id, name = %env.name, class = %env.class, "registering environment");
308 self.environments.insert(env.id.clone(), env);
309 Ok(())
310 }
311
312 pub fn set_active(&self, id: &str) -> Result<(), EnvironmentError> {
314 if !self.environments.contains_key(id) {
315 return Err(EnvironmentError::NotFound { id: id.to_owned() });
316 }
317
318 #[cfg(feature = "exochain")]
320 if let Some(ref gate) = self.gate {
321 use crate::gate::GateBackend;
322 let env = self.environments.get(id).unwrap();
323 let decision = gate.check(
324 "system",
325 "env.switch",
326 &serde_json::json!({
327 "target_env": id,
328 "class": env.class.to_string(),
329 "effect": { "risk": 0.3, "security": 0.2 },
330 }),
331 );
332 if decision.is_deny() {
333 return Err(EnvironmentError::GovernanceDenied {
334 reason: format!("environment switch to '{id}' denied by governance policy"),
335 });
336 }
337 }
338
339 #[cfg(feature = "exochain")]
341 if let Some(ref cm) = self.chain {
342 let prev = self.active_id();
343 cm.append(
344 "environment",
345 crate::chain::EVENT_KIND_ENV_SWITCH,
346 Some(serde_json::json!({
347 "id": id,
348 "previous": prev,
349 })),
350 );
351 }
352
353 let mut active = self.active.write().unwrap();
354 *active = Some(id.to_owned());
355 Ok(())
356 }
357
358 pub fn active_id(&self) -> Option<EnvironmentId> {
360 self.active.read().unwrap().clone()
361 }
362
363 pub fn active(&self) -> Option<Environment> {
365 let id = self.active_id()?;
366 self.environments.get(&id).map(|e| e.value().clone())
367 }
368
369 pub fn get(&self, id: &str) -> Option<Environment> {
371 self.environments.get(id).map(|e| e.value().clone())
372 }
373
374 pub fn list(&self) -> Vec<(EnvironmentId, EnvironmentClass, bool)> {
376 let active_id = self.active_id();
377 self.environments
378 .iter()
379 .map(|e| {
380 let is_active = active_id.as_deref() == Some(e.key().as_str());
381 (e.key().clone(), e.class.clone(), is_active)
382 })
383 .collect()
384 }
385
386 pub fn remove(&self, id: &str) -> Result<Environment, EnvironmentError> {
388 if self.active_id().as_deref() == Some(id) {
390 let mut active = self.active.write().unwrap();
392 *active = None;
393 }
394
395 let result = self
396 .environments
397 .remove(id)
398 .map(|(_, env)| env)
399 .ok_or_else(|| EnvironmentError::NotFound { id: id.to_owned() });
400
401 #[cfg(feature = "exochain")]
403 if let (Ok(env), Some(cm)) = (&result, &self.chain) {
404 cm.append(
405 "environment",
406 crate::chain::EVENT_KIND_ENV_REMOVE,
407 Some(serde_json::json!({
408 "id": env.id,
409 "name": env.name,
410 "class": env.class.to_string(),
411 })),
412 );
413 }
414
415 result
416 }
417
418 pub fn len(&self) -> usize {
420 self.environments.len()
421 }
422
423 pub fn is_empty(&self) -> bool {
425 self.environments.is_empty()
426 }
427
428 pub fn create_standard_set(&self) -> Result<(), EnvironmentError> {
430 self.register(Environment {
431 id: "dev".into(),
432 name: "Development".into(),
433 class: EnvironmentClass::Development,
434 governance: GovernanceScope {
435 risk_threshold: 0.9,
436 human_approval_required: false,
437 active_branches: GovernanceBranches {
438 legislative: true,
439 executive: true,
440 judicial: false,
441 },
442 audit_level: AuditLevel::SessionSummary,
443 learning_mode: LearningMode::Explore,
444 max_effect_magnitude: 2.0,
445 },
446 labels: HashMap::new(),
447 })?;
448
449 self.register(Environment {
450 id: "staging".into(),
451 name: "Staging".into(),
452 class: EnvironmentClass::Staging,
453 governance: GovernanceScope {
454 risk_threshold: 0.6,
455 human_approval_required: false,
456 active_branches: GovernanceBranches {
457 legislative: true,
458 executive: true,
459 judicial: true,
460 },
461 audit_level: AuditLevel::PerAction,
462 learning_mode: LearningMode::Validate,
463 max_effect_magnitude: 1.0,
464 },
465 labels: HashMap::new(),
466 })?;
467
468 self.register(Environment {
469 id: "prod".into(),
470 name: "Production".into(),
471 class: EnvironmentClass::Production,
472 governance: GovernanceScope {
473 risk_threshold: 0.3,
474 human_approval_required: true,
475 active_branches: GovernanceBranches {
476 legislative: true,
477 executive: true,
478 judicial: true,
479 },
480 audit_level: AuditLevel::PerActionWithEffects,
481 learning_mode: LearningMode::Exploit,
482 max_effect_magnitude: 0.5,
483 },
484 labels: HashMap::new(),
485 })?;
486
487 Ok(())
488 }
489}
490
491impl Default for EnvironmentManager {
492 fn default() -> Self {
493 Self::new()
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn make_dev_env() -> Environment {
502 Environment {
503 id: "dev".into(),
504 name: "Development".into(),
505 class: EnvironmentClass::Development,
506 governance: GovernanceScope {
507 risk_threshold: 0.9,
508 ..Default::default()
509 },
510 labels: HashMap::new(),
511 }
512 }
513
514 #[test]
515 fn environment_class_risk_threshold() {
516 assert!((EnvironmentClass::Development.risk_threshold() - 0.9).abs() < f64::EPSILON);
517 assert!((EnvironmentClass::Staging.risk_threshold() - 0.6).abs() < f64::EPSILON);
518 assert!((EnvironmentClass::Production.risk_threshold() - 0.3).abs() < f64::EPSILON);
519 let custom = EnvironmentClass::Custom {
520 name: "test".into(),
521 risk_threshold: 0.75,
522 };
523 assert!((custom.risk_threshold() - 0.75).abs() < f64::EPSILON);
524 }
525
526 #[test]
527 fn environment_class_display() {
528 assert_eq!(EnvironmentClass::Development.to_string(), "development");
529 assert_eq!(EnvironmentClass::Production.to_string(), "production");
530 assert_eq!(
531 EnvironmentClass::Custom {
532 name: "qa".into(),
533 risk_threshold: 0.5
534 }
535 .to_string(),
536 "custom(qa)"
537 );
538 }
539
540 #[test]
541 fn governance_scope_default() {
542 let scope = GovernanceScope::default();
543 assert!((scope.risk_threshold - 0.6).abs() < f64::EPSILON);
544 assert!(!scope.human_approval_required);
545 assert!(scope.active_branches.legislative);
546 assert!(scope.active_branches.executive);
547 assert!(!scope.active_branches.judicial);
548 }
549
550 #[test]
551 fn governance_scope_serde_roundtrip() {
552 let scope = GovernanceScope {
553 risk_threshold: 0.3,
554 human_approval_required: true,
555 active_branches: GovernanceBranches {
556 legislative: true,
557 executive: true,
558 judicial: true,
559 },
560 audit_level: AuditLevel::PerActionWithEffects,
561 learning_mode: LearningMode::Exploit,
562 max_effect_magnitude: 0.5,
563 };
564 let json = serde_json::to_string(&scope).unwrap();
565 let restored: GovernanceScope = serde_json::from_str(&json).unwrap();
566 assert!((restored.risk_threshold - 0.3).abs() < f64::EPSILON);
567 assert!(restored.human_approval_required);
568 assert_eq!(restored.learning_mode, LearningMode::Exploit);
569 }
570
571 #[test]
572 fn register_and_list() {
573 let mgr = EnvironmentManager::new();
574 mgr.register(make_dev_env()).unwrap();
575 let list = mgr.list();
576 assert_eq!(list.len(), 1);
577 }
578
579 #[test]
580 fn register_duplicate_fails() {
581 let mgr = EnvironmentManager::new();
582 mgr.register(make_dev_env()).unwrap();
583 assert!(matches!(
584 mgr.register(make_dev_env()),
585 Err(EnvironmentError::AlreadyExists { .. })
586 ));
587 }
588
589 #[test]
590 fn invalid_risk_threshold() {
591 let mgr = EnvironmentManager::new();
592 let mut env = make_dev_env();
593 env.governance.risk_threshold = 1.5;
594 assert!(matches!(
595 mgr.register(env),
596 Err(EnvironmentError::InvalidRiskThreshold { .. })
597 ));
598 }
599
600 #[test]
601 fn set_active() {
602 let mgr = EnvironmentManager::new();
603 mgr.register(make_dev_env()).unwrap();
604 mgr.set_active("dev").unwrap();
605 assert_eq!(mgr.active_id().as_deref(), Some("dev"));
606 let active = mgr.active().unwrap();
607 assert_eq!(active.name, "Development");
608 }
609
610 #[test]
611 fn set_active_nonexistent_fails() {
612 let mgr = EnvironmentManager::new();
613 assert!(matches!(
614 mgr.set_active("nope"),
615 Err(EnvironmentError::NotFound { .. })
616 ));
617 }
618
619 #[test]
620 fn remove_environment() {
621 let mgr = EnvironmentManager::new();
622 mgr.register(make_dev_env()).unwrap();
623 let removed = mgr.remove("dev").unwrap();
624 assert_eq!(removed.name, "Development");
625 assert!(mgr.is_empty());
626 }
627
628 #[test]
629 fn remove_active_clears_active() {
630 let mgr = EnvironmentManager::new();
631 mgr.register(make_dev_env()).unwrap();
632 mgr.set_active("dev").unwrap();
633 mgr.remove("dev").unwrap();
634 assert!(mgr.active_id().is_none());
635 }
636
637 #[test]
638 fn create_standard_set() {
639 let mgr = EnvironmentManager::new();
640 mgr.create_standard_set().unwrap();
641 assert_eq!(mgr.len(), 3);
642
643 let dev = mgr.get("dev").unwrap();
644 assert!((dev.governance.risk_threshold - 0.9).abs() < f64::EPSILON);
645 assert_eq!(dev.governance.learning_mode, LearningMode::Explore);
646
647 let prod = mgr.get("prod").unwrap();
648 assert!((prod.governance.risk_threshold - 0.3).abs() < f64::EPSILON);
649 assert!(prod.governance.human_approval_required);
650 assert_eq!(prod.governance.learning_mode, LearningMode::Exploit);
651 }
652
653 #[test]
654 fn environment_serde_roundtrip() {
655 let env = make_dev_env();
656 let json = serde_json::to_string(&env).unwrap();
657 let restored: Environment = serde_json::from_str(&json).unwrap();
658 assert_eq!(restored.id, "dev");
659 assert_eq!(restored.class, EnvironmentClass::Development);
660 }
661
662 #[test]
663 fn environment_error_display() {
664 let err = EnvironmentError::NotFound { id: "prod".into() };
665 assert!(err.to_string().contains("prod"));
666
667 let err = EnvironmentError::InvalidRiskThreshold { value: 1.5 };
668 assert!(err.to_string().contains("1.5"));
669 }
670
671 #[test]
672 fn audit_level_default() {
673 assert_eq!(AuditLevel::default(), AuditLevel::SessionSummary);
674 }
675
676 #[test]
677 fn learning_mode_default() {
678 assert_eq!(LearningMode::default(), LearningMode::Explore);
679 }
680}