use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum GovernedArtifactState {
#[default]
Draft,
Approved,
Active,
Quarantined,
Deprecated,
RolledBack,
}
impl GovernedArtifactState {
#[must_use]
pub fn allows_production_use(&self) -> bool {
matches!(self, Self::Approved | Self::Active)
}
#[must_use]
pub fn accepts_new_runs(&self) -> bool {
matches!(self, Self::Approved | Self::Active)
}
#[must_use]
pub fn allows_replay(&self) -> bool {
matches!(self, Self::Approved | Self::Active | Self::Quarantined)
}
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Deprecated | Self::RolledBack)
}
#[must_use]
pub fn requires_investigation(&self) -> bool {
matches!(self, Self::Quarantined | Self::RolledBack)
}
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Draft => "In development/testing, not approved for production",
Self::Approved => "Reviewed and approved, ready for production",
Self::Active => "Currently deployed and in use",
Self::Quarantined => "Stopped for investigation, replay allowed",
Self::Deprecated => "Superseded, should migrate away",
Self::RolledBack => "Rolled back due to issues",
}
}
}
impl std::fmt::Display for GovernedArtifactState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Draft => write!(f, "draft"),
Self::Approved => write!(f, "approved"),
Self::Active => write!(f, "active"),
Self::Quarantined => write!(f, "quarantined"),
Self::Deprecated => write!(f, "deprecated"),
Self::RolledBack => write!(f, "rolled_back"),
}
}
}
pub fn validate_transition(
from: GovernedArtifactState,
to: GovernedArtifactState,
) -> Result<(), InvalidStateTransition> {
use GovernedArtifactState::*;
let valid = match (from, to) {
(Draft, Approved) => true,
(Draft, Deprecated) => true,
(Approved, Active) => true,
(Approved, Quarantined) => true,
(Approved, RolledBack) => true,
(Active, Quarantined) => true, (Active, Deprecated) => true,
(Active, RolledBack) => true,
(Quarantined, Active) => true, (Quarantined, RolledBack) => true, (Quarantined, Deprecated) => true,
(Deprecated, _) => false,
(RolledBack, _) => false,
_ => false,
};
if valid {
Ok(())
} else {
Err(InvalidStateTransition { from, to })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidStateTransition {
pub from: GovernedArtifactState,
pub to: GovernedArtifactState,
}
impl std::fmt::Display for InvalidStateTransition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Invalid artifact state transition: {} → {} (from '{}' to '{}')",
self.from,
self.to,
self.from.description(),
self.to.description()
)
}
}
impl std::error::Error for InvalidStateTransition {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleEvent {
pub from_state: GovernedArtifactState,
pub to_state: GovernedArtifactState,
pub timestamp: String,
pub actor: String,
pub reason: String,
pub ticket_ref: Option<String>,
pub tenant_id: Option<String>,
}
impl LifecycleEvent {
pub fn new(
from_state: GovernedArtifactState,
to_state: GovernedArtifactState,
actor: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
from_state,
to_state,
timestamp: Self::now_iso8601(),
actor: actor.into(),
reason: reason.into(),
ticket_ref: None,
tenant_id: None,
}
}
#[must_use]
pub fn with_ticket(mut self, ticket: impl Into<String>) -> Self {
self.ticket_ref = Some(ticket.into());
self
}
#[must_use]
pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
#[must_use]
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.timestamp = timestamp.into();
self
}
fn now_iso8601() -> String {
"1970-01-01T00:00:00Z".to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum RollbackSeverity {
#[default]
Low,
Medium,
High,
Critical,
}
impl RollbackSeverity {
#[must_use]
pub fn requires_immediate_action(&self) -> bool {
matches!(self, Self::High | Self::Critical)
}
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Low => "Minor issue, no incorrect outputs",
Self::Medium => "Some outputs may be suboptimal",
Self::High => "Outputs may be incorrect",
Self::Critical => "Critical, potential harm, immediate action required",
}
}
}
impl std::fmt::Display for RollbackSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RollbackImpact {
pub affected_count: Option<u64>,
pub quality_issues: Vec<String>,
pub invalidates_outputs: bool,
pub severity: RollbackSeverity,
pub affected_tenants: Vec<String>,
}
impl RollbackImpact {
pub fn new(severity: RollbackSeverity) -> Self {
Self {
severity,
..Default::default()
}
}
#[must_use]
pub fn with_affected_count(mut self, count: u64) -> Self {
self.affected_count = Some(count);
self
}
#[must_use]
pub fn with_quality_issue(mut self, issue: impl Into<String>) -> Self {
self.quality_issues.push(issue.into());
self
}
#[must_use]
pub fn invalidates_outputs(mut self) -> Self {
self.invalidates_outputs = true;
self
}
#[must_use]
pub fn with_affected_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.affected_tenants.push(tenant_id.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackRecord {
pub artifact_id: String,
pub previous_state: GovernedArtifactState,
pub rolled_back_at: String,
pub actor: String,
pub reason: String,
pub impact: RollbackImpact,
pub incident_ref: Option<String>,
pub tenant_id: Option<String>,
}
impl RollbackRecord {
pub fn new(
artifact_id: impl Into<String>,
previous_state: GovernedArtifactState,
actor: impl Into<String>,
reason: impl Into<String>,
impact: RollbackImpact,
) -> Self {
Self {
artifact_id: artifact_id.into(),
previous_state,
rolled_back_at: LifecycleEvent::now_iso8601(),
actor: actor.into(),
reason: reason.into(),
impact,
incident_ref: None,
tenant_id: None,
}
}
#[must_use]
pub fn with_incident(mut self, incident_ref: impl Into<String>) -> Self {
self.incident_ref = Some(incident_ref.into());
self
}
#[must_use]
pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayIntegrityViolation {
ArtifactMismatch { expected: String, actual: String },
ContentHashMismatch { expected: String, actual: String },
VersionMismatch { expected: String, actual: String },
InvalidState {
state: GovernedArtifactState,
reason: String,
},
MissingMetadata { field: String },
Custom { category: String, message: String },
}
impl std::fmt::Display for ReplayIntegrityViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ArtifactMismatch { expected, actual } => {
write!(
f,
"Artifact mismatch: expected '{}', got '{}'",
expected, actual
)
}
Self::ContentHashMismatch { expected, actual } => {
write!(
f,
"Content hash mismatch: expected '{}', got '{}'",
expected, actual
)
}
Self::VersionMismatch { expected, actual } => {
write!(
f,
"Version mismatch: expected '{}', got '{}'",
expected, actual
)
}
Self::InvalidState { state, reason } => {
write!(f, "Invalid state '{}' for replay: {}", state, reason)
}
Self::MissingMetadata { field } => {
write!(f, "Missing required metadata field: '{}'", field)
}
Self::Custom { category, message } => {
write!(f, "Replay integrity violation [{}]: {}", category, message)
}
}
}
}
impl std::error::Error for ReplayIntegrityViolation {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_state_is_draft() {
assert_eq!(
GovernedArtifactState::default(),
GovernedArtifactState::Draft
);
}
#[test]
fn test_allows_production_use() {
assert!(!GovernedArtifactState::Draft.allows_production_use());
assert!(GovernedArtifactState::Approved.allows_production_use());
assert!(GovernedArtifactState::Active.allows_production_use());
assert!(!GovernedArtifactState::Quarantined.allows_production_use());
assert!(!GovernedArtifactState::Deprecated.allows_production_use());
assert!(!GovernedArtifactState::RolledBack.allows_production_use());
}
#[test]
fn test_accepts_new_runs() {
assert!(!GovernedArtifactState::Draft.accepts_new_runs());
assert!(GovernedArtifactState::Approved.accepts_new_runs());
assert!(GovernedArtifactState::Active.accepts_new_runs());
assert!(!GovernedArtifactState::Quarantined.accepts_new_runs());
assert!(!GovernedArtifactState::Deprecated.accepts_new_runs());
assert!(!GovernedArtifactState::RolledBack.accepts_new_runs());
}
#[test]
fn test_allows_replay() {
assert!(!GovernedArtifactState::Draft.allows_replay());
assert!(GovernedArtifactState::Approved.allows_replay());
assert!(GovernedArtifactState::Active.allows_replay());
assert!(GovernedArtifactState::Quarantined.allows_replay()); assert!(!GovernedArtifactState::Deprecated.allows_replay());
assert!(!GovernedArtifactState::RolledBack.allows_replay());
}
#[test]
fn test_is_terminal() {
assert!(!GovernedArtifactState::Draft.is_terminal());
assert!(!GovernedArtifactState::Approved.is_terminal());
assert!(!GovernedArtifactState::Active.is_terminal());
assert!(!GovernedArtifactState::Quarantined.is_terminal());
assert!(GovernedArtifactState::Deprecated.is_terminal());
assert!(GovernedArtifactState::RolledBack.is_terminal());
}
#[test]
fn test_valid_transitions_from_draft() {
assert!(
validate_transition(
GovernedArtifactState::Draft,
GovernedArtifactState::Approved
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Draft,
GovernedArtifactState::Deprecated
)
.is_ok()
);
}
#[test]
fn test_valid_transitions_from_approved() {
assert!(
validate_transition(
GovernedArtifactState::Approved,
GovernedArtifactState::Active
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Approved,
GovernedArtifactState::Quarantined
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Approved,
GovernedArtifactState::RolledBack
)
.is_ok()
);
}
#[test]
fn test_valid_transitions_from_active() {
assert!(
validate_transition(
GovernedArtifactState::Active,
GovernedArtifactState::Quarantined
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Active,
GovernedArtifactState::Deprecated
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Active,
GovernedArtifactState::RolledBack
)
.is_ok()
);
}
#[test]
fn test_valid_transitions_from_quarantined() {
assert!(
validate_transition(
GovernedArtifactState::Quarantined,
GovernedArtifactState::Active
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Quarantined,
GovernedArtifactState::RolledBack
)
.is_ok()
);
assert!(
validate_transition(
GovernedArtifactState::Quarantined,
GovernedArtifactState::Deprecated
)
.is_ok()
);
}
#[test]
fn test_invalid_transitions() {
assert!(
validate_transition(GovernedArtifactState::Draft, GovernedArtifactState::Active)
.is_err()
);
assert!(
validate_transition(
GovernedArtifactState::Active,
GovernedArtifactState::Approved
)
.is_err()
);
assert!(
validate_transition(
GovernedArtifactState::Deprecated,
GovernedArtifactState::Active
)
.is_err()
);
assert!(
validate_transition(
GovernedArtifactState::RolledBack,
GovernedArtifactState::Draft
)
.is_err()
);
}
#[test]
fn test_state_serialization_stable() {
assert_eq!(
serde_json::to_string(&GovernedArtifactState::Draft).unwrap(),
"\"Draft\""
);
assert_eq!(
serde_json::to_string(&GovernedArtifactState::Approved).unwrap(),
"\"Approved\""
);
assert_eq!(
serde_json::to_string(&GovernedArtifactState::Active).unwrap(),
"\"Active\""
);
assert_eq!(
serde_json::to_string(&GovernedArtifactState::Quarantined).unwrap(),
"\"Quarantined\""
);
assert_eq!(
serde_json::to_string(&GovernedArtifactState::Deprecated).unwrap(),
"\"Deprecated\""
);
assert_eq!(
serde_json::to_string(&GovernedArtifactState::RolledBack).unwrap(),
"\"RolledBack\""
);
}
#[test]
fn test_severity_serialization_stable() {
assert_eq!(
serde_json::to_string(&RollbackSeverity::Low).unwrap(),
"\"Low\""
);
assert_eq!(
serde_json::to_string(&RollbackSeverity::Medium).unwrap(),
"\"Medium\""
);
assert_eq!(
serde_json::to_string(&RollbackSeverity::High).unwrap(),
"\"High\""
);
assert_eq!(
serde_json::to_string(&RollbackSeverity::Critical).unwrap(),
"\"Critical\""
);
}
#[test]
fn test_lifecycle_event_roundtrip() {
let event = LifecycleEvent::new(
GovernedArtifactState::Draft,
GovernedArtifactState::Approved,
"reviewer@example.com",
"Passed quality review",
)
.with_ticket("TICKET-123")
.with_tenant("tenant-abc")
.with_timestamp("2026-01-19T12:00:00Z");
let json = serde_json::to_string(&event).unwrap();
let restored: LifecycleEvent = serde_json::from_str(&json).unwrap();
assert_eq!(restored.from_state, GovernedArtifactState::Draft);
assert_eq!(restored.to_state, GovernedArtifactState::Approved);
assert_eq!(restored.actor, "reviewer@example.com");
assert_eq!(restored.reason, "Passed quality review");
assert_eq!(restored.ticket_ref, Some("TICKET-123".to_string()));
assert_eq!(restored.tenant_id, Some("tenant-abc".to_string()));
assert_eq!(restored.timestamp, "2026-01-19T12:00:00Z");
}
#[test]
fn test_rollback_impact_roundtrip() {
let impact = RollbackImpact::new(RollbackSeverity::High)
.with_affected_count(1500)
.with_quality_issue("Incorrect grounding")
.with_quality_issue("Missing citations")
.invalidates_outputs()
.with_affected_tenant("tenant-1");
let json = serde_json::to_string(&impact).unwrap();
let restored: RollbackImpact = serde_json::from_str(&json).unwrap();
assert_eq!(restored.severity, RollbackSeverity::High);
assert_eq!(restored.affected_count, Some(1500));
assert_eq!(restored.quality_issues.len(), 2);
assert!(restored.invalidates_outputs);
assert_eq!(restored.affected_tenants, vec!["tenant-1"]);
}
#[test]
fn test_rollback_record_roundtrip() {
let impact = RollbackImpact::new(RollbackSeverity::Critical);
let record = RollbackRecord::new(
"llm/adapter@1.0.0",
GovernedArtifactState::Active,
"incident-commander",
"Critical grounding failure",
impact,
)
.with_incident("INC-456")
.with_tenant("tenant-xyz");
let json = serde_json::to_string(&record).unwrap();
let restored: RollbackRecord = serde_json::from_str(&json).unwrap();
assert_eq!(restored.artifact_id, "llm/adapter@1.0.0");
assert_eq!(restored.previous_state, GovernedArtifactState::Active);
assert_eq!(restored.actor, "incident-commander");
assert_eq!(restored.incident_ref, Some("INC-456".to_string()));
assert_eq!(restored.tenant_id, Some("tenant-xyz".to_string()));
}
#[test]
fn test_replay_integrity_violation_display() {
let v1 = ReplayIntegrityViolation::ArtifactMismatch {
expected: "adapter-v1".to_string(),
actual: "adapter-v2".to_string(),
};
assert!(v1.to_string().contains("adapter-v1"));
assert!(v1.to_string().contains("adapter-v2"));
let v2 = ReplayIntegrityViolation::InvalidState {
state: GovernedArtifactState::RolledBack,
reason: "Artifact was rolled back".to_string(),
};
assert!(v2.to_string().contains("rolled_back"));
}
}