1use crate::types::Time;
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeMap;
30use std::sync::Arc;
31
32pub const SNAPSHOT_VERSION: SnapshotVersion = SnapshotVersion { major: 1, minor: 0 };
34
35pub const CONTROLLER_SNAPSHOT_LEDGER_SCHEMA_VERSION: &str = "controller-snapshot-ledger-v1";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40pub struct SnapshotVersion {
41 pub major: u32,
43 pub minor: u32,
45}
46
47impl std::fmt::Display for SnapshotVersion {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 write!(f, "{}.{}", self.major, self.minor)
50 }
51}
52
53impl SnapshotVersion {
54 #[must_use]
56 #[inline]
57 pub fn is_compatible_with(&self, other: &Self) -> bool {
58 self.major == other.major && self.minor >= other.minor
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64pub struct SnapshotId(pub u64);
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RuntimeKernelSnapshot {
73 pub id: SnapshotId,
75 pub version: SnapshotVersion,
77 pub timestamp: Time,
79
80 pub ready_queue_len: usize,
83 pub cancel_lane_len: usize,
85 pub finalize_lane_len: usize,
87 pub total_tasks: usize,
89 pub active_regions: usize,
91 pub cancel_streak_current: usize,
93 pub cancel_streak_limit: usize,
95
96 pub outstanding_obligations: usize,
99 pub obligation_leak_count: u64,
101
102 pub pending_io_registrations: usize,
105 pub active_timers: usize,
107
108 pub worker_count: usize,
111 pub workers_parked: usize,
113 pub blocking_threads_active: usize,
115
116 pub governor_enabled: bool,
119 pub adaptive_cancel_enabled: bool,
121 pub adaptive_epoch: u64,
123
124 pub registered_controllers: usize,
127 pub shadow_controllers: usize,
129}
130
131impl RuntimeKernelSnapshot {
132 #[cfg(any(test, feature = "test-internals"))]
134 #[must_use]
135 pub fn test_default(id: u64, now: Time) -> Self {
136 Self {
137 id: SnapshotId(id),
138 version: SNAPSHOT_VERSION,
139 timestamp: now,
140 ready_queue_len: 0,
141 cancel_lane_len: 0,
142 finalize_lane_len: 0,
143 total_tasks: 0,
144 active_regions: 0,
145 cancel_streak_current: 0,
146 cancel_streak_limit: 16,
147 outstanding_obligations: 0,
148 obligation_leak_count: 0,
149 pending_io_registrations: 0,
150 active_timers: 0,
151 worker_count: 1,
152 workers_parked: 0,
153 blocking_threads_active: 0,
154 governor_enabled: false,
155 adaptive_cancel_enabled: false,
156 adaptive_epoch: 0,
157 registered_controllers: 0,
158 shadow_controllers: 0,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165pub enum ControllerMode {
166 Shadow,
168 Canary,
170 Active,
172 Hold,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ControllerDecision {
179 pub controller_id: ControllerId,
181 pub snapshot_id: SnapshotId,
183 pub label: String,
185 pub payload: serde_json::Value,
187 pub confidence: f64,
189 pub fallback_label: String,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
195pub struct ControllerId(pub u64);
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ControllerSnapshotState {
200 pub controller_id: ControllerId,
202 pub controller_name: String,
204 pub mode: ControllerMode,
206 pub decisions_this_epoch: u32,
208 pub fallback_active: bool,
210 pub calibration_score: f64,
212 pub last_decision_confidence: Option<f64>,
214 pub last_action_label: Option<String>,
216 pub last_evidence_tick: Option<u64>,
218 pub last_snapshot_id: Option<SnapshotId>,
220 pub epochs_in_current_mode: u64,
222 pub budget_overruns: u32,
224 pub proof_artifact_id: Option<String>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ControllerSnapshotLedger {
231 pub schema_version: String,
233 pub registered_controllers: usize,
235 pub shadow_controllers: usize,
237 pub controllers: Vec<ControllerSnapshotState>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ControllerRegistration {
244 pub name: String,
246 pub min_version: SnapshotVersion,
248 pub max_version: SnapshotVersion,
250 pub required_fields: Vec<String>,
252 pub target_seams: Vec<String>,
254 pub initial_mode: ControllerMode,
256 pub proof_artifact_id: Option<String>,
258 pub budget: ControllerBudget,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ControllerBudget {
265 pub max_decisions_per_epoch: u32,
267 pub max_decision_latency_us: u64,
269}
270
271impl Default for ControllerBudget {
272 fn default() -> Self {
273 Self {
274 max_decisions_per_epoch: 1,
275 max_decision_latency_us: 100,
276 }
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub enum RegistrationError {
283 EmptyName,
285 InvertedVersionRange,
287 IncompatibleVersion {
289 current: SnapshotVersion,
291 min: SnapshotVersion,
293 max: SnapshotVersion,
295 },
296 UnsupportedFields(Vec<String>),
298 NoTargetSeams,
300 ZeroBudget,
302 DuplicateName(String),
304}
305
306impl std::fmt::Display for RegistrationError {
307 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308 match self {
309 Self::EmptyName => write!(f, "controller name must not be empty"),
310 Self::InvertedVersionRange => write!(f, "min_version must be <= max_version"),
311 Self::IncompatibleVersion { current, min, max } => {
312 write!(
313 f,
314 "snapshot version {current} outside controller range [{min}, {max}]"
315 )
316 }
317 Self::UnsupportedFields(fields) => {
318 write!(f, "unsupported snapshot fields: {}", fields.join(", "))
319 }
320 Self::NoTargetSeams => write!(f, "controller must target at least one seam"),
321 Self::ZeroBudget => write!(f, "budget must allow at least one decision per epoch"),
322 Self::DuplicateName(name) => {
323 write!(f, "controller with name '{name}' already registered")
324 }
325 }
326 }
327}
328
329impl std::error::Error for RegistrationError {}
330
331const KNOWN_FIELDS: &[&str] = &[
333 "ready_queue_len",
334 "cancel_lane_len",
335 "finalize_lane_len",
336 "total_tasks",
337 "active_regions",
338 "cancel_streak_current",
339 "cancel_streak_limit",
340 "outstanding_obligations",
341 "obligation_leak_count",
342 "pending_io_registrations",
343 "active_timers",
344 "worker_count",
345 "workers_parked",
346 "blocking_threads_active",
347 "governor_enabled",
348 "adaptive_cancel_enabled",
349 "adaptive_epoch",
350 "registered_controllers",
351 "shadow_controllers",
352];
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct PromotionPolicy {
357 pub min_calibration_score: f64,
359 pub min_shadow_epochs: u64,
361 pub min_canary_epochs: u64,
363 pub max_budget_overruns: u32,
365 pub policy_id: String,
367}
368
369impl Default for PromotionPolicy {
370 fn default() -> Self {
371 Self {
372 min_calibration_score: 0.8,
373 min_shadow_epochs: 3,
374 min_canary_epochs: 2,
375 max_budget_overruns: 3,
376 policy_id: "default-promotion-policy-v1".to_string(),
377 }
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub enum PromotionRejection {
384 ControllerNotFound,
386 CalibrationTooLow {
388 current: f64,
390 required: f64,
392 },
393 InsufficientEpochs {
395 current: u64,
397 required: u64,
399 mode: ControllerMode,
401 },
402 InvalidTransition {
404 from: ControllerMode,
406 to: ControllerMode,
408 },
409 HeldForInvestigation,
411}
412
413impl std::fmt::Display for PromotionRejection {
414 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 match self {
416 Self::ControllerNotFound => write!(f, "controller not found"),
417 Self::CalibrationTooLow { current, required } => {
418 write!(
419 f,
420 "calibration score {current:.3} below threshold {required:.3}"
421 )
422 }
423 Self::InsufficientEpochs {
424 current,
425 required,
426 mode,
427 } => {
428 write!(f, "only {current} epochs in {mode:?}, need {required}")
429 }
430 Self::InvalidTransition { from, to } => {
431 write!(f, "invalid transition from {from:?} to {to:?}")
432 }
433 Self::HeldForInvestigation => {
434 write!(
435 f,
436 "controller held for investigation; release before promoting"
437 )
438 }
439 }
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445pub enum RollbackReason {
446 CalibrationRegression {
448 score: f64,
450 },
451 BudgetOverruns {
453 count: u32,
455 },
456 ManualRollback,
458 FallbackTriggered {
460 decision_label: String,
462 },
463}
464
465impl std::fmt::Display for RollbackReason {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 match self {
468 Self::CalibrationRegression { score } => {
469 write!(f, "calibration regressed to {score:.3}")
470 }
471 Self::BudgetOverruns { count } => {
472 write!(f, "budget overruns reached {count}")
473 }
474 Self::ManualRollback => write!(f, "manual rollback requested"),
475 Self::FallbackTriggered { decision_label } => {
476 write!(f, "fallback triggered by decision: {decision_label}")
477 }
478 }
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct RecoveryCommand {
485 pub controller_id: ControllerId,
487 pub controller_name: String,
489 pub rolled_back_from: ControllerMode,
491 pub rolled_back_to: ControllerMode,
493 pub reason: RollbackReason,
495 pub policy_id: String,
497 pub at_snapshot_id: Option<SnapshotId>,
499 pub remediation: Vec<String>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct EvidenceLedgerEntry {
506 pub entry_id: u64,
508 pub controller_id: ControllerId,
510 pub snapshot_id: Option<SnapshotId>,
512 pub event: LedgerEvent,
514 pub policy_id: String,
516 pub timestamp: Time,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522pub enum LedgerEvent {
523 Registered {
525 initial_mode: ControllerMode,
527 },
528 Promoted {
530 from: ControllerMode,
532 to: ControllerMode,
534 calibration_score: f64,
536 },
537 RolledBack {
539 from: ControllerMode,
541 to: ControllerMode,
543 reason: RollbackReason,
545 },
546 Held {
548 from: ControllerMode,
550 },
551 Released {
553 to: ControllerMode,
555 },
556 Deregistered,
558 PromotionRejected {
560 target: ControllerMode,
562 rejection: PromotionRejection,
564 },
565 DecisionRecorded {
567 label: String,
569 confidence: f64,
571 fallback_label: String,
573 within_budget: bool,
575 },
576}
577
578#[derive(Debug, Clone)]
580struct RegisteredController {
581 registration: ControllerRegistration,
582 mode: ControllerMode,
583 decisions_this_epoch: u32,
584 last_snapshot_id: Option<SnapshotId>,
585 calibration_score: f64,
586 last_decision_confidence: Option<f64>,
587 epochs_in_current_mode: u64,
588 budget_overruns: u32,
589 held_from_mode: Option<ControllerMode>,
591 fallback_active: bool,
592 last_evidence_tick: Option<u64>,
593 last_action_label: String,
594}
595
596type LogSink = Arc<dyn Fn(&str) + Send + Sync>;
598
599pub struct ControllerRegistry {
609 controllers: BTreeMap<ControllerId, RegisteredController>,
610 next_id: u64,
611 next_snapshot_id: u64,
612 log_sink: Option<LogSink>,
614 promotion_policy: PromotionPolicy,
616 evidence_ledger: Vec<EvidenceLedgerEntry>,
618 next_ledger_id: u64,
620}
621
622impl ControllerRegistry {
623 fn snapshot_version_supported(
624 current: SnapshotVersion,
625 min: SnapshotVersion,
626 max: SnapshotVersion,
627 ) -> bool {
628 current.major == min.major && current.major == max.major && min <= current && current <= max
629 }
630
631 fn set_controller_mode_state(controller: &mut RegisteredController, mode: ControllerMode) {
632 if controller.mode == ControllerMode::Hold && mode != ControllerMode::Hold {
633 controller.held_from_mode = None;
634 }
635 controller.mode = mode;
636 controller.epochs_in_current_mode = 0;
640 }
641
642 #[must_use]
644 pub fn new() -> Self {
645 Self {
646 controllers: BTreeMap::new(),
647 next_id: 1,
648 next_snapshot_id: 1,
649 log_sink: None,
650 promotion_policy: PromotionPolicy::default(),
651 evidence_ledger: Vec::new(),
652 next_ledger_id: 1,
653 }
654 }
655
656 #[must_use]
658 pub fn with_log_sink(mut self, sink: LogSink) -> Self {
659 self.log_sink = Some(sink);
660 self
661 }
662
663 pub fn register(
665 &mut self,
666 registration: ControllerRegistration,
667 ) -> Result<ControllerId, RegistrationError> {
668 self.validate(®istration)?;
669
670 let id = ControllerId(self.next_id);
671 self.next_id = self
672 .next_id
673 .checked_add(1)
674 .expect("runtime kernel controller id counter exhausted");
675
676 let mode = if (registration.initial_mode == ControllerMode::Active
677 || registration.initial_mode == ControllerMode::Canary)
678 && !registration
679 .max_version
680 .is_compatible_with(&SNAPSHOT_VERSION)
681 {
682 ControllerMode::Shadow
684 } else {
685 registration.initial_mode
686 };
687
688 if let Some(ref sink) = self.log_sink {
689 sink(&format!(
690 "controller_registered id={} name={} mode={:?} seams={:?} version_range=[{}, {}]",
691 id.0,
692 registration.name,
693 mode,
694 registration.target_seams,
695 registration.min_version,
696 registration.max_version,
697 ));
698 }
699
700 self.controllers.insert(
701 id,
702 RegisteredController {
703 registration,
704 mode,
705 decisions_this_epoch: 0,
706 last_snapshot_id: None,
707 calibration_score: 0.0,
708 last_decision_confidence: None,
709 epochs_in_current_mode: 0,
710 budget_overruns: 0,
711 held_from_mode: None,
712 fallback_active: false,
713 last_evidence_tick: None,
714 last_action_label: String::new(),
715 },
716 );
717
718 self.record_ledger_entry(id, None, LedgerEvent::Registered { initial_mode: mode });
719
720 Ok(id)
721 }
722
723 fn validate(&self, reg: &ControllerRegistration) -> Result<(), RegistrationError> {
725 if reg.name.is_empty() {
726 return Err(RegistrationError::EmptyName);
727 }
728 if reg.min_version > reg.max_version {
729 return Err(RegistrationError::InvertedVersionRange);
730 }
731 if !Self::snapshot_version_supported(SNAPSHOT_VERSION, reg.min_version, reg.max_version) {
732 return Err(RegistrationError::IncompatibleVersion {
733 current: SNAPSHOT_VERSION,
734 min: reg.min_version,
735 max: reg.max_version,
736 });
737 }
738 let unknown: Vec<String> = reg
739 .required_fields
740 .iter()
741 .filter(|f| !KNOWN_FIELDS.contains(&f.as_str()))
742 .cloned()
743 .collect();
744 if !unknown.is_empty() {
745 return Err(RegistrationError::UnsupportedFields(unknown));
746 }
747 if reg.target_seams.is_empty() {
748 return Err(RegistrationError::NoTargetSeams);
749 }
750 if reg.budget.max_decisions_per_epoch == 0 {
751 return Err(RegistrationError::ZeroBudget);
752 }
753 if self
754 .controllers
755 .values()
756 .any(|c| c.registration.name == reg.name)
757 {
758 return Err(RegistrationError::DuplicateName(reg.name.clone()));
759 }
760 Ok(())
761 }
762
763 pub fn deregister(&mut self, id: ControllerId) -> bool {
765 let removed = self.controllers.remove(&id).is_some();
766 if removed {
767 self.record_ledger_entry(id, None, LedgerEvent::Deregistered);
768 }
769 removed
770 }
771
772 #[must_use]
774 #[inline]
775 pub fn mode(&self, id: ControllerId) -> Option<ControllerMode> {
776 self.controllers.get(&id).map(|c| c.mode)
777 }
778
779 #[inline]
781 pub fn set_mode(&mut self, id: ControllerId, mode: ControllerMode) -> bool {
782 let Some(controller) = self.controllers.get_mut(&id) else {
783 return false;
784 };
785 if mode == ControllerMode::Hold && controller.mode != ControllerMode::Hold {
786 controller.held_from_mode = Some(controller.mode);
787 }
788 Self::set_controller_mode_state(controller, mode);
789 true
790 }
791
792 #[must_use]
794 pub fn registration(&self, id: ControllerId) -> Option<&ControllerRegistration> {
795 self.controllers.get(&id).map(|c| &c.registration)
796 }
797
798 #[must_use]
800 pub fn len(&self) -> usize {
801 self.controllers.len()
802 }
803
804 #[must_use]
806 pub fn is_empty(&self) -> bool {
807 self.controllers.is_empty()
808 }
809
810 #[must_use]
812 pub fn shadow_count(&self) -> usize {
813 self.controllers
814 .values()
815 .filter(|c| c.mode == ControllerMode::Shadow)
816 .count()
817 }
818
819 pub fn next_snapshot_id(&mut self) -> SnapshotId {
821 let id = SnapshotId(self.next_snapshot_id);
822 self.next_snapshot_id = self
823 .next_snapshot_id
824 .checked_add(1)
825 .expect("runtime kernel snapshot id counter exhausted");
826 id
827 }
828
829 pub fn reset_epoch(&mut self) {
833 for controller in self.controllers.values_mut() {
834 controller.decisions_this_epoch = 0;
835 }
836 }
837
838 pub fn record_decision(&mut self, decision: &ControllerDecision) -> bool {
841 let Some(controller) = self.controllers.get_mut(&decision.controller_id) else {
842 return false;
843 };
844 controller.last_snapshot_id = Some(
845 controller
846 .last_snapshot_id
847 .map_or(decision.snapshot_id, |current| {
848 current.max(decision.snapshot_id)
849 }),
850 );
851 controller.last_decision_confidence = Some(decision.confidence);
852 let within_budget = controller.decisions_this_epoch
853 < controller.registration.budget.max_decisions_per_epoch;
854 controller.decisions_this_epoch = controller.decisions_this_epoch.saturating_add(1);
855 if !within_budget {
856 controller.budget_overruns = controller.budget_overruns.saturating_add(1);
857 }
858
859 self.record_ledger_entry(
860 decision.controller_id,
861 Some(decision.snapshot_id),
862 LedgerEvent::DecisionRecorded {
863 label: decision.label.clone(),
864 confidence: decision.confidence,
865 fallback_label: decision.fallback_label.clone(),
866 within_budget,
867 },
868 );
869
870 within_budget
871 }
872
873 pub fn update_calibration(&mut self, id: ControllerId, score: f64) {
875 if let Some(controller) = self.controllers.get_mut(&id) {
876 controller.calibration_score = score;
877 }
878 }
879
880 #[must_use]
882 pub fn calibration_score(&self, id: ControllerId) -> Option<f64> {
883 self.controllers.get(&id).map(|c| c.calibration_score)
884 }
885
886 #[must_use]
888 pub fn controller_ids(&self) -> Vec<ControllerId> {
889 self.controllers.keys().copied().collect()
890 }
891
892 pub fn set_promotion_policy(&mut self, policy: PromotionPolicy) {
894 self.promotion_policy = policy;
895 }
896
897 #[must_use]
899 pub fn promotion_policy(&self) -> &PromotionPolicy {
900 &self.promotion_policy
901 }
902
903 pub fn advance_epoch(&mut self) {
905 for controller in self.controllers.values_mut() {
906 controller.epochs_in_current_mode += 1;
907 controller.decisions_this_epoch = 0;
908 }
909 }
910
911 pub fn try_promote(
917 &mut self,
918 id: ControllerId,
919 target: ControllerMode,
920 ) -> Result<ControllerMode, PromotionRejection> {
921 let policy = self.promotion_policy.clone();
922 let controller = self
923 .controllers
924 .get(&id)
925 .ok_or(PromotionRejection::ControllerNotFound)?;
926
927 let current_mode = controller.mode;
928 let calibration = controller.calibration_score;
929 let epochs = controller.epochs_in_current_mode;
930
931 if current_mode == ControllerMode::Hold {
933 let rejection = PromotionRejection::HeldForInvestigation;
934 self.record_ledger_entry(
935 id,
936 None,
937 LedgerEvent::PromotionRejected {
938 target,
939 rejection: rejection.clone(),
940 },
941 );
942 self.log_promotion_rejection(id, &rejection, &policy);
943 return Err(rejection);
944 }
945
946 let valid = matches!(
948 (current_mode, target),
949 (ControllerMode::Shadow, ControllerMode::Canary)
950 | (ControllerMode::Canary, ControllerMode::Active)
951 );
952 if !valid {
953 let rejection = PromotionRejection::InvalidTransition {
954 from: current_mode,
955 to: target,
956 };
957 self.record_ledger_entry(
958 id,
959 None,
960 LedgerEvent::PromotionRejected {
961 target,
962 rejection: rejection.clone(),
963 },
964 );
965 self.log_promotion_rejection(id, &rejection, &policy);
966 return Err(rejection);
967 }
968
969 if calibration < policy.min_calibration_score {
971 let rejection = PromotionRejection::CalibrationTooLow {
972 current: calibration,
973 required: policy.min_calibration_score,
974 };
975 self.record_ledger_entry(
976 id,
977 None,
978 LedgerEvent::PromotionRejected {
979 target,
980 rejection: rejection.clone(),
981 },
982 );
983 self.log_promotion_rejection(id, &rejection, &policy);
984 return Err(rejection);
985 }
986
987 let required_epochs = match current_mode {
989 ControllerMode::Shadow => policy.min_shadow_epochs,
990 ControllerMode::Canary => policy.min_canary_epochs,
991 _ => 0,
992 };
993 if epochs < required_epochs {
994 let rejection = PromotionRejection::InsufficientEpochs {
995 current: epochs,
996 required: required_epochs,
997 mode: current_mode,
998 };
999 self.record_ledger_entry(
1000 id,
1001 None,
1002 LedgerEvent::PromotionRejected {
1003 target,
1004 rejection: rejection.clone(),
1005 },
1006 );
1007 self.log_promotion_rejection(id, &rejection, &policy);
1008 return Err(rejection);
1009 }
1010
1011 let controller = self.controllers.get_mut(&id).expect("checked above");
1013 Self::set_controller_mode_state(controller, target);
1014 controller.budget_overruns = 0;
1015
1016 self.record_ledger_entry(
1017 id,
1018 None,
1019 LedgerEvent::Promoted {
1020 from: current_mode,
1021 to: target,
1022 calibration_score: calibration,
1023 },
1024 );
1025
1026 if let Some(ref sink) = self.log_sink {
1027 sink(&format!(
1028 "controller_promoted id={} from={:?} to={:?} calibration={:.3} policy_id={}",
1029 id.0, current_mode, target, calibration, policy.policy_id,
1030 ));
1031 }
1032
1033 Ok(target)
1034 }
1035
1036 pub fn rollback(
1038 &mut self,
1039 id: ControllerId,
1040 reason: RollbackReason,
1041 ) -> Option<RecoveryCommand> {
1042 let policy_id = self.promotion_policy.policy_id.clone();
1043 let controller = self.controllers.get_mut(&id)?;
1044 let from = controller.mode;
1045
1046 if from == ControllerMode::Shadow {
1047 return None;
1049 }
1050
1051 let to = ControllerMode::Shadow;
1052 Self::set_controller_mode_state(controller, to);
1053 controller.fallback_active = true;
1054 let name = controller.registration.name.clone();
1055 let snapshot_id = controller.last_snapshot_id;
1056
1057 self.record_ledger_entry(
1058 id,
1059 snapshot_id,
1060 LedgerEvent::RolledBack {
1061 from,
1062 to,
1063 reason: reason.clone(),
1064 },
1065 );
1066
1067 if let Some(ref sink) = self.log_sink {
1068 sink(&format!(
1069 "controller_rolled_back id={} from={:?} to={:?} reason={} policy_id={} snapshot_id={:?}",
1070 id.0, from, to, reason, policy_id, snapshot_id,
1071 ));
1072 }
1073
1074 let remediation = match &reason {
1075 RollbackReason::CalibrationRegression { score } => vec![
1076 format!("Investigate calibration drop to {score:.3}"),
1077 "Review recent decision evidence in ledger".to_string(),
1078 "Re-run shadow validation before re-promotion".to_string(),
1079 ],
1080 RollbackReason::BudgetOverruns { count } => vec![
1081 format!("Controller exceeded budget {count} times"),
1082 "Review decision frequency and payload complexity".to_string(),
1083 "Consider increasing budget or reducing decision scope".to_string(),
1084 ],
1085 RollbackReason::ManualRollback => vec![
1086 "Manual rollback — verify runtime stability".to_string(),
1087 "Check evidence ledger for preceding anomalies".to_string(),
1088 ],
1089 RollbackReason::FallbackTriggered { decision_label } => vec![
1090 format!("Fallback triggered by decision: {decision_label}"),
1091 "Inspect decision payload and snapshot context".to_string(),
1092 "Validate fallback path is functioning correctly".to_string(),
1093 ],
1094 };
1095
1096 Some(RecoveryCommand {
1097 controller_id: id,
1098 controller_name: name,
1099 rolled_back_from: from,
1100 rolled_back_to: to,
1101 reason,
1102 policy_id,
1103 at_snapshot_id: snapshot_id,
1104 remediation,
1105 })
1106 }
1107
1108 pub fn hold(&mut self, id: ControllerId) -> bool {
1110 let Some(controller) = self.controllers.get_mut(&id) else {
1111 return false;
1112 };
1113 if controller.mode == ControllerMode::Hold {
1114 return false; }
1116 let from = controller.mode;
1117 controller.held_from_mode = Some(from);
1118 Self::set_controller_mode_state(controller, ControllerMode::Hold);
1119
1120 self.record_ledger_entry(id, None, LedgerEvent::Held { from });
1121
1122 if let Some(ref sink) = self.log_sink {
1123 sink(&format!(
1124 "controller_held id={} from={:?} policy_id={}",
1125 id.0, from, self.promotion_policy.policy_id,
1126 ));
1127 }
1128 true
1129 }
1130
1131 pub fn release_hold(&mut self, id: ControllerId) -> Option<ControllerMode> {
1133 let controller = self.controllers.get_mut(&id)?;
1134 if controller.mode != ControllerMode::Hold {
1135 return None;
1136 }
1137 let restored = controller
1138 .held_from_mode
1139 .take()
1140 .unwrap_or(ControllerMode::Shadow);
1141 Self::set_controller_mode_state(controller, restored);
1142
1143 self.record_ledger_entry(id, None, LedgerEvent::Released { to: restored });
1144
1145 if let Some(ref sink) = self.log_sink {
1146 sink(&format!(
1147 "controller_released id={} to={:?} policy_id={}",
1148 id.0, restored, self.promotion_policy.policy_id,
1149 ));
1150 }
1151 Some(restored)
1152 }
1153
1154 #[must_use]
1156 pub fn is_fallback_active(&self, id: ControllerId) -> bool {
1157 self.controllers.get(&id).is_some_and(|c| c.fallback_active)
1158 }
1159
1160 pub fn clear_fallback(&mut self, id: ControllerId) {
1162 if let Some(controller) = self.controllers.get_mut(&id) {
1163 controller.fallback_active = false;
1164 controller.last_action_label = "fallback_cleared".to_string();
1165 }
1166 }
1167
1168 #[must_use]
1170 pub fn evidence_ledger(&self) -> &[EvidenceLedgerEntry] {
1171 &self.evidence_ledger
1172 }
1173
1174 #[must_use]
1176 pub fn controller_ledger(&self, id: ControllerId) -> Vec<&EvidenceLedgerEntry> {
1177 self.evidence_ledger
1178 .iter()
1179 .filter(|entry| entry.controller_id == id)
1180 .collect()
1181 }
1182
1183 #[must_use]
1185 pub fn epochs_in_current_mode(&self, id: ControllerId) -> Option<u64> {
1186 self.controllers.get(&id).map(|c| c.epochs_in_current_mode)
1187 }
1188
1189 #[must_use]
1191 pub fn budget_overruns(&self, id: ControllerId) -> Option<u32> {
1192 self.controllers.get(&id).map(|c| c.budget_overruns)
1193 }
1194
1195 #[must_use]
1197 pub fn controller_snapshot_ledger(&self) -> ControllerSnapshotLedger {
1198 let controllers = self
1199 .controllers
1200 .iter()
1201 .map(|(&controller_id, controller)| ControllerSnapshotState {
1202 controller_id,
1203 controller_name: controller.registration.name.clone(),
1204 mode: controller.mode,
1205 decisions_this_epoch: controller.decisions_this_epoch,
1206 fallback_active: controller.fallback_active,
1207 calibration_score: controller.calibration_score,
1208 last_decision_confidence: controller.last_decision_confidence,
1209 last_action_label: (!controller.last_action_label.is_empty())
1210 .then(|| controller.last_action_label.clone()),
1211 last_evidence_tick: controller.last_evidence_tick,
1212 last_snapshot_id: controller.last_snapshot_id,
1213 epochs_in_current_mode: controller.epochs_in_current_mode,
1214 budget_overruns: controller.budget_overruns,
1215 proof_artifact_id: controller.registration.proof_artifact_id.clone(),
1216 })
1217 .collect();
1218 ControllerSnapshotLedger {
1219 schema_version: CONTROLLER_SNAPSHOT_LEDGER_SCHEMA_VERSION.to_string(),
1220 registered_controllers: self.len(),
1221 shadow_controllers: self.shadow_count(),
1222 controllers,
1223 }
1224 }
1225
1226 fn record_ledger_entry(
1227 &mut self,
1228 controller_id: ControllerId,
1229 snapshot_id: Option<SnapshotId>,
1230 event: LedgerEvent,
1231 ) {
1232 let entry_id = self.next_ledger_id;
1233 let action_label = Self::ledger_event_action_label(&event);
1234 let entry = EvidenceLedgerEntry {
1235 entry_id,
1236 controller_id,
1237 snapshot_id,
1238 event,
1239 policy_id: self.promotion_policy.policy_id.clone(),
1240 timestamp: Time::ZERO, };
1242 if let Some(controller) = self.controllers.get_mut(&controller_id) {
1243 controller.last_evidence_tick = Some(entry_id);
1244 controller.last_action_label = action_label;
1245 }
1246 self.next_ledger_id = self
1247 .next_ledger_id
1248 .checked_add(1)
1249 .expect("ledger ID overflow");
1250 self.evidence_ledger.push(entry);
1251 }
1252
1253 fn ledger_event_action_label(event: &LedgerEvent) -> String {
1254 match event {
1255 LedgerEvent::Registered { .. } => "registered".to_string(),
1256 LedgerEvent::Promoted { to, .. } => format!("promoted:{to:?}"),
1257 LedgerEvent::RolledBack { reason, .. } => {
1258 format!("rolled_back:{}", Self::rollback_reason_code(reason))
1259 }
1260 LedgerEvent::Held { .. } => "held".to_string(),
1261 LedgerEvent::Released { to } => format!("released:{to:?}"),
1262 LedgerEvent::Deregistered => "deregistered".to_string(),
1263 LedgerEvent::PromotionRejected { target, rejection } => format!(
1264 "promotion_rejected:{target:?}:{}",
1265 Self::promotion_rejection_code(rejection)
1266 ),
1267 LedgerEvent::DecisionRecorded { label, .. } => format!("decision:{label}"),
1268 }
1269 }
1270
1271 fn promotion_rejection_code(rejection: &PromotionRejection) -> &'static str {
1272 match rejection {
1273 PromotionRejection::ControllerNotFound => "controller_not_found",
1274 PromotionRejection::CalibrationTooLow { .. } => "calibration_too_low",
1275 PromotionRejection::InsufficientEpochs { .. } => "insufficient_epochs",
1276 PromotionRejection::InvalidTransition { .. } => "invalid_transition",
1277 PromotionRejection::HeldForInvestigation => "held_for_investigation",
1278 }
1279 }
1280
1281 fn rollback_reason_code(reason: &RollbackReason) -> &'static str {
1282 match reason {
1283 RollbackReason::CalibrationRegression { .. } => "calibration_regression",
1284 RollbackReason::BudgetOverruns { .. } => "budget_overruns",
1285 RollbackReason::ManualRollback => "manual_rollback",
1286 RollbackReason::FallbackTriggered { .. } => "fallback_triggered",
1287 }
1288 }
1289
1290 fn log_promotion_rejection(
1291 &self,
1292 id: ControllerId,
1293 rejection: &PromotionRejection,
1294 policy: &PromotionPolicy,
1295 ) {
1296 if let Some(ref sink) = self.log_sink {
1297 sink(&format!(
1298 "controller_promotion_rejected id={} reason={} policy_id={}",
1299 id.0, rejection, policy.policy_id,
1300 ));
1301 }
1302 }
1303}
1304
1305impl Default for ControllerRegistry {
1306 fn default() -> Self {
1307 Self::new()
1308 }
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313 #![allow(
1314 clippy::pedantic,
1315 clippy::nursery,
1316 clippy::expect_fun_call,
1317 clippy::map_unwrap_or,
1318 clippy::cast_possible_wrap,
1319 clippy::future_not_send
1320 )]
1321 use super::*;
1322
1323 fn test_registration(name: &str) -> ControllerRegistration {
1324 ControllerRegistration {
1325 name: name.to_string(),
1326 min_version: SnapshotVersion { major: 1, minor: 0 },
1327 max_version: SnapshotVersion { major: 1, minor: 0 },
1328 required_fields: vec!["ready_queue_len".to_string(), "cancel_lane_len".to_string()],
1329 target_seams: vec!["AA01-SEAM-SCHED-CANCEL-STREAK".to_string()],
1330 initial_mode: ControllerMode::Shadow,
1331 proof_artifact_id: None,
1332 budget: ControllerBudget::default(),
1333 }
1334 }
1335
1336 #[test]
1337 fn snapshot_version_compatibility() {
1338 let v1_0 = SnapshotVersion { major: 1, minor: 0 };
1339 let v1_1 = SnapshotVersion { major: 1, minor: 1 };
1340 let v2_0 = SnapshotVersion { major: 2, minor: 0 };
1341
1342 assert!(v1_0.is_compatible_with(&v1_0));
1343 assert!(v1_1.is_compatible_with(&v1_0));
1344 assert!(!v1_0.is_compatible_with(&v1_1));
1345 assert!(!v2_0.is_compatible_with(&v1_0));
1346 }
1347
1348 #[test]
1349 fn snapshot_serialization_roundtrip() {
1350 let snap = RuntimeKernelSnapshot::test_default(1, Time::ZERO);
1351 let json = serde_json::to_string(&snap).unwrap();
1352 let deser: RuntimeKernelSnapshot = serde_json::from_str(&json).unwrap();
1353 assert_eq!(deser.id, snap.id);
1354 assert_eq!(deser.version, snap.version);
1355 assert_eq!(deser.ready_queue_len, 0);
1356 assert_eq!(deser.worker_count, 1);
1357 }
1358
1359 #[test]
1360 fn snapshot_deterministic_serialization() {
1361 let snap1 = RuntimeKernelSnapshot::test_default(42, Time::ZERO);
1362 let snap2 = RuntimeKernelSnapshot::test_default(42, Time::ZERO);
1363 assert_eq!(
1364 serde_json::to_string(&snap1).unwrap(),
1365 serde_json::to_string(&snap2).unwrap(),
1366 );
1367 }
1368
1369 #[test]
1370 fn register_valid_controller() {
1371 let mut registry = ControllerRegistry::new();
1372 let id = registry.register(test_registration("test-ctrl")).unwrap();
1373 assert_eq!(id.0, 1);
1374 assert_eq!(registry.len(), 1);
1375 assert_eq!(registry.mode(id), Some(ControllerMode::Shadow));
1376 }
1377
1378 #[test]
1379 fn reject_empty_name() {
1380 let mut registry = ControllerRegistry::new();
1381 let mut reg = test_registration("");
1382 reg.name = String::new();
1383 assert_eq!(
1384 registry.register(reg).unwrap_err(),
1385 RegistrationError::EmptyName,
1386 );
1387 }
1388
1389 #[test]
1390 fn reject_inverted_version_range() {
1391 let mut registry = ControllerRegistry::new();
1392 let mut reg = test_registration("bad-range");
1393 reg.min_version = SnapshotVersion { major: 2, minor: 0 };
1394 reg.max_version = SnapshotVersion { major: 1, minor: 0 };
1395 assert_eq!(
1396 registry.register(reg).unwrap_err(),
1397 RegistrationError::InvertedVersionRange,
1398 );
1399 }
1400
1401 #[test]
1402 fn reject_incompatible_version() {
1403 let mut registry = ControllerRegistry::new();
1404 let mut reg = test_registration("future-ctrl");
1405 reg.min_version = SnapshotVersion { major: 5, minor: 0 };
1406 reg.max_version = SnapshotVersion { major: 5, minor: 0 };
1407 assert!(matches!(
1408 registry.register(reg).unwrap_err(),
1409 RegistrationError::IncompatibleVersion { .. }
1410 ));
1411
1412 let mut reg2 = test_registration("future-minor-ctrl");
1414 reg2.min_version = SnapshotVersion {
1415 major: SNAPSHOT_VERSION.major,
1416 minor: SNAPSHOT_VERSION.minor + 1,
1417 };
1418 reg2.max_version = SnapshotVersion {
1419 major: SNAPSHOT_VERSION.major,
1420 minor: SNAPSHOT_VERSION.minor + 1,
1421 };
1422 assert!(matches!(
1423 registry.register(reg2).unwrap_err(),
1424 RegistrationError::IncompatibleVersion { .. }
1425 ));
1426 }
1427
1428 #[test]
1429 fn snapshot_version_supported_enforces_upper_minor_bound() {
1430 let current = SnapshotVersion { major: 1, minor: 2 };
1431 let min = SnapshotVersion { major: 1, minor: 0 };
1432 let max = SnapshotVersion { major: 1, minor: 1 };
1433
1434 assert!(
1435 !ControllerRegistry::snapshot_version_supported(current, min, max),
1436 "future snapshot minor versions must respect the declared max bound"
1437 );
1438 }
1439
1440 #[test]
1441 fn reject_unsupported_fields() {
1442 let mut registry = ControllerRegistry::new();
1443 let mut reg = test_registration("bad-fields");
1444 reg.required_fields = vec!["nonexistent_field".to_string()];
1445 assert!(matches!(
1446 registry.register(reg).unwrap_err(),
1447 RegistrationError::UnsupportedFields(_)
1448 ));
1449 }
1450
1451 #[test]
1452 fn reject_no_target_seams() {
1453 let mut registry = ControllerRegistry::new();
1454 let mut reg = test_registration("no-seams");
1455 reg.target_seams = vec![];
1456 assert_eq!(
1457 registry.register(reg).unwrap_err(),
1458 RegistrationError::NoTargetSeams,
1459 );
1460 }
1461
1462 #[test]
1463 fn reject_zero_budget() {
1464 let mut registry = ControllerRegistry::new();
1465 let mut reg = test_registration("zero-budget");
1466 reg.budget.max_decisions_per_epoch = 0;
1467 assert_eq!(
1468 registry.register(reg).unwrap_err(),
1469 RegistrationError::ZeroBudget,
1470 );
1471 }
1472
1473 #[test]
1474 fn reject_duplicate_name() {
1475 let mut registry = ControllerRegistry::new();
1476 registry.register(test_registration("dup")).unwrap();
1477 assert_eq!(
1478 registry.register(test_registration("dup")).unwrap_err(),
1479 RegistrationError::DuplicateName("dup".to_string()),
1480 );
1481 }
1482
1483 #[test]
1484 fn deregister_controller() {
1485 let mut registry = ControllerRegistry::new();
1486 let id = registry.register(test_registration("removable")).unwrap();
1487 assert!(registry.deregister(id));
1488 assert_eq!(registry.len(), 0);
1489 assert!(!registry.deregister(id));
1490 }
1491
1492 #[test]
1493 fn set_mode() {
1494 let mut registry = ControllerRegistry::new();
1495 let id = registry.register(test_registration("mode-test")).unwrap();
1496 assert_eq!(registry.mode(id), Some(ControllerMode::Shadow));
1497 assert!(registry.set_mode(id, ControllerMode::Active));
1498 assert_eq!(registry.mode(id), Some(ControllerMode::Active));
1499 }
1500
1501 #[test]
1502 fn set_mode_resets_epoch_residency() {
1503 let mut registry = ControllerRegistry::new();
1504 let id = registry.register(test_registration("mode-reset")).unwrap();
1505 registry.advance_epoch();
1506 registry.advance_epoch();
1507 assert_eq!(registry.epochs_in_current_mode(id), Some(2));
1508
1509 assert!(registry.set_mode(id, ControllerMode::Shadow));
1510 assert_eq!(registry.epochs_in_current_mode(id), Some(0));
1511
1512 registry.advance_epoch();
1513 assert_eq!(registry.epochs_in_current_mode(id), Some(1));
1514 }
1515
1516 #[test]
1517 fn shadow_count() {
1518 let mut registry = ControllerRegistry::new();
1519 let id1 = registry.register(test_registration("s1")).unwrap();
1520 let _id2 = registry.register(test_registration("s2")).unwrap();
1521 assert_eq!(registry.shadow_count(), 2);
1522 registry.set_mode(id1, ControllerMode::Active);
1523 assert_eq!(registry.shadow_count(), 1);
1524 }
1525
1526 #[test]
1527 fn decision_budget_enforcement() {
1528 let mut registry = ControllerRegistry::new();
1529 let id = registry.register(test_registration("budget-ctrl")).unwrap();
1530 let snap_id = registry.next_snapshot_id();
1531
1532 let decision = ControllerDecision {
1533 controller_id: id,
1534 snapshot_id: snap_id,
1535 label: "test".to_string(),
1536 payload: serde_json::Value::Null,
1537 confidence: 0.9,
1538 fallback_label: "noop".to_string(),
1539 };
1540
1541 assert!(registry.record_decision(&decision));
1543 assert!(!registry.record_decision(&decision));
1545 registry.reset_epoch();
1547 assert!(registry.record_decision(&decision));
1549 }
1550
1551 #[test]
1552 fn calibration_tracking() {
1553 let mut registry = ControllerRegistry::new();
1554 let id = registry.register(test_registration("calib")).unwrap();
1555 assert_eq!(registry.calibration_score(id), Some(0.0));
1556 registry.update_calibration(id, 0.85);
1557 assert_eq!(registry.calibration_score(id), Some(0.85));
1558 }
1559
1560 #[test]
1561 fn snapshot_id_monotonic() {
1562 let mut registry = ControllerRegistry::new();
1563 let id1 = registry.next_snapshot_id();
1564 let id2 = registry.next_snapshot_id();
1565 let id3 = registry.next_snapshot_id();
1566 assert!(id1 < id2);
1567 assert!(id2 < id3);
1568 }
1569
1570 #[test]
1571 fn snapshot_id_overflow_panics() {
1572 let mut registry = ControllerRegistry::new();
1573 registry.next_snapshot_id = u64::MAX;
1574
1575 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1576 let _ = registry.next_snapshot_id();
1577 }));
1578 assert!(panic.is_err(), "snapshot id overflow must panic");
1579 }
1580
1581 #[test]
1582 fn active_mode_not_downgraded_when_snapshot_matches() {
1583 let mut registry = ControllerRegistry::new();
1584 let mut reg = test_registration("downgrade-test");
1585 reg.initial_mode = ControllerMode::Active;
1586 reg.min_version = SNAPSHOT_VERSION;
1588 reg.max_version = SNAPSHOT_VERSION;
1589 let id = registry.register(reg).unwrap();
1591 assert_eq!(registry.mode(id), Some(ControllerMode::Active));
1592 }
1593
1594 #[test]
1595 fn known_fields_completeness() {
1596 let snap = RuntimeKernelSnapshot::test_default(1, Time::ZERO);
1598 let json = serde_json::to_value(&snap).unwrap();
1599 let obj = json.as_object().unwrap();
1600 let meta_fields = [
1602 "id",
1603 "version",
1604 "timestamp",
1605 "registered_controllers",
1606 "shadow_controllers",
1607 ];
1608 for field in KNOWN_FIELDS {
1609 assert!(
1610 obj.contains_key(*field),
1611 "KNOWN_FIELDS contains '{field}' but snapshot JSON does not"
1612 );
1613 }
1614 for key in obj.keys() {
1615 if meta_fields.contains(&key.as_str()) {
1616 continue;
1617 }
1618 assert!(
1619 KNOWN_FIELDS.contains(&key.as_str()),
1620 "snapshot JSON has field '{key}' not in KNOWN_FIELDS"
1621 );
1622 }
1623 }
1624
1625 #[test]
1626 fn registration_info_accessible() {
1627 let mut registry = ControllerRegistry::new();
1628 let id = registry.register(test_registration("info-test")).unwrap();
1629 let reg = registry.registration(id).unwrap();
1630 assert_eq!(reg.name, "info-test");
1631 assert_eq!(reg.target_seams, vec!["AA01-SEAM-SCHED-CANCEL-STREAK"]);
1632 }
1633
1634 #[test]
1635 fn controller_ids_listed() {
1636 let mut registry = ControllerRegistry::new();
1637 let id1 = registry.register(test_registration("a")).unwrap();
1638 let id2 = registry.register(test_registration("b")).unwrap();
1639 let ids = registry.controller_ids();
1640 assert!(ids.contains(&id1));
1641 assert!(ids.contains(&id2));
1642 assert_eq!(ids.len(), 2);
1643 }
1644
1645 #[test]
1646 fn log_sink_receives_registration_event() {
1647 use parking_lot::Mutex;
1648 let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1649 let logs_clone = Arc::clone(&logs);
1650 let mut registry = ControllerRegistry::new().with_log_sink(Arc::new(move |msg: &str| {
1651 logs_clone.lock().push(msg.to_string());
1652 }));
1653 registry.register(test_registration("logged")).unwrap();
1654 {
1655 let captured = logs.lock();
1656 assert_eq!(captured.len(), 1);
1657 assert!(captured[0].contains("controller_registered"));
1658 assert!(captured[0].contains("logged"));
1659 drop(captured);
1660 }
1661 }
1662
1663 #[test]
1664 fn decision_for_unknown_controller_returns_false() {
1665 let mut registry = ControllerRegistry::new();
1666 let decision = ControllerDecision {
1667 controller_id: ControllerId(999),
1668 snapshot_id: SnapshotId(1),
1669 label: "ghost".to_string(),
1670 payload: serde_json::Value::Null,
1671 confidence: 1.0,
1672 fallback_label: "noop".to_string(),
1673 };
1674 assert!(!registry.record_decision(&decision));
1675 }
1676
1677 #[test]
1678 fn version_display() {
1679 let v = SnapshotVersion { major: 1, minor: 2 };
1680 assert_eq!(format!("{v}"), "1.2");
1681 }
1682
1683 #[test]
1684 fn error_display_coverage() {
1685 let errors = [
1686 RegistrationError::EmptyName,
1687 RegistrationError::InvertedVersionRange,
1688 RegistrationError::IncompatibleVersion {
1689 current: SnapshotVersion { major: 1, minor: 0 },
1690 min: SnapshotVersion { major: 2, minor: 0 },
1691 max: SnapshotVersion { major: 2, minor: 0 },
1692 },
1693 RegistrationError::UnsupportedFields(vec!["foo".to_string()]),
1694 RegistrationError::NoTargetSeams,
1695 RegistrationError::ZeroBudget,
1696 RegistrationError::DuplicateName("dup".to_string()),
1697 ];
1698 for error in &errors {
1699 let msg = format!("{error}");
1700 assert!(!msg.is_empty());
1701 }
1702 }
1703
1704 fn registry_with_policy(policy: PromotionPolicy) -> ControllerRegistry {
1707 let mut r = ControllerRegistry::new();
1708 r.set_promotion_policy(policy);
1709 r
1710 }
1711
1712 fn fast_policy() -> PromotionPolicy {
1713 PromotionPolicy {
1714 min_calibration_score: 0.8,
1715 min_shadow_epochs: 2,
1716 min_canary_epochs: 1,
1717 max_budget_overruns: 3,
1718 policy_id: "test-fast-v1".to_string(),
1719 }
1720 }
1721
1722 #[test]
1723 fn promote_shadow_to_canary() {
1724 let mut registry = registry_with_policy(fast_policy());
1725 let id = registry.register(test_registration("promo")).unwrap();
1726 registry.update_calibration(id, 0.9);
1727 registry.advance_epoch();
1729 registry.advance_epoch();
1730 let result = registry.try_promote(id, ControllerMode::Canary);
1731 assert_eq!(result, Ok(ControllerMode::Canary));
1732 assert_eq!(registry.mode(id), Some(ControllerMode::Canary));
1733 assert_eq!(registry.epochs_in_current_mode(id), Some(0));
1734 }
1735
1736 #[test]
1737 fn promote_canary_to_active() {
1738 let mut registry = registry_with_policy(fast_policy());
1739 let id = registry.register(test_registration("canary-up")).unwrap();
1740 registry.update_calibration(id, 0.95);
1741 registry.advance_epoch();
1742 registry.advance_epoch();
1743 registry.try_promote(id, ControllerMode::Canary).unwrap();
1744 registry.advance_epoch();
1745 let result = registry.try_promote(id, ControllerMode::Active);
1746 assert_eq!(result, Ok(ControllerMode::Active));
1747 }
1748
1749 #[test]
1750 fn promote_rejects_insufficient_epochs() {
1751 let mut registry = registry_with_policy(fast_policy());
1752 let id = registry.register(test_registration("too-soon")).unwrap();
1753 registry.update_calibration(id, 0.9);
1754 registry.advance_epoch();
1756 let result = registry.try_promote(id, ControllerMode::Canary);
1757 assert!(matches!(
1758 result,
1759 Err(PromotionRejection::InsufficientEpochs {
1760 current: 1,
1761 required: 2,
1762 ..
1763 })
1764 ));
1765 }
1766
1767 #[test]
1768 fn promote_rejects_low_calibration() {
1769 let mut registry = registry_with_policy(fast_policy());
1770 let id = registry.register(test_registration("low-cal")).unwrap();
1771 registry.update_calibration(id, 0.5);
1772 registry.advance_epoch();
1773 registry.advance_epoch();
1774 let result = registry.try_promote(id, ControllerMode::Canary);
1775 assert!(matches!(
1776 result,
1777 Err(PromotionRejection::CalibrationTooLow { .. })
1778 ));
1779 }
1780
1781 #[test]
1782 fn promote_rejects_invalid_transition_shadow_to_active() {
1783 let mut registry = registry_with_policy(fast_policy());
1784 let id = registry.register(test_registration("skip")).unwrap();
1785 registry.update_calibration(id, 0.99);
1786 registry.advance_epoch();
1787 registry.advance_epoch();
1788 registry.advance_epoch();
1789 let result = registry.try_promote(id, ControllerMode::Active);
1790 assert!(matches!(
1791 result,
1792 Err(PromotionRejection::InvalidTransition { .. })
1793 ));
1794 }
1795
1796 #[test]
1797 fn promote_rejects_active_to_canary() {
1798 let mut registry = registry_with_policy(fast_policy());
1799 let id = registry.register(test_registration("backward")).unwrap();
1800 registry.update_calibration(id, 0.95);
1801 registry.advance_epoch();
1802 registry.advance_epoch();
1803 registry.try_promote(id, ControllerMode::Canary).unwrap();
1804 registry.advance_epoch();
1805 registry.try_promote(id, ControllerMode::Active).unwrap();
1806 let result = registry.try_promote(id, ControllerMode::Canary);
1807 assert!(matches!(
1808 result,
1809 Err(PromotionRejection::InvalidTransition { .. })
1810 ));
1811 }
1812
1813 #[test]
1814 fn rollback_from_active_to_shadow() {
1815 let mut registry = registry_with_policy(fast_policy());
1816 let id = registry.register(test_registration("rollme")).unwrap();
1817 registry.update_calibration(id, 0.95);
1818 registry.advance_epoch();
1819 registry.advance_epoch();
1820 registry.try_promote(id, ControllerMode::Canary).unwrap();
1821 registry.advance_epoch();
1822 registry.try_promote(id, ControllerMode::Active).unwrap();
1823
1824 let cmd = registry
1825 .rollback(id, RollbackReason::CalibrationRegression { score: 0.3 })
1826 .unwrap();
1827 assert_eq!(registry.mode(id), Some(ControllerMode::Shadow));
1828 assert_eq!(cmd.rolled_back_from, ControllerMode::Active);
1829 assert_eq!(cmd.rolled_back_to, ControllerMode::Shadow);
1830 assert_eq!(cmd.controller_name, "rollme");
1831 assert!(!cmd.remediation.is_empty());
1832 assert!(registry.is_fallback_active(id));
1833 }
1834
1835 #[test]
1836 fn rollback_from_canary_to_shadow() {
1837 let mut registry = registry_with_policy(fast_policy());
1838 let id = registry.register(test_registration("can-roll")).unwrap();
1839 registry.update_calibration(id, 0.9);
1840 registry.advance_epoch();
1841 registry.advance_epoch();
1842 registry.try_promote(id, ControllerMode::Canary).unwrap();
1843
1844 let cmd = registry
1845 .rollback(id, RollbackReason::ManualRollback)
1846 .unwrap();
1847 assert_eq!(cmd.rolled_back_from, ControllerMode::Canary);
1848 assert_eq!(cmd.rolled_back_to, ControllerMode::Shadow);
1849 }
1850
1851 #[test]
1852 fn rollback_from_shadow_returns_none() {
1853 let mut registry = ControllerRegistry::new();
1854 let id = registry
1855 .register(test_registration("already-shadow"))
1856 .unwrap();
1857 assert!(
1858 registry
1859 .rollback(id, RollbackReason::ManualRollback)
1860 .is_none()
1861 );
1862 }
1863
1864 #[test]
1865 fn hold_and_release() {
1866 let mut registry = registry_with_policy(fast_policy());
1867 let id = registry.register(test_registration("holdme")).unwrap();
1868 registry.update_calibration(id, 0.9);
1869 registry.advance_epoch();
1870 registry.advance_epoch();
1871 registry.try_promote(id, ControllerMode::Canary).unwrap();
1872
1873 assert!(registry.hold(id));
1874 assert_eq!(registry.mode(id), Some(ControllerMode::Hold));
1875
1876 let result = registry.try_promote(id, ControllerMode::Active);
1878 assert!(matches!(
1879 result,
1880 Err(PromotionRejection::HeldForInvestigation)
1881 ));
1882
1883 let restored = registry.release_hold(id).unwrap();
1885 assert_eq!(restored, ControllerMode::Canary);
1886 assert_eq!(registry.mode(id), Some(ControllerMode::Canary));
1887 }
1888
1889 #[test]
1890 fn hold_already_held_returns_false() {
1891 let mut registry = ControllerRegistry::new();
1892 let id = registry.register(test_registration("double-hold")).unwrap();
1893 assert!(registry.hold(id));
1894 assert!(!registry.hold(id));
1895 }
1896
1897 #[test]
1898 fn release_non_held_returns_none() {
1899 let mut registry = ControllerRegistry::new();
1900 let id = registry.register(test_registration("not-held")).unwrap();
1901 assert!(registry.release_hold(id).is_none());
1902 }
1903
1904 #[test]
1905 fn fallback_lifecycle() {
1906 let mut registry = registry_with_policy(fast_policy());
1907 let id = registry.register(test_registration("fb")).unwrap();
1908 assert!(!registry.is_fallback_active(id));
1909 registry.update_calibration(id, 0.9);
1910 registry.advance_epoch();
1911 registry.advance_epoch();
1912 registry.try_promote(id, ControllerMode::Canary).unwrap();
1913 registry.rollback(
1914 id,
1915 RollbackReason::FallbackTriggered {
1916 decision_label: "bad-decision".to_string(),
1917 },
1918 );
1919 assert!(registry.is_fallback_active(id));
1920 registry.clear_fallback(id);
1921 assert!(!registry.is_fallback_active(id));
1922 }
1923
1924 #[test]
1925 fn evidence_ledger_records_registration() {
1926 let mut registry = ControllerRegistry::new();
1927 let id = registry.register(test_registration("ledger-reg")).unwrap();
1928 let entries = registry.controller_ledger(id);
1929 assert_eq!(entries.len(), 1);
1930 assert!(matches!(entries[0].event, LedgerEvent::Registered { .. }));
1931 }
1932
1933 #[test]
1934 fn evidence_ledger_records_full_lifecycle() {
1935 let mut registry = registry_with_policy(fast_policy());
1936 let id = registry.register(test_registration("full-life")).unwrap();
1937 registry.update_calibration(id, 0.95);
1938 registry.advance_epoch();
1939 registry.advance_epoch();
1940
1941 registry.try_promote(id, ControllerMode::Canary).unwrap();
1943 registry.advance_epoch();
1944
1945 registry.try_promote(id, ControllerMode::Active).unwrap();
1947
1948 registry.rollback(id, RollbackReason::ManualRollback);
1950
1951 let entries = registry.controller_ledger(id);
1952 assert_eq!(entries.len(), 4);
1954 assert!(matches!(entries[0].event, LedgerEvent::Registered { .. }));
1955 assert!(matches!(
1956 entries[1].event,
1957 LedgerEvent::Promoted {
1958 from: ControllerMode::Shadow,
1959 to: ControllerMode::Canary,
1960 ..
1961 }
1962 ));
1963 assert!(matches!(
1964 entries[2].event,
1965 LedgerEvent::Promoted {
1966 from: ControllerMode::Canary,
1967 to: ControllerMode::Active,
1968 ..
1969 }
1970 ));
1971 assert!(matches!(
1972 entries[3].event,
1973 LedgerEvent::RolledBack {
1974 from: ControllerMode::Active,
1975 to: ControllerMode::Shadow,
1976 ..
1977 }
1978 ));
1979 }
1980
1981 #[test]
1982 fn evidence_ledger_records_decisions() {
1983 let mut registry = ControllerRegistry::new();
1984 let id = registry.register(test_registration("dec-ledger")).unwrap();
1985 let snap_id = registry.next_snapshot_id();
1986 let decision = ControllerDecision {
1987 controller_id: id,
1988 snapshot_id: snap_id,
1989 label: "adjust-streak".to_string(),
1990 payload: serde_json::Value::Null,
1991 confidence: 0.9,
1992 fallback_label: "noop".to_string(),
1993 };
1994 registry.record_decision(&decision);
1995 let entries = registry.controller_ledger(id);
1996 assert_eq!(entries.len(), 2);
1998 assert!(matches!(
1999 &entries[1].event,
2000 LedgerEvent::DecisionRecorded {
2001 label,
2002 confidence,
2003 fallback_label,
2004 within_budget: true,
2005 } if label == "adjust-streak" && (*confidence - 0.9).abs() < f64::EPSILON && fallback_label == "noop"
2006 ));
2007 }
2008
2009 #[test]
2010 fn evidence_ledger_records_decision_metadata() {
2011 let mut registry = ControllerRegistry::new();
2012 let id = registry
2013 .register(test_registration("decision-metadata"))
2014 .unwrap();
2015 let snap_id = registry.next_snapshot_id();
2016 let decision = ControllerDecision {
2017 controller_id: id,
2018 snapshot_id: snap_id,
2019 label: "retune-queue".to_string(),
2020 payload: serde_json::json!({ "limit": 8 }),
2021 confidence: 0.42,
2022 fallback_label: "shadow-default".to_string(),
2023 };
2024
2025 assert!(registry.record_decision(&decision));
2026
2027 let entries = registry.controller_ledger(id);
2028 let event = &entries[1].event;
2029 match event {
2030 LedgerEvent::DecisionRecorded {
2031 label,
2032 confidence,
2033 fallback_label,
2034 within_budget,
2035 } => {
2036 assert_eq!(label, "retune-queue");
2037 assert!((*confidence - 0.42).abs() < f64::EPSILON);
2038 assert_eq!(fallback_label, "shadow-default");
2039 assert!(*within_budget);
2040 }
2041 other => panic!("unexpected ledger event: {other:?}"),
2042 }
2043 }
2044
2045 #[test]
2046 fn stale_decision_does_not_regress_last_snapshot_watermark() {
2047 let mut registry = registry_with_policy(fast_policy());
2048 let id = registry
2049 .register(test_registration("stale-snapshot"))
2050 .unwrap();
2051 registry.update_calibration(id, 0.95);
2052 registry.advance_epoch();
2053 registry.advance_epoch();
2054 registry.try_promote(id, ControllerMode::Canary).unwrap();
2055
2056 let first = registry.next_snapshot_id();
2057 let second = registry.next_snapshot_id();
2058 let newer = ControllerDecision {
2059 controller_id: id,
2060 snapshot_id: second,
2061 label: "newer".to_string(),
2062 payload: serde_json::Value::Null,
2063 confidence: 0.9,
2064 fallback_label: "noop".to_string(),
2065 };
2066 let stale = ControllerDecision {
2067 controller_id: id,
2068 snapshot_id: first,
2069 label: "stale".to_string(),
2070 payload: serde_json::Value::Null,
2071 confidence: 0.9,
2072 fallback_label: "noop".to_string(),
2073 };
2074
2075 assert!(registry.record_decision(&newer));
2076 assert!(!registry.record_decision(&stale));
2077
2078 let rollback = registry
2079 .rollback(id, RollbackReason::ManualRollback)
2080 .expect("canary controller should roll back");
2081 assert_eq!(rollback.at_snapshot_id, Some(second));
2082 }
2083
2084 #[test]
2085 fn evidence_ledger_records_promotion_rejections() {
2086 let mut registry = registry_with_policy(fast_policy());
2087 let id = registry
2088 .register(test_registration("reject-ledger"))
2089 .unwrap();
2090 registry.update_calibration(id, 0.5);
2091 registry.advance_epoch();
2092 registry.advance_epoch();
2093 let _ = registry.try_promote(id, ControllerMode::Canary);
2094 let entries = registry.controller_ledger(id);
2095 assert_eq!(entries.len(), 2);
2097 assert!(matches!(
2098 entries[1].event,
2099 LedgerEvent::PromotionRejected { .. }
2100 ));
2101 }
2102
2103 #[test]
2104 fn evidence_ledger_records_hold_and_release() {
2105 let mut registry = ControllerRegistry::new();
2106 let id = registry.register(test_registration("hold-ledger")).unwrap();
2107 registry.hold(id);
2108 registry.release_hold(id);
2109 let entries = registry.controller_ledger(id);
2110 assert_eq!(entries.len(), 3);
2112 assert!(matches!(entries[1].event, LedgerEvent::Held { .. }));
2113 assert!(matches!(entries[2].event, LedgerEvent::Released { .. }));
2114 }
2115
2116 #[test]
2117 fn evidence_ledger_records_deregistration() {
2118 let mut registry = ControllerRegistry::new();
2119 let id = registry
2120 .register(test_registration("dereg-ledger"))
2121 .unwrap();
2122 registry.deregister(id);
2123 let entries = registry.controller_ledger(id);
2124 assert_eq!(entries.len(), 2);
2126 assert!(matches!(entries[1].event, LedgerEvent::Deregistered));
2127 }
2128
2129 #[test]
2130 fn ledger_entry_ids_are_monotonic() {
2131 let mut registry = ControllerRegistry::new();
2132 let id = registry.register(test_registration("mono")).unwrap();
2133 registry.hold(id);
2134 registry.release_hold(id);
2135 let ledger = registry.evidence_ledger();
2136 for pair in ledger.windows(2) {
2137 assert!(pair[0].entry_id < pair[1].entry_id);
2138 }
2139 }
2140
2141 #[test]
2142 fn ledger_entries_carry_policy_id() {
2143 let policy = fast_policy();
2144 let expected_id = policy.policy_id.clone();
2145 let mut registry = registry_with_policy(policy);
2146 let id = registry
2147 .register(test_registration("policy-trace"))
2148 .unwrap();
2149 registry.hold(id);
2150 for entry in registry.controller_ledger(id) {
2151 assert_eq!(entry.policy_id, expected_id);
2152 }
2153 }
2154
2155 #[test]
2156 fn budget_overruns_tracked() {
2157 let mut registry = ControllerRegistry::new();
2158 let id = registry.register(test_registration("overruns")).unwrap();
2159 let snap_id = registry.next_snapshot_id();
2160 let decision = ControllerDecision {
2161 controller_id: id,
2162 snapshot_id: snap_id,
2163 label: "test".to_string(),
2164 payload: serde_json::Value::Null,
2165 confidence: 0.9,
2166 fallback_label: "noop".to_string(),
2167 };
2168 registry.record_decision(&decision);
2170 registry.record_decision(&decision);
2171 registry.record_decision(&decision);
2172 assert_eq!(registry.budget_overruns(id), Some(2));
2173 }
2174
2175 #[test]
2176 fn decision_counters_saturate_without_wrapping() {
2177 let mut registry = ControllerRegistry::new();
2178 let id = registry
2179 .register(test_registration("saturating-counters"))
2180 .unwrap();
2181 let snap_id = registry.next_snapshot_id();
2182 let decision = ControllerDecision {
2183 controller_id: id,
2184 snapshot_id: snap_id,
2185 label: "spam".to_string(),
2186 payload: serde_json::Value::Null,
2187 confidence: 0.9,
2188 fallback_label: "noop".to_string(),
2189 };
2190
2191 let controller = registry
2192 .controllers
2193 .get_mut(&id)
2194 .expect("controller must exist");
2195 controller.registration.budget.max_decisions_per_epoch = u32::MAX;
2196 controller.decisions_this_epoch = u32::MAX;
2197 controller.budget_overruns = u32::MAX;
2198
2199 assert!(
2200 !registry.record_decision(&decision),
2201 "a saturated decision counter must stay over-budget instead of wrapping back in-budget"
2202 );
2203 let controller = registry
2204 .controllers
2205 .get(&id)
2206 .expect("controller must exist");
2207 assert_eq!(controller.decisions_this_epoch, u32::MAX);
2208 assert_eq!(controller.budget_overruns, u32::MAX);
2209 }
2210
2211 #[test]
2212 fn advance_epoch_increments_mode_counter() {
2213 let mut registry = ControllerRegistry::new();
2214 let id = registry.register(test_registration("epoch-count")).unwrap();
2215 assert_eq!(registry.epochs_in_current_mode(id), Some(0));
2216 registry.advance_epoch();
2217 assert_eq!(registry.epochs_in_current_mode(id), Some(1));
2218 registry.advance_epoch();
2219 assert_eq!(registry.epochs_in_current_mode(id), Some(2));
2220 }
2221
2222 #[test]
2223 fn recovery_command_has_remediation() {
2224 let mut registry = registry_with_policy(fast_policy());
2225 let id = registry.register(test_registration("recovery")).unwrap();
2226 registry.update_calibration(id, 0.95);
2227 registry.advance_epoch();
2228 registry.advance_epoch();
2229 registry.try_promote(id, ControllerMode::Canary).unwrap();
2230
2231 let cmd = registry
2232 .rollback(id, RollbackReason::BudgetOverruns { count: 5 })
2233 .unwrap();
2234 assert_eq!(cmd.policy_id, "test-fast-v1");
2235 assert!(!cmd.remediation.is_empty());
2236 assert!(cmd.remediation.iter().any(|r| r.contains("budget")));
2237 }
2238
2239 #[test]
2240 fn recovery_command_for_fallback_triggered() {
2241 let mut registry = registry_with_policy(fast_policy());
2242 let id = registry
2243 .register(test_registration("fallback-cmd"))
2244 .unwrap();
2245 registry.update_calibration(id, 0.9);
2246 registry.advance_epoch();
2247 registry.advance_epoch();
2248 registry.try_promote(id, ControllerMode::Canary).unwrap();
2249
2250 let cmd = registry
2251 .rollback(
2252 id,
2253 RollbackReason::FallbackTriggered {
2254 decision_label: "bad-action".to_string(),
2255 },
2256 )
2257 .unwrap();
2258 assert!(cmd.remediation.iter().any(|r| r.contains("bad-action")));
2259 }
2260
2261 #[test]
2262 fn structured_log_covers_promotion_and_rollback() {
2263 use parking_lot::Mutex;
2264 let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2265 let logs_clone = Arc::clone(&logs);
2266 let mut registry = registry_with_policy(fast_policy());
2267 registry = registry.with_log_sink(Arc::new(move |msg: &str| {
2268 logs_clone.lock().push(msg.to_string());
2269 }));
2270 let id = registry.register(test_registration("log-promo")).unwrap();
2271 registry.update_calibration(id, 0.9);
2272 registry.advance_epoch();
2273 registry.advance_epoch();
2274 registry.try_promote(id, ControllerMode::Canary).unwrap();
2275 registry.rollback(id, RollbackReason::ManualRollback);
2276
2277 {
2278 let captured = logs.lock();
2279 assert!(captured.iter().any(|l| l.contains("controller_promoted")));
2280 assert!(
2281 captured
2282 .iter()
2283 .any(|l| l.contains("controller_rolled_back"))
2284 );
2285 assert!(
2286 captured
2287 .iter()
2288 .any(|l| l.contains("policy_id=test-fast-v1"))
2289 );
2290 drop(captured);
2291 }
2292 }
2293
2294 #[test]
2295 fn structured_log_covers_promotion_rejection() {
2296 use parking_lot::Mutex;
2297 let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2298 let logs_clone = Arc::clone(&logs);
2299 let mut registry = registry_with_policy(fast_policy());
2300 registry = registry.with_log_sink(Arc::new(move |msg: &str| {
2301 logs_clone.lock().push(msg.to_string());
2302 }));
2303 let id = registry.register(test_registration("log-reject")).unwrap();
2304 registry.update_calibration(id, 0.5);
2305 registry.advance_epoch();
2306 registry.advance_epoch();
2307 let _ = registry.try_promote(id, ControllerMode::Canary);
2308
2309 {
2310 let captured = logs.lock();
2311 assert!(
2312 captured
2313 .iter()
2314 .any(|l| l.contains("controller_promotion_rejected"))
2315 );
2316 drop(captured);
2317 }
2318 }
2319
2320 #[test]
2321 fn structured_log_covers_hold_and_release() {
2322 use parking_lot::Mutex;
2323 let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2324 let logs_clone = Arc::clone(&logs);
2325 let mut registry = ControllerRegistry::new();
2326 registry = registry.with_log_sink(Arc::new(move |msg: &str| {
2327 logs_clone.lock().push(msg.to_string());
2328 }));
2329 let id = registry.register(test_registration("log-hold")).unwrap();
2330 registry.hold(id);
2331 registry.release_hold(id);
2332
2333 {
2334 let captured = logs.lock();
2335 assert!(captured.iter().any(|l| l.contains("controller_held")));
2336 assert!(captured.iter().any(|l| l.contains("controller_released")));
2337 drop(captured);
2338 }
2339 }
2340
2341 #[test]
2342 fn promotion_rejection_display_coverage() {
2343 let rejections = [
2344 PromotionRejection::ControllerNotFound,
2345 PromotionRejection::CalibrationTooLow {
2346 current: 0.5,
2347 required: 0.8,
2348 },
2349 PromotionRejection::InsufficientEpochs {
2350 current: 1,
2351 required: 3,
2352 mode: ControllerMode::Shadow,
2353 },
2354 PromotionRejection::InvalidTransition {
2355 from: ControllerMode::Shadow,
2356 to: ControllerMode::Active,
2357 },
2358 PromotionRejection::HeldForInvestigation,
2359 ];
2360 for rejection in &rejections {
2361 let msg = format!("{rejection}");
2362 assert!(!msg.is_empty());
2363 }
2364 }
2365
2366 #[test]
2367 fn rollback_reason_display_coverage() {
2368 let reasons = [
2369 RollbackReason::CalibrationRegression { score: 0.3 },
2370 RollbackReason::BudgetOverruns { count: 5 },
2371 RollbackReason::ManualRollback,
2372 RollbackReason::FallbackTriggered {
2373 decision_label: "test".to_string(),
2374 },
2375 ];
2376 for reason in &reasons {
2377 let msg = format!("{reason}");
2378 assert!(!msg.is_empty());
2379 }
2380 }
2381
2382 #[test]
2383 fn e2e_promotion_cannot_bypass_verification() {
2384 let mut registry = registry_with_policy(fast_policy());
2386 let id = registry
2387 .register(test_registration("bypass-attempt"))
2388 .unwrap();
2389
2390 registry.update_calibration(id, 0.99);
2392 for _ in 0..10 {
2393 registry.advance_epoch();
2394 }
2395 assert!(matches!(
2396 registry.try_promote(id, ControllerMode::Active),
2397 Err(PromotionRejection::InvalidTransition { .. })
2398 ));
2399
2400 registry.update_calibration(id, 0.1);
2402 assert!(matches!(
2403 registry.try_promote(id, ControllerMode::Canary),
2404 Err(PromotionRejection::CalibrationTooLow { .. })
2405 ));
2406
2407 registry.update_calibration(id, 0.99);
2410 registry.set_mode(id, ControllerMode::Shadow);
2411 assert!(matches!(
2412 registry.try_promote(id, ControllerMode::Canary),
2413 Err(PromotionRejection::InsufficientEpochs {
2414 current: 0,
2415 required: 2,
2416 ..
2417 })
2418 ));
2419 registry.advance_epoch();
2420 assert!(matches!(
2421 registry.try_promote(id, ControllerMode::Canary),
2422 Err(PromotionRejection::InsufficientEpochs {
2423 current: 1,
2424 required: 2,
2425 ..
2426 })
2427 ));
2428 registry.advance_epoch();
2429 assert!(registry.try_promote(id, ControllerMode::Canary).is_ok());
2430
2431 let id2 = registry
2433 .register(test_registration("correct-path"))
2434 .unwrap();
2435 registry.update_calibration(id2, 0.9);
2436 assert!(registry.try_promote(id2, ControllerMode::Canary).is_err()); registry.advance_epoch();
2438 assert!(registry.try_promote(id2, ControllerMode::Canary).is_err()); registry.advance_epoch();
2440 assert!(registry.try_promote(id2, ControllerMode::Canary).is_ok()); assert!(registry.try_promote(id2, ControllerMode::Active).is_err()); registry.advance_epoch();
2443 assert!(registry.try_promote(id2, ControllerMode::Active).is_ok()); assert_eq!(registry.mode(id2), Some(ControllerMode::Active));
2445 }
2446
2447 #[test]
2448 fn e2e_failed_rollout_leaves_conservative_state() {
2449 let mut registry = registry_with_policy(fast_policy());
2450 let id = registry
2451 .register(test_registration("failed-rollout"))
2452 .unwrap();
2453 registry.update_calibration(id, 0.9);
2454 registry.advance_epoch();
2455 registry.advance_epoch();
2456 registry.try_promote(id, ControllerMode::Canary).unwrap();
2457 registry.advance_epoch();
2458 registry.try_promote(id, ControllerMode::Active).unwrap();
2459
2460 registry.update_calibration(id, 0.2);
2462 let cmd = registry
2463 .rollback(id, RollbackReason::CalibrationRegression { score: 0.2 })
2464 .unwrap();
2465
2466 assert_eq!(registry.mode(id), Some(ControllerMode::Shadow));
2468 assert!(registry.is_fallback_active(id));
2469 assert_eq!(cmd.rolled_back_to, ControllerMode::Shadow);
2470 assert!(!cmd.remediation.is_empty());
2471
2472 assert!(registry.try_promote(id, ControllerMode::Canary).is_err());
2474 }
2475
2476 #[test]
2477 fn e2e_hold_blocks_entire_pipeline() {
2478 let mut registry = registry_with_policy(fast_policy());
2479 let id = registry.register(test_registration("hold-block")).unwrap();
2480 registry.update_calibration(id, 0.99);
2481 registry.advance_epoch();
2482 registry.advance_epoch();
2483
2484 registry.hold(id);
2485 assert!(matches!(
2487 registry.try_promote(id, ControllerMode::Canary),
2488 Err(PromotionRejection::HeldForInvestigation)
2489 ));
2490
2491 registry.release_hold(id);
2493 registry.advance_epoch();
2495 registry.advance_epoch();
2496 assert!(registry.try_promote(id, ControllerMode::Canary).is_ok());
2497 }
2498
2499 #[test]
2500 fn recovery_command_serializable() {
2501 let cmd = RecoveryCommand {
2502 controller_id: ControllerId(42),
2503 controller_name: "test-ctrl".to_string(),
2504 rolled_back_from: ControllerMode::Active,
2505 rolled_back_to: ControllerMode::Shadow,
2506 reason: RollbackReason::ManualRollback,
2507 policy_id: "test-v1".to_string(),
2508 at_snapshot_id: Some(SnapshotId(100)),
2509 remediation: vec!["check logs".to_string()],
2510 };
2511 let json = serde_json::to_string(&cmd).unwrap();
2512 let deser: RecoveryCommand = serde_json::from_str(&json).unwrap();
2513 assert_eq!(deser.controller_id, ControllerId(42));
2514 assert_eq!(deser.controller_name, "test-ctrl");
2515 }
2516
2517 #[test]
2518 fn evidence_ledger_entry_serializable() {
2519 let entry = EvidenceLedgerEntry {
2520 entry_id: 1,
2521 controller_id: ControllerId(1),
2522 snapshot_id: Some(SnapshotId(5)),
2523 event: LedgerEvent::Promoted {
2524 from: ControllerMode::Shadow,
2525 to: ControllerMode::Canary,
2526 calibration_score: 0.85,
2527 },
2528 policy_id: "test".to_string(),
2529 timestamp: Time::ZERO,
2530 };
2531 let json = serde_json::to_string(&entry).unwrap();
2532 let deser: EvidenceLedgerEntry = serde_json::from_str(&json).unwrap();
2533 assert_eq!(deser.entry_id, 1);
2534 }
2535
2536 #[test]
2537 fn default_promotion_policy_values() {
2538 let policy = PromotionPolicy::default();
2539 assert!((policy.min_calibration_score - 0.8).abs() < f64::EPSILON);
2540 assert_eq!(policy.min_shadow_epochs, 3);
2541 assert_eq!(policy.min_canary_epochs, 2);
2542 assert_eq!(policy.max_budget_overruns, 3);
2543 assert_eq!(policy.policy_id, "default-promotion-policy-v1");
2544 }
2545}