1use serde::{Deserialize, Serialize};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
76pub enum GovernedArtifactState {
77 #[default]
79 Draft,
80 Approved,
82 Active,
84 Quarantined,
87 Deprecated,
89 RolledBack,
91}
92
93impl GovernedArtifactState {
94 #[must_use]
96 pub fn allows_production_use(&self) -> bool {
97 matches!(self, Self::Approved | Self::Active)
98 }
99
100 #[must_use]
102 pub fn accepts_new_runs(&self) -> bool {
103 matches!(self, Self::Approved | Self::Active)
104 }
105
106 #[must_use]
111 pub fn allows_replay(&self) -> bool {
112 matches!(self, Self::Approved | Self::Active | Self::Quarantined)
113 }
114
115 #[must_use]
117 pub fn is_terminal(&self) -> bool {
118 matches!(self, Self::Deprecated | Self::RolledBack)
119 }
120
121 #[must_use]
123 pub fn requires_investigation(&self) -> bool {
124 matches!(self, Self::Quarantined | Self::RolledBack)
125 }
126
127 #[must_use]
129 pub fn description(&self) -> &'static str {
130 match self {
131 Self::Draft => "In development/testing, not approved for production",
132 Self::Approved => "Reviewed and approved, ready for production",
133 Self::Active => "Currently deployed and in use",
134 Self::Quarantined => "Stopped for investigation, replay allowed",
135 Self::Deprecated => "Superseded, should migrate away",
136 Self::RolledBack => "Rolled back due to issues",
137 }
138 }
139}
140
141impl std::fmt::Display for GovernedArtifactState {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 Self::Draft => write!(f, "draft"),
145 Self::Approved => write!(f, "approved"),
146 Self::Active => write!(f, "active"),
147 Self::Quarantined => write!(f, "quarantined"),
148 Self::Deprecated => write!(f, "deprecated"),
149 Self::RolledBack => write!(f, "rolled_back"),
150 }
151 }
152}
153
154pub fn validate_transition(
187 from: GovernedArtifactState,
188 to: GovernedArtifactState,
189) -> Result<(), InvalidStateTransition> {
190 use GovernedArtifactState::*;
191
192 let valid = match (from, to) {
193 (Draft, Approved) => true,
195 (Draft, Deprecated) => true, (Approved, Active) => true,
199 (Approved, Quarantined) => true,
200 (Approved, RolledBack) => true,
201
202 (Active, Quarantined) => true, (Active, Deprecated) => true,
205 (Active, RolledBack) => true,
206
207 (Quarantined, Active) => true, (Quarantined, RolledBack) => true, (Quarantined, Deprecated) => true, (Deprecated, _) => false,
214 (RolledBack, _) => false,
215
216 _ => false,
218 };
219
220 if valid {
221 Ok(())
222 } else {
223 Err(InvalidStateTransition { from, to })
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct InvalidStateTransition {
230 pub from: GovernedArtifactState,
231 pub to: GovernedArtifactState,
232}
233
234impl std::fmt::Display for InvalidStateTransition {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 write!(
237 f,
238 "Invalid artifact state transition: {} → {} (from '{}' to '{}')",
239 self.from,
240 self.to,
241 self.from.description(),
242 self.to.description()
243 )
244 }
245}
246
247impl std::error::Error for InvalidStateTransition {}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct LifecycleEvent {
267 pub from_state: GovernedArtifactState,
269 pub to_state: GovernedArtifactState,
271 pub timestamp: String,
273 pub actor: String,
275 pub reason: String,
277 pub ticket_ref: Option<String>,
279 pub tenant_id: Option<String>,
281}
282
283impl LifecycleEvent {
284 pub fn new(
289 from_state: GovernedArtifactState,
290 to_state: GovernedArtifactState,
291 actor: impl Into<String>,
292 reason: impl Into<String>,
293 ) -> Self {
294 Self {
295 from_state,
296 to_state,
297 timestamp: Self::now_iso8601(),
299 actor: actor.into(),
300 reason: reason.into(),
301 ticket_ref: None,
302 tenant_id: None,
303 }
304 }
305
306 #[must_use]
308 pub fn with_ticket(mut self, ticket: impl Into<String>) -> Self {
309 self.ticket_ref = Some(ticket.into());
310 self
311 }
312
313 #[must_use]
315 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
316 self.tenant_id = Some(tenant_id.into());
317 self
318 }
319
320 #[must_use]
322 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
323 self.timestamp = timestamp.into();
324 self
325 }
326
327 fn now_iso8601() -> String {
332 "1970-01-01T00:00:00Z".to_string()
335 }
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
344pub enum RollbackSeverity {
345 #[default]
347 Low,
348 Medium,
350 High,
352 Critical,
354}
355
356impl RollbackSeverity {
357 #[must_use]
359 pub fn requires_immediate_action(&self) -> bool {
360 matches!(self, Self::High | Self::Critical)
361 }
362
363 #[must_use]
365 pub fn description(&self) -> &'static str {
366 match self {
367 Self::Low => "Minor issue, no incorrect outputs",
368 Self::Medium => "Some outputs may be suboptimal",
369 Self::High => "Outputs may be incorrect",
370 Self::Critical => "Critical, potential harm, immediate action required",
371 }
372 }
373}
374
375impl std::fmt::Display for RollbackSeverity {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 match self {
378 Self::Low => write!(f, "low"),
379 Self::Medium => write!(f, "medium"),
380 Self::High => write!(f, "high"),
381 Self::Critical => write!(f, "critical"),
382 }
383 }
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct RollbackImpact {
391 pub affected_count: Option<u64>,
393 pub quality_issues: Vec<String>,
395 pub invalidates_outputs: bool,
397 pub severity: RollbackSeverity,
399 pub affected_tenants: Vec<String>,
401}
402
403impl RollbackImpact {
404 pub fn new(severity: RollbackSeverity) -> Self {
406 Self {
407 severity,
408 ..Default::default()
409 }
410 }
411
412 #[must_use]
414 pub fn with_affected_count(mut self, count: u64) -> Self {
415 self.affected_count = Some(count);
416 self
417 }
418
419 #[must_use]
421 pub fn with_quality_issue(mut self, issue: impl Into<String>) -> Self {
422 self.quality_issues.push(issue.into());
423 self
424 }
425
426 #[must_use]
428 pub fn invalidates_outputs(mut self) -> Self {
429 self.invalidates_outputs = true;
430 self
431 }
432
433 #[must_use]
435 pub fn with_affected_tenant(mut self, tenant_id: impl Into<String>) -> Self {
436 self.affected_tenants.push(tenant_id.into());
437 self
438 }
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct RollbackRecord {
455 pub artifact_id: String,
457 pub previous_state: GovernedArtifactState,
459 pub rolled_back_at: String,
461 pub actor: String,
463 pub reason: String,
465 pub impact: RollbackImpact,
467 pub incident_ref: Option<String>,
469 pub tenant_id: Option<String>,
471}
472
473impl RollbackRecord {
474 pub fn new(
476 artifact_id: impl Into<String>,
477 previous_state: GovernedArtifactState,
478 actor: impl Into<String>,
479 reason: impl Into<String>,
480 impact: RollbackImpact,
481 ) -> Self {
482 Self {
483 artifact_id: artifact_id.into(),
484 previous_state,
485 rolled_back_at: LifecycleEvent::now_iso8601(),
486 actor: actor.into(),
487 reason: reason.into(),
488 impact,
489 incident_ref: None,
490 tenant_id: None,
491 }
492 }
493
494 #[must_use]
496 pub fn with_incident(mut self, incident_ref: impl Into<String>) -> Self {
497 self.incident_ref = Some(incident_ref.into());
498 self
499 }
500
501 #[must_use]
503 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
504 self.tenant_id = Some(tenant_id.into());
505 self
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub enum ReplayIntegrityViolation {
519 ArtifactMismatch { expected: String, actual: String },
521 ContentHashMismatch { expected: String, actual: String },
523 VersionMismatch { expected: String, actual: String },
525 InvalidState {
527 state: GovernedArtifactState,
528 reason: String,
529 },
530 MissingMetadata { field: String },
532 Custom { category: String, message: String },
534}
535
536impl std::fmt::Display for ReplayIntegrityViolation {
537 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538 match self {
539 Self::ArtifactMismatch { expected, actual } => {
540 write!(
541 f,
542 "Artifact mismatch: expected '{}', got '{}'",
543 expected, actual
544 )
545 }
546 Self::ContentHashMismatch { expected, actual } => {
547 write!(
548 f,
549 "Content hash mismatch: expected '{}', got '{}'",
550 expected, actual
551 )
552 }
553 Self::VersionMismatch { expected, actual } => {
554 write!(
555 f,
556 "Version mismatch: expected '{}', got '{}'",
557 expected, actual
558 )
559 }
560 Self::InvalidState { state, reason } => {
561 write!(f, "Invalid state '{}' for replay: {}", state, reason)
562 }
563 Self::MissingMetadata { field } => {
564 write!(f, "Missing required metadata field: '{}'", field)
565 }
566 Self::Custom { category, message } => {
567 write!(f, "Replay integrity violation [{}]: {}", category, message)
568 }
569 }
570 }
571}
572
573impl std::error::Error for ReplayIntegrityViolation {}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
584 fn test_default_state_is_draft() {
585 assert_eq!(
586 GovernedArtifactState::default(),
587 GovernedArtifactState::Draft
588 );
589 }
590
591 #[test]
592 fn test_allows_production_use() {
593 assert!(!GovernedArtifactState::Draft.allows_production_use());
594 assert!(GovernedArtifactState::Approved.allows_production_use());
595 assert!(GovernedArtifactState::Active.allows_production_use());
596 assert!(!GovernedArtifactState::Quarantined.allows_production_use());
597 assert!(!GovernedArtifactState::Deprecated.allows_production_use());
598 assert!(!GovernedArtifactState::RolledBack.allows_production_use());
599 }
600
601 #[test]
602 fn test_accepts_new_runs() {
603 assert!(!GovernedArtifactState::Draft.accepts_new_runs());
604 assert!(GovernedArtifactState::Approved.accepts_new_runs());
605 assert!(GovernedArtifactState::Active.accepts_new_runs());
606 assert!(!GovernedArtifactState::Quarantined.accepts_new_runs());
607 assert!(!GovernedArtifactState::Deprecated.accepts_new_runs());
608 assert!(!GovernedArtifactState::RolledBack.accepts_new_runs());
609 }
610
611 #[test]
612 fn test_allows_replay() {
613 assert!(!GovernedArtifactState::Draft.allows_replay());
614 assert!(GovernedArtifactState::Approved.allows_replay());
615 assert!(GovernedArtifactState::Active.allows_replay());
616 assert!(GovernedArtifactState::Quarantined.allows_replay()); assert!(!GovernedArtifactState::Deprecated.allows_replay());
618 assert!(!GovernedArtifactState::RolledBack.allows_replay());
619 }
620
621 #[test]
622 fn test_is_terminal() {
623 assert!(!GovernedArtifactState::Draft.is_terminal());
624 assert!(!GovernedArtifactState::Approved.is_terminal());
625 assert!(!GovernedArtifactState::Active.is_terminal());
626 assert!(!GovernedArtifactState::Quarantined.is_terminal());
627 assert!(GovernedArtifactState::Deprecated.is_terminal());
628 assert!(GovernedArtifactState::RolledBack.is_terminal());
629 }
630
631 #[test]
636 fn test_valid_transitions_from_draft() {
637 assert!(
638 validate_transition(
639 GovernedArtifactState::Draft,
640 GovernedArtifactState::Approved
641 )
642 .is_ok()
643 );
644 assert!(
645 validate_transition(
646 GovernedArtifactState::Draft,
647 GovernedArtifactState::Deprecated
648 )
649 .is_ok()
650 );
651 }
652
653 #[test]
654 fn test_valid_transitions_from_approved() {
655 assert!(
656 validate_transition(
657 GovernedArtifactState::Approved,
658 GovernedArtifactState::Active
659 )
660 .is_ok()
661 );
662 assert!(
663 validate_transition(
664 GovernedArtifactState::Approved,
665 GovernedArtifactState::Quarantined
666 )
667 .is_ok()
668 );
669 assert!(
670 validate_transition(
671 GovernedArtifactState::Approved,
672 GovernedArtifactState::RolledBack
673 )
674 .is_ok()
675 );
676 }
677
678 #[test]
679 fn test_valid_transitions_from_active() {
680 assert!(
681 validate_transition(
682 GovernedArtifactState::Active,
683 GovernedArtifactState::Quarantined
684 )
685 .is_ok()
686 );
687 assert!(
688 validate_transition(
689 GovernedArtifactState::Active,
690 GovernedArtifactState::Deprecated
691 )
692 .is_ok()
693 );
694 assert!(
695 validate_transition(
696 GovernedArtifactState::Active,
697 GovernedArtifactState::RolledBack
698 )
699 .is_ok()
700 );
701 }
702
703 #[test]
704 fn test_valid_transitions_from_quarantined() {
705 assert!(
706 validate_transition(
707 GovernedArtifactState::Quarantined,
708 GovernedArtifactState::Active
709 )
710 .is_ok()
711 );
712 assert!(
713 validate_transition(
714 GovernedArtifactState::Quarantined,
715 GovernedArtifactState::RolledBack
716 )
717 .is_ok()
718 );
719 assert!(
720 validate_transition(
721 GovernedArtifactState::Quarantined,
722 GovernedArtifactState::Deprecated
723 )
724 .is_ok()
725 );
726 }
727
728 #[test]
729 fn test_invalid_transitions() {
730 assert!(
732 validate_transition(GovernedArtifactState::Draft, GovernedArtifactState::Active)
733 .is_err()
734 );
735 assert!(
737 validate_transition(
738 GovernedArtifactState::Active,
739 GovernedArtifactState::Approved
740 )
741 .is_err()
742 );
743 assert!(
745 validate_transition(
746 GovernedArtifactState::Deprecated,
747 GovernedArtifactState::Active
748 )
749 .is_err()
750 );
751 assert!(
752 validate_transition(
753 GovernedArtifactState::RolledBack,
754 GovernedArtifactState::Draft
755 )
756 .is_err()
757 );
758 }
759
760 #[test]
765 fn test_state_serialization_stable() {
766 assert_eq!(
767 serde_json::to_string(&GovernedArtifactState::Draft).unwrap(),
768 "\"Draft\""
769 );
770 assert_eq!(
771 serde_json::to_string(&GovernedArtifactState::Approved).unwrap(),
772 "\"Approved\""
773 );
774 assert_eq!(
775 serde_json::to_string(&GovernedArtifactState::Active).unwrap(),
776 "\"Active\""
777 );
778 assert_eq!(
779 serde_json::to_string(&GovernedArtifactState::Quarantined).unwrap(),
780 "\"Quarantined\""
781 );
782 assert_eq!(
783 serde_json::to_string(&GovernedArtifactState::Deprecated).unwrap(),
784 "\"Deprecated\""
785 );
786 assert_eq!(
787 serde_json::to_string(&GovernedArtifactState::RolledBack).unwrap(),
788 "\"RolledBack\""
789 );
790 }
791
792 #[test]
793 fn test_severity_serialization_stable() {
794 assert_eq!(
795 serde_json::to_string(&RollbackSeverity::Low).unwrap(),
796 "\"Low\""
797 );
798 assert_eq!(
799 serde_json::to_string(&RollbackSeverity::Medium).unwrap(),
800 "\"Medium\""
801 );
802 assert_eq!(
803 serde_json::to_string(&RollbackSeverity::High).unwrap(),
804 "\"High\""
805 );
806 assert_eq!(
807 serde_json::to_string(&RollbackSeverity::Critical).unwrap(),
808 "\"Critical\""
809 );
810 }
811
812 #[test]
813 fn test_lifecycle_event_roundtrip() {
814 let event = LifecycleEvent::new(
815 GovernedArtifactState::Draft,
816 GovernedArtifactState::Approved,
817 "reviewer@example.com",
818 "Passed quality review",
819 )
820 .with_ticket("TICKET-123")
821 .with_tenant("tenant-abc")
822 .with_timestamp("2026-01-19T12:00:00Z");
823
824 let json = serde_json::to_string(&event).unwrap();
825 let restored: LifecycleEvent = serde_json::from_str(&json).unwrap();
826
827 assert_eq!(restored.from_state, GovernedArtifactState::Draft);
828 assert_eq!(restored.to_state, GovernedArtifactState::Approved);
829 assert_eq!(restored.actor, "reviewer@example.com");
830 assert_eq!(restored.reason, "Passed quality review");
831 assert_eq!(restored.ticket_ref, Some("TICKET-123".to_string()));
832 assert_eq!(restored.tenant_id, Some("tenant-abc".to_string()));
833 assert_eq!(restored.timestamp, "2026-01-19T12:00:00Z");
834 }
835
836 #[test]
837 fn test_rollback_impact_roundtrip() {
838 let impact = RollbackImpact::new(RollbackSeverity::High)
839 .with_affected_count(1500)
840 .with_quality_issue("Incorrect grounding")
841 .with_quality_issue("Missing citations")
842 .invalidates_outputs()
843 .with_affected_tenant("tenant-1");
844
845 let json = serde_json::to_string(&impact).unwrap();
846 let restored: RollbackImpact = serde_json::from_str(&json).unwrap();
847
848 assert_eq!(restored.severity, RollbackSeverity::High);
849 assert_eq!(restored.affected_count, Some(1500));
850 assert_eq!(restored.quality_issues.len(), 2);
851 assert!(restored.invalidates_outputs);
852 assert_eq!(restored.affected_tenants, vec!["tenant-1"]);
853 }
854
855 #[test]
856 fn test_rollback_record_roundtrip() {
857 let impact = RollbackImpact::new(RollbackSeverity::Critical);
858 let record = RollbackRecord::new(
859 "llm/adapter@1.0.0",
860 GovernedArtifactState::Active,
861 "incident-commander",
862 "Critical grounding failure",
863 impact,
864 )
865 .with_incident("INC-456")
866 .with_tenant("tenant-xyz");
867
868 let json = serde_json::to_string(&record).unwrap();
869 let restored: RollbackRecord = serde_json::from_str(&json).unwrap();
870
871 assert_eq!(restored.artifact_id, "llm/adapter@1.0.0");
872 assert_eq!(restored.previous_state, GovernedArtifactState::Active);
873 assert_eq!(restored.actor, "incident-commander");
874 assert_eq!(restored.incident_ref, Some("INC-456".to_string()));
875 assert_eq!(restored.tenant_id, Some("tenant-xyz".to_string()));
876 }
877
878 #[test]
879 fn test_replay_integrity_violation_display() {
880 let v1 = ReplayIntegrityViolation::ArtifactMismatch {
881 expected: "adapter-v1".to_string(),
882 actual: "adapter-v2".to_string(),
883 };
884 assert!(v1.to_string().contains("adapter-v1"));
885 assert!(v1.to_string().contains("adapter-v2"));
886
887 let v2 = ReplayIntegrityViolation::InvalidState {
888 state: GovernedArtifactState::RolledBack,
889 reason: "Artifact was rolled back".to_string(),
890 };
891 assert!(v2.to_string().contains("rolled_back"));
892 }
893}