Skip to main content

orbok_models/
generation.rs

1//! Typed managed-generation lifecycle state for RFC-050 ยง3B.
2//!
3//! This module is pure: it does not touch SQLite or the filesystem. Later
4//! phases may execute only transitions that are valid here and persist them
5//! under the model-store mutation guard.
6
7use std::collections::BTreeMap;
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct ManagedGenerationId(String);
12
13impl ManagedGenerationId {
14    pub fn generate() -> Self {
15        Self(format!("gen_{}", uuid::Uuid::now_v7()))
16    }
17
18    pub fn parse(value: impl Into<String>) -> Result<Self, GenerationValueError> {
19        let value = value.into();
20        let uuid = value
21            .strip_prefix("gen_")
22            .and_then(|suffix| uuid::Uuid::parse_str(suffix).ok())
23            .filter(|uuid| uuid.get_version() == Some(uuid::Version::SortRand))
24            .ok_or(GenerationValueError::InvalidGenerationId)?;
25        let canonical = format!("gen_{uuid}");
26        if value != canonical {
27            return Err(GenerationValueError::InvalidGenerationId);
28        }
29        Ok(Self(value))
30    }
31
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37impl fmt::Display for ManagedGenerationId {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        formatter.write_str(&self.0)
40    }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct ModelStoreProfileId(String);
45
46impl ModelStoreProfileId {
47    pub fn parse(value: impl Into<String>) -> Result<Self, GenerationValueError> {
48        let value = value.into();
49        if value.is_empty()
50            || value.len() > 64
51            || !value
52                .bytes()
53                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
54        {
55            return Err(GenerationValueError::InvalidProfileId);
56        }
57        Ok(Self(value))
58    }
59
60    pub fn default_embedding() -> Self {
61        Self("default-embedding".to_string())
62    }
63
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67}
68
69impl fmt::Display for ModelStoreProfileId {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        formatter.write_str(&self.0)
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
76pub struct StartupEpoch(u64);
77
78impl StartupEpoch {
79    pub const ZERO: Self = Self(0);
80
81    pub fn new(value: u64) -> Result<Self, GenerationValueError> {
82        if value > i64::MAX as u64 {
83            return Err(GenerationValueError::EpochOutOfRange);
84        }
85        Ok(Self(value))
86    }
87
88    pub fn get(self) -> u64 {
89        self.0
90    }
91
92    pub fn next(self) -> Result<Self, GenerationTransitionError> {
93        let next = self
94            .0
95            .checked_add(1)
96            .ok_or(GenerationTransitionError::EpochExhausted)?;
97        Self::new(next).map_err(|_| GenerationTransitionError::EpochExhausted)
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ManagedGenerationState {
103    Inactive,
104    Current,
105    Previous,
106    Invalid,
107}
108
109impl ManagedGenerationState {
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::Inactive => "inactive",
113            Self::Current => "current",
114            Self::Previous => "previous",
115            Self::Invalid => "invalid",
116        }
117    }
118
119    pub fn parse(value: &str) -> Result<Self, GenerationValueError> {
120        match value {
121            "inactive" => Ok(Self::Inactive),
122            "current" => Ok(Self::Current),
123            "previous" => Ok(Self::Previous),
124            "invalid" => Ok(Self::Invalid),
125            _ => Err(GenerationValueError::InvalidLifecycleState),
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ManagedGenerationRecord {
132    pub generation_id: ManagedGenerationId,
133    pub manifest_id: String,
134    pub state: ManagedGenerationState,
135    pub activation_epoch: Option<StartupEpoch>,
136    pub validated_startup_epoch: Option<StartupEpoch>,
137}
138
139/// Catalog identity observed for the generation that completed startup load
140/// validation. Both fields must still match when validation is recorded.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct StartupValidationEvidence {
143    generation_id: ManagedGenerationId,
144    activation_epoch: StartupEpoch,
145}
146
147impl StartupValidationEvidence {
148    pub fn generation_id(&self) -> &ManagedGenerationId {
149        &self.generation_id
150    }
151
152    pub fn activation_epoch(&self) -> StartupEpoch {
153        self.activation_epoch
154    }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ManagedProfileState {
159    pub profile_id: ModelStoreProfileId,
160    pub startup_epoch: StartupEpoch,
161    pub current_generation_id: Option<ManagedGenerationId>,
162    pub previous_generation_id: Option<ManagedGenerationId>,
163    pub state_revision: u64,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ManagedGenerationSnapshot {
168    pub profile: ManagedProfileState,
169    pub generations: BTreeMap<ManagedGenerationId, ManagedGenerationRecord>,
170}
171
172impl ManagedGenerationSnapshot {
173    pub fn empty(profile_id: ModelStoreProfileId) -> Self {
174        Self {
175            profile: ManagedProfileState {
176                profile_id,
177                startup_epoch: StartupEpoch::ZERO,
178                current_generation_id: None,
179                previous_generation_id: None,
180                state_revision: 0,
181            },
182            generations: BTreeMap::new(),
183        }
184    }
185
186    pub fn validate(&self) -> Result<(), GenerationTransitionError> {
187        if self.profile.current_generation_id == self.profile.previous_generation_id
188            && self.profile.current_generation_id.is_some()
189        {
190            return Err(GenerationTransitionError::EqualCurrentAndPrevious);
191        }
192        if self.profile.current_generation_id.is_none()
193            && self.profile.previous_generation_id.is_some()
194        {
195            return Err(GenerationTransitionError::PreviousWithoutCurrent);
196        }
197
198        let mut current_count = 0;
199        let mut previous_count = 0;
200        for (id, record) in &self.generations {
201            if id != &record.generation_id {
202                return Err(GenerationTransitionError::RecordIdentityMismatch);
203            }
204            if let Some(validated) = record.validated_startup_epoch {
205                let activation = record
206                    .activation_epoch
207                    .ok_or(GenerationTransitionError::InvalidEpochPair)?;
208                if validated <= activation {
209                    return Err(GenerationTransitionError::InvalidEpochPair);
210                }
211            }
212            match record.state {
213                ManagedGenerationState::Current => {
214                    current_count += 1;
215                    if self.profile.current_generation_id.as_ref() != Some(id)
216                        || record.activation_epoch.is_none()
217                    {
218                        return Err(GenerationTransitionError::PointerStateMismatch);
219                    }
220                }
221                ManagedGenerationState::Previous => {
222                    previous_count += 1;
223                    if self.profile.previous_generation_id.as_ref() != Some(id)
224                        || record.activation_epoch.is_none()
225                    {
226                        return Err(GenerationTransitionError::PointerStateMismatch);
227                    }
228                }
229                ManagedGenerationState::Inactive | ManagedGenerationState::Invalid => {
230                    if self.profile.current_generation_id.as_ref() == Some(id)
231                        || self.profile.previous_generation_id.as_ref() == Some(id)
232                    {
233                        return Err(GenerationTransitionError::PointerStateMismatch);
234                    }
235                }
236            }
237        }
238        if current_count != usize::from(self.profile.current_generation_id.is_some())
239            || previous_count != usize::from(self.profile.previous_generation_id.is_some())
240        {
241            return Err(GenerationTransitionError::PointerStateMismatch);
242        }
243        Ok(())
244    }
245
246    pub fn register_inactive(
247        &self,
248        generation_id: ManagedGenerationId,
249        manifest_id: impl Into<String>,
250    ) -> Result<Self, GenerationTransitionError> {
251        if self.generations.contains_key(&generation_id) {
252            return Err(GenerationTransitionError::GenerationAlreadyExists);
253        }
254        let manifest_id = manifest_id.into();
255        if manifest_id.is_empty() {
256            return Err(GenerationTransitionError::EmptyManifestId);
257        }
258        let mut next = self.clone();
259        next.generations.insert(
260            generation_id.clone(),
261            ManagedGenerationRecord {
262                generation_id,
263                manifest_id,
264                state: ManagedGenerationState::Inactive,
265                activation_epoch: None,
266                validated_startup_epoch: None,
267            },
268        );
269        next.bump_revision()?;
270        next.validate()?;
271        Ok(next)
272    }
273
274    pub fn advance_startup_epoch(&self) -> Result<Self, GenerationTransitionError> {
275        let mut next = self.clone();
276        next.profile.startup_epoch = next.profile.startup_epoch.next()?;
277        next.bump_revision()?;
278        next.validate()?;
279        Ok(next)
280    }
281
282    pub fn activate(
283        &self,
284        generation_id: &ManagedGenerationId,
285    ) -> Result<Self, GenerationTransitionError> {
286        self.validate()?;
287        let candidate = self
288            .generations
289            .get(generation_id)
290            .ok_or(GenerationTransitionError::GenerationNotFound)?;
291        if candidate.state != ManagedGenerationState::Inactive {
292            return Err(GenerationTransitionError::GenerationNotInactive);
293        }
294        if let Some(current_id) = &self.profile.current_generation_id {
295            let current = self
296                .generations
297                .get(current_id)
298                .ok_or(GenerationTransitionError::PointerStateMismatch)?;
299            if current.validated_startup_epoch.is_none() {
300                return Err(GenerationTransitionError::CurrentNotStartupValidated);
301            }
302        }
303
304        let mut next = self.clone();
305        if let Some(old_previous) = next.profile.previous_generation_id.take() {
306            next.generations
307                .get_mut(&old_previous)
308                .ok_or(GenerationTransitionError::PointerStateMismatch)?
309                .state = ManagedGenerationState::Inactive;
310        }
311        if let Some(old_current) = next
312            .profile
313            .current_generation_id
314            .replace(generation_id.clone())
315        {
316            next.generations
317                .get_mut(&old_current)
318                .ok_or(GenerationTransitionError::PointerStateMismatch)?
319                .state = ManagedGenerationState::Previous;
320            next.profile.previous_generation_id = Some(old_current);
321        }
322        let new_current = next
323            .generations
324            .get_mut(generation_id)
325            .ok_or(GenerationTransitionError::GenerationNotFound)?;
326        new_current.state = ManagedGenerationState::Current;
327        new_current.activation_epoch = Some(next.profile.startup_epoch);
328        new_current.validated_startup_epoch = None;
329        next.bump_revision()?;
330        next.validate()?;
331        Ok(next)
332    }
333
334    pub fn observe_current_for_startup_validation(
335        &self,
336        loaded_generation_id: &ManagedGenerationId,
337    ) -> Result<StartupValidationEvidence, GenerationTransitionError> {
338        self.validate()?;
339        if self.profile.current_generation_id.as_ref() != Some(loaded_generation_id) {
340            return Err(GenerationTransitionError::StaleValidationResult);
341        }
342        let current = self
343            .generations
344            .get(loaded_generation_id)
345            .ok_or(GenerationTransitionError::PointerStateMismatch)?;
346        let activation_epoch = current
347            .activation_epoch
348            .ok_or(GenerationTransitionError::InvalidEpochPair)?;
349        Ok(StartupValidationEvidence {
350            generation_id: loaded_generation_id.clone(),
351            activation_epoch,
352        })
353    }
354
355    pub fn validate_current_after_startup(
356        &self,
357        evidence: &StartupValidationEvidence,
358    ) -> Result<Self, GenerationTransitionError> {
359        self.validate()?;
360        if self.profile.current_generation_id.as_ref() != Some(&evidence.generation_id) {
361            return Err(GenerationTransitionError::StaleValidationResult);
362        }
363        let mut next = self.clone();
364        let current = next
365            .generations
366            .get_mut(&evidence.generation_id)
367            .ok_or(GenerationTransitionError::PointerStateMismatch)?;
368        let activation = current
369            .activation_epoch
370            .ok_or(GenerationTransitionError::InvalidEpochPair)?;
371        if activation != evidence.activation_epoch {
372            return Err(GenerationTransitionError::StaleValidationResult);
373        }
374        if next.profile.startup_epoch <= activation {
375            return Err(GenerationTransitionError::ValidationNotFromLaterStartup);
376        }
377        current.validated_startup_epoch = Some(next.profile.startup_epoch);
378        next.bump_revision()?;
379        next.validate()?;
380        Ok(next)
381    }
382
383    pub fn rollback_invalid_current(
384        &self,
385        previous_verified: bool,
386    ) -> Result<Self, GenerationTransitionError> {
387        self.validate()?;
388        let current_id = self
389            .profile
390            .current_generation_id
391            .as_ref()
392            .ok_or(GenerationTransitionError::NoCurrentGeneration)?
393            .clone();
394        let mut next = self.clone();
395        next.generations
396            .get_mut(&current_id)
397            .ok_or(GenerationTransitionError::PointerStateMismatch)?
398            .state = ManagedGenerationState::Invalid;
399        next.profile.current_generation_id = None;
400
401        if let Some(previous_id) = next.profile.previous_generation_id.take() {
402            let previous = next
403                .generations
404                .get_mut(&previous_id)
405                .ok_or(GenerationTransitionError::PointerStateMismatch)?;
406            if previous_verified {
407                previous.state = ManagedGenerationState::Current;
408                next.profile.current_generation_id = Some(previous_id);
409            } else {
410                previous.state = ManagedGenerationState::Invalid;
411            }
412        }
413        next.bump_revision()?;
414        next.validate()?;
415        Ok(next)
416    }
417
418    /// Release the rollback generation only after the current generation has
419    /// durably survived later-startup validation.
420    pub fn release_previous_after_startup_validation(
421        &self,
422    ) -> Result<Self, GenerationTransitionError> {
423        self.validate()?;
424        let current_id = self
425            .profile
426            .current_generation_id
427            .as_ref()
428            .ok_or(GenerationTransitionError::NoCurrentGeneration)?;
429        let current = self
430            .generations
431            .get(current_id)
432            .ok_or(GenerationTransitionError::PointerStateMismatch)?;
433        if current.validated_startup_epoch.is_none() {
434            return Err(GenerationTransitionError::CurrentNotStartupValidated);
435        }
436        let previous_id = self
437            .profile
438            .previous_generation_id
439            .as_ref()
440            .ok_or(GenerationTransitionError::NoPreviousGeneration)?
441            .clone();
442
443        let mut next = self.clone();
444        next.profile.previous_generation_id = None;
445        next.generations
446            .get_mut(&previous_id)
447            .ok_or(GenerationTransitionError::PointerStateMismatch)?
448            .state = ManagedGenerationState::Inactive;
449        next.bump_revision()?;
450        next.validate()?;
451        Ok(next)
452    }
453
454    fn bump_revision(&mut self) -> Result<(), GenerationTransitionError> {
455        self.profile.state_revision = self
456            .profile
457            .state_revision
458            .checked_add(1)
459            .ok_or(GenerationTransitionError::RevisionExhausted)?;
460        if self.profile.state_revision > i64::MAX as u64 {
461            return Err(GenerationTransitionError::RevisionExhausted);
462        }
463        Ok(())
464    }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum GenerationValueError {
469    InvalidGenerationId,
470    InvalidProfileId,
471    InvalidLifecycleState,
472    EpochOutOfRange,
473}
474
475impl fmt::Display for GenerationValueError {
476    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
477        write!(formatter, "invalid managed-generation value: {self:?}")
478    }
479}
480
481impl std::error::Error for GenerationValueError {}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub enum GenerationTransitionError {
485    EqualCurrentAndPrevious,
486    PreviousWithoutCurrent,
487    RecordIdentityMismatch,
488    InvalidEpochPair,
489    PointerStateMismatch,
490    GenerationAlreadyExists,
491    GenerationNotFound,
492    GenerationNotInactive,
493    CurrentNotStartupValidated,
494    StaleValidationResult,
495    ValidationNotFromLaterStartup,
496    NoCurrentGeneration,
497    NoPreviousGeneration,
498    EmptyManifestId,
499    EpochExhausted,
500    RevisionExhausted,
501}
502
503impl fmt::Display for GenerationTransitionError {
504    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
505        write!(formatter, "invalid managed-generation transition: {self:?}")
506    }
507}
508
509impl std::error::Error for GenerationTransitionError {}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn new_id() -> ManagedGenerationId {
516        ManagedGenerationId::generate()
517    }
518
519    fn validate_after_restart(
520        state: ManagedGenerationSnapshot,
521        generation_id: &ManagedGenerationId,
522    ) -> ManagedGenerationSnapshot {
523        let later = state.advance_startup_epoch().unwrap();
524        let evidence = later
525            .observe_current_for_startup_validation(generation_id)
526            .unwrap();
527        later.validate_current_after_startup(&evidence).unwrap()
528    }
529
530    #[test]
531    fn identifiers_are_path_safe_and_strict() {
532        let generated = ManagedGenerationId::generate();
533        assert_eq!(
534            ManagedGenerationId::parse(generated.as_str()),
535            Ok(generated)
536        );
537        assert!(ManagedGenerationId::parse("gen_../../escape").is_err());
538        assert!(ManagedGenerationId::parse("gen_550e8400-e29b-41d4-a716-446655440000").is_err());
539        assert!(ModelStoreProfileId::parse("default-embedding").is_ok());
540        assert!(ModelStoreProfileId::parse("../profile").is_err());
541    }
542
543    #[test]
544    fn second_activation_requires_later_startup_validation() {
545        let a = new_id();
546        let b = new_id();
547        let state = ManagedGenerationSnapshot::empty(ModelStoreProfileId::default_embedding())
548            .register_inactive(a.clone(), "manifest")
549            .unwrap()
550            .register_inactive(b.clone(), "manifest")
551            .unwrap()
552            .activate(&a)
553            .unwrap();
554        assert_eq!(
555            state.activate(&b),
556            Err(GenerationTransitionError::CurrentNotStartupValidated)
557        );
558        let state = validate_after_restart(state, &a).activate(&b).unwrap();
559        assert_eq!(state.profile.current_generation_id, Some(b));
560        assert_eq!(state.profile.previous_generation_id, Some(a));
561    }
562
563    #[test]
564    fn stale_or_same_startup_validation_is_rejected() {
565        let a = new_id();
566        let other = new_id();
567        let state = ManagedGenerationSnapshot::empty(ModelStoreProfileId::default_embedding())
568            .register_inactive(a.clone(), "manifest")
569            .unwrap()
570            .activate(&a)
571            .unwrap();
572        let evidence = state.observe_current_for_startup_validation(&a).unwrap();
573        assert_eq!(
574            state.validate_current_after_startup(&evidence),
575            Err(GenerationTransitionError::ValidationNotFromLaterStartup)
576        );
577        let later = state.advance_startup_epoch().unwrap();
578        assert_eq!(
579            later.observe_current_for_startup_validation(&other),
580            Err(GenerationTransitionError::StaleValidationResult)
581        );
582    }
583
584    #[test]
585    fn stale_same_generation_activation_evidence_is_rejected() {
586        let a = new_id();
587        let b = new_id();
588        let c = new_id();
589        let active_a = ManagedGenerationSnapshot::empty(ModelStoreProfileId::default_embedding())
590            .register_inactive(a.clone(), "manifest")
591            .unwrap()
592            .register_inactive(b.clone(), "manifest")
593            .unwrap()
594            .register_inactive(c.clone(), "manifest")
595            .unwrap()
596            .activate(&a)
597            .unwrap();
598        let stale_evidence = active_a.observe_current_for_startup_validation(&a).unwrap();
599        let active_b = validate_after_restart(active_a, &a).activate(&b).unwrap();
600        let active_c = validate_after_restart(active_b, &b).activate(&c).unwrap();
601        let active_a_again = validate_after_restart(active_c, &c).activate(&a).unwrap();
602        let later = active_a_again.advance_startup_epoch().unwrap();
603
604        assert_eq!(
605            later.validate_current_after_startup(&stale_evidence),
606            Err(GenerationTransitionError::StaleValidationResult)
607        );
608    }
609
610    #[test]
611    fn rollback_never_keeps_invalid_current_as_previous() {
612        let a = new_id();
613        let b = new_id();
614        let active_a = ManagedGenerationSnapshot::empty(ModelStoreProfileId::default_embedding())
615            .register_inactive(a.clone(), "manifest")
616            .unwrap()
617            .register_inactive(b.clone(), "manifest")
618            .unwrap()
619            .activate(&a)
620            .unwrap();
621        let active_b = validate_after_restart(active_a, &a).activate(&b).unwrap();
622        let rolled_back = active_b.rollback_invalid_current(true).unwrap();
623        assert_eq!(rolled_back.profile.current_generation_id, Some(a.clone()));
624        assert_eq!(rolled_back.profile.previous_generation_id, None);
625        assert_eq!(
626            rolled_back.generations[&b].state,
627            ManagedGenerationState::Invalid
628        );
629        assert_eq!(
630            rolled_back.generations[&a].state,
631            ManagedGenerationState::Current
632        );
633    }
634
635    #[test]
636    fn rollback_with_two_invalid_generations_clears_both_pointers() {
637        let a = new_id();
638        let b = new_id();
639        let active_a = ManagedGenerationSnapshot::empty(ModelStoreProfileId::default_embedding())
640            .register_inactive(a.clone(), "manifest")
641            .unwrap()
642            .register_inactive(b.clone(), "manifest")
643            .unwrap()
644            .activate(&a)
645            .unwrap();
646        let active_b = validate_after_restart(active_a, &a).activate(&b).unwrap();
647        let rolled_back = active_b.rollback_invalid_current(false).unwrap();
648        assert_eq!(rolled_back.profile.current_generation_id, None);
649        assert_eq!(rolled_back.profile.previous_generation_id, None);
650        assert_eq!(
651            rolled_back.generations[&a].state,
652            ManagedGenerationState::Invalid
653        );
654        assert_eq!(
655            rolled_back.generations[&b].state,
656            ManagedGenerationState::Invalid
657        );
658    }
659
660    #[test]
661    fn previous_is_released_only_after_current_survives_later_startup() {
662        let a = new_id();
663        let b = new_id();
664        let active_a = ManagedGenerationSnapshot::empty(ModelStoreProfileId::default_embedding())
665            .register_inactive(a.clone(), "manifest")
666            .unwrap()
667            .register_inactive(b.clone(), "manifest")
668            .unwrap()
669            .activate(&a)
670            .unwrap();
671        let active_b = validate_after_restart(active_a, &a).activate(&b).unwrap();
672        assert_eq!(
673            active_b.release_previous_after_startup_validation(),
674            Err(GenerationTransitionError::CurrentNotStartupValidated)
675        );
676
677        let validated_b = validate_after_restart(active_b, &b);
678        let released = validated_b
679            .release_previous_after_startup_validation()
680            .unwrap();
681        assert_eq!(released.profile.current_generation_id, Some(b));
682        assert_eq!(released.profile.previous_generation_id, None);
683        assert_eq!(
684            released.generations[&a].state,
685            ManagedGenerationState::Inactive
686        );
687    }
688}