use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
pub const ROOT_CAUSE_TAXONOMY_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RootCauseFamily {
CassDerivedState,
FrankensqliteStorage,
FrankensearchSearch,
AsupersyncRuntime,
RemoteTransportAuth,
SemanticAssets,
WorkspaceProvenance,
HostDiskPressure,
HostOomLoad,
OldBinarySkew,
Unknown,
}
impl RootCauseFamily {
pub const ALL: [RootCauseFamily; 11] = [
RootCauseFamily::CassDerivedState,
RootCauseFamily::FrankensqliteStorage,
RootCauseFamily::FrankensearchSearch,
RootCauseFamily::AsupersyncRuntime,
RootCauseFamily::RemoteTransportAuth,
RootCauseFamily::SemanticAssets,
RootCauseFamily::WorkspaceProvenance,
RootCauseFamily::HostDiskPressure,
RootCauseFamily::HostOomLoad,
RootCauseFamily::OldBinarySkew,
RootCauseFamily::Unknown,
];
pub const fn as_str(self) -> &'static str {
match self {
RootCauseFamily::CassDerivedState => "cass-derived-state",
RootCauseFamily::FrankensqliteStorage => "frankensqlite-storage",
RootCauseFamily::FrankensearchSearch => "frankensearch-search",
RootCauseFamily::AsupersyncRuntime => "asupersync-runtime",
RootCauseFamily::RemoteTransportAuth => "remote-transport-auth",
RootCauseFamily::SemanticAssets => "semantic-assets",
RootCauseFamily::WorkspaceProvenance => "workspace-provenance",
RootCauseFamily::HostDiskPressure => "host-disk-pressure",
RootCauseFamily::HostOomLoad => "host-oom-load",
RootCauseFamily::OldBinarySkew => "old-binary-skew",
RootCauseFamily::Unknown => "unknown",
}
}
pub const fn locus(self) -> FaultLocus {
match self {
RootCauseFamily::CassDerivedState | RootCauseFamily::WorkspaceProvenance => {
FaultLocus::Cass
}
RootCauseFamily::FrankensqliteStorage
| RootCauseFamily::FrankensearchSearch
| RootCauseFamily::AsupersyncRuntime
| RootCauseFamily::RemoteTransportAuth
| RootCauseFamily::SemanticAssets => FaultLocus::Dependency,
RootCauseFamily::HostDiskPressure | RootCauseFamily::HostOomLoad => FaultLocus::Host,
RootCauseFamily::OldBinarySkew => FaultLocus::BinarySkew,
RootCauseFamily::Unknown => FaultLocus::Unknown,
}
}
pub const fn is_external_to_cass(self) -> bool {
matches!(
self.locus(),
FaultLocus::Dependency | FaultLocus::Host | FaultLocus::BinarySkew
)
}
pub const fn descriptor(self) -> RootCauseDescriptor {
match self {
RootCauseFamily::CassDerivedState => RootCauseDescriptor {
family: self,
title: "CASS derived state is wrong or stale",
examples: &[
"status reports healthy but search returns nothing after a reindex",
"quarantine count disagrees with the derived-asset truth table",
"summaries reference sessions that no longer exist on disk",
],
typical_evidence: &[
"cass.derived_asset.truth_table_mismatch",
"cass.index.last_indexed_ms",
"cass.cache.generation",
],
first_probe: "cass doctor check --json",
false_positive_guidance: "A cold or never-indexed data dir is NOT this family: \
zero derived state is expected, not corrupt. Require evidence of a mismatch \
between two CASS-owned facts (e.g. index count vs. truth table), not merely \
emptiness.",
},
RootCauseFamily::FrankensqliteStorage => RootCauseDescriptor {
family: self,
title: "frankensqlite storage engine fault",
examples: &[
"OpenRead error opening the main DB under noisy fsqlite tracing",
"FTS query fails while plain row reads succeed",
"WAL sidecar present but unreadable; busy-lock under concurrent writers",
],
typical_evidence: &[
"fsqlite.error_code",
"fsqlite.open_read_failure",
"file:cass.db-wal",
],
first_probe: "cass diag --json",
false_positive_guidance: "fsqlite INFO/TRACE log lines are noise, not evidence — \
the robot hygiene chokepoint suppresses them in machine modes. Attribute here \
only on a structured fsqlite error code or an OpenRead/FTS failure, never on \
the presence of tracing output.",
},
RootCauseFamily::FrankensearchSearch => RootCauseDescriptor {
family: self,
title: "frankensearch search-stack fault",
examples: &[
"semantic results empty while lexical fail-open still returns hits",
"tantivy segment corruption on the lexical index",
"RRF fusion panics or returns degenerate ordering",
],
typical_evidence: &[
"frankensearch.fusion_error",
"frankensearch.lexical_fail_open",
"frankensearch.tantivy_segment_error",
],
first_probe: "cass search --json --dry-run <query>",
false_positive_guidance: "Lexical fail-open returning results is the designed \
graceful degradation, not a fault by itself. Require a search-stack error or a \
semantic/lexical divergence beyond the documented fail-open contract.",
},
RootCauseFamily::AsupersyncRuntime => RootCauseDescriptor {
family: self,
title: "asupersync runtime fault",
examples: &[
"a read-only probe hangs because a blocking call ran on the runtime",
"task starvation under load; cancellation does not propagate",
"Cx propagation lost across a spawn boundary",
],
typical_evidence: &[
"asupersync.task_stall_ms",
"asupersync.blocking_on_runtime",
"asupersync.cx_propagation_lost",
],
first_probe: "cass status --json --timeout-ms 8000",
false_positive_guidance: "A command that is merely slow because of real I/O is not \
a runtime fault. Attribute here only when work stalls with no underlying I/O \
progress, or cancellation/timeout fails to take effect.",
},
RootCauseFamily::RemoteTransportAuth => RootCauseDescriptor {
family: self,
title: "remote transport or authentication fault",
examples: &[
"rsync/scp over system OpenSSH fails before the ssh2 fallback",
"expired credentials or host-key mismatch on a remote mirror",
"network timeout pulling a remote source",
],
typical_evidence: &[
"transport.ssh_exit_code",
"transport.auth_failure",
"transport.connect_timeout_ms",
],
first_probe: "cass sources probe --json",
false_positive_guidance: "A locally-configured source with no remote does not \
belong here. Distinguish transport/auth failure from a missing or misconfigured \
source path (which is workspace-provenance).",
},
RootCauseFamily::SemanticAssets => RootCauseDescriptor {
family: self,
title: "semantic assets missing or incompatible",
examples: &[
"embedding model not downloaded; ONNX runtime missing",
"vector index build artifacts absent or partial",
"embedding dimension mismatch vs. the stored index",
],
typical_evidence: &[
"semantic.model_present",
"semantic.vector_index_built",
"semantic.embedding_dim_mismatch",
],
first_probe: "cass diag --json",
false_positive_guidance: "Semantic search being disabled by configuration is not a \
fault. Attribute here only when semantic mode is requested/enabled but its \
assets are missing, partial, or incompatible.",
},
RootCauseFamily::WorkspaceProvenance => RootCauseDescriptor {
family: self,
title: "workspace provenance or configuration fault",
examples: &[
"sources config points at a stale or moved data dir",
"agent-detection mapping misclassifies a session source",
".env / data-dir resolution disagrees with the actual layout",
],
typical_evidence: &[
"config.data_dir",
"config.sources_config_path",
"provenance.agent_mapping",
],
first_probe: "cass status --json",
false_positive_guidance: "Default first-run configuration is not a fault. Require a \
concrete mismatch between configured provenance and the on-disk reality, not the \
mere absence of customization.",
},
RootCauseFamily::HostDiskPressure => RootCauseDescriptor {
family: self,
title: "host disk pressure",
examples: &[
"writes fail or stall because the filesystem is near-full",
"tmpfs build/cache directory exhausted",
"ballast eviction triggered by an external disk-pressure guard",
],
typical_evidence: &[
"host.disk_free_bytes",
"host.disk_free_pct",
"host.tmpfs_free_bytes",
],
first_probe: "df -h (host) / cass diag --json",
false_positive_guidance: "Plenty of free space rules this out — never attribute \
disk pressure without a free-space metric below threshold. A single ENOSPC from \
an unrelated bind mount is not host-wide pressure.",
},
RootCauseFamily::HostOomLoad => RootCauseDescriptor {
family: self,
title: "host OOM or load pressure",
examples: &[
"process killed by systemd-oomd under memory pressure",
"runaway load average from competing builds starves the probe",
"swap thrash makes every operation time out",
],
typical_evidence: &[
"host.mem_available_bytes",
"host.load_avg_1m",
"host.oom_kill_count",
],
first_probe: "cass diag --json / dmesg oom scan",
false_positive_guidance: "A normally-loaded host is not under OOM/load pressure. \
Require memory-available below threshold, an oom-kill record, or load average \
well above core count — not merely a busy machine.",
},
RootCauseFamily::OldBinarySkew => RootCauseDescriptor {
family: self,
title: "old binary / contract skew",
examples: &[
"running binary predates the on-disk schema or contract version",
"a requested flag is missing because the installed build is stale",
"fleet nodes report different api-version values",
],
typical_evidence: &[
"binary.api_version",
"binary.contract_version",
"state.schema_version",
],
first_probe: "cass api-version --json",
false_positive_guidance: "A version difference within the supported compatibility \
window is not skew. Attribute here only when the binary's contract/api version is \
behind what the on-disk state or the fleet requires.",
},
RootCauseFamily::Unknown => RootCauseDescriptor {
family: self,
title: "unattributed",
examples: &[
"a failure with no evidence pointing at any specific family",
"conflicting signals across families with none dominant",
],
typical_evidence: &["diagnostic.unattributed_reason"],
first_probe: "cass diag --json && cass doctor check --json",
false_positive_guidance: "Do not use Unknown to avoid investigation: if any family \
reaches `Possible` confidence, attribute to it instead. Unknown means evidence \
was gathered and still pointed nowhere — record why in the attribution summary.",
},
}
}
}
impl fmt::Display for RootCauseFamily {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseRootCauseFamilyError(pub String);
impl fmt::Display for ParseRootCauseFamilyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unrecognized root-cause family: {:?}", self.0)
}
}
impl std::error::Error for ParseRootCauseFamilyError {}
impl FromStr for RootCauseFamily {
type Err = ParseRootCauseFamilyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
RootCauseFamily::ALL
.into_iter()
.find(|family| family.as_str() == s)
.ok_or_else(|| ParseRootCauseFamilyError(s.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FaultLocus {
Cass,
Dependency,
Host,
BinarySkew,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AttributionConfidence {
Confirmed,
Probable,
Possible,
Unknown,
}
impl AttributionConfidence {
pub const fn rank(self) -> u8 {
match self {
AttributionConfidence::Confirmed => 3,
AttributionConfidence::Probable => 2,
AttributionConfidence::Possible => 1,
AttributionConfidence::Unknown => 0,
}
}
pub const fn is_actionable(self) -> bool {
self.rank() >= AttributionConfidence::Probable.rank()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct RootCauseDescriptor {
pub family: RootCauseFamily,
pub title: &'static str,
pub examples: &'static [&'static str],
pub typical_evidence: &'static [&'static str],
pub first_probe: &'static str,
pub false_positive_guidance: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceRef {
pub kind: String,
pub locator: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl EvidenceRef {
pub fn new(kind: impl Into<String>, locator: impl Into<String>) -> Self {
Self {
kind: kind.into(),
locator: locator.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RootCauseAttribution {
pub schema_version: u32,
pub family: RootCauseFamily,
pub locus: FaultLocus,
pub confidence: AttributionConfidence,
#[serde(default)]
pub evidence_refs: Vec<EvidenceRef>,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommended_next_probe: Option<String>,
}
impl RootCauseAttribution {
pub fn new(
family: RootCauseFamily,
confidence: AttributionConfidence,
summary: impl Into<String>,
) -> Self {
Self {
schema_version: ROOT_CAUSE_TAXONOMY_VERSION,
family,
locus: family.locus(),
confidence,
evidence_refs: Vec::new(),
summary: summary.into(),
recommended_next_probe: Some(family.descriptor().first_probe.to_string()),
}
}
pub fn unattributed(summary: impl Into<String>) -> Self {
let mut attribution = Self::new(
RootCauseFamily::Unknown,
AttributionConfidence::Unknown,
summary,
);
attribution.evidence_refs.clear();
attribution
}
pub fn with_evidence(mut self, evidence: Vec<EvidenceRef>) -> Self {
self.evidence_refs = evidence;
self
}
pub fn push_evidence(&mut self, evidence: EvidenceRef) {
self.evidence_refs.push(evidence);
}
pub fn with_next_probe(mut self, probe: Option<String>) -> Self {
self.recommended_next_probe = probe;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_families_present_unique_and_include_unknown() {
let required = [
"cass-derived-state",
"frankensqlite-storage",
"frankensearch-search",
"asupersync-runtime",
"remote-transport-auth",
"semantic-assets",
"workspace-provenance",
"host-disk-pressure",
"host-oom-load",
"old-binary-skew",
"unknown",
];
let actual: Vec<&str> = RootCauseFamily::ALL.iter().map(|f| f.as_str()).collect();
assert_eq!(
actual, required,
"family set/order must match the bead contract"
);
let mut sorted = actual.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
RootCauseFamily::ALL.len(),
"families must be unique"
);
assert!(RootCauseFamily::ALL.contains(&RootCauseFamily::Unknown));
}
#[test]
fn as_str_round_trips_through_from_str() {
for family in RootCauseFamily::ALL {
let parsed: RootCauseFamily = family.as_str().parse().expect("parse stable str");
assert_eq!(parsed, family);
}
}
#[test]
fn from_str_rejects_unknown_value() {
let err = "not-a-family".parse::<RootCauseFamily>().unwrap_err();
assert_eq!(err, ParseRootCauseFamilyError("not-a-family".to_string()));
}
#[test]
fn serde_wire_value_matches_as_str() {
for family in RootCauseFamily::ALL {
let json = serde_json::to_string(&family).expect("serialize");
assert_eq!(json, format!("\"{}\"", family.as_str()));
let back: RootCauseFamily = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, family);
}
}
#[test]
fn every_family_has_a_complete_descriptor() {
for family in RootCauseFamily::ALL {
let d = family.descriptor();
assert_eq!(d.family, family, "descriptor must report its own family");
assert!(!d.title.is_empty(), "{family}: empty title");
assert!(!d.examples.is_empty(), "{family}: must have examples");
assert!(
d.examples.iter().all(|e| !e.is_empty()),
"{family}: empty example"
);
assert!(
!d.typical_evidence.is_empty(),
"{family}: must list typical evidence kinds"
);
assert!(
!d.first_probe.is_empty(),
"{family}: must have a first probe"
);
assert!(
d.false_positive_guidance.len() > 20,
"{family}: false-positive guidance must be substantive"
);
}
}
#[test]
fn locus_classification_separates_cass_from_dependency_and_host() {
assert_eq!(RootCauseFamily::CassDerivedState.locus(), FaultLocus::Cass);
assert_eq!(
RootCauseFamily::WorkspaceProvenance.locus(),
FaultLocus::Cass
);
for dep in [
RootCauseFamily::FrankensqliteStorage,
RootCauseFamily::FrankensearchSearch,
RootCauseFamily::AsupersyncRuntime,
RootCauseFamily::RemoteTransportAuth,
RootCauseFamily::SemanticAssets,
] {
assert_eq!(
dep.locus(),
FaultLocus::Dependency,
"{dep} should be a dependency"
);
assert!(dep.is_external_to_cass(), "{dep} masquerades as CASS");
}
assert_eq!(RootCauseFamily::HostDiskPressure.locus(), FaultLocus::Host);
assert_eq!(RootCauseFamily::HostOomLoad.locus(), FaultLocus::Host);
assert_eq!(
RootCauseFamily::OldBinarySkew.locus(),
FaultLocus::BinarySkew
);
assert_eq!(RootCauseFamily::Unknown.locus(), FaultLocus::Unknown);
assert!(!RootCauseFamily::CassDerivedState.is_external_to_cass());
assert!(!RootCauseFamily::Unknown.is_external_to_cass());
}
#[test]
fn confidence_rank_is_monotonic_and_actionability_threshold_holds() {
assert!(AttributionConfidence::Confirmed.rank() > AttributionConfidence::Probable.rank());
assert!(AttributionConfidence::Probable.rank() > AttributionConfidence::Possible.rank());
assert!(AttributionConfidence::Possible.rank() > AttributionConfidence::Unknown.rank());
assert!(AttributionConfidence::Confirmed.is_actionable());
assert!(AttributionConfidence::Probable.is_actionable());
assert!(!AttributionConfidence::Possible.is_actionable());
assert!(!AttributionConfidence::Unknown.is_actionable());
}
#[test]
fn attribution_serializes_with_stable_fields_and_locus() {
let attribution = RootCauseAttribution::new(
RootCauseFamily::FrankensqliteStorage,
AttributionConfidence::Confirmed,
"OpenRead failed on main DB",
)
.with_evidence(vec![
EvidenceRef::new("fsqlite.error_code", "cass.db").with_detail("SQLITE_CANTOPEN"),
]);
let value = serde_json::to_value(&attribution).expect("serialize");
assert_eq!(value["schema_version"], ROOT_CAUSE_TAXONOMY_VERSION);
assert_eq!(value["family"], "frankensqlite-storage");
assert_eq!(value["locus"], "dependency");
assert_eq!(value["confidence"], "confirmed");
assert_eq!(value["summary"], "OpenRead failed on main DB");
assert_eq!(value["evidence_refs"][0]["kind"], "fsqlite.error_code");
assert_eq!(value["evidence_refs"][0]["detail"], "SQLITE_CANTOPEN");
assert_eq!(value["recommended_next_probe"], "cass diag --json");
let back: RootCauseAttribution = serde_json::from_value(value).expect("deserialize");
assert_eq!(back, attribution);
}
#[test]
fn unattributed_is_unknown_with_no_evidence() {
let attribution = RootCauseAttribution::unattributed("probed status+doctor, no signal");
assert_eq!(attribution.family, RootCauseFamily::Unknown);
assert_eq!(attribution.confidence, AttributionConfidence::Unknown);
assert_eq!(attribution.locus, FaultLocus::Unknown);
assert!(attribution.evidence_refs.is_empty());
}
#[test]
fn evidence_ref_omits_detail_when_absent() {
let value = serde_json::to_value(EvidenceRef::new("host.disk_free_pct", "/")).unwrap();
assert!(
value.get("detail").is_none(),
"absent detail must be skipped"
);
assert_eq!(value["kind"], "host.disk_free_pct");
assert_eq!(value["locator"], "/");
}
#[test]
fn full_taxonomy_catalog_is_serializable_and_complete() {
let catalog: Vec<RootCauseDescriptor> = RootCauseFamily::ALL
.iter()
.map(|f| f.descriptor())
.collect();
let json = serde_json::to_string(&catalog).expect("serialize catalog");
for family in RootCauseFamily::ALL {
assert!(
json.contains(family.as_str()),
"catalog JSON missing family {family}"
);
}
}
}