Skip to main content

converge_core/
governed_artifact.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Governed artifact lifecycle management.
5//!
6//! This module provides types for managing the lifecycle of governed artifacts
7//! in the Converge platform. A governed artifact is anything that can change
8//! execution outcomes and therefore requires:
9//!
10//! - Explicit lifecycle states with audit trails
11//! - Approval workflows before production use
12//! - Rollback capability with impact tracking
13//! - Replay integrity verification
14//!
15//! # Governed Artifact Examples
16//!
17//! - LoRA adapters (model behavior modification)
18//! - Prompt/contract versions
19//! - Recall corpora snapshots
20//! - Domain packs / eval packs
21//! - Any "thing that can change outcomes"
22//!
23//! # Axiom Compliance
24//!
25//! - **No Hidden State**: Lifecycle state is explicit and auditable
26//! - **Safety by Construction**: Invalid state transitions are rejected
27//! - **Explicit Authority**: State changes require actor and justification
28//! - **System Tells Truth**: Rollback captures 'why' for forensic audit
29//!
30//! # Example
31//!
32//! ```
33//! use converge_core::governed_artifact::{
34//!     GovernedArtifactState, LifecycleEvent, validate_transition,
35//! };
36//!
37//! // Start in Draft
38//! let mut state = GovernedArtifactState::Draft;
39//!
40//! // Approve for production
41//! validate_transition(state, GovernedArtifactState::Approved).unwrap();
42//! state = GovernedArtifactState::Approved;
43//!
44//! // Activate
45//! validate_transition(state, GovernedArtifactState::Active).unwrap();
46//! state = GovernedArtifactState::Active;
47//! ```
48
49use serde::{Deserialize, Serialize};
50
51// ============================================================================
52// Governed Artifact State
53// ============================================================================
54
55/// Lifecycle state of a governed artifact.
56///
57/// Governed artifacts progress through explicit lifecycle states:
58/// - Draft → Approved → Active → Deprecated | RolledBack | Quarantined
59///
60/// # State Semantics
61///
62/// | State | Can Use in Production | Accepts New Runs | Notes |
63/// |-------|----------------------|------------------|-------|
64/// | Draft | No | No | Development/testing |
65/// | Approved | Yes | Yes | Passed review |
66/// | Active | Yes | Yes | Currently deployed |
67/// | Quarantined | No | No | Stopped for investigation |
68/// | Deprecated | No | No | Superseded, migrate away |
69/// | RolledBack | No | No | Issues discovered |
70///
71/// # Tenant Scoping
72///
73/// "Active" is typically tenant-scoped to avoid cross-tenant authority leakage.
74/// An artifact can be Active for tenant A but still Draft for tenant B.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
76pub enum GovernedArtifactState {
77    /// Artifact is in development/testing - not approved for production
78    #[default]
79    Draft,
80    /// Artifact has been reviewed and approved for production use
81    Approved,
82    /// Artifact is actively deployed and in use
83    Active,
84    /// Artifact is quarantined - stopped for investigation but preserved for audit
85    /// Allowed: replay old traces. Blocked: new runs.
86    Quarantined,
87    /// Artifact is deprecated - should be migrated away from
88    Deprecated,
89    /// Artifact has been rolled back due to issues
90    RolledBack,
91}
92
93impl GovernedArtifactState {
94    /// Check if this state allows production use.
95    #[must_use]
96    pub fn allows_production_use(&self) -> bool {
97        matches!(self, Self::Approved | Self::Active)
98    }
99
100    /// Check if this state accepts new runs.
101    #[must_use]
102    pub fn accepts_new_runs(&self) -> bool {
103        matches!(self, Self::Approved | Self::Active)
104    }
105
106    /// Check if this state allows replaying old traces.
107    ///
108    /// Quarantined artifacts can replay old traces for forensic analysis
109    /// but cannot be used for new runs.
110    #[must_use]
111    pub fn allows_replay(&self) -> bool {
112        matches!(self, Self::Approved | Self::Active | Self::Quarantined)
113    }
114
115    /// Check if this state is terminal (no further transitions allowed).
116    #[must_use]
117    pub fn is_terminal(&self) -> bool {
118        matches!(self, Self::Deprecated | Self::RolledBack)
119    }
120
121    /// Check if this state requires investigation.
122    #[must_use]
123    pub fn requires_investigation(&self) -> bool {
124        matches!(self, Self::Quarantined | Self::RolledBack)
125    }
126
127    /// Get human-readable description of this state.
128    #[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
154// ============================================================================
155// State Transition Validation
156// ============================================================================
157
158/// Validate a state transition.
159///
160/// This is a pure function that checks if a transition is allowed
161/// without modifying any state.
162///
163/// # Valid Transitions
164///
165/// ```text
166/// Draft → Approved (after review)
167/// Draft → Deprecated (abandoned)
168/// Approved → Active (deployment)
169/// Approved → Quarantined (issues found before activation)
170/// Approved → RolledBack (issues found before activation)
171/// Active → Quarantined (immediate stop for investigation)
172/// Active → Deprecated (superseded by new version)
173/// Active → RolledBack (issues discovered)
174/// Quarantined → Active (investigation complete, cleared)
175/// Quarantined → RolledBack (investigation confirms issues)
176/// Quarantined → Deprecated (decided to replace)
177/// ```
178///
179/// # Terminal States
180///
181/// No transitions allowed from Deprecated or RolledBack.
182///
183/// # Errors
184///
185/// Returns `InvalidStateTransition` if the transition is not allowed.
186pub fn validate_transition(
187    from: GovernedArtifactState,
188    to: GovernedArtifactState,
189) -> Result<(), InvalidStateTransition> {
190    use GovernedArtifactState::*;
191
192    let valid = match (from, to) {
193        // From Draft
194        (Draft, Approved) => true,
195        (Draft, Deprecated) => true, // Abandoned
196
197        // From Approved
198        (Approved, Active) => true,
199        (Approved, Quarantined) => true,
200        (Approved, RolledBack) => true,
201
202        // From Active
203        (Active, Quarantined) => true, // Immediate stop
204        (Active, Deprecated) => true,
205        (Active, RolledBack) => true,
206
207        // From Quarantined (investigation outcomes)
208        (Quarantined, Active) => true,     // Cleared
209        (Quarantined, RolledBack) => true, // Confirmed issues
210        (Quarantined, Deprecated) => true, // Replace
211
212        // No transitions from terminal states
213        (Deprecated, _) => false,
214        (RolledBack, _) => false,
215
216        // All other transitions invalid
217        _ => false,
218    };
219
220    if valid {
221        Ok(())
222    } else {
223        Err(InvalidStateTransition { from, to })
224    }
225}
226
227/// Error when attempting an invalid state transition.
228#[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// ============================================================================
250// Lifecycle Events
251// ============================================================================
252
253/// Record of a lifecycle state change.
254///
255/// Every state transition is recorded with:
256/// - Who/what initiated the change
257/// - Why the change was made
258/// - When it occurred
259/// - Optional reference to approval/review ticket
260///
261/// # Audit Trail
262///
263/// LifecycleEvents form an append-only audit trail that captures
264/// the full history of an artifact's governance journey.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct LifecycleEvent {
267    /// Previous state
268    pub from_state: GovernedArtifactState,
269    /// New state
270    pub to_state: GovernedArtifactState,
271    /// ISO 8601 timestamp
272    pub timestamp: String,
273    /// Who or what initiated the change (user, system, policy engine)
274    pub actor: String,
275    /// Reason for the state change
276    pub reason: String,
277    /// Optional link to approval/review ticket
278    pub ticket_ref: Option<String>,
279    /// Optional tenant scope (if transition is tenant-specific)
280    pub tenant_id: Option<String>,
281}
282
283impl LifecycleEvent {
284    /// Create a new lifecycle event with current timestamp.
285    ///
286    /// Note: For portability, this uses a simple ISO 8601 string.
287    /// Production systems should inject the timestamp from a trusted source.
288    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            // Portable timestamp - production should inject from trusted source
298            timestamp: Self::now_iso8601(),
299            actor: actor.into(),
300            reason: reason.into(),
301            ticket_ref: None,
302            tenant_id: None,
303        }
304    }
305
306    /// Add a ticket reference.
307    #[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    /// Add tenant scope.
314    #[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    /// Set explicit timestamp (for replay/testing).
321    #[must_use]
322    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
323        self.timestamp = timestamp.into();
324        self
325    }
326
327    /// Generate ISO 8601 timestamp.
328    ///
329    /// Note: This returns a placeholder. Production systems should use
330    /// `with_timestamp()` to inject a timestamp from a trusted source.
331    fn now_iso8601() -> String {
332        // Portable placeholder - production should inject timestamp via with_timestamp()
333        // This avoids adding chrono as a dependency to converge-core
334        "1970-01-01T00:00:00Z".to_string()
335    }
336}
337
338// ============================================================================
339// Rollback Types
340// ============================================================================
341
342/// Severity of the issue causing a rollback.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
344pub enum RollbackSeverity {
345    /// Minor issue - inconvenience but no incorrect outputs
346    #[default]
347    Low,
348    /// Moderate issue - some outputs may be suboptimal
349    Medium,
350    /// Serious issue - outputs may be incorrect
351    High,
352    /// Critical issue - must rollback immediately, potential harm
353    Critical,
354}
355
356impl RollbackSeverity {
357    /// Check if this severity requires immediate action.
358    #[must_use]
359    pub fn requires_immediate_action(&self) -> bool {
360        matches!(self, Self::High | Self::Critical)
361    }
362
363    /// Get human-readable description.
364    #[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/// Impact assessment for a rollback.
387///
388/// Captures the scope and nature of impact from rolling back an artifact.
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct RollbackImpact {
391    /// Number of requests/runs affected (if known)
392    pub affected_count: Option<u64>,
393    /// Quality issues observed (descriptions)
394    pub quality_issues: Vec<String>,
395    /// Whether rollback invalidates existing outputs
396    pub invalidates_outputs: bool,
397    /// Severity level
398    pub severity: RollbackSeverity,
399    /// Affected tenant IDs (if known)
400    pub affected_tenants: Vec<String>,
401}
402
403impl RollbackImpact {
404    /// Create a new impact assessment.
405    pub fn new(severity: RollbackSeverity) -> Self {
406        Self {
407            severity,
408            ..Default::default()
409        }
410    }
411
412    /// Add affected count.
413    #[must_use]
414    pub fn with_affected_count(mut self, count: u64) -> Self {
415        self.affected_count = Some(count);
416        self
417    }
418
419    /// Add quality issue.
420    #[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    /// Mark as invalidating outputs.
427    #[must_use]
428    pub fn invalidates_outputs(mut self) -> Self {
429        self.invalidates_outputs = true;
430        self
431    }
432
433    /// Add affected tenant.
434    #[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/// Rollback record with full context for audit.
442///
443/// When an artifact is rolled back, we capture everything needed to:
444/// 1. Understand why it was rolled back
445/// 2. Reproduce the issue
446/// 3. Prevent reactivation without addressing the issue
447///
448/// # Portable Shape
449///
450/// This type is intentionally generic - it captures rollback semantics
451/// without referencing implementation-specific details like merge hashes.
452/// Capability-specific rollback records can embed this and add their fields.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct RollbackRecord {
455    /// Artifact identifier (opaque string, format depends on artifact type)
456    pub artifact_id: String,
457    /// Previous state before rollback
458    pub previous_state: GovernedArtifactState,
459    /// ISO 8601 timestamp of rollback
460    pub rolled_back_at: String,
461    /// Who initiated the rollback
462    pub actor: String,
463    /// Detailed reason for rollback
464    pub reason: String,
465    /// Impact assessment
466    pub impact: RollbackImpact,
467    /// Optional: Link to incident ticket
468    pub incident_ref: Option<String>,
469    /// Optional: Tenant scope
470    pub tenant_id: Option<String>,
471}
472
473impl RollbackRecord {
474    /// Create a new rollback record.
475    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    /// Add incident reference.
495    #[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    /// Add tenant scope.
502    #[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// ============================================================================
510// Replay Integrity
511// ============================================================================
512
513/// Categories of replay integrity violations.
514///
515/// When verifying that a replay is valid, these are the categories
516/// of mismatches that can occur.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub enum ReplayIntegrityViolation {
519    /// Artifact identifier doesn't match
520    ArtifactMismatch { expected: String, actual: String },
521    /// Content hash doesn't match (artifact was modified)
522    ContentHashMismatch { expected: String, actual: String },
523    /// Version mismatch
524    VersionMismatch { expected: String, actual: String },
525    /// Artifact is in a state that doesn't allow replay
526    InvalidState {
527        state: GovernedArtifactState,
528        reason: String,
529    },
530    /// Required metadata is missing
531    MissingMetadata { field: String },
532    /// Custom violation (for capability-specific checks)
533    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    // ========================================================================
580    // State Tests
581    // ========================================================================
582
583    #[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()); // Forensic replay
617        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    // ========================================================================
632    // Transition Tests
633    // ========================================================================
634
635    #[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        // Cannot skip states
731        assert!(
732            validate_transition(GovernedArtifactState::Draft, GovernedArtifactState::Active)
733                .is_err()
734        );
735        // Cannot go backwards
736        assert!(
737            validate_transition(
738                GovernedArtifactState::Active,
739                GovernedArtifactState::Approved
740            )
741            .is_err()
742        );
743        // Cannot transition from terminal states
744        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    // ========================================================================
761    // Serialization Stability Tests
762    // ========================================================================
763
764    #[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}