use std::{fmt, str::FromStr};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DegradationSeverity {
#[default]
Info,
Low,
Warning,
Medium,
High,
Critical,
}
impl DegradationSeverity {
pub const ALL: [Self; 6] = [
Self::Info,
Self::Low,
Self::Warning,
Self::Medium,
Self::High,
Self::Critical,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Low => "low",
Self::Warning => "warning",
Self::Medium => "medium",
Self::High => "high",
Self::Critical => "critical",
}
}
#[must_use]
pub const fn rank(self) -> u8 {
self as u8
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"info" => Some(Self::Info),
"low" => Some(Self::Low),
"warning" => Some(Self::Warning),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"critical" => Some(Self::Critical),
_ => None,
}
}
#[must_use]
pub fn parse_lossy(s: &str) -> Self {
if s.trim().eq_ignore_ascii_case("advisory") {
Self::Low
} else {
Self::parse(s).unwrap_or(Self::Info)
}
}
}
impl FromStr for DegradationSeverity {
type Err = ParseDegradationSeverityError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s).ok_or_else(|| ParseDegradationSeverityError {
value: s.to_owned(),
})
}
}
impl fmt::Display for DegradationSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseDegradationSeverityError {
value: String,
}
impl fmt::Display for ParseDegradationSeverityError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"unknown degradation severity {:?}; expected info, low, warning, medium, high, or critical",
self.value
)
}
}
impl std::error::Error for ParseDegradationSeverityError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DegradedSubsystem {
Search,
Storage,
Cass,
Graph,
Pack,
Curate,
Policy,
Network,
Science,
}
impl DegradedSubsystem {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Search => "search",
Self::Storage => "storage",
Self::Cass => "cass",
Self::Graph => "graph",
Self::Pack => "pack",
Self::Curate => "curate",
Self::Policy => "policy",
Self::Network => "network",
Self::Science => "science",
}
}
}
impl fmt::Display for DegradedSubsystem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DegradationCode {
pub id: &'static str,
pub subsystem: DegradedSubsystem,
pub severity: DegradationSeverity,
pub description: &'static str,
pub behavior_change: &'static str,
pub auto_recoverable: bool,
pub repair: Option<&'static str>,
}
impl DegradationCode {
#[must_use]
pub const fn number(&self) -> u16 {
let bytes = self.id.as_bytes();
if bytes.len() >= 4 {
let d1 = (bytes[1] as u16).wrapping_sub(b'0' as u16);
let d2 = (bytes[2] as u16).wrapping_sub(b'0' as u16);
let d3 = (bytes[3] as u16).wrapping_sub(b'0' as u16);
d1 * 100 + d2 * 10 + d3
} else {
0
}
}
}
pub const SEMANTIC_SEARCH_UNAVAILABLE: DegradationCode = DegradationCode {
id: "D001",
subsystem: DegradedSubsystem::Search,
severity: DegradationSeverity::Warning,
description: "Semantic search unavailable",
behavior_change: "Falling back to lexical (BM25) search only",
auto_recoverable: true,
repair: Some("ee index reembed --dry-run"),
};
pub const EMBEDDING_MODEL_MISSING: DegradationCode = DegradationCode {
id: "D002",
subsystem: DegradedSubsystem::Search,
severity: DegradationSeverity::Warning,
description: "Embedding model not loaded",
behavior_change: "Semantic similarity disabled; lexical matching only",
auto_recoverable: false,
repair: Some("ee index rebuild"),
};
pub const SEARCH_INDEX_STALE: DegradationCode = DegradationCode {
id: "D003",
subsystem: DegradedSubsystem::Search,
severity: DegradationSeverity::Low,
description: "Search index is behind database",
behavior_change: "Recent memories may not appear in search results",
auto_recoverable: true,
repair: Some("ee index rebuild"),
};
pub const FTS5_UNAVAILABLE: DegradationCode = DegradationCode {
id: "D004",
subsystem: DegradedSubsystem::Search,
severity: DegradationSeverity::Critical,
description: "FTS5 extension not available",
behavior_change: "Full-text search disabled; only exact matches work",
auto_recoverable: false,
repair: None,
};
pub const DATABASE_READ_ONLY: DegradationCode = DegradationCode {
id: "D100",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Warning,
description: "Database is in read-only mode",
behavior_change: "Write operations will fail; reads work normally",
auto_recoverable: false,
repair: Some("ee doctor --fix-plan --json"),
};
pub const WAL_MODE_DISABLED: DegradationCode = DegradationCode {
id: "D101",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Low,
description: "WAL mode not enabled",
behavior_change: "Reduced concurrent read performance",
auto_recoverable: false,
repair: Some("ee init --workspace . --repair-plan --json"),
};
pub const LARGE_DATABASE: DegradationCode = DegradationCode {
id: "D102",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Low,
description: "Database size exceeds recommended threshold",
behavior_change: "Some operations may be slower",
auto_recoverable: false,
repair: Some("ee doctor --fix-plan --json"),
};
pub const ADVISORY_LOCK_TIMEOUT: DegradationCode = DegradationCode {
id: "D103",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Warning,
description: "Advisory lock acquisition exceeded its retry budget",
behavior_change: "Concurrent write operation could not prove exclusive ownership",
auto_recoverable: true,
repair: Some("ee diag advisory-lock --workspace . --resource-type workspace --release --json"),
};
pub const SNAPSHOT_PIN_EXPIRED: DegradationCode = DegradationCode {
id: "D104",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Warning,
description: "Read snapshot pin exceeded its configured lifetime",
behavior_change: "The stale read snapshot is poisoned so later reads fail cleanly",
auto_recoverable: true,
repair: Some("ee config set storage.read_pool.max_pin_duration_seconds 1"),
};
pub const SNAPSHOT_RELEASE_FAILED: DegradationCode = DegradationCode {
id: "D105",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Warning,
description: "Read snapshot release failed",
behavior_change: "The affected pooled connection is abandoned instead of returned to the idle pool",
auto_recoverable: true,
repair: Some("ee doctor --workspace . --json"),
};
pub const SNAPSHOT_PIN_FORCE_RELEASED: DegradationCode = DegradationCode {
id: "D106",
subsystem: DegradedSubsystem::Storage,
severity: DegradationSeverity::Warning,
description: "Read snapshot pin was force-released during workspace close",
behavior_change: "The remaining pinned reader is poisoned so shutdown can complete without leaking WAL frames",
auto_recoverable: true,
repair: Some("ee status --workspace . --json"),
};
pub const ADVISORY_LOCK_TIMEOUT_CODE: &str = "advisory_lock_timeout";
pub const SNAPSHOT_PIN_EXPIRED_CODE: &str = "snapshot_pin_expired";
pub const SNAPSHOT_RELEASE_FAILED_CODE: &str = "snapshot_release_failed";
pub const SNAPSHOT_PIN_FORCE_RELEASED_CODE: &str = "snapshot_pin_force_released";
pub const CASS_NOT_FOUND: DegradationCode = DegradationCode {
id: "D200",
subsystem: DegradedSubsystem::Cass,
severity: DegradationSeverity::Warning,
description: "CASS binary not found",
behavior_change: "Session import disabled; explicit memories work",
auto_recoverable: false,
repair: None,
};
pub const CASS_VERSION_MISMATCH: DegradationCode = DegradationCode {
id: "D201",
subsystem: DegradedSubsystem::Cass,
severity: DegradationSeverity::Warning,
description: "CASS version incompatible",
behavior_change: "Session import may fail or produce unexpected results",
auto_recoverable: false,
repair: None,
};
pub const CASS_INDEX_STALE: DegradationCode = DegradationCode {
id: "D202",
subsystem: DegradedSubsystem::Cass,
severity: DegradationSeverity::Low,
description: "CASS index is stale",
behavior_change: "Recent sessions may not be available for import",
auto_recoverable: true,
repair: Some("cass index --full"),
};
pub const GRAPH_SNAPSHOT_STALE: DegradationCode = DegradationCode {
id: "D300",
subsystem: DegradedSubsystem::Graph,
severity: DegradationSeverity::Low,
description: "Graph snapshot is stale",
behavior_change: "Graph metrics may not reflect recent changes",
auto_recoverable: true,
repair: Some("ee graph centrality-refresh"),
};
pub const GRAPH_METRICS_UNAVAILABLE: DegradationCode = DegradationCode {
id: "D301",
subsystem: DegradedSubsystem::Graph,
severity: DegradationSeverity::Warning,
description: "Graph metrics not computed",
behavior_change: "Related memories and why explanations limited",
auto_recoverable: true,
repair: Some("ee graph centrality-refresh"),
};
pub const GRAPH_PPR_SNAPSHOT_STALE_CODE: &str = "graph_ppr_snapshot_stale";
pub const GRAPH_PPR_EMPTY_SEED_SET_CODE: &str = "graph_ppr_empty_seed_set";
pub const GRAPH_PPR_UPSTREAM_UNAVAILABLE_CODE: &str = "graph_ppr_upstream_unavailable";
pub const GRAPH_PACK_DNA_NO_DOMINATOR_CODE: &str = "graph_pack_dna_no_dominator";
pub const GRAPH_PACK_DNA_TIMEOUT_CODE: &str = "graph_pack_dna_timeout";
pub const GRAPH_CAUSAL_NO_EVIDENCE_CODE: &str = "graph_causal_no_evidence";
pub const GRAPH_HEALTH_NO_CONTRADICTIONS_CODE: &str = "graph_health_no_contradictions";
pub const GRAPH_CURATE_DISCONNECTED_GRAPH_CODE: &str = "graph_curate_disconnected_graph";
pub const GRAPH_PROXIMITY_UNREACHABLE_CODE: &str = "graph_proximity_unreachable";
pub const GRAPH_DOMINANCE_NO_REVISION_CHAIN_CODE: &str = "graph_dominance_no_revision_chain";
pub const GRAPH_SKYLINE_DEGENERATE_COMMUNITIES_CODE: &str = "graph_skyline_degenerate_communities";
pub const GRAPH_HITS_CONVERGENCE_FAILURE_CODE: &str = "graph_hits_convergence_failure";
pub const CONFORMAL_CALIBRATION_INSUFFICIENT_CODE: &str = "conformal_calibration_insufficient";
pub const SEARCH_SCORE_CALIBRATION_ROWS_CORRUPT_CODE: &str =
"search_score_calibration_rows_corrupt";
pub const SEARCH_SCORE_CALIBRATION_FILE_TOO_LARGE_CODE: &str =
"search_score_calibration_file_too_large";
pub const SEARCH_SCORE_CALIBRATION_UNREADABLE_CODE: &str = "search_score_calibration_unreadable";
pub const HARMFUL_BURST_QUARANTINE_CODE: &str = "harmful_burst_quarantine";
pub const SPRT_QUARANTINE_CODE: &str = "sprt_quarantine";
pub const TOKEN_BUDGET_EXCEEDED: DegradationCode = DegradationCode {
id: "D400",
subsystem: DegradedSubsystem::Pack,
severity: DegradationSeverity::Low,
description: "Token budget exceeded",
behavior_change: "Context pack truncated; some memories omitted",
auto_recoverable: true,
repair: None,
};
pub const MMR_FALLBACK: DegradationCode = DegradationCode {
id: "D401",
subsystem: DegradedSubsystem::Pack,
severity: DegradationSeverity::Low,
description: "MMR diversity selection disabled",
behavior_change: "Pack may contain redundant memories",
auto_recoverable: true,
repair: None,
};
pub const PACK_BUDGET_TOO_SMALL: DegradationCode = DegradationCode {
id: "D402",
subsystem: DegradedSubsystem::Pack,
severity: DegradationSeverity::Warning,
description: "Pack budget too small",
behavior_change: "Context pack has matching candidates but cannot fit any candidate within the requested token budget",
auto_recoverable: true,
repair: None,
};
pub const CURATION_QUEUE_FULL: DegradationCode = DegradationCode {
id: "D500",
subsystem: DegradedSubsystem::Curate,
severity: DegradationSeverity::Low,
description: "Curation candidate queue is full",
behavior_change: "New candidates will be dropped until reviewed",
auto_recoverable: false,
repair: Some("ee curate candidates --all --json"),
};
pub const AUTO_CURATION_DISABLED: DegradationCode = DegradationCode {
id: "D501",
subsystem: DegradedSubsystem::Curate,
severity: DegradationSeverity::Low,
description: "Automatic curation disabled",
behavior_change: "Rules will not auto-promote; manual review required",
auto_recoverable: false,
repair: Some("ee curate candidates --json"),
};
pub const POLICY_NOT_LOADED: DegradationCode = DegradationCode {
id: "D600",
subsystem: DegradedSubsystem::Policy,
severity: DegradationSeverity::Warning,
description: "Policy file not loaded",
behavior_change: "Default policies in effect; custom rules ignored",
auto_recoverable: false,
repair: Some("ee doctor --fix-plan --json"),
};
pub const REDACTION_PATTERNS_STALE: DegradationCode = DegradationCode {
id: "D601",
subsystem: DegradedSubsystem::Policy,
severity: DegradationSeverity::Low,
description: "Redaction patterns may be outdated",
behavior_change: "Some sensitive data may not be caught",
auto_recoverable: false,
repair: Some("ee doctor --fix-plan --json"),
};
pub const NETWORK_UNAVAILABLE: DegradationCode = DegradationCode {
id: "D700",
subsystem: DegradedSubsystem::Network,
severity: DegradationSeverity::Warning,
description: "Network access unavailable",
behavior_change: "Remote operations disabled; local-only mode",
auto_recoverable: true,
repair: None,
};
pub const AGENT_MAIL_ARCHIVE_DEGRADED_CODE: &str = "agent_mail_archive_degraded";
pub const WORKSPACE_HYGIENE_GIT_UNAVAILABLE_CODE: &str = "git_unavailable";
pub const WORKSPACE_HYGIENE_GIT_NOT_REPOSITORY_CODE: &str = "git_not_repository";
pub const WORKSPACE_HYGIENE_PARTIAL_METADATA_CODE: &str = "workspace_hygiene_partial_metadata";
pub const WORKSPACE_HYGIENE_SECRET_SCAN_SKIPPED_CODE: &str =
"workspace_hygiene_secret_scan_skipped";
pub const WORKSPACE_HYGIENE_AGENT_MAIL_UNAVAILABLE_CODE: &str =
"workspace_hygiene_agent_mail_unavailable";
pub const WORKSPACE_HYGIENE_AGENT_MAIL_TIMEOUT_CODE: &str = "workspace_hygiene_agent_mail_timeout";
pub const WORKSPACE_HYGIENE_BEADS_UNAVAILABLE_CODE: &str = "workspace_hygiene_beads_unavailable";
pub const BEADS_JSONL_PARTIAL_WRITE_TRANSIENT_CODE: &str = "beads_jsonl_partial_write_transient";
pub const WORKSPACE_HYGIENE_BEADS_PARSE_ERROR_CODE: &str = "workspace_hygiene_beads_parse_error";
pub const WORKSPACE_HYGIENE_BEADS_RESERVED_CODE: &str = "workspace_hygiene_beads_reserved";
pub const WORKSPACE_HYGIENE_CONFIG_INVALID_CODE: &str = "workspace_hygiene_config_invalid";
pub const WORKSPACE_HYGIENE_OUTPUT_TRUNCATED_CODE: &str = "workspace_hygiene_output_truncated";
pub const SCIENCE_BACKEND_UNAVAILABLE: DegradationCode = DegradationCode {
id: "D800",
subsystem: DegradedSubsystem::Science,
severity: DegradationSeverity::Warning,
description: "Science analytics backend unavailable",
behavior_change: "Optional science analytics are disabled; deterministic core diagnostics still work",
auto_recoverable: false,
repair: Some("ee doctor --json"),
};
pub const SCIENCE_INPUT_TOO_LARGE: DegradationCode = DegradationCode {
id: "D801",
subsystem: DegradedSubsystem::Science,
severity: DegradationSeverity::Warning,
description: "Science analytics input too large",
behavior_change: "Science analysis is skipped for oversized inputs; use a smaller sample or narrower query",
auto_recoverable: false,
repair: None,
};
pub const SCIENCE_BUDGET_EXCEEDED: DegradationCode = DegradationCode {
id: "D802",
subsystem: DegradedSubsystem::Science,
severity: DegradationSeverity::Warning,
description: "Science analytics budget exceeded",
behavior_change: "Science analysis stops at the configured budget; core command output remains available",
auto_recoverable: false,
repair: None,
};
pub const PERF_LATENCY_EVIDENCE_MISSING_CODE: &str = "perf_latency_evidence_missing";
pub const PERF_LATENCY_EVIDENCE_PARTIAL_CODE: &str = "perf_latency_evidence_partial";
pub const ALL_DEGRADATION_CODES: &[DegradationCode] = &[
SEMANTIC_SEARCH_UNAVAILABLE,
EMBEDDING_MODEL_MISSING,
SEARCH_INDEX_STALE,
FTS5_UNAVAILABLE,
DATABASE_READ_ONLY,
WAL_MODE_DISABLED,
LARGE_DATABASE,
ADVISORY_LOCK_TIMEOUT,
SNAPSHOT_PIN_EXPIRED,
SNAPSHOT_RELEASE_FAILED,
SNAPSHOT_PIN_FORCE_RELEASED,
CASS_NOT_FOUND,
CASS_VERSION_MISMATCH,
CASS_INDEX_STALE,
GRAPH_SNAPSHOT_STALE,
GRAPH_METRICS_UNAVAILABLE,
TOKEN_BUDGET_EXCEEDED,
MMR_FALLBACK,
PACK_BUDGET_TOO_SMALL,
CURATION_QUEUE_FULL,
AUTO_CURATION_DISABLED,
POLICY_NOT_LOADED,
REDACTION_PATTERNS_STALE,
NETWORK_UNAVAILABLE,
SCIENCE_BACKEND_UNAVAILABLE,
SCIENCE_INPUT_TOO_LARGE,
SCIENCE_BUDGET_EXCEEDED,
];
#[must_use]
pub fn lookup(id: &str) -> Option<DegradationCode> {
ALL_DEGRADATION_CODES
.iter()
.find(|code| code.id == id)
.copied()
}
#[must_use]
pub fn by_subsystem(subsystem: DegradedSubsystem) -> Vec<DegradationCode> {
ALL_DEGRADATION_CODES
.iter()
.filter(|code| code.subsystem == subsystem)
.copied()
.collect()
}
#[must_use]
pub fn by_severity(severity: DegradationSeverity) -> Vec<DegradationCode> {
ALL_DEGRADATION_CODES
.iter()
.filter(|code| code.severity == severity)
.copied()
.collect()
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActiveDegradation {
pub code: DegradationCode,
pub detected_at: Option<String>,
pub context: Option<String>,
}
impl ActiveDegradation {
#[must_use]
pub const fn new(code: DegradationCode) -> Self {
Self {
code,
detected_at: None,
context: None,
}
}
#[must_use]
pub fn at(mut self, timestamp: impl Into<String>) -> Self {
self.detected_at = Some(timestamp.into());
self
}
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), String>;
fn ensure<T: std::fmt::Debug + PartialEq>(actual: T, expected: T, ctx: &str) -> TestResult {
if actual == expected {
Ok(())
} else {
Err(format!("{ctx}: expected {expected:?}, got {actual:?}"))
}
}
#[test]
fn degradation_code_ids_are_unique() -> TestResult {
let mut seen = std::collections::HashSet::new();
for code in ALL_DEGRADATION_CODES {
if !seen.insert(code.id) {
return Err(format!("Duplicate degradation code ID: {}", code.id));
}
}
Ok(())
}
#[test]
fn degradation_code_ids_follow_format() -> TestResult {
for code in ALL_DEGRADATION_CODES {
if !code.id.starts_with('D') {
return Err(format!("Code {} does not start with D", code.id));
}
if code.id.len() != 4 {
return Err(format!("Code {} is not 4 characters", code.id));
}
}
Ok(())
}
#[test]
fn degradation_code_numbers_are_in_range() -> TestResult {
for code in ALL_DEGRADATION_CODES {
let num = code.number();
let expected_range = match code.subsystem {
DegradedSubsystem::Search => 1..100,
DegradedSubsystem::Storage => 100..200,
DegradedSubsystem::Cass => 200..300,
DegradedSubsystem::Graph => 300..400,
DegradedSubsystem::Pack => 400..500,
DegradedSubsystem::Curate => 500..600,
DegradedSubsystem::Policy => 600..700,
DegradedSubsystem::Network => 700..800,
DegradedSubsystem::Science => 800..900,
};
if !expected_range.contains(&num) {
return Err(format!(
"Code {} has number {} outside range {:?}",
code.id, num, expected_range
));
}
}
Ok(())
}
#[test]
fn lookup_finds_existing_code() -> TestResult {
let found = lookup("D001");
ensure(found.is_some(), true, "D001 exists")?;
ensure(found.map(|c| c.id), Some("D001"), "found correct code")
}
#[test]
fn lookup_returns_none_for_unknown() -> TestResult {
ensure(lookup("D999"), None, "unknown code returns None")
}
#[test]
fn by_subsystem_returns_correct_codes() -> TestResult {
let search = by_subsystem(DegradedSubsystem::Search);
ensure(search.len() >= 3, true, "at least 3 search codes")?;
for code in &search {
ensure(
code.subsystem,
DegradedSubsystem::Search,
"correct subsystem",
)?;
}
Ok(())
}
#[test]
fn by_severity_returns_correct_codes() -> TestResult {
let warnings = by_severity(DegradationSeverity::Warning);
ensure(warnings.len() >= 3, true, "at least 3 warning codes")?;
for code in &warnings {
ensure(
code.severity,
DegradationSeverity::Warning,
"correct severity",
)?;
}
Ok(())
}
#[test]
fn severity_ordering() -> TestResult {
for pair in DegradationSeverity::ALL.windows(2) {
ensure(pair[0] < pair[1], true, "canonical severity ordering")?;
}
Ok(())
}
#[test]
fn severity_strings_are_stable() -> TestResult {
let expected = [
(DegradationSeverity::Info, "info", 0),
(DegradationSeverity::Low, "low", 1),
(DegradationSeverity::Warning, "warning", 2),
(DegradationSeverity::Medium, "medium", 3),
(DegradationSeverity::High, "high", 4),
(DegradationSeverity::Critical, "critical", 5),
];
for (severity, name, rank) in expected {
ensure(severity.as_str(), name, "severity string")?;
ensure(severity.rank(), rank, "severity rank")?;
ensure(
serde_json::to_string(&severity).map_err(|error| error.to_string())?,
format!("\"{name}\""),
"serialized severity",
)?;
}
Ok(())
}
#[test]
fn severity_parsers_distinguish_contract_values_from_fallbacks() -> TestResult {
ensure(
DegradationSeverity::parse(" Warning "),
Some(DegradationSeverity::Warning),
"strict parser trims and lowercases",
)?;
ensure(
DegradationSeverity::parse("advisory"),
None,
"retired advisory value is not canonical",
)?;
ensure(
"unknown".parse::<DegradationSeverity>().is_err(),
true,
"FromStr rejects unknown values",
)?;
ensure(
DegradationSeverity::parse_lossy("advisory"),
DegradationSeverity::Low,
"retired advisory maps to low",
)?;
ensure(
DegradationSeverity::parse_lossy("unknown"),
DegradationSeverity::Info,
"unknown values fall back to info",
)?;
ensure(
serde_json::from_str::<DegradationSeverity>("\"medium\"")
.map_err(|error| error.to_string())?,
DegradationSeverity::Medium,
"deserialized severity",
)
}
#[test]
fn subsystem_strings_are_stable() -> TestResult {
ensure(DegradedSubsystem::Search.as_str(), "search", "search")?;
ensure(DegradedSubsystem::Storage.as_str(), "storage", "storage")?;
ensure(DegradedSubsystem::Cass.as_str(), "cass", "cass")?;
ensure(DegradedSubsystem::Graph.as_str(), "graph", "graph")?;
ensure(DegradedSubsystem::Pack.as_str(), "pack", "pack")?;
ensure(DegradedSubsystem::Curate.as_str(), "curate", "curate")?;
ensure(DegradedSubsystem::Policy.as_str(), "policy", "policy")?;
ensure(DegradedSubsystem::Network.as_str(), "network", "network")?;
ensure(DegradedSubsystem::Science.as_str(), "science", "science")
}
#[test]
fn all_subsystems_have_at_least_one_code() -> TestResult {
let subsystems = [
DegradedSubsystem::Search,
DegradedSubsystem::Storage,
DegradedSubsystem::Cass,
DegradedSubsystem::Graph,
DegradedSubsystem::Pack,
DegradedSubsystem::Curate,
DegradedSubsystem::Policy,
DegradedSubsystem::Network,
DegradedSubsystem::Science,
];
for sub in subsystems {
let codes = by_subsystem(sub);
if codes.is_empty() {
return Err(format!("Subsystem {:?} has no codes", sub));
}
}
Ok(())
}
#[test]
fn science_degradation_codes_are_registered() -> TestResult {
let science = by_subsystem(DegradedSubsystem::Science);
ensure(
science.iter().map(|code| code.id).collect(),
vec!["D800", "D801", "D802"],
"science code ids",
)?;
ensure(
science
.iter()
.map(|code| code.description)
.collect::<Vec<_>>(),
vec![
"Science analytics backend unavailable",
"Science analytics input too large",
"Science analytics budget exceeded",
],
"science code descriptions",
)
}
#[test]
fn active_degradation_builder() {
let active = ActiveDegradation::new(SEMANTIC_SEARCH_UNAVAILABLE)
.at("2026-01-01T00:00:00Z")
.with_context("Model file missing");
assert_eq!(active.code.id, "D001");
assert_eq!(active.detected_at, Some("2026-01-01T00:00:00Z".to_string()));
assert_eq!(active.context, Some("Model file missing".to_string()));
}
}