Skip to main content

asupersync/runtime/
kernel.rs

1//! Proof-carrying decision-plane kernel for runtime controllers.
2//!
3//! This module defines the canonical [`crate::runtime::kernel::RuntimeKernelSnapshot`]
4//! that controllers observe, the [`crate::runtime::kernel::ControllerRegistration`]
5//! contract they must satisfy, and the [`crate::runtime::kernel::ControllerRegistry`]
6//! that validates and manages controller participation.
7//!
8//! # Design Principles
9//!
10//! - **Narrow surface**: Snapshot fields are the minimum needed for decision-making.
11//!   Adding a field requires explicit justification and version bump.
12//! - **Deterministic**: Snapshot creation and serialization are deterministic given
13//!   the same runtime state, enabling replay and comparison.
14//! - **Auditable**: Every controller action is traced with snapshot ID, version,
15//!   and decision metadata for post-hoc analysis.
16//! - **No ambient authority**: Controllers receive snapshots; they cannot reach
17//!   into runtime internals directly.
18//!
19//! # Versioning
20//!
21//! Snapshots carry a [`crate::runtime::kernel::SnapshotVersion`] that controllers
22//! declare support for.
23//! The registry rejects controllers whose expected version range does not overlap
24//! with the current snapshot version. Controllers consuming a reduced snapshot
25//! (fewer fields than the full version) remain in shadow mode until they upgrade.
26
27use crate::types::Time;
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeMap;
30use std::sync::Arc;
31
32/// Current snapshot schema version.
33pub const SNAPSHOT_VERSION: SnapshotVersion = SnapshotVersion { major: 1, minor: 0 };
34
35/// Schema version for exported controller snapshot ledgers.
36pub const CONTROLLER_SNAPSHOT_LEDGER_SCHEMA_VERSION: &str = "controller-snapshot-ledger-v1";
37
38/// Schema version for runtime kernel snapshots.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40pub struct SnapshotVersion {
41    /// Snapshot schema major version.
42    pub major: u32,
43    /// Snapshot schema minor version.
44    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    /// Check if `other` is compatible (same major, <= minor).
55    #[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/// Monotonic snapshot identifier. Each snapshot gets a unique, increasing ID.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64pub struct SnapshotId(pub u64);
65
66/// A point-in-time snapshot of observable runtime state for controllers.
67///
68/// Controllers receive this snapshot via their `observe` callback. They must
69/// not cache snapshots across decision boundaries — each decision must use
70/// the snapshot provided for that epoch.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RuntimeKernelSnapshot {
73    /// Unique identifier for this snapshot.
74    pub id: SnapshotId,
75    /// Schema version of this snapshot.
76    pub version: SnapshotVersion,
77    /// Logical time at which this snapshot was taken.
78    pub timestamp: Time,
79
80    // ── Scheduler state ───────────────────────────────────────────────
81    /// Number of tasks currently in the ready queue.
82    pub ready_queue_len: usize,
83    /// Number of tasks in the cancel lane.
84    pub cancel_lane_len: usize,
85    /// Number of tasks in the finalize lane.
86    pub finalize_lane_len: usize,
87    /// Total tasks currently tracked by the runtime.
88    pub total_tasks: usize,
89    /// Number of active (non-closed) regions.
90    pub active_regions: usize,
91    /// Current cancel-lane streak count within the active epoch.
92    pub cancel_streak_current: usize,
93    /// Configured cancel-lane max streak.
94    pub cancel_streak_limit: usize,
95
96    // ── Obligation state ──────────────────────────────────────────────
97    /// Number of outstanding (uncommitted) obligations.
98    pub outstanding_obligations: usize,
99    /// Cumulative obligation leak count since runtime start.
100    pub obligation_leak_count: u64,
101
102    // ── I/O and timer state ───────────────────────────────────────────
103    /// Number of pending I/O registrations in the reactor.
104    pub pending_io_registrations: usize,
105    /// Number of active timers in the timer wheel.
106    pub active_timers: usize,
107
108    // ── Worker state ──────────────────────────────────────────────────
109    /// Number of worker threads configured.
110    pub worker_count: usize,
111    /// Number of workers currently parked (idle).
112    pub workers_parked: usize,
113    /// Number of active blocking pool threads.
114    pub blocking_threads_active: usize,
115
116    // ── Governor and adaptive state ───────────────────────────────────
117    /// Whether the Lyapunov governor is enabled.
118    pub governor_enabled: bool,
119    /// Whether adaptive cancel-streak is enabled.
120    pub adaptive_cancel_enabled: bool,
121    /// Current adaptive cancel-streak epoch number (if adaptive enabled).
122    pub adaptive_epoch: u64,
123
124    // ── Controller metadata ───────────────────────────────────────────
125    /// Number of registered controllers.
126    pub registered_controllers: usize,
127    /// Number of controllers in shadow mode.
128    pub shadow_controllers: usize,
129}
130
131impl RuntimeKernelSnapshot {
132    /// Create a minimal snapshot for testing.
133    #[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/// Operating mode for a controller.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165pub enum ControllerMode {
166    /// Controller observes snapshots but does not influence decisions.
167    Shadow,
168    /// Controller decisions are compared against baseline but not applied.
169    Canary,
170    /// Controller decisions are applied to the runtime.
171    Active,
172    /// Controller is paused pending investigation or manual intervention.
173    Hold,
174}
175
176/// A decision emitted by a controller.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ControllerDecision {
179    /// ID of the controller that made this decision.
180    pub controller_id: ControllerId,
181    /// Snapshot ID this decision was based on.
182    pub snapshot_id: SnapshotId,
183    /// Human-readable decision label.
184    pub label: String,
185    /// Structured decision payload (controller-specific).
186    pub payload: serde_json::Value,
187    /// Confidence score in `[0.0, 1.0]` for the decision.
188    pub confidence: f64,
189    /// Fallback: if this decision is rejected, what should happen.
190    pub fallback_label: String,
191}
192
193/// Unique identifier for a registered controller.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
195pub struct ControllerId(pub u64);
196
197/// Planner-facing snapshot of one controller's observable runtime state.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ControllerSnapshotState {
200    /// Unique identifier for the controller.
201    pub controller_id: ControllerId,
202    /// Human-readable controller name.
203    pub controller_name: String,
204    /// Current operating mode.
205    pub mode: ControllerMode,
206    /// Decisions recorded in the current epoch.
207    pub decisions_this_epoch: u32,
208    /// Whether the controller is running on a conservative fallback path.
209    pub fallback_active: bool,
210    /// Latest calibration score tracked for this controller.
211    pub calibration_score: f64,
212    /// Latest decision confidence observed for this controller, if any.
213    pub last_decision_confidence: Option<f64>,
214    /// Last high-level action recorded for this controller, if any.
215    pub last_action_label: Option<String>,
216    /// Monotonic evidence tick (ledger entry ID) of the last recorded action.
217    pub last_evidence_tick: Option<u64>,
218    /// Latest runtime snapshot ID consumed by the controller, if any.
219    pub last_snapshot_id: Option<SnapshotId>,
220    /// Epochs spent in the current operating mode.
221    pub epochs_in_current_mode: u64,
222    /// Budget overruns accumulated since the last successful promotion.
223    pub budget_overruns: u32,
224    /// Proof artifact associated with the controller registration, if any.
225    pub proof_artifact_id: Option<String>,
226}
227
228/// Deterministic controller-state ledger exported for operator bundles.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ControllerSnapshotLedger {
231    /// Version tag for the controller snapshot ledger schema.
232    pub schema_version: String,
233    /// Number of registered controllers included in this ledger.
234    pub registered_controllers: usize,
235    /// Number of controllers currently operating in shadow mode.
236    pub shadow_controllers: usize,
237    /// Stable controller state rows sorted by controller ID.
238    pub controllers: Vec<ControllerSnapshotState>,
239}
240
241/// Metadata a controller must provide at registration time.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ControllerRegistration {
244    /// Human-readable name for this controller.
245    pub name: String,
246    /// Minimum snapshot version this controller can consume.
247    pub min_version: SnapshotVersion,
248    /// Maximum snapshot version this controller can consume.
249    pub max_version: SnapshotVersion,
250    /// Snapshot fields this controller requires (for forward-compat checks).
251    pub required_fields: Vec<String>,
252    /// Which seam IDs this controller targets (from the control-seam inventory).
253    pub target_seams: Vec<String>,
254    /// Initial operating mode.
255    pub initial_mode: ControllerMode,
256    /// Artifact ID for the controller's proof bundle (if any).
257    pub proof_artifact_id: Option<String>,
258    /// Budget counters: max decisions per epoch, max latency per decision.
259    pub budget: ControllerBudget,
260}
261
262/// Resource budget constraints for a controller.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ControllerBudget {
265    /// Maximum number of decisions per snapshot epoch.
266    pub max_decisions_per_epoch: u32,
267    /// Maximum wall-clock microseconds per decision.
268    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/// Reason a controller registration was rejected.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub enum RegistrationError {
283    /// Controller name is empty.
284    EmptyName,
285    /// Version range is inverted (min > max).
286    InvertedVersionRange,
287    /// Current snapshot version is outside controller's supported range.
288    IncompatibleVersion {
289        /// Snapshot version found in the runtime state being validated.
290        current: SnapshotVersion,
291        /// Minimum snapshot version accepted by the controller.
292        min: SnapshotVersion,
293        /// Maximum snapshot version accepted by the controller.
294        max: SnapshotVersion,
295    },
296    /// Required fields are not present in the current snapshot schema.
297    UnsupportedFields(Vec<String>),
298    /// No target seams specified.
299    NoTargetSeams,
300    /// Budget has zero decisions allowed.
301    ZeroBudget,
302    /// A controller with this name is already registered.
303    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
331/// Known snapshot field names for validation.
332const 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/// Policy governing controller promotion through the lifecycle.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct PromotionPolicy {
357    /// Minimum calibration score in `[0.0, 1.0]` required for promotion.
358    pub min_calibration_score: f64,
359    /// Minimum epochs a controller must spend in Shadow before promoting to Canary.
360    pub min_shadow_epochs: u64,
361    /// Minimum epochs a controller must spend in Canary before promoting to Active.
362    pub min_canary_epochs: u64,
363    /// Maximum allowed budget overruns before automatic rollback.
364    pub max_budget_overruns: u32,
365    /// Policy identifier for audit trail.
366    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/// Reason a promotion was rejected.
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub enum PromotionRejection {
384    /// Controller not found.
385    ControllerNotFound,
386    /// Calibration score below threshold.
387    CalibrationTooLow {
388        /// Current calibration score.
389        current: f64,
390        /// Required minimum calibration score.
391        required: f64,
392    },
393    /// Not enough epochs in the prerequisite mode.
394    InsufficientEpochs {
395        /// Current number of epochs in the prerequisite mode.
396        current: u64,
397        /// Required minimum number of epochs.
398        required: u64,
399        /// The mode the controller is currently in.
400        mode: ControllerMode,
401    },
402    /// Invalid transition (e.g., Shadow directly to Active).
403    InvalidTransition {
404        /// Current mode.
405        from: ControllerMode,
406        /// Requested mode.
407        to: ControllerMode,
408    },
409    /// Controller is in Hold mode and cannot be promoted without explicit release.
410    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/// Reason a controller was rolled back.
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445pub enum RollbackReason {
446    /// Calibration score dropped below threshold.
447    CalibrationRegression {
448        /// Calibration score that triggered the rollback.
449        score: f64,
450    },
451    /// Budget overruns exceeded policy limit.
452    BudgetOverruns {
453        /// Number of overruns accumulated.
454        count: u32,
455    },
456    /// Manual rollback requested by operator.
457    ManualRollback,
458    /// Fallback triggered by a decision rejection.
459    FallbackTriggered {
460        /// The decision label that caused the fallback.
461        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/// A recovery command emitted when a rollout fails.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct RecoveryCommand {
485    /// Controller that needs recovery.
486    pub controller_id: ControllerId,
487    /// Controller name for human identification.
488    pub controller_name: String,
489    /// Mode the controller was rolled back from.
490    pub rolled_back_from: ControllerMode,
491    /// Mode the controller was rolled back to.
492    pub rolled_back_to: ControllerMode,
493    /// Reason for the rollback.
494    pub reason: RollbackReason,
495    /// Policy ID that governed the decision.
496    pub policy_id: String,
497    /// Snapshot ID at the time of rollback.
498    pub at_snapshot_id: Option<SnapshotId>,
499    /// Suggested remediation steps.
500    pub remediation: Vec<String>,
501}
502
503/// An entry in the evidence ledger.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct EvidenceLedgerEntry {
506    /// Sequential entry ID.
507    pub entry_id: u64,
508    /// Controller ID this entry pertains to.
509    pub controller_id: ControllerId,
510    /// Snapshot ID at the time of the event (if available).
511    pub snapshot_id: Option<SnapshotId>,
512    /// Type of event.
513    pub event: LedgerEvent,
514    /// Policy ID governing this event.
515    pub policy_id: String,
516    /// Timestamp (logical).
517    pub timestamp: Time,
518}
519
520/// Events recorded in the evidence ledger.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub enum LedgerEvent {
523    /// Controller was registered.
524    Registered {
525        /// Initial mode assigned.
526        initial_mode: ControllerMode,
527    },
528    /// Controller mode was changed via promotion.
529    Promoted {
530        /// Previous mode.
531        from: ControllerMode,
532        /// New mode.
533        to: ControllerMode,
534        /// Calibration score at time of promotion.
535        calibration_score: f64,
536    },
537    /// Controller was rolled back.
538    RolledBack {
539        /// Previous mode.
540        from: ControllerMode,
541        /// New mode.
542        to: ControllerMode,
543        /// Reason for rollback.
544        reason: RollbackReason,
545    },
546    /// Controller was placed on hold.
547    Held {
548        /// Previous mode.
549        from: ControllerMode,
550    },
551    /// Controller was released from hold.
552    Released {
553        /// Mode restored to.
554        to: ControllerMode,
555    },
556    /// Controller was deregistered.
557    Deregistered,
558    /// Promotion was rejected.
559    PromotionRejected {
560        /// The target mode that was requested.
561        target: ControllerMode,
562        /// Why the promotion was rejected.
563        rejection: PromotionRejection,
564    },
565    /// Decision recorded.
566    DecisionRecorded {
567        /// Decision label.
568        label: String,
569        /// Confidence score recorded with the decision.
570        confidence: f64,
571        /// Fallback label recorded with the decision.
572        fallback_label: String,
573        /// Whether the decision was within budget.
574        within_budget: bool,
575    },
576}
577
578/// Record of a registered controller within the registry.
579#[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    /// Mode before entering Hold, so we can restore on release.
590    held_from_mode: Option<ControllerMode>,
591    fallback_active: bool,
592    last_evidence_tick: Option<u64>,
593    last_action_label: String,
594}
595
596/// Type alias for log sink callbacks.
597type LogSink = Arc<dyn Fn(&str) + Send + Sync>;
598
599/// Registry that validates and manages controller participation.
600///
601/// The registry enforces:
602/// - Version compatibility between controllers and snapshots
603/// - Required field existence in the snapshot schema
604/// - Uniqueness of controller names
605/// - Budget constraints per epoch
606/// - Promotion pipeline (Shadow → Canary → Active) with calibration gates
607/// - Evidence ledger for audit trail
608pub struct ControllerRegistry {
609    controllers: BTreeMap<ControllerId, RegisteredController>,
610    next_id: u64,
611    next_snapshot_id: u64,
612    /// Callback for structured logging of registration events.
613    log_sink: Option<LogSink>,
614    /// Promotion policy governing lifecycle transitions.
615    promotion_policy: PromotionPolicy,
616    /// Evidence ledger for audit and replay.
617    evidence_ledger: Vec<EvidenceLedgerEntry>,
618    /// Next evidence ledger entry ID.
619    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        // Epoch residency is scoped to the controller's current mode. Any
637        // explicit mode set starts a fresh residency window, even when the
638        // caller is re-asserting the same mode to reset stale state.
639        controller.epochs_in_current_mode = 0;
640    }
641
642    /// Create a new empty registry.
643    #[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    /// Set a structured log sink for registration and decision events.
657    #[must_use]
658    pub fn with_log_sink(mut self, sink: LogSink) -> Self {
659        self.log_sink = Some(sink);
660        self
661    }
662
663    /// Register a controller, returning its ID on success.
664    pub fn register(
665        &mut self,
666        registration: ControllerRegistration,
667    ) -> Result<ControllerId, RegistrationError> {
668        self.validate(&registration)?;
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            // Downgrade to shadow if snapshot is newer than controller expects
683            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    /// Validate a registration without inserting it.
724    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    /// Deregister a controller.
764    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    /// Get the current mode of a controller.
773    #[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    /// Set the mode of a controller.
780    #[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    /// Get registration info for a controller.
793    #[must_use]
794    pub fn registration(&self, id: ControllerId) -> Option<&ControllerRegistration> {
795        self.controllers.get(&id).map(|c| &c.registration)
796    }
797
798    /// Number of registered controllers.
799    #[must_use]
800    pub fn len(&self) -> usize {
801        self.controllers.len()
802    }
803
804    /// Whether the registry is empty.
805    #[must_use]
806    pub fn is_empty(&self) -> bool {
807        self.controllers.is_empty()
808    }
809
810    /// Count of controllers in shadow mode.
811    #[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    /// Allocate the next snapshot ID.
820    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    /// Reset per-epoch decision counters for all controllers.
830    ///
831    /// Note: prefer `advance_epoch()` which also increments epoch-in-mode counters.
832    pub fn reset_epoch(&mut self) {
833        for controller in self.controllers.values_mut() {
834            controller.decisions_this_epoch = 0;
835        }
836    }
837
838    /// Record a decision and check budget.
839    /// Returns `true` if the decision is within budget, `false` if over budget.
840    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    /// Update calibration score for a controller (e.g., after shadow comparison).
874    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    /// Get calibration score for a controller.
881    #[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    /// List all controller IDs.
887    #[must_use]
888    pub fn controller_ids(&self) -> Vec<ControllerId> {
889        self.controllers.keys().copied().collect()
890    }
891
892    /// Set the promotion policy.
893    pub fn set_promotion_policy(&mut self, policy: PromotionPolicy) {
894        self.promotion_policy = policy;
895    }
896
897    /// Get the current promotion policy.
898    #[must_use]
899    pub fn promotion_policy(&self) -> &PromotionPolicy {
900        &self.promotion_policy
901    }
902
903    /// Advance epoch counters for all controllers.
904    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    /// Try to promote a controller to the next mode in the pipeline.
912    ///
913    /// Promotion follows the pipeline: Shadow → Canary → Active.
914    /// Each transition requires calibration and epoch thresholds defined by
915    /// the promotion policy. Returns a `RecoveryCommand` on rejection.
916    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        // Hold blocks all promotions
932        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        // Validate transition is valid
947        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        // Check calibration threshold
970        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        // Check epoch requirements
988        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        // All gates passed — promote
1012        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    /// Roll back a controller to Shadow mode, producing a recovery command.
1037    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            // Already in the most conservative mode; nothing to roll back.
1048            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    /// Place a controller on hold, pausing its participation.
1109    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; // already held
1115        }
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    /// Release a controller from hold, restoring its previous mode.
1132    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    /// Whether a controller's fallback is currently active.
1155    #[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    /// Clear fallback flag (e.g., after recovery is confirmed).
1161    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    /// Get the evidence ledger.
1169    #[must_use]
1170    pub fn evidence_ledger(&self) -> &[EvidenceLedgerEntry] {
1171        &self.evidence_ledger
1172    }
1173
1174    /// Get ledger entries for a specific controller.
1175    #[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    /// Get the number of epochs a controller has spent in its current mode.
1184    #[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    /// Get the number of budget overruns for a controller.
1190    #[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    /// Export deterministic planner-facing controller state.
1196    #[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, // Logical time injected by caller in production
1241        };
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        // Test minor version incompatibility
1413        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        // First decision within budget (max_decisions_per_epoch = 1)
1542        assert!(registry.record_decision(&decision));
1543        // Second decision exceeds budget
1544        assert!(!registry.record_decision(&decision));
1545        // Reset epoch
1546        registry.reset_epoch();
1547        // First decision after reset is within budget again
1548        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        // Controller supports up to SNAPSHOT_VERSION
1587        reg.min_version = SNAPSHOT_VERSION;
1588        reg.max_version = SNAPSHOT_VERSION;
1589        // This should work since versions match
1590        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        // Verify KNOWN_FIELDS matches snapshot struct fields
1597        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        // Non-data fields that aren't in KNOWN_FIELDS
1601        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    // ── AA-02.3: Shadow, canary, rollback, evidence-ledger validation ──
1705
1706    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        // Need 2 epochs in shadow
1728        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        // Only 1 epoch, need 2
1755        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        // Cannot promote while held
1877        let result = registry.try_promote(id, ControllerMode::Active);
1878        assert!(matches!(
1879            result,
1880            Err(PromotionRejection::HeldForInvestigation)
1881        ));
1882
1883        // Release restores previous mode
1884        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        // Promote to canary
1942        registry.try_promote(id, ControllerMode::Canary).unwrap();
1943        registry.advance_epoch();
1944
1945        // Promote to active
1946        registry.try_promote(id, ControllerMode::Active).unwrap();
1947
1948        // Rollback
1949        registry.rollback(id, RollbackReason::ManualRollback);
1950
1951        let entries = registry.controller_ledger(id);
1952        // Registered + 2 Promoted + RolledBack = 4
1953        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        // Registered + DecisionRecorded
1997        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        // Registered + PromotionRejected
2096        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        // Registered + Held + Released
2111        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        // Registered + Deregistered
2125        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        // 1st within budget, 2nd exceeds (budget=1)
2169        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        // Scenario: a controller tries to skip the pipeline
2385        let mut registry = registry_with_policy(fast_policy());
2386        let id = registry
2387            .register(test_registration("bypass-attempt"))
2388            .unwrap();
2389
2390        // Attempt 1: promote directly to Active from Shadow (must fail)
2391        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        // Attempt 2: promote to Canary without sufficient calibration
2401        registry.update_calibration(id, 0.1);
2402        assert!(matches!(
2403            registry.try_promote(id, ControllerMode::Canary),
2404            Err(PromotionRejection::CalibrationTooLow { .. })
2405        ));
2406
2407        // Attempt 3: re-asserting Shadow starts a fresh residency window, so
2408        // the controller still cannot bypass the minimum shadow epochs.
2409        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        // Correct path: full pipeline
2432        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()); // 0 epochs
2437        registry.advance_epoch();
2438        assert!(registry.try_promote(id2, ControllerMode::Canary).is_err()); // 1 epoch
2439        registry.advance_epoch();
2440        assert!(registry.try_promote(id2, ControllerMode::Canary).is_ok()); // 2 epochs
2441        assert!(registry.try_promote(id2, ControllerMode::Active).is_err()); // 0 canary epochs
2442        registry.advance_epoch();
2443        assert!(registry.try_promote(id2, ControllerMode::Active).is_ok()); // 1 canary epoch
2444        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        // Simulate calibration regression triggering rollback
2461        registry.update_calibration(id, 0.2);
2462        let cmd = registry
2463            .rollback(id, RollbackReason::CalibrationRegression { score: 0.2 })
2464            .unwrap();
2465
2466        // Verify conservative state
2467        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        // Cannot re-promote without clearing conditions
2473        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        // All promotion attempts fail while held
2486        assert!(matches!(
2487            registry.try_promote(id, ControllerMode::Canary),
2488            Err(PromotionRejection::HeldForInvestigation)
2489        ));
2490
2491        // Release and verify pipeline resumes
2492        registry.release_hold(id);
2493        // Epochs reset on release, need to accumulate again
2494        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}