//! Transactional committed-versus-active configuration lifecycle. use crate::{ ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, }; use kaptan_log::KaptanError; /// Observable state of the bounded configuration transaction slot. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfigState { /// The boot-active and committed snapshots match and no edit is open. Active, /// One isolated RAM transaction is open. Editing, /// A newer committed snapshot will take effect after restart. RestartRequired, } /// Failure while recovering a boot configuration from a store. #[derive(Debug, KaptanError)] pub enum StartupError { /// The storage backend could not provide a committed record. Store(E), /// A committed record did not validate against this firmware's schema. InvalidPersisted(ConfigViolation), } /// Failure while beginning a transaction. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BeginError { /// Exactly one transaction is already open. TransactionActive, } /// Failure while writing a scalar into the bounded transaction. #[derive(Clone, Copy, Debug, PartialEq, KaptanError)] pub enum SetError { /// No transaction is open. NoTransaction, /// The candidate rejected the local scalar without mutation. Field(FieldWriteError), } /// Failure while validating or committing a transaction. #[derive(Debug, PartialEq, KaptanError)] pub enum CommitError { /// No transaction is open. NoTransaction, /// The complete candidate violates a relational or capability invariant. InvalidCandidate(ConfigViolation), /// The storage backend could not commit the snapshot. Store(E), } /// Owns the immutable boot-active configuration, the latest durable candidate, and /// exactly one RAM edit transaction. /// /// P1 deliberately has no activation operation: [`Self::active`] never /// changes after boot. `commit` atomically persists a complete validated /// candidate, then reports [`ConfigState::RestartRequired`]. pub struct ConfigManager { active: C::Active, factory: C, committed: C, transaction: Option, context: C::Context, store: S, restart_required: bool, } impl ConfigManager where C: ConfigModel, S: ConfigStore, { /// Starts from a validated factory candidate when no persistent record is /// available. Flight callers separately decide whether setup/calibration /// is sufficient to arm; no recovery is implicit. pub fn new(factory: C, context: C::Context, store: S) -> Result { let active = factory.validate(&context)?; Ok(Self { active, factory: factory.clone(), committed: factory, transaction: None, context, store, restart_required: false, }) } /// Recovers the newest valid committed snapshot or uses the supplied /// factory candidate when the store is empty. pub fn recover( factory: C, context: C::Context, mut store: S, ) -> Result> { let Some(committed) = store.load().map_err(StartupError::Store)? else { return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted); }; let active = committed .validate(&context) .map_err(StartupError::InvalidPersisted)?; Ok(Self { active, factory, committed, transaction: None, context, store, restart_required: false, }) } /// Recovers the newest candidate compatible with the current context, /// falling back to the supplied factory when no compatible record exists. /// /// This is an explicit recovery policy for output-inhibited setup images. /// Safety-critical compositions should use [`Self::recover`] if an /// incompatible durable record must prevent initialization. pub fn recover_compatible_or_factory( factory: C, context: C::Context, mut store: S, ) -> Result> { let Some(committed) = store .load_matching(|candidate| candidate.validate(&context).is_ok()) .map_err(StartupError::Store)? else { return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted); }; let active = committed .validate(&context) .map_err(StartupError::InvalidPersisted)?; Ok(Self { active, factory, committed, transaction: None, context, store, restart_required: false, }) } /// Returns the immutable configuration selected at boot. pub const fn active(&self) -> &C::Active { &self.active } /// Returns the last durably committed candidate value. pub fn committed_value(&self, id: FieldId) -> Option { self.committed.field_value(id) } /// Returns the selected board/image factory value for descriptor metadata. pub fn factory_value(&self, id: FieldId) -> Option { self.factory.field_value(id) } /// Returns a value from the immutable boot-active snapshot. pub fn active_value(&self, id: FieldId) -> Option { C::active_field_value(&self.active, id) } /// Returns a transaction value when an edit is open. pub fn transaction_value(&self, id: FieldId) -> Option { self.transaction.as_ref()?.field_value(id) } /// Returns descriptor metadata by stable schema index. pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> { C::descriptor(index) } /// Number of fields exposed by the compiled registry. pub fn field_count(&self) -> u16 { C::field_count() } /// Returns the current lifecycle state. pub const fn state(&self) -> ConfigState { if self.transaction.is_some() { ConfigState::Editing } else if self.restart_required { ConfigState::RestartRequired } else { ConfigState::Active } } /// Opens the only transaction slot from the last durably committed state. pub fn begin(&mut self) -> Result<(), BeginError> { if self.transaction.is_some() { return Err(BeginError::TransactionActive); } self.transaction = Some(self.committed.clone()); Ok(()) } /// Applies one locally valid scalar in the open transaction. /// /// Cross-field-invalid intermediate states are allowed so multi-field /// edits can be made naturally. They are rejected by `validate`/`commit`. pub fn set(&mut self, id: FieldId, value: ConfigValue) -> Result { let candidate = self.transaction.as_mut().ok_or(SetError::NoTransaction)?; candidate.set_field(id, value).map_err(SetError::Field)?; candidate .field_value(id) .ok_or(SetError::Field(FieldWriteError::UnknownField(id))) } /// Validates the complete open transaction without changing persistent or /// active state. pub fn validate(&self) -> Result<(), CommitError> { let candidate = self .transaction .as_ref() .ok_or(CommitError::NoTransaction)?; candidate .validate(&self.context) .map(|_| ()) .map_err(CommitError::InvalidCandidate) } /// Validates and durably commits the complete open transaction. pub fn commit(&mut self) -> Result<(), CommitError> { let candidate = self .transaction .as_ref() .ok_or(CommitError::NoTransaction)?; candidate .validate(&self.context) .map_err(CommitError::InvalidCandidate)?; self.store.persist(candidate).map_err(CommitError::Store)?; self.committed = candidate.clone(); self.transaction = None; self.restart_required = true; Ok(()) } /// Drops the RAM transaction without changing active or committed state. pub fn abort(&mut self) -> bool { self.transaction.take().is_some() } } #[cfg(test)] mod tests { use super::*; use crate::{ConfigValue, ValidationRule, ValueKind}; const VALUE: FieldId = FieldId::new(1); const OTHER: FieldId = FieldId::new(2); const DESCRIPTORS: [FieldDescriptor; 2] = [ FieldDescriptor { id: VALUE, display_key: "test.value", kind: ValueKind::U32, minimum: Some(ConfigValue::U32(1)), maximum: Some(ConfigValue::U32(10)), enum_options: &[], unit: "count", }, FieldDescriptor { id: OTHER, display_key: "test.other", kind: ValueKind::U32, minimum: Some(ConfigValue::U32(1)), maximum: Some(ConfigValue::U32(10)), enum_options: &[], unit: "count", }, ]; #[derive(Clone)] struct Candidate { value: u32, other: u32, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Active { value: u32, other: u32, } impl ConfigModel for Candidate { type Active = Active; type Context = (); const SCHEMA_ID: [u8; 16] = *b"CONFIGLIFECYCLE1"; fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { DESCRIPTORS.get(usize::from(index)) } fn field_count() -> u16 { DESCRIPTORS.len() as u16 } fn field_value(&self, id: FieldId) -> Option { match id { VALUE => Some(ConfigValue::U32(self.value)), OTHER => Some(ConfigValue::U32(self.other)), _ => None, } } fn active_field_value(active: &Self::Active, id: FieldId) -> Option { match id { VALUE => Some(ConfigValue::U32(active.value)), OTHER => Some(ConfigValue::U32(active.other)), _ => None, } } fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { let ConfigValue::U32(value) = value else { return Err(FieldWriteError::WrongType { field: id, expected: ValueKind::U32, actual: value.kind(), }); }; if !(1..=10).contains(&value) { return Err(FieldWriteError::Violation(ConfigViolation::field( id, ValidationRule::AboveMaximum, ))); } match id { VALUE => self.value = value, OTHER => self.other = value, _ => return Err(FieldWriteError::UnknownField(id)), } Ok(()) } fn validate(&self, _: &Self::Context) -> Result { if self.value > self.other { return Err(ConfigViolation::related( VALUE, OTHER, ValidationRule::Inconsistent, )); } Ok(Active { value: self.value, other: self.other, }) } } struct MemoryStore; impl ConfigStore for MemoryStore { type Error = (); fn load(&mut self) -> Result, Self::Error> { Ok(None) } fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { Ok(()) } } struct IncompatibleStore; impl ConfigStore for IncompatibleStore { type Error = (); fn load(&mut self) -> Result, Self::Error> { Ok(Some(Candidate { value: 9, other: 1 })) } fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { Ok(()) } } #[test] fn invalid_intermediate_transaction_never_changes_active_or_committed() { let mut manager = ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap(); manager.begin().unwrap(); assert_eq!( manager.set(VALUE, ConfigValue::U32(5)), Ok(ConfigValue::U32(5)) ); assert_eq!( manager.validate(), Err(CommitError::InvalidCandidate(ConfigViolation::related( VALUE, OTHER, ValidationRule::Inconsistent ))) ); assert_eq!(manager.active(), &Active { value: 2, other: 4 }); assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(2))); assert_eq!(manager.transaction_value(VALUE), Some(ConfigValue::U32(5))); assert_eq!(manager.state(), ConfigState::Editing); } #[test] fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() { let mut manager = ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap(); manager.begin().unwrap(); manager.set(VALUE, ConfigValue::U32(3)).unwrap(); assert_eq!(manager.commit(), Ok(())); assert_eq!(manager.active(), &Active { value: 2, other: 4 }); assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3))); assert_eq!(manager.state(), ConfigState::RestartRequired); manager.begin().unwrap(); manager.set(VALUE, ConfigValue::U32(4)).unwrap(); assert!(manager.abort()); assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3))); assert_eq!(manager.state(), ConfigState::RestartRequired); } #[test] fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() { let factory = Candidate { value: 2, other: 4 }; assert!(matches!( ConfigManager::recover(factory.clone(), (), IncompatibleStore), Err(StartupError::InvalidPersisted(_)) )); let manager = ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap(); assert_eq!(manager.active(), &Active { value: 2, other: 4 }); } }