diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs index 7a8eaf309..68e2c3952 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -1,7 +1,9 @@ mod catalog; mod intake; mod management_point; +mod site_core; pub use catalog::*; pub use intake::*; pub use management_point::*; +pub use site_core::*; diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs new file mode 100644 index 000000000..7347eeb3c --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -0,0 +1,2104 @@ +//! Role-local SCCM site-core and status analysis. +//! +//! This reducer consumes only the normalized server-intake assessment. It does +//! not reconstruct manifest state, inspect files, infer an installed role from +//! a default path, or correlate a client with a server by time. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + classify_artifact_name, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, + SccmFindingClass, SccmFindingCoverageGap, SccmPhase, SccmRole, SccmTerminalEvidence, + SccmTimeOrderingState, SccmTimestamp, +}; + +use super::{ + SccmServerArtifactAssessment, SccmServerConfiguredPathState, SccmServerIntakeAssessment, +}; + +pub const SCCM_SITE_CORE_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_SITE_CORE_PROFILE_ID: &str = "sccm-site-core"; +pub const SCCM_SITE_CORE_PROFILE_VERSION: u32 = 1; +pub const SCCM_SITE_CORE_PROFILE_STABILITY: &str = "experimental"; +pub const SCCM_SITE_CORE_COMPONENT_GROUP: &str = "server-sitecomp"; +pub const SCCM_SITE_CORE_STATUS_GROUP: &str = "server-status"; + +const SITE_CORE_PROFILE_VERSION_TOKEN: &str = "5.00.TEST"; +const RECAPTURE_FLOOR_BYTES: u64 = 4096; +const MAX_SITE_CORE_REQUEST_ARTIFACTS: usize = 2; + +const STATE_CHAIN: [SccmSiteCorePhase; 5] = [ + SccmSiteCorePhase::ComponentStart, + SccmSiteCorePhase::ComponentWork, + SccmSiteCorePhase::InboxOrQueue, + SccmSiteCorePhase::StatusOrStateProcessing, + SccmSiteCorePhase::HealthyOrTerminal, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreWorkflow { + SiteCore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCorePhase { + ComponentStart, + ComponentWork, + InboxOrQueue, + StatusOrStateProcessing, + HealthyOrTerminal, +} + +impl SccmSiteCorePhase { + fn serialized_name(self) -> &'static str { + match self { + Self::ComponentStart => "componentStart", + Self::ComponentWork => "componentWork", + Self::InboxOrQueue => "inboxOrQueue", + Self::StatusOrStateProcessing => "statusOrStateProcessing", + Self::HealthyOrTerminal => "healthyOrTerminal", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreState { + Healthy, + TerminalFailure, + BlockedOrDeferred, + Recovered, + Incomplete, + Contradictory, + ParseGap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreConfidence { + None, + Low, + Moderate, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmSiteCoreDiagnosticMeaning { + CoverageOnly, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreProfile { + pub id: String, + pub version: u32, + pub stability: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreTransactionKey { + pub profile_id: String, + pub profile_version: u32, + pub site_handle: String, + pub producer_host_handle: String, + pub component_id: String, + pub work_item_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreEvidence { + pub artifact_id: String, + pub entry_id: String, + pub line_start: u32, + pub line_end: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub terminal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub complete_logical_record: Option, +} + +impl SccmSiteCoreEvidence { + fn reference(&self) -> SccmEvidenceRef { + SccmEvidenceRef { + artifact_id: self.artifact_id.clone(), + entry_id: self.entry_id.clone(), + line_start: Some(self.line_start), + line_end: Some(self.line_end), + } + } + + fn sort_key(&self) -> (&str, u32, u32, &str) { + ( + self.artifact_id.as_str(), + self.line_start, + self.line_end, + self.entry_id.as_str(), + ) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreRequestScope { + #[serde(skip_serializing_if = "Option::is_none")] + pub producer_host_handle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub component_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub work_item_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rotation_lineage_handle: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreArtifactCandidate { + pub basename: String, + pub rotation: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreArtifactRequest { + pub logical_name: String, + pub role: SccmRole, + pub reason_code: String, + pub candidates: Vec, + pub max_artifacts: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bytes_per_artifact: Option, + pub scope: SccmSiteCoreRequestScope, +} + +impl SccmSiteCoreArtifactRequest { + fn sort_key(&self) -> (&str, &str, &str, &SccmSiteCoreRequestScope) { + ( + self.logical_name.as_str(), + role_sort_key(&self.role), + self.reason_code.as_str(), + &self.scope, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreResult { + pub result_id: String, + pub transaction_key: SccmSiteCoreTransactionKey, + pub state: SccmSiteCoreState, + pub last_successful_phase: Option, + pub finding_class: Option, + pub confidence: SccmSiteCoreConfidence, + pub confidence_ceiling: SccmSiteCoreConfidence, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreObservation { + pub observation_id: String, + pub state: SccmSiteCoreState, + pub finding_class: SccmFindingClass, + pub confidence: SccmSiteCoreConfidence, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, + pub next_artifacts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreCoverageGap { + pub artifact_id: String, + pub source_id: String, + pub state: SccmCoverageState, + pub reason_code: String, + pub diagnostic_meaning: SccmSiteCoreDiagnosticMeaning, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreFinding { + #[serde(flatten)] + pub finding: SccmFinding, + pub subject_id: String, + pub last_successful_phase: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreAnalysis { + pub schema_version: u32, + pub workflow: SccmSiteCoreWorkflow, + pub profile: SccmSiteCoreProfile, + pub state_chain: Vec, + pub results: Vec, + pub unlinked_observations: Vec, + pub coverage_gaps: Vec, + pub findings: Vec, + pub artifact_requests: Vec, + pub cross_side_correlation_performed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SiteCoreGroup { + Component, + Status, +} + +impl SiteCoreGroup { + fn source_id(self) -> &'static str { + match self { + Self::Component => SCCM_SITE_CORE_COMPONENT_GROUP, + Self::Status => SCCM_SITE_CORE_STATUS_GROUP, + } + } + + fn family(self) -> SccmArtifactFamily { + match self { + Self::Component => SccmArtifactFamily::SiteComponent, + Self::Status => SccmArtifactFamily::SiteStatus, + } + } + + fn from_source_id(value: &str) -> Option { + match value { + SCCM_SITE_CORE_COMPONENT_GROUP => Some(Self::Component), + SCCM_SITE_CORE_STATUS_GROUP => Some(Self::Status), + _ => None, + } + } +} + +#[derive(Clone, Copy)] +struct AdmittedSource<'a> { + artifact: &'a SccmServerArtifactAssessment, + group: SiteCoreGroup, + fact_eligible: bool, + rejection_reason: Option<&'static str>, +} + +struct SiteCoreContext<'a> { + artifacts: &'a [SccmServerArtifactAssessment], + sources: BTreeMap<&'a str, AdmittedSource<'a>>, + evidence_identity_is_unique: Vec, + coverage_gaps: Vec, + coverage_gap_producer_hosts: BTreeMap, +} + +impl<'a> SiteCoreContext<'a> { + fn new(intake: &'a SccmServerIntakeAssessment) -> Self { + let evidence_identity_is_unique = unique_evidence_identities(&intake.evidence); + let collision_artifact_ids = + evidence_collision_artifact_ids(&intake.evidence, &evidence_identity_is_unique); + let (evidence_source_rejections, unresolved_evidence_gaps) = + evidence_source_rejections(intake); + let coverage_congruent = site_core_coverage_is_congruent(intake); + let sources = admitted_sources( + intake, + &collision_artifact_ids, + &evidence_source_rejections, + coverage_congruent, + ); + let mut coverage_gaps = collect_coverage_gaps(intake, &sources); + coverage_gaps.extend(unresolved_evidence_gaps); + sort_and_dedup_coverage_gaps(&mut coverage_gaps); + Self { + artifacts: &intake.artifacts, + sources, + evidence_identity_is_unique, + coverage_gaps, + coverage_gap_producer_hosts: BTreeMap::new(), + } + } + + fn add_undeclared_peer_source_gaps( + &mut self, + grouped: &BTreeMap>, + ) { + for (observed_group, required_group, reason_code) in [ + ( + SiteCoreGroup::Component, + SiteCoreGroup::Status, + "required-status-source-not-declared", + ), + ( + SiteCoreGroup::Status, + SiteCoreGroup::Component, + "required-component-source-not-declared", + ), + ] { + let producer_hosts = grouped + .iter() + .filter(|(_, facts)| facts.iter().any(|fact| fact.marker.group == observed_group)) + .map(|(key, _)| key.producer_host_handle.clone()) + .collect::>(); + for producer_host_handle in producer_hosts { + let compatible_source_exists = self.sources.values().any(|source| { + source.group == required_group + && source.artifact.producer_role == SccmRole::SiteServer + && source.artifact.producer_host_handle.as_deref() + == Some(producer_host_handle.as_str()) + && source.artifact.workflow_subject_role.is_none() + && source.artifact.workflow_subject_handle.is_none() + && (source.fact_eligible + || self.coverage_gaps.iter().any(|gap| { + gap.artifact_id == source.artifact.artifact_id + && gap.source_id == source.artifact.source_id + })) + }); + if compatible_source_exists { + continue; + } + let artifact_id = stable_opaque_id( + "site-core:missing-source:v1:", + &[required_group.source_id(), &producer_host_handle], + ); + self.coverage_gap_producer_hosts + .insert(artifact_id.clone(), producer_host_handle); + self.coverage_gaps.push(SccmSiteCoreCoverageGap { + artifact_id, + source_id: required_group.source_id().to_owned(), + state: SccmCoverageState::Absent, + reason_code: reason_code.to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + } + sort_and_dedup_coverage_gaps(&mut self.coverage_gaps); + } +} + +pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAnalysis { + let mut context = SiteCoreContext::new(intake); + let mut grouped = BTreeMap::>::new(); + let mut record_observations = Vec::new(); + for (position, evidence) in intake.evidence.iter().enumerate() { + let Some(source) = context.sources.get(evidence.reference.artifact_id.as_str()) else { + continue; + }; + if let Some(reason_code) = evidence_record_rejection_reason(evidence, source.group) { + if is_profile_record_candidate(&evidence.message) { + record_observations.push(rejected_record_observation( + evidence, + source, + reason_code, + )); + } + continue; + } + if !source.fact_eligible || !context.evidence_identity_is_unique[position] { + continue; + } + match parse_fact(evidence, source, &intake.topology.site_handle) { + ProfileRecordParse::Accepted(fact) => { + grouped.entry(fact.key.clone()).or_default().push(*fact); + } + ProfileRecordParse::Rejected(reason_code) => { + record_observations.push(rejected_record_observation( + evidence, + source, + reason_code, + )); + } + ProfileRecordParse::NotCandidate => {} + } + } + context.add_undeclared_peer_source_gaps(&grouped); + + let mut results = Vec::new(); + let mut findings = Vec::new(); + for (key, mut facts) in grouped { + facts.sort_by(compare_facts); + let gap_ids = coverage_gap_ids_for_key(&context, &key); + let reduced = reduce_transaction(key, &facts, &context, &gap_ids); + if let Some(class) = reduced.finding_class.clone() { + if let Some(finding) = build_result_finding(&reduced, class, &facts, &context) { + findings.push(finding); + } + } + results.push(reduced); + } + + results.sort_by(|left, right| left.result_id.cmp(&right.result_id)); + findings.sort_by(|left, right| { + left.subject_id + .cmp(&right.subject_id) + .then_with(|| left.finding.finding_id.cmp(&right.finding.finding_id)) + }); + + let mut unlinked_observations = coverage_observations(&context.coverage_gaps, &context); + unlinked_observations.extend(record_observations); + unlinked_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + unlinked_observations.dedup_by(|left, right| left.observation_id == right.observation_id); + for observation in &unlinked_observations { + if let Some(finding) = build_observation_finding(observation, &context) { + findings.push(finding); + } + } + findings.sort_by(|left, right| { + left.subject_id + .cmp(&right.subject_id) + .then_with(|| left.finding.finding_id.cmp(&right.finding.finding_id)) + }); + + let mut artifact_requests = results + .iter() + .flat_map(|result| result.next_artifacts.iter()) + .chain( + unlinked_observations + .iter() + .flat_map(|observation| observation.next_artifacts.iter()), + ) + .cloned() + .collect::>(); + artifact_requests.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + artifact_requests.dedup(); + + SccmSiteCoreAnalysis { + schema_version: SCCM_SITE_CORE_ANALYSIS_SCHEMA_VERSION, + workflow: SccmSiteCoreWorkflow::SiteCore, + profile: SccmSiteCoreProfile { + id: SCCM_SITE_CORE_PROFILE_ID.to_owned(), + version: SCCM_SITE_CORE_PROFILE_VERSION, + stability: SCCM_SITE_CORE_PROFILE_STABILITY.to_owned(), + }, + state_chain: STATE_CHAIN.to_vec(), + results, + unlinked_observations, + coverage_gaps: context.coverage_gaps, + findings, + artifact_requests, + cross_side_correlation_performed: false, + } +} + +fn admitted_sources<'a>( + intake: &'a SccmServerIntakeAssessment, + collision_artifact_ids: &BTreeSet, + evidence_source_rejections: &BTreeMap, + coverage_congruent: bool, +) -> BTreeMap<&'a str, AdmittedSource<'a>> { + let mut occurrences = BTreeMap::<&str, usize>::new(); + for artifact in &intake.artifacts { + *occurrences + .entry(artifact.artifact_id.as_str()) + .or_default() += 1; + } + + intake + .artifacts + .iter() + .filter_map(|artifact| { + let group = SiteCoreGroup::from_source_id(&artifact.source_id)?; + if occurrences.get(artifact.artifact_id.as_str()) != Some(&1) { + return None; + } + let shape_valid = source_shape_is_valid(artifact, group); + let rejection_reason = if artifact.producer_role != SccmRole::SiteServer + || artifact.workflow_subject_role.is_some() + || artifact.workflow_subject_handle.is_some() + { + Some("source-role-or-subject-rejected") + } else if !coverage_congruent { + Some("intake-coverage-incongruent") + } else if collision_artifact_ids.contains(&artifact.artifact_id) { + Some("evidence-identity-collision") + } else if let Some(reason_code) = evidence_source_rejections.get(&artifact.artifact_id) + { + Some(*reason_code) + } else if !shape_valid { + Some("source-shape-invalid") + } else if artifact.state != SccmCoverageState::Captured { + Some(coverage_rejection_reason(&artifact.state)) + } else if !source_carries_facts(artifact) { + Some("source-profile-or-provenance-unusable") + } else { + None + }; + Some(( + artifact.artifact_id.as_str(), + AdmittedSource { + artifact, + group, + fact_eligible: rejection_reason.is_none(), + rejection_reason, + }, + )) + }) + .collect() +} + +fn evidence_source_rejections( + intake: &SccmServerIntakeAssessment, +) -> (BTreeMap, Vec) { + let mut groups_by_artifact = BTreeMap::<&str, Vec>::new(); + let mut artifact_ids = BTreeSet::<&str>::new(); + for artifact in &intake.artifacts { + artifact_ids.insert(artifact.artifact_id.as_str()); + if let Some(group) = SiteCoreGroup::from_source_id(&artifact.source_id) { + groups_by_artifact + .entry(artifact.artifact_id.as_str()) + .or_default() + .push(group); + } + } + + let mut source_rejections = BTreeMap::::new(); + let mut unresolved_gaps = Vec::new(); + for evidence in &intake.evidence { + match groups_by_artifact.get(evidence.reference.artifact_id.as_str()) { + Some(groups) if groups.len() == 1 => { + if let Some(reason_code) = evidence_record_rejection_reason(evidence, groups[0]) { + source_rejections + .entry(evidence.reference.artifact_id.clone()) + .and_modify(|current| { + if reason_code < *current { + *current = reason_code; + } + }) + .or_insert(reason_code); + } + } + None if is_profile_record_candidate(&evidence.message) => { + let Some(group) = evidence_component_group(evidence.component.as_deref()) else { + continue; + }; + unresolved_gaps.push(SccmSiteCoreCoverageGap { + artifact_id: unresolved_coverage_artifact_id( + evidence, + artifact_ids.contains(evidence.reference.artifact_id.as_str()), + ), + source_id: group.source_id().to_owned(), + state: SccmCoverageState::ParseFailed, + reason_code: "evidence-source-unresolved".to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + _ => {} + } + } + sort_and_dedup_coverage_gaps(&mut unresolved_gaps); + (source_rejections, unresolved_gaps) +} + +fn evidence_record_rejection_reason( + evidence: &SccmEvidence, + source_group: SiteCoreGroup, +) -> Option<&'static str> { + if evidence.role != SccmRole::SiteServer { + return Some("evidence-role-rejected"); + } + if !reference_is_complete(evidence) { + return Some("evidence-reference-rejected"); + } + evidence_component_group(evidence.component.as_deref()) + .is_some_and(|evidence_group| evidence_group != source_group) + .then_some("evidence-source-attribution-rejected") +} + +fn evidence_component_group(component: Option<&str>) -> Option { + match component? { + "SMS_SITE_COMPONENT_MANAGER" | "SMS_HIERARCHY_MANAGER" => Some(SiteCoreGroup::Component), + "SMS_STATUS_MANAGER" | "SMS_STATE_SYSTEM" => Some(SiteCoreGroup::Status), + _ => None, + } +} + +fn unresolved_coverage_artifact_id(evidence: &SccmEvidence, is_foreign_source: bool) -> String { + if !is_foreign_source && safe_site_core_opaque_id(&evidence.reference.artifact_id) { + evidence.reference.artifact_id.clone() + } else { + stable_opaque_id( + "site-core:rejected-artifact:v1:", + &[&evidence.reference.artifact_id, &evidence.evidence_id], + ) + } +} + +fn coverage_rejection_reason(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "source-contract-rejected", + SccmCoverageState::Absent => "required-source-absent", + SccmCoverageState::AccessDenied => "required-source-access-denied", + SccmCoverageState::Capped => "required-source-capped", + SccmCoverageState::Skipped => "required-source-skipped", + SccmCoverageState::Unsupported => "required-source-unsupported", + SccmCoverageState::ParseFailed => "required-source-parse-failed", + } +} + +fn source_shape_is_valid(artifact: &SccmServerArtifactAssessment, group: SiteCoreGroup) -> bool { + let Some(basename) = artifact.original_basename.as_deref() else { + return false; + }; + let classified = classify_artifact_name(basename, SccmRole::SiteServer); + let validated_logical_source = match group { + SiteCoreGroup::Component => matches!(classified.logical_name.as_str(), "sitecomp" | "hman"), + SiteCoreGroup::Status => matches!(classified.logical_name.as_str(), "statmgr" | "statesys"), + }; + validated_logical_source + && safe_site_core_opaque_id(&artifact.artifact_id) + && safe_site_core_opaque_id(&artifact.rotation_lineage_handle) + && artifact.source_id == group.source_id() + && artifact.family == group.family() + && artifact.rotation.as_ref() == Some(&classified.rotation) + && classified.supported_for_diagnosis + && classified.family == group.family() + && classified.role == SccmRole::SiteServer + && artifact.parser_eligible + && artifact + .producer_host_handle + .as_deref() + .is_some_and(|host| { + !host.is_empty() + && host.len() <= 256 + && host.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-' | b'_') + }) + }) +} + +fn expected_evidence_component(artifact: &SccmServerArtifactAssessment) -> Option<&'static str> { + let basename = artifact.original_basename.as_deref()?; + let classified = classify_artifact_name(basename, SccmRole::SiteServer); + Some(match classified.logical_name.as_str() { + "sitecomp" => "SMS_SITE_COMPONENT_MANAGER", + "hman" => "SMS_HIERARCHY_MANAGER", + "statmgr" => "SMS_STATUS_MANAGER", + "statesys" => "SMS_STATE_SYSTEM", + _ => return None, + }) +} + +fn source_carries_facts(artifact: &SccmServerArtifactAssessment) -> bool { + let provenance_is_usable = artifact + .capture_provenance + .as_ref() + .is_some_and(|provenance| { + provenance.schema_version == 1 + && provenance.encoding == "utf-8" + && !provenance.limit_applied + && provenance.byte_limit >= artifact.bytes_copied + && provenance.byte_limit > 0 + }); + artifact.state == SccmCoverageState::Captured + && artifact.profile_eligible + && artifact.source_version.as_deref() == Some(SITE_CORE_PROFILE_VERSION_TOKEN) + && artifact.fragment_complete != Some(false) + && artifact.truncated != Some(true) + && artifact.bytes_copied > 0 + && artifact.relative_path.is_some() + && artifact.content_sha256.as_deref().is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + && provenance_is_usable +} + +fn coverage_gap_ids_for_key( + context: &SiteCoreContext<'_>, + key: &SccmSiteCoreTransactionKey, +) -> Vec { + context + .coverage_gaps + .iter() + .filter(|gap| { + context + .coverage_gap_producer_hosts + .get(&gap.artifact_id) + .is_some_and(|producer| producer == &key.producer_host_handle) + || context + .sources + .get(gap.artifact_id.as_str()) + .is_some_and(|source| { + source.artifact.producer_host_handle.as_deref() + == Some(key.producer_host_handle.as_str()) + }) + }) + .map(|gap| gap.artifact_id.clone()) + .collect() +} + +fn collect_coverage_gaps( + intake: &SccmServerIntakeAssessment, + sources: &BTreeMap<&str, AdmittedSource<'_>>, +) -> Vec { + let mut occurrences = BTreeMap::<&str, usize>::new(); + for artifact in &intake.artifacts { + if SiteCoreGroup::from_source_id(&artifact.source_id).is_some() { + *occurrences + .entry(artifact.artifact_id.as_str()) + .or_default() += 1; + } + } + + let mut gaps = Vec::new(); + for artifact in &intake.artifacts { + let Some(_group) = SiteCoreGroup::from_source_id(&artifact.source_id) else { + continue; + }; + let duplicate_identity = occurrences.get(artifact.artifact_id.as_str()) != Some(&1); + let source = sources.get(artifact.artifact_id.as_str()); + if !duplicate_identity + && source.is_some_and(|source| { + source.fact_eligible || absent_default_is_superseded(source.artifact, sources) + }) + { + continue; + } + let reason_code = if duplicate_identity { + "duplicate-source-identity" + } else { + source + .and_then(|source| source.rejection_reason) + .unwrap_or("source-contract-rejected") + }; + let state = if duplicate_identity + || matches!( + reason_code, + "source-role-or-subject-rejected" + | "intake-coverage-incongruent" + | "evidence-identity-collision" + | "evidence-reference-rejected" + | "evidence-role-rejected" + | "evidence-source-attribution-rejected" + | "source-shape-invalid" + | "source-contract-rejected" + ) { + SccmCoverageState::ParseFailed + } else if artifact.state == SccmCoverageState::Captured { + if artifact.fragment_complete == Some(false) || artifact.truncated == Some(true) { + SccmCoverageState::ParseFailed + } else { + SccmCoverageState::Unsupported + } + } else { + artifact.state.clone() + }; + let artifact_id = if safe_site_core_opaque_id(&artifact.artifact_id) { + artifact.artifact_id.clone() + } else { + stable_opaque_id( + "site-core:rejected-artifact:v1:", + &[&artifact.artifact_id, &artifact.source_id], + ) + }; + gaps.push(SccmSiteCoreCoverageGap { + artifact_id, + source_id: artifact.source_id.clone(), + state, + reason_code: reason_code.to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + sort_and_dedup_coverage_gaps(&mut gaps); + gaps +} + +fn sort_and_dedup_coverage_gaps(gaps: &mut Vec) { + gaps.sort_by(|left, right| { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| left.source_id.cmp(&right.source_id)) + .then_with(|| coverage_sort_key(&left.state).cmp(coverage_sort_key(&right.state))) + .then_with(|| left.reason_code.cmp(&right.reason_code)) + }); + gaps.dedup_by(|left, right| { + left.artifact_id == right.artifact_id + && left.source_id == right.source_id + && left.state == right.state + && left.reason_code == right.reason_code + }); +} + +fn absent_default_is_superseded( + artifact: &SccmServerArtifactAssessment, + sources: &BTreeMap<&str, AdmittedSource<'_>>, +) -> bool { + artifact.state == SccmCoverageState::Absent + && artifact.configured_path_state == SccmServerConfiguredPathState::DefaultCandidate + && sources.values().any(|candidate| { + candidate.fact_eligible + && candidate.artifact.artifact_id != artifact.artifact_id + && candidate.artifact.source_id == artifact.source_id + && candidate.artifact.producer_role == artifact.producer_role + && candidate.artifact.producer_host_handle == artifact.producer_host_handle + && candidate.artifact.workflow_subject_role == artifact.workflow_subject_role + && candidate.artifact.workflow_subject_handle == artifact.workflow_subject_handle + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FactOutcome { + Succeeded, + Failed, + Deferred, +} + +impl FactOutcome { + fn token(self) -> &'static str { + match self { + Self::Succeeded => "success", + Self::Failed => "failure", + Self::Deferred => "deferred", + } + } +} + +#[derive(Debug, Clone, Copy)] +struct StatusMarker { + phase: SccmSiteCorePhase, + outcome: FactOutcome, + terminal: bool, + recovery: bool, + group: SiteCoreGroup, +} + +fn status_marker(value: &str) -> Option { + Some(match value { + "SC_COMPONENT_START_OK" => StatusMarker { + phase: SccmSiteCorePhase::ComponentStart, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_COMPONENT_WORK_OK" => StatusMarker { + phase: SccmSiteCorePhase::ComponentWork, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_INBOX_ACCEPTED" => StatusMarker { + phase: SccmSiteCorePhase::InboxOrQueue, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_INBOX_BACKLOG" => StatusMarker { + phase: SccmSiteCorePhase::InboxOrQueue, + outcome: FactOutcome::Deferred, + terminal: false, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_COMPONENT_TERMINAL_FAILURE" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Failed, + terminal: true, + recovery: false, + group: SiteCoreGroup::Component, + }, + "SC_STATUS_PROCESSING_OK" => StatusMarker { + phase: SccmSiteCorePhase::StatusOrStateProcessing, + outcome: FactOutcome::Succeeded, + terminal: false, + recovery: false, + group: SiteCoreGroup::Status, + }, + "SC_STATUS_TERMINAL_FAILURE" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Failed, + terminal: true, + recovery: false, + group: SiteCoreGroup::Status, + }, + "SC_COMPONENT_HEALTHY" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Succeeded, + terminal: true, + recovery: false, + group: SiteCoreGroup::Status, + }, + "SC_COMPONENT_RECOVERED" => StatusMarker { + phase: SccmSiteCorePhase::HealthyOrTerminal, + outcome: FactOutcome::Succeeded, + terminal: true, + recovery: true, + group: SiteCoreGroup::Status, + }, + _ => return None, + }) +} + +#[derive(Debug, Clone)] +struct SiteCoreFact { + key: SccmSiteCoreTransactionKey, + marker: StatusMarker, + reference: SccmEvidenceRef, + timestamp: SccmTimestamp, +} + +impl SiteCoreFact { + fn ordering_millis(&self) -> Option { + (self.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc) + .then_some(self.timestamp.utc_millis) + .flatten() + } + + fn public_evidence(&self) -> SccmSiteCoreEvidence { + SccmSiteCoreEvidence { + artifact_id: self.reference.artifact_id.clone(), + entry_id: self.reference.entry_id.clone(), + line_start: self.reference.line_start.unwrap_or_default(), + line_end: self.reference.line_end.unwrap_or_default(), + terminal: match self.marker.outcome { + FactOutcome::Failed if self.marker.terminal => Some(true), + FactOutcome::Deferred => Some(false), + _ if self.marker.recovery => Some(true), + _ => None, + }, + recovery: self.marker.recovery.then_some(true), + complete_logical_record: None, + } + } +} + +enum ProfileRecordParse { + NotCandidate, + Rejected(&'static str), + Accepted(Box), +} + +fn parse_fact( + evidence: &SccmEvidence, + source: &AdmittedSource<'_>, + site_handle: &str, +) -> ProfileRecordParse { + let message = evidence.message.as_str(); + if !is_profile_record_candidate(message) { + return ProfileRecordParse::NotCandidate; + } + if evidence.component.as_deref() != expected_evidence_component(source.artifact) { + return ProfileRecordParse::Rejected("profile-component-source-mismatch"); + } + if !profile_labels_are_closed(message) { + return ProfileRecordParse::Rejected("profile-field-schema-rejected"); + } + let Some(profile_id) = token_value(message, "profileId") else { + return ProfileRecordParse::Rejected("profile-identity-missing"); + }; + let Some(profile_version) = token_value(message, "profileVersion") else { + return ProfileRecordParse::Rejected("profile-version-missing"); + }; + let Some(site) = token_value(message, "site") else { + return ProfileRecordParse::Rejected("profile-site-missing"); + }; + if profile_id != SCCM_SITE_CORE_PROFILE_ID + || profile_version != SCCM_SITE_CORE_PROFILE_VERSION.to_string() + || site_handle != "synthetic:site:lab" + || site != "LAB" + { + return ProfileRecordParse::Rejected("profile-identity-rejected"); + } + + let Some(component_id) = + token_value(message, "componentId").and_then(|value| validated_component_id(&value)) + else { + return ProfileRecordParse::Rejected("profile-component-id-rejected"); + }; + let Some(work_item_id) = + token_value(message, "workItemId").and_then(|value| validated_work_item_id(&value)) + else { + return ProfileRecordParse::Rejected("profile-work-item-id-rejected"); + }; + let Some(marker) = token_value(message, "statusId").and_then(|value| status_marker(&value)) + else { + return ProfileRecordParse::Rejected("profile-status-id-rejected"); + }; + let Some(outcome) = token_value(message, "outcome") else { + return ProfileRecordParse::Rejected("profile-outcome-missing"); + }; + let Some(terminal) = token_value(message, "terminal") else { + return ProfileRecordParse::Rejected("profile-terminal-missing"); + }; + if marker.group != source.group + || outcome != marker.outcome.token() + || terminal != if marker.terminal { "true" } else { "false" } + || !queue_depth_matches_marker(message, marker) + { + return ProfileRecordParse::Rejected("profile-status-schema-rejected"); + } + + ProfileRecordParse::Accepted(Box::new(SiteCoreFact { + key: SccmSiteCoreTransactionKey { + profile_id: SCCM_SITE_CORE_PROFILE_ID.to_owned(), + profile_version: SCCM_SITE_CORE_PROFILE_VERSION, + site_handle: site_handle.to_owned(), + producer_host_handle: source + .artifact + .producer_host_handle + .clone() + .expect("fact-eligible sources have a validated producer host"), + component_id, + work_item_id, + }, + marker, + reference: evidence.reference.clone(), + timestamp: evidence.timestamp.clone(), + })) +} + +fn reduce_transaction( + key: SccmSiteCoreTransactionKey, + facts: &[SiteCoreFact], + context: &SiteCoreContext<'_>, + coverage_gap_artifact_ids: &[String], +) -> SccmSiteCoreResult { + let comparable = facts.iter().all(|fact| fact.ordering_millis().is_some()); + let contradictory = comparable && has_same_instant_conflict(facts); + let successes = facts + .iter() + .filter(|fact| fact.marker.outcome == FactOutcome::Succeeded) + .collect::>(); + let last_successful_phase = facts + .iter() + .rev() + .find(|fact| fact.marker.outcome == FactOutcome::Succeeded) + .map(|fact| fact.marker.phase); + let last_terminal = facts.iter().rev().find(|fact| fact.marker.terminal); + let has_prior_failure = last_terminal.is_some_and(|terminal| { + terminal.marker.outcome == FactOutcome::Succeeded + && facts.iter().any(|fact| { + fact.marker.terminal + && fact.marker.outcome == FactOutcome::Failed + && fact + .ordering_millis() + .zip(terminal.ordering_millis()) + .is_some_and(|(failure, recovery)| failure < recovery) + }) + }); + let has_deferred = has_unrecovered_deferred(facts); + let has_component_progress = successes.iter().any(|fact| { + matches!( + fact.marker.phase, + SccmSiteCorePhase::ComponentStart | SccmSiteCorePhase::ComponentWork + ) + }); + let terminal_is_last = last_terminal.is_some_and(|terminal| { + terminal.ordering_millis().is_some_and(|terminal_time| { + facts.iter().all(|fact| { + std::ptr::eq(fact, terminal) + || fact + .ordering_millis() + .is_some_and(|fact_time| fact_time < terminal_time) + }) + }) + }); + let success_progress_is_ordered = + last_terminal.is_some_and(|terminal| observed_success_progress_is_ordered(facts, terminal)); + let full_success_chain = + last_terminal.is_some_and(|terminal| complete_success_chain_is_ordered(facts, terminal)); + + let (state, finding_class, confidence) = if contradictory { + ( + SccmSiteCoreState::Contradictory, + Some(SccmFindingClass::Symptom), + SccmSiteCoreConfidence::Low, + ) + } else if !comparable { + ( + SccmSiteCoreState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + SccmSiteCoreConfidence::None, + ) + } else if let Some(terminal) = last_terminal { + match terminal.marker.outcome { + FactOutcome::Failed + if terminal_is_last && has_component_progress && success_progress_is_ordered => + { + ( + SccmSiteCoreState::TerminalFailure, + Some(SccmFindingClass::ConfirmedFailure), + SccmSiteCoreConfidence::High, + ) + } + FactOutcome::Succeeded + if terminal_is_last && terminal.marker.recovery && has_prior_failure => + { + ( + SccmSiteCoreState::Recovered, + Some(SccmFindingClass::Symptom), + SccmSiteCoreConfidence::High, + ) + } + FactOutcome::Succeeded + if terminal_is_last && !terminal.marker.recovery && full_success_chain => + { + ( + SccmSiteCoreState::Healthy, + None, + SccmSiteCoreConfidence::High, + ) + } + _ => ( + SccmSiteCoreState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + SccmSiteCoreConfidence::None, + ), + } + } else if has_deferred { + ( + SccmSiteCoreState::BlockedOrDeferred, + Some(SccmFindingClass::BlockedOrDeferred), + SccmSiteCoreConfidence::Low, + ) + } else { + ( + SccmSiteCoreState::Incomplete, + Some(SccmFindingClass::InsufficientEvidence), + SccmSiteCoreConfidence::None, + ) + }; + + let mut evidence = facts + .iter() + .map(SiteCoreFact::public_evidence) + .collect::>(); + evidence.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())); + evidence.dedup(); + let next_artifacts = next_artifacts_for_state(state, &key, facts, context); + let result_id = stable_opaque_id( + "site-core:result:v1:", + &[ + &key.site_handle, + &key.producer_host_handle, + &key.component_id, + &key.work_item_id, + ], + ); + + SccmSiteCoreResult { + result_id, + transaction_key: key, + state, + last_successful_phase, + finding_class, + confidence, + confidence_ceiling: confidence, + evidence, + coverage_gap_artifact_ids: coverage_gap_artifact_ids.to_vec(), + next_artifacts, + } +} + +fn has_unrecovered_deferred(facts: &[SiteCoreFact]) -> bool { + facts + .iter() + .filter(|fact| fact.marker.outcome == FactOutcome::Deferred) + .any(|deferred| { + let Some(deferred_time) = deferred.ordering_millis() else { + return true; + }; + !facts.iter().any(|candidate| { + candidate.marker.phase == deferred.marker.phase + && candidate.marker.outcome == FactOutcome::Succeeded + && candidate + .ordering_millis() + .is_some_and(|success_time| success_time > deferred_time) + }) + }) +} + +fn observed_success_progress_is_ordered(facts: &[SiteCoreFact], terminal: &SiteCoreFact) -> bool { + let Some(terminal_time) = terminal.ordering_millis() else { + return false; + }; + let mut previous_phase = None; + let mut observed = false; + for fact in facts.iter().filter(|fact| { + !std::ptr::eq(*fact, terminal) && fact.marker.outcome == FactOutcome::Succeeded + }) { + let Some(instant) = fact.ordering_millis() else { + return false; + }; + if instant >= terminal_time || previous_phase.is_some_and(|phase| fact.marker.phase < phase) + { + return false; + } + previous_phase = Some(fact.marker.phase); + observed = true; + } + observed +} + +fn complete_success_chain_is_ordered(facts: &[SiteCoreFact], terminal: &SiteCoreFact) -> bool { + let Some(terminal_time) = terminal.ordering_millis() else { + return false; + }; + let mut previous_time = None; + for phase in &STATE_CHAIN[..4] { + let Some(instant) = facts + .iter() + .filter(|fact| { + fact.marker.outcome == FactOutcome::Succeeded && fact.marker.phase == *phase + }) + .filter_map(SiteCoreFact::ordering_millis) + .find(|instant| { + *instant < terminal_time && previous_time.is_none_or(|previous| *instant > previous) + }) + else { + return false; + }; + previous_time = Some(instant); + } + true +} + +fn has_same_instant_conflict(facts: &[SiteCoreFact]) -> bool { + let mut outcomes = BTreeMap::<(i64, SccmSiteCorePhase), FactOutcome>::new(); + facts.iter().any(|fact| { + let Some(instant) = fact.ordering_millis() else { + return false; + }; + outcomes + .insert((instant, fact.marker.phase), fact.marker.outcome) + .is_some_and(|previous| previous != fact.marker.outcome) + }) +} + +fn next_artifacts_for_state( + state: SccmSiteCoreState, + key: &SccmSiteCoreTransactionKey, + facts: &[SiteCoreFact], + context: &SiteCoreContext<'_>, +) -> Vec { + if state == SccmSiteCoreState::BlockedOrDeferred { + return vec![status_request( + "matching-status-terminal-evidence-missing", + key, + )]; + } + if state != SccmSiteCoreState::Incomplete { + return Vec::new(); + } + + if let Some(source) = context.sources.values().find(|source| { + source.artifact.state == SccmCoverageState::Capped + && facts + .iter() + .any(|fact| fact.reference.artifact_id == source.artifact.artifact_id) + }) { + return recapture_request(source.artifact, Some(key)) + .into_iter() + .collect(); + } + if facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Component) + && !facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Status) + { + return vec![status_request("matching-status-evidence-missing", key)]; + } + if facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Status) + && !facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Component) + { + return vec![component_request( + "matching-component-evidence-missing", + key, + )]; + } + Vec::new() +} + +fn status_request( + reason_code: &str, + key: &SccmSiteCoreTransactionKey, +) -> SccmSiteCoreArtifactRequest { + matching_group_request(SiteCoreGroup::Status, reason_code, key) +} + +fn component_request( + reason_code: &str, + key: &SccmSiteCoreTransactionKey, +) -> SccmSiteCoreArtifactRequest { + matching_group_request(SiteCoreGroup::Component, reason_code, key) +} + +fn matching_group_request( + group: SiteCoreGroup, + reason_code: &str, + key: &SccmSiteCoreTransactionKey, +) -> SccmSiteCoreArtifactRequest { + group_request( + group, + reason_code, + SccmSiteCoreRequestScope { + producer_host_handle: Some(key.producer_host_handle.clone()), + component_id: Some(key.component_id.clone()), + work_item_id: Some(key.work_item_id.clone()), + rotation_lineage_handle: None, + }, + ) +} + +fn recapture_request( + artifact: &SccmServerArtifactAssessment, + key: Option<&SccmSiteCoreTransactionKey>, +) -> Option { + let candidate = request_candidate(artifact)?; + let scope = request_scope_for_artifact(artifact, key)?; + let current_limit = artifact + .capture_provenance + .as_ref() + .map(|provenance| provenance.byte_limit) + .unwrap_or(RECAPTURE_FLOOR_BYTES); + let requested = current_limit.saturating_mul(2).max(RECAPTURE_FLOOR_BYTES); + let bounded = requested.checked_next_power_of_two().unwrap_or(1u64 << 63); + Some(SccmSiteCoreArtifactRequest { + logical_name: artifact.source_id.clone(), + role: SccmRole::SiteServer, + reason_code: "capped-before-next-phase".to_owned(), + candidates: vec![candidate], + max_artifacts: 1, + max_bytes_per_artifact: Some(bounded), + scope, + }) +} + +fn coverage_observations( + gaps: &[SccmSiteCoreCoverageGap], + context: &SiteCoreContext<'_>, +) -> Vec { + gaps.iter() + .map(|gap| { + let request = coverage_request(gap, context).into_iter().collect(); + SccmSiteCoreObservation { + observation_id: stable_opaque_id( + "site-core:observation:v1:", + &[ + &gap.artifact_id, + &gap.source_id, + coverage_sort_key(&gap.state), + &gap.reason_code, + ], + ), + state: SccmSiteCoreState::ParseGap, + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmSiteCoreConfidence::None, + evidence: Vec::new(), + coverage_gap_artifact_ids: vec![gap.artifact_id.clone()], + next_artifacts: request, + } + }) + .collect() +} + +fn rejected_record_observation( + evidence: &SccmEvidence, + source: &AdmittedSource<'_>, + reason_code: &str, +) -> SccmSiteCoreObservation { + let retained_evidence = reference_is_complete(evidence).then(|| SccmSiteCoreEvidence { + artifact_id: evidence.reference.artifact_id.clone(), + entry_id: evidence.reference.entry_id.clone(), + line_start: evidence + .reference + .line_start + .expect("complete reference start"), + line_end: evidence.reference.line_end.expect("complete reference end"), + terminal: None, + recovery: None, + complete_logical_record: Some(true), + }); + let request = complete_source_request(source.artifact, reason_code).or_else(|| { + request_scope_for_artifact(source.artifact, None) + .map(|scope| group_request(source.group, reason_code, scope)) + }); + SccmSiteCoreObservation { + observation_id: stable_opaque_id( + "site-core:observation:v1:", + &[ + &evidence.reference.artifact_id, + &evidence.reference.entry_id, + reason_code, + ], + ), + state: SccmSiteCoreState::ParseGap, + finding_class: SccmFindingClass::Symptom, + confidence: SccmSiteCoreConfidence::Low, + evidence: retained_evidence.into_iter().collect(), + coverage_gap_artifact_ids: Vec::new(), + next_artifacts: request.into_iter().collect(), + } +} + +fn coverage_request( + gap: &SccmSiteCoreCoverageGap, + context: &SiteCoreContext<'_>, +) -> Option { + let group = SiteCoreGroup::from_source_id(&gap.source_id)?; + if let Some(producer_host_handle) = context.coverage_gap_producer_hosts.get(&gap.artifact_id) { + let scope = SccmSiteCoreRequestScope { + producer_host_handle: Some(producer_host_handle.clone()), + component_id: None, + work_item_id: None, + rotation_lineage_handle: None, + }; + return request_scope_is_specific(&scope) + .then(|| group_request(group, &gap.reason_code, scope)); + } + if let Some(source) = context.sources.get(gap.artifact_id.as_str()) { + if gap.state == SccmCoverageState::Capped { + if let Some(request) = recapture_request(source.artifact, None) { + return Some(request); + } + } else if let Some(request) = complete_source_request(source.artifact, &gap.reason_code) { + return Some(request); + } + let scope = request_scope_for_artifact(source.artifact, None)?; + return Some(group_request(group, &gap.reason_code, scope)); + } + let scope = request_scope_for_gap(gap, context)?; + Some(group_request(group, &gap.reason_code, scope)) +} + +fn complete_source_request( + artifact: &SccmServerArtifactAssessment, + reason_code: &str, +) -> Option { + let candidate = request_candidate(artifact)?; + let scope = request_scope_for_artifact(artifact, None)?; + Some(SccmSiteCoreArtifactRequest { + logical_name: artifact.source_id.clone(), + role: SccmRole::SiteServer, + reason_code: reason_code.to_owned(), + candidates: vec![candidate], + max_artifacts: 1, + max_bytes_per_artifact: None, + scope, + }) +} + +fn group_request( + group: SiteCoreGroup, + reason_code: &str, + scope: SccmSiteCoreRequestScope, +) -> SccmSiteCoreArtifactRequest { + let stem = match group { + SiteCoreGroup::Component => "sitecomp", + SiteCoreGroup::Status => "statmgr", + }; + SccmSiteCoreArtifactRequest { + logical_name: group.source_id().to_owned(), + role: SccmRole::SiteServer, + reason_code: reason_code.to_owned(), + candidates: vec![ + SccmSiteCoreArtifactCandidate { + basename: format!("{stem}.log"), + rotation: "current".to_owned(), + }, + SccmSiteCoreArtifactCandidate { + basename: format!("{stem}.lo_"), + rotation: "loUnderscore".to_owned(), + }, + ], + max_artifacts: MAX_SITE_CORE_REQUEST_ARTIFACTS, + max_bytes_per_artifact: None, + scope, + } +} + +fn request_scope_for_gap( + gap: &SccmSiteCoreCoverageGap, + context: &SiteCoreContext<'_>, +) -> Option { + let exact_artifacts = context + .artifacts + .iter() + .filter(|artifact| { + artifact.artifact_id == gap.artifact_id && artifact.source_id == gap.source_id + }) + .collect::>(); + let artifacts = if exact_artifacts.is_empty() { + context + .artifacts + .iter() + .filter(|artifact| artifact.source_id == gap.source_id) + .collect() + } else { + exact_artifacts + }; + consensus_request_scope(&artifacts) +} + +fn request_scope_for_artifact( + artifact: &SccmServerArtifactAssessment, + key: Option<&SccmSiteCoreTransactionKey>, +) -> Option { + let producer_host_handle = key + .map(|key| key.producer_host_handle.as_str()) + .or(artifact.producer_host_handle.as_deref()) + .filter(|value| safe_site_core_opaque_id(value)) + .map(str::to_owned); + let component_id = key + .and_then(|key| validated_component_id(&key.component_id)) + .filter(|value| safe_site_core_opaque_id(value)); + let work_item_id = key + .and_then(|key| validated_work_item_id(&key.work_item_id)) + .filter(|value| safe_site_core_opaque_id(value)); + let rotation_lineage_handle = safe_site_core_opaque_id(&artifact.rotation_lineage_handle) + .then(|| artifact.rotation_lineage_handle.clone()); + let scope = SccmSiteCoreRequestScope { + producer_host_handle, + component_id, + work_item_id, + rotation_lineage_handle, + }; + request_scope_is_specific(&scope).then_some(scope) +} + +fn consensus_request_scope( + artifacts: &[&SccmServerArtifactAssessment], +) -> Option { + let producer_host_handle = consensus_scope_value(artifacts, |artifact| { + artifact.producer_host_handle.as_deref() + }); + let rotation_lineage_handle = consensus_scope_value(artifacts, |artifact| { + Some(artifact.rotation_lineage_handle.as_str()) + }); + let scope = SccmSiteCoreRequestScope { + producer_host_handle, + component_id: None, + work_item_id: None, + rotation_lineage_handle, + }; + request_scope_is_specific(&scope).then_some(scope) +} + +fn consensus_scope_value( + artifacts: &[&SccmServerArtifactAssessment], + value: impl Fn(&SccmServerArtifactAssessment) -> Option<&str>, +) -> Option { + let first = value(*artifacts.first()?)?; + (safe_site_core_opaque_id(first) + && artifacts.iter().all(|artifact| { + value(artifact) + .is_some_and(|candidate| candidate == first && safe_site_core_opaque_id(candidate)) + })) + .then(|| first.to_owned()) +} + +fn request_scope_is_specific(scope: &SccmSiteCoreRequestScope) -> bool { + scope + .producer_host_handle + .as_deref() + .is_some_and(safe_site_core_opaque_id) + || scope + .component_id + .as_deref() + .is_some_and(safe_site_core_opaque_id) + || scope + .work_item_id + .as_deref() + .is_some_and(safe_site_core_opaque_id) + || scope + .rotation_lineage_handle + .as_deref() + .is_some_and(safe_site_core_opaque_id) +} + +fn request_candidate( + artifact: &SccmServerArtifactAssessment, +) -> Option { + let basename = artifact.original_basename.as_ref()?; + let rotation = artifact.rotation.as_ref()?; + let classified = classify_artifact_name(basename, SccmRole::SiteServer); + (classified.supported_for_diagnosis + && classified.role == SccmRole::SiteServer + && classified.family == artifact.family + && &classified.rotation == rotation) + .then(|| SccmSiteCoreArtifactCandidate { + basename: basename.clone(), + rotation: rotation_name(rotation).expect("classified rotations are declared"), + }) +} + +fn build_result_finding( + result: &SccmSiteCoreResult, + class: SccmFindingClass, + facts: &[SiteCoreFact], + context: &SiteCoreContext<'_>, +) -> Option { + let terminal_evidence = if class == SccmFindingClass::ConfirmedFailure { + facts + .iter() + .rev() + .find(|fact| fact.marker.terminal && fact.marker.outcome == FactOutcome::Failed) + .map(|fact| { + vec![SccmTerminalEvidence::observed_failure( + fact.reference.clone(), + )] + }) + .unwrap_or_default() + } else { + Vec::new() + }; + let finding = SccmFindingBuilder::new(stable_opaque_id( + "site-core:finding:v1:", + &[&result.result_id], + )) + .class(class) + .phase(SccmPhase::Unknown( + result + .last_successful_phase + .map(SccmSiteCorePhase::serialized_name) + .unwrap_or("siteCoreUnconfirmed") + .to_owned(), + )) + .role(SccmRole::SiteServer) + .severity(if result.state == SccmSiteCoreState::TerminalFailure { + Severity::Error + } else { + Severity::Warning + }) + .confidence(shared_confidence(result.confidence)) + .title("Site component and status evidence") + .summary(match result.last_successful_phase { + Some(phase) => format!( + "The last confirmed successful phase is {}; later phases are bounded to cited evidence.", + phase.serialized_name() + ), + None => "No site component phase is confirmed by the cited evidence.".to_owned(), + }) + .evidence( + result + .evidence + .iter() + .map(SccmSiteCoreEvidence::reference) + .collect(), + ) + .terminal_evidence(terminal_evidence) + .coverage_gaps(finding_gaps( + &result.coverage_gap_artifact_ids, + context, + )) + .next_artifacts(shared_requests(&result.next_artifacts)) + .build() + .ok()?; + Some(SccmSiteCoreFinding { + finding, + subject_id: result.result_id.clone(), + last_successful_phase: result.last_successful_phase, + }) +} + +fn build_observation_finding( + observation: &SccmSiteCoreObservation, + context: &SiteCoreContext<'_>, +) -> Option { + let is_coverage_gap = !observation.coverage_gap_artifact_ids.is_empty(); + let (phase, title, summary) = if is_coverage_gap { + ( + "siteCoreCoverage", + "Site core coverage gap", + "The source is incomplete and cannot establish a component outcome.", + ) + } else { + ( + "siteCoreProfile", + "Unrecognized site core profile record", + "A source-local record was retained as a symptom but did not match the selected extraction profile.", + ) + }; + let finding = SccmFindingBuilder::new(stable_opaque_id( + "site-core:finding:v1:", + &[&observation.observation_id], + )) + .class(observation.finding_class.clone()) + .phase(SccmPhase::Unknown(phase.to_owned())) + .role(SccmRole::SiteServer) + .severity(Severity::Warning) + .confidence(shared_confidence(observation.confidence)) + .title(title) + .summary(summary) + .evidence( + observation + .evidence + .iter() + .map(SccmSiteCoreEvidence::reference) + .collect(), + ) + .coverage_gaps(finding_gaps( + &observation.coverage_gap_artifact_ids, + context, + )) + .next_artifacts(shared_requests(&observation.next_artifacts)) + .build() + .ok()?; + Some(SccmSiteCoreFinding { + finding, + subject_id: observation.observation_id.clone(), + last_successful_phase: None, + }) +} + +fn finding_gaps( + artifact_ids: &[String], + context: &SiteCoreContext<'_>, +) -> Vec { + context + .coverage_gaps + .iter() + .filter(|gap| artifact_ids.contains(&gap.artifact_id)) + .map(|gap| SccmFindingCoverageGap { + artifact_id: gap.artifact_id.clone(), + role: SccmRole::SiteServer, + coverage: gap.state.clone(), + }) + .collect() +} + +fn shared_requests(requests: &[SccmSiteCoreArtifactRequest]) -> Vec { + let mut shared = requests + .iter() + .flat_map(|request| request.candidates.iter()) + .filter_map(|candidate| { + let classified = classify_artifact_name(&candidate.basename, SccmRole::SiteServer); + classified + .supported_for_diagnosis + .then(|| SccmArtifactRequest { + logical_id: classified.logical_name, + role: SccmRole::SiteServer, + reason: format!("Collect the complete {} file.", classified.basename), + }) + }) + .collect::>(); + shared.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then_with(|| left.reason.cmp(&right.reason)) + }); + shared.dedup(); + shared +} + +fn shared_confidence(confidence: SccmSiteCoreConfidence) -> SccmConfidence { + match confidence { + SccmSiteCoreConfidence::None => SccmConfidence::None, + SccmSiteCoreConfidence::Low => SccmConfidence::Low, + SccmSiteCoreConfidence::Moderate => SccmConfidence::Moderate, + SccmSiteCoreConfidence::High => SccmConfidence::High, + } +} + +fn validated_token_value(message: &str, label: &str) -> Option> { + let lowercase = message.to_ascii_lowercase(); + let needle = format!("{}=", label.to_ascii_lowercase()); + let mut value = None; + for (label_start, _) in lowercase.match_indices(&needle) { + let exact_boundary = label_start == 0 + || message[..label_start] + .chars() + .next_back() + .is_some_and(is_token_boundary); + if !exact_boundary { + return None; + } + let remainder = &message[label_start + needle.len()..]; + let end = remainder.find(is_token_boundary).unwrap_or(remainder.len()); + if end == 0 || value.replace(remainder[..end].to_owned()).is_some() { + return None; + } + } + Some(value) +} + +fn token_value(message: &str, label: &str) -> Option { + validated_token_value(message, label)? +} + +fn is_token_boundary(character: char) -> bool { + character.is_whitespace() || matches!(character, ',' | ';' | '&') +} + +fn is_profile_record_candidate(message: &str) -> bool { + let lowercase = message.to_ascii_lowercase(); + lowercase.contains("profileid=") || lowercase.contains("statusid=sc_") +} + +fn profile_labels_are_closed(message: &str) -> bool { + message.split(is_token_boundary).all(|token| { + let Some((label, _)) = token.split_once('=') else { + return true; + }; + matches!( + label.to_ascii_lowercase().as_str(), + "profileid" + | "profileversion" + | "site" + | "componentid" + | "workitemid" + | "statusid" + | "outcome" + | "terminal" + | "queuedepth" + ) + }) +} + +fn validated_component_id(value: &str) -> Option { + matches!(value, "SMS_EXECUTIVE" | "SMS_DISTRIBUTION_MANAGER").then(|| value.to_owned()) +} + +fn validated_work_item_id(value: &str) -> Option { + let suffix = value.strip_prefix("SC-")?; + (!suffix.is_empty() + && value.len() <= 64 + && suffix.split('-').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + })) + .then(|| value.to_owned()) +} + +fn queue_depth_matches_marker(message: &str, marker: StatusMarker) -> bool { + let Some(queue_depth) = validated_token_value(message, "queueDepth") else { + return false; + }; + match (marker.outcome, queue_depth) { + (FactOutcome::Deferred, Some(value)) => value + .parse::() + .is_ok_and(|depth| (1..=1_000_000).contains(&depth)), + (FactOutcome::Deferred, None) => false, + (_, None) => true, + (_, Some(_)) => false, + } +} + +fn reference_is_complete(evidence: &SccmEvidence) -> bool { + safe_site_core_opaque_id(&evidence.evidence_id) + && safe_site_core_opaque_id(&evidence.reference.artifact_id) + && safe_site_core_opaque_id(&evidence.reference.entry_id) + && evidence.evidence_id == evidence.reference.entry_id + && matches!( + (evidence.reference.line_start, evidence.reference.line_end), + (Some(start), Some(end)) if start > 0 && end >= start + ) +} + +fn unique_evidence_identities(evidence: &[SccmEvidence]) -> Vec { + let mut unique = vec![true; evidence.len()]; + mark_repeated_keys( + &mut unique, + evidence.iter().map(|record| record.evidence_id.as_str()), + ); + mark_repeated_keys( + &mut unique, + evidence + .iter() + .map(|record| record.reference.entry_id.as_str()), + ); + mark_overlapping_ranges(&mut unique, evidence); + unique +} + +fn evidence_collision_artifact_ids( + evidence: &[SccmEvidence], + identity_is_unique: &[bool], +) -> BTreeSet { + evidence + .iter() + .zip(identity_is_unique) + .filter(|(_, unique)| !**unique) + .map(|(record, _)| record.reference.artifact_id.clone()) + .collect() +} + +fn site_core_coverage_is_congruent(intake: &SccmServerIntakeAssessment) -> bool { + type CoverageKey = (String, String, String, String); + + let mut expected = BTreeMap::>::new(); + for artifact in &intake.artifacts { + if SiteCoreGroup::from_source_id(&artifact.source_id).is_none() { + continue; + } + expected + .entry(( + role_sort_key(&artifact.producer_role).to_owned(), + artifact + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + artifact.source_id.clone(), + coverage_sort_key(&artifact.state).to_owned(), + )) + .or_default() + .push(artifact.artifact_id.clone()); + } + + let mut observed = BTreeMap::>::new(); + for coverage in &intake.coverage { + if SiteCoreGroup::from_source_id(&coverage.source_id).is_none() { + continue; + } + observed + .entry(( + role_sort_key(&coverage.producer_role).to_owned(), + coverage + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + coverage.source_id.clone(), + coverage_sort_key(&coverage.state).to_owned(), + )) + .or_default() + .extend(coverage.artifact_ids.iter().cloned()); + } + for artifact_ids in expected.values_mut().chain(observed.values_mut()) { + artifact_ids.sort(); + } + expected == observed +} + +fn safe_site_core_opaque_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.trim() == value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-')) +} + +fn mark_repeated_keys<'a>(unique: &mut [bool], keys: impl Iterator) { + let mut positions = BTreeMap::<&str, Vec>::new(); + for (position, key) in keys.enumerate() { + positions.entry(key).or_default().push(position); + } + for repeated in positions + .into_values() + .filter(|positions| positions.len() > 1) + { + for position in repeated { + unique[position] = false; + } + } +} + +fn mark_overlapping_ranges(unique: &mut [bool], evidence: &[SccmEvidence]) { + let mut by_artifact = BTreeMap::<&str, Vec<(u32, u32, usize)>>::new(); + for (position, record) in evidence.iter().enumerate() { + if let (Some(start), Some(end)) = (record.reference.line_start, record.reference.line_end) { + by_artifact + .entry(record.reference.artifact_id.as_str()) + .or_default() + .push((start, end, position)); + } + } + for ranges in by_artifact.values_mut() { + ranges.sort_unstable(); + let mut active: Option<(u32, usize)> = None; + for &(start, end, position) in ranges.iter() { + if let Some((active_end, active_position)) = active { + if start <= active_end { + unique[position] = false; + unique[active_position] = false; + } + } + if active.is_none_or(|(active_end, _)| end > active_end) { + active = Some((end, position)); + } + } + } +} + +fn compare_facts(left: &SiteCoreFact, right: &SiteCoreFact) -> Ordering { + left.ordering_millis() + .cmp(&right.ordering_millis()) + .then_with(|| left.marker.phase.cmp(&right.marker.phase)) + .then_with(|| compare_references(&left.reference, &right.reference)) +} + +fn compare_references(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Ordering { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| left.line_start.cmp(&right.line_start)) + .then_with(|| left.line_end.cmp(&right.line_end)) + .then_with(|| left.entry_id.cmp(&right.entry_id)) +} + +fn rotation_name(rotation: &crate::sccm::SccmRotation) -> Option { + Some(match rotation { + crate::sccm::SccmRotation::Current => "current".to_owned(), + crate::sccm::SccmRotation::LoUnderscore => "loUnderscore".to_owned(), + crate::sccm::SccmRotation::Numbered(value) => format!("numbered-{value}"), + crate::sccm::SccmRotation::Timestamped(value) => format!("timestamped-{value}"), + crate::sccm::SccmRotation::Unknown(_) => return None, + }) +} + +fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "captured", + SccmCoverageState::Absent => "absent", + SccmCoverageState::AccessDenied => "accessDenied", + SccmCoverageState::Capped => "capped", + SccmCoverageState::Skipped => "skipped", + SccmCoverageState::Unsupported => "unsupported", + SccmCoverageState::ParseFailed => "parseFailed", + } +} + +fn role_sort_key(role: &SccmRole) -> &str { + match role { + SccmRole::Client => "client", + SccmRole::SiteServer => "siteServer", + SccmRole::ManagementPoint => "managementPoint", + SccmRole::DistributionPoint => "distributionPoint", + SccmRole::SoftwareUpdatePoint => "softwareUpdatePoint", + SccmRole::WsUs => "wsUs", + SccmRole::Provider => "provider", + SccmRole::AdminService => "adminService", + SccmRole::Unknown(value) => value, + } +} + +fn stable_opaque_id(prefix: &str, parts: &[&str]) -> String { + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + + let mut digest = Sha256::new(); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part.as_bytes()); + } + let digest = digest.finalize(); + let mut encoded = String::with_capacity(prefix.len() + digest.len() * 2); + encoded.push_str(prefix); + for byte in digest { + encoded.push(char::from(LOWER_HEX[usize::from(byte >> 4)])); + encoded.push(char::from(LOWER_HEX[usize::from(byte & 0x0f)])); + } + encoded +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json index 9ae3b814a..28545de8a 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "component-failure", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-COMPFAIL-001", + "resultId": "site-core:result:v1:a3220d3e9eebc073daf7575a24654c9c3174140ad298b9b2d72b4ebfa95b957d", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-COMPFAIL-001" }, @@ -23,60 +31,156 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "component-failure-sitecomp-current", - "entryId": "component-failure-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "component-failure-sitecomp-current", - "entryId": "component-failure-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "component-failure-sitecomp-current", - "entryId": "component-failure-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3, "terminal": true } ], - "coverageGapArtifactIds": [ - "component-failure-statmgr-absent" - ], + "coverageGapArtifactIds": ["z-site-status"], "nextArtifacts": [] } ], - "unlinkedObservations": [], + "unlinkedObservations": [ + { + "observationId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } + ] + } + ], "coverageGaps": [ { - "artifactId": "component-failure-statmgr-absent", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "absent", + "reasonCode": "required-source-absent", "diagnosticMeaning": "coverageOnly" } ], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:3904d5916025a996b54227f390835cf3a9417264aa9d80d592e59e58793fe0ee", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:e8e0a9faadf8fc5d56fc8540208dd8e5dce0cb60ff0a27e4bee6cb36d1f36d3f", + "class": "confirmedFailure", + "phase": "componentWork", + "role": "siteServer", + "severity": "Error", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + } + ], + "terminalEvidence": [ + { + "reference": { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + "kind": "observedFailure" + } + ], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:a3220d3e9eebc073daf7575a24654c9c3174140ad298b9b2d72b4ebfa95b957d", + "lastSuccessfulPhase": "componentWork" + } + ], + "artifactRequests": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json index dcf518d11..f8192b1a0 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json @@ -1,51 +1,53 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "component-failure", + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" + "siteCode": "LAB", + "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "component-failure-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" }, - "rotationLineage": "sitecomp.log", + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:11:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:11:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 1000 }, { - "artifactId": "component-failure-statmgr-absent", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": false, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:z-site" }, - "rotationLineage": "statmgr.log", + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "absent", - "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T14:11:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json index 9d26c212c..14488c594 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json @@ -1,18 +1,65 @@ { - "expectedContractVersion": 1, - "scenario": "contradictory", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_DISTRIBUTION_MANAGER/SC-CONTRA-DIST-001", + "resultId": "site-core:result:v1:17a3f55ffd5eb73fa686eb49ba0ee620d965de9a33ba240d7e87cba666952884", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-CONTRA-EXEC-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "componentWork", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:4-4", + "lineStart": 4, + "lineEnd": 4 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", + "lineStart": 6, + "lineEnd": 6, + "terminal": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + }, + { + "resultId": "site-core:result:v1:5d5fc621e38bc0572b168833e7bef6fa352cb2a35414e52a97bfd998a8a1c1b3", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_DISTRIBUTION_MANAGER", "workItemId": "SC-CONTRA-DIST-001" }, @@ -23,107 +70,90 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:5-5", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:5-5", "lineStart": 5, "lineEnd": 5 }, { - "artifactId": "contradictory-statmgr-current", - "entryId": "contradictory-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "contradictory-statmgr-current", - "entryId": "contradictory-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2 } ], "coverageGapArtifactIds": [], "nextArtifacts": [] - }, + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "findings": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-CONTRA-EXEC-001", - "transactionKey": { - "profileId": "sccm-site-core", - "profileVersion": 1, - "siteCode": "LAB", - "componentId": "SMS_EXECUTIVE", - "workItemId": "SC-CONTRA-EXEC-001" - }, - "state": "terminalFailure", - "lastSuccessfulPhase": "componentWork", - "findingClass": "confirmedFailure", + "findingId": "site-core:finding:v1:0dc933141f9f40354f35c580303bdb6c5b47f287e5829f055620a73e75277650", + "class": "confirmedFailure", + "phase": "componentWork", + "role": "siteServer", + "severity": "Error", "confidence": "high", - "confidenceCeiling": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", "evidence": [ { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:4-4", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:4-4", "lineStart": 4, "lineEnd": 4 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:6-6", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", "lineStart": 6, - "lineEnd": 6, - "terminal": true + "lineEnd": 6 } ], - "coverageGapArtifactIds": [], - "nextArtifacts": [] + "terminalEvidence": [ + { + "reference": { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", + "lineStart": 6, + "lineEnd": 6 + }, + "kind": "observedFailure" + } + ], + "coverageGaps": [], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:17a3f55ffd5eb73fa686eb49ba0ee620d965de9a33ba240d7e87cba666952884", + "lastSuccessfulPhase": "componentWork" } ], - "unlinkedObservations": [], - "coverageGaps": [], - "adversarialAssertions": { - "resultCount": 2, - "sameMinuteMayMerge": false, - "crossComponentRecovery": false, - "timeOnlyCausalClaim": false - }, - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" - ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "artifactRequests": [], + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json index 3d8f5dadd..2ecf8f066 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json @@ -1,52 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "contradictory", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "contradictory-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:51:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:51:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 2003 }, { - "artifactId": "contradictory-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:51:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:51:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 685 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json index 7a2fa2ec5..9e1f6346d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "healthy", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-HEALTH-001", + "resultId": "site-core:result:v1:4333cffa83f498b6a451e36269b164ae23717b1e790f4e0af31beb4821eece3b", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-HEALTH-001" }, @@ -23,32 +31,32 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "healthy-sitecomp-current", - "entryId": "healthy-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "healthy-sitecomp-current", - "entryId": "healthy-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "healthy-sitecomp-current", - "entryId": "healthy-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3 }, { - "artifactId": "healthy-statmgr-current", - "entryId": "healthy-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "healthy-statmgr-current", - "entryId": "healthy-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2 } @@ -59,27 +67,7 @@ ], "unlinkedObservations": [], "coverageGaps": [], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" - ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "findings": [], + "artifactRequests": [], + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json index 4e9380969..01f2112c9 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json @@ -1,52 +1,55 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "healthy", + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" + "siteCode": "LAB", + "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "healthy-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" }, - "rotationLineage": "sitecomp.log", + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 983 }, { - "artifactId": "healthy-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:z-site" }, - "rotationLineage": "statmgr.log", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 653 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json index 5c88ecdb6..a42e67a60 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "inbox-backlog", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-BACKLOG-001", + "resultId": "site-core:result:v1:8f00cd22916faa6850e60171e8392ee5dd7aa55efe883c7d76546a3c3d221f1f", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-BACKLOG-001" }, @@ -23,42 +31,38 @@ "confidenceCeiling": "low", "evidence": [ { - "artifactId": "inbox-backlog-sitecomp-current", - "entryId": "inbox-backlog-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "inbox-backlog-sitecomp-current", - "entryId": "inbox-backlog-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "inbox-backlog-sitecomp-current", - "entryId": "inbox-backlog-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3, "terminal": false } ], - "coverageGapArtifactIds": [ - "inbox-backlog-statmgr-absent" - ], + "coverageGapArtifactIds": ["z-site-status"], "nextArtifacts": [ { "logicalName": "server-status", "role": "siteServer", "reasonCode": "matching-status-terminal-evidence-missing", - "basenames": [ - "statmgr.log" - ], - "rotations": [ - "current", - "loUnderscore" + "candidates": [ + { "basename": "statmgr.log", "rotation": "current" }, + { "basename": "statmgr.lo_", "rotation": "loUnderscore" } ], "maxArtifacts": 2, "scope": { + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-BACKLOG-001" } @@ -66,35 +70,144 @@ ] } ], - "unlinkedObservations": [], + "unlinkedObservations": [ + { + "observationId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } + ] + } + ], "coverageGaps": [ { - "artifactId": "inbox-backlog-statmgr-absent", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "absent", + "reasonCode": "required-source-absent", "diagnosticMeaning": "coverageOnly" } ], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:3904d5916025a996b54227f390835cf3a9417264aa9d80d592e59e58793fe0ee", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:3c9d38e20784d06345b49e9978c0ee0a106b87001ac62428d18c41115f72a2f0", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:7f88716d8276aa14790bb220f91a66086fdc10d86f55a5aeb831f2aacf2944ef", + "class": "blockedOrDeferred", + "phase": "componentWork", + "role": "siteServer", + "severity": "Warning", + "confidence": "low", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + } + ], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:result:v1:8f00cd22916faa6850e60171e8392ee5dd7aa55efe883c7d76546a3c3d221f1f", + "lastSuccessfulPhase": "componentWork" + } + ], + "artifactRequests": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "matching-status-terminal-evidence-missing", + "candidates": [ + { "basename": "statmgr.log", "rotation": "current" }, + { "basename": "statmgr.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 2, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-BACKLOG-001" + } + }, + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json index c7e4e20be..a3096d550 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json @@ -1,51 +1,53 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "inbox-backlog", + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" + "siteCode": "LAB", + "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "inbox-backlog-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" }, - "rotationLineage": "sitecomp.log", + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:21:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:21:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 1000 }, { - "artifactId": "inbox-backlog-statmgr-absent", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": false, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:z-site" }, - "rotationLineage": "statmgr.log", + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "absent", - "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T14:21:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json index 933adcc51..b72411f39 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json @@ -1,112 +1,232 @@ { - "expectedContractVersion": 1, - "scenario": "incomplete", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, - "results": [ + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [], + "unlinkedObservations": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-INCOMPLETE-001", - "transactionKey": { - "profileId": "sccm-site-core", - "profileVersion": 1, - "siteCode": "LAB", - "componentId": "SMS_EXECUTIVE", - "workItemId": "SC-INCOMPLETE-001" - }, - "state": "incomplete", - "lastSuccessfulPhase": "componentWork", + "observationId": "site-core:observation:v1:20750165151067511511fa5d55ec29a0891eba7992f4e1b23a4eafd132ca9879", + "state": "parseGap", "findingClass": "insufficientEvidence", "confidence": "none", - "confidenceCeiling": "none", - "evidence": [ - { - "artifactId": "incomplete-sitecomp-capped", - "entryId": "incomplete-sitecomp-capped:1-1", - "lineStart": 1, - "lineEnd": 1 - }, - { - "artifactId": "incomplete-sitecomp-capped", - "entryId": "incomplete-sitecomp-capped:2-2", - "lineStart": 2, - "lineEnd": 2 - } - ], - "coverageGapArtifactIds": [ - "incomplete-sitecomp-capped", - "incomplete-statesys-absent", - "incomplete-statmgr-access-denied" - ], + "evidence": [], + "coverageGapArtifactIds": ["sitecomp-current"], "nextArtifacts": [ { "logicalName": "server-sitecomp", "role": "siteServer", "reasonCode": "capped-before-next-phase", - "basenames": [ - "sitecomp.log" - ], - "rotations": [ - "current" - ], + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], "maxArtifacts": 1, "maxBytesPerArtifact": 4096, "scope": { - "componentId": "SMS_EXECUTIVE", - "workItemId": "SC-INCOMPLETE-001" + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + } + ] + }, + { + "observationId": "site-core:observation:v1:54d36c8102796161d9f08cf1b504631c3268a5d3d5617cfdece01908c2b4a8db", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["b-sitecomp"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statesys.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-a" + } + } + ] + }, + { + "observationId": "site-core:observation:v1:9edbdd94bfcb99682d8938a3eceb1122760ee2aadeb9b9fad5dca1a4d5544005", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-access-denied", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" } } ] } ], - "unlinkedObservations": [], "coverageGaps": [ { - "artifactId": "incomplete-sitecomp-capped", - "state": "capped", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "incomplete-sitecomp-capped", - "entryId": "incomplete-sitecomp-capped:3-3", - "lineStart": 3, - "lineEnd": 3, - "completeLogicalRecord": false - } + "artifactId": "b-sitecomp", + "sourceId": "server-status", + "state": "absent", + "reasonCode": "required-source-absent", + "diagnosticMeaning": "coverageOnly" }, { - "artifactId": "incomplete-statesys-absent", - "state": "absent", + "artifactId": "sitecomp-current", + "sourceId": "server-sitecomp", + "state": "capped", + "reasonCode": "required-source-capped", "diagnosticMeaning": "coverageOnly" }, { - "artifactId": "incomplete-statmgr-access-denied", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "accessDenied", + "reasonCode": "required-source-access-denied", "diagnosticMeaning": "coverageOnly" } ], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:f128f013625df6f648e3bcbea04797dd865613144d069349b2094a760b0ae2c0", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "sitecomp-current", + "role": "siteServer", + "coverage": "capped" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "sitecomp", + "role": "siteServer", + "reason": "Collect the complete sitecomp.log file." + } + ], + "subjectId": "site-core:observation:v1:20750165151067511511fa5d55ec29a0891eba7992f4e1b23a4eafd132ca9879", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:044cb34792a9fc91e1f780871421b55859c60ee5dbbb1788ed0a87be772cedfe", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "b-sitecomp", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statesys", + "role": "siteServer", + "reason": "Collect the complete statesys.log file." + } + ], + "subjectId": "site-core:observation:v1:54d36c8102796161d9f08cf1b504631c3268a5d3d5617cfdece01908c2b4a8db", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:e8826b8f7c9bcdfd45124cd11eed064bbf61b799edcaa022c7a3eff210ed8966", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "accessDenied" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:9edbdd94bfcb99682d8938a3eceb1122760ee2aadeb9b9fad5dca1a4d5544005", + "lastSuccessfulPhase": null + } + ], + "artifactRequests": [ + { + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "capped-before-next-phase", + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], + "maxArtifacts": 1, + "maxBytesPerArtifact": 4096, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + }, + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statesys.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-a" + } + }, + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-access-denied", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json index e8ff810bc..d82244b65 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json @@ -1,72 +1,62 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "incomplete", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "incomplete-sitecomp-capped", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current", - "fragmentComplete": false - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "capped", - "captureLimitBytes": 834, - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:11:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 834, "limitApplied": true }, + "truncated": true, + "fragmentComplete": false, + "collectedUtc": "2026-07-30T15:11:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 834 }, { - "artifactId": "incomplete-statesys-absent", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", - "originalBasename": "statesys.log", - "configuredPath": false, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statesys.log", - "captureState": "absent", "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, + "captureState": "accessDenied", + "collectionDetail": "synthetic permission denial", "collectedUtc": "2026-07-30T15:11:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 }, { - "artifactId": "incomplete-statmgr-access-denied", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "b-sitecomp", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", - "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", - "captureState": "accessDenied", "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statesys.log", + "configuredPathProvenance": { "state": "defaultCandidate", "pathFingerprint": "synthetic:path:a-site" }, + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "sitecomp-a" }, + "captureState": "absent", "collectedUtc": "2026-07-30T15:11:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-server/server-status/current/statmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-server/server-status/current/statmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json index 20cc200ff..c07c77a59 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json @@ -1,46 +1,37 @@ { - "expectedContractVersion": 1, - "scenario": "malformed", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [], "unlinkedObservations": [ { - "observationId": "malformed-status-record", + "observationId": "site-core:observation:v1:11120408c9126ff56585ab7f779a4fa8d13bb6867cf806f3674d16e4e1c9afd6", "state": "parseGap", - "lastSuccessfulPhase": null, - "findingClass": "symptom", - "confidence": "low", - "confidenceCeiling": "low", - "evidence": [ - { - "artifactId": "malformed-statmgr-current", - "entryId": "malformed-statmgr-current:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false - } - ], - "coverageGapArtifactIds": [ - "malformed-statmgr-current" - ], + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], "nextArtifacts": [ { "logicalName": "server-status", "role": "siteServer", - "reasonCode": "complete-status-record-required", - "basenames": [ - "statmgr.log" - ], - "rotations": [ - "current" - ], + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], "maxArtifacts": 1, "scope": { - "rotationLineage": "statmgr.log" + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" } } ] @@ -48,46 +39,56 @@ ], "coverageGaps": [ { - "artifactId": "malformed-statmgr-current", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "parseFailed", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "malformed-statmgr-current", - "entryId": "malformed-statmgr-current:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false - } + "reasonCode": "required-source-parse-failed", + "diagnosticMeaning": "coverageOnly" } ], - "adversarialAssertions": { - "transactionCount": 0, - "componentKeyAdmitted": false, - "terminalMayBeInferred": false, - "confirmedFailure": false, - "highConfidenceCause": false - }, - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:59627c557b8120023694ee3f871c6a6c989d35cdeab47c081e435543283912f1", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "z-site-status", + "role": "siteServer", + "coverage": "parseFailed" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:11120408c9126ff56585ab7f779a4fa8d13bb6867cf806f3674d16e4e1c9afd6", + "lastSuccessfulPhase": null + } + ], + "artifactRequests": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json index d4962f56b..17c14492e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json @@ -1,35 +1,27 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "malformed", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "malformed-statmgr-current", - "syntheticFixture": true, - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current", - "fragmentComplete": false - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:21:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T15:21:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 215 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json index 26d76c4c1..ad304a594 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "recovery", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-RECOVER-001", + "resultId": "site-core:result:v1:6481f8fcb2dc2b2d95e759d00b6c5332fc83f74a8ce808c4079681340c0547a9", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-RECOVER-001" }, @@ -23,33 +31,33 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "recovery-sitecomp-current", - "entryId": "recovery-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "recovery-sitecomp-current", - "entryId": "recovery-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "recovery-sitecomp-current", - "entryId": "recovery-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3, "terminal": true }, { - "artifactId": "recovery-statmgr-current", - "entryId": "recovery-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "recovery-statmgr-current", - "entryId": "recovery-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2, "terminal": true, @@ -62,27 +70,56 @@ ], "unlinkedObservations": [], "coverageGaps": [], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:0dc72fd549b222924cd7a0207cae9a9e127f0ce27126a7091697d14cd8dfc0fe", + "class": "symptom", + "phase": "healthyOrTerminal", + "role": "siteServer", + "severity": "Warning", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is healthyOrTerminal; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "terminalEvidence": [], + "coverageGaps": [], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:6481f8fcb2dc2b2d95e759d00b6c5332fc83f74a8ce808c4079681340c0547a9", + "lastSuccessfulPhase": "healthyOrTerminal" + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "artifactRequests": [], + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json index 712a2edbc..46aa8fd77 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json @@ -1,52 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "recovery", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "recovery-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:43:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:43:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 997 }, { - "artifactId": "recovery-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:43:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:43:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 657 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_ diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json index 851570d25..c41f5935c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json @@ -1,56 +1,60 @@ { - "expectedContractVersion": 1, - "scenario": "rotation-boundary", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [], "unlinkedObservations": [ { - "observationId": "rotation-boundary-fragments", + "observationId": "site-core:observation:v1:451100e2fecf411dab719c39efac17c4a413cc9159154cbf7b8e852b2d5e0d2e", "state": "parseGap", - "lastSuccessfulPhase": null, "findingClass": "insufficientEvidence", "confidence": "none", - "confidenceCeiling": "none", - "evidence": [ - { - "artifactId": "rotation-boundary-sitecomp-current", - "entryId": "rotation-boundary-sitecomp-current:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false - }, + "evidence": [], + "coverageGapArtifactIds": ["b-sitecomp"], + "nextArtifacts": [ { - "artifactId": "rotation-boundary-sitecomp-lo", - "entryId": "rotation-boundary-sitecomp-lo:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [ + { "basename": "sitecomp.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } } - ], - "coverageGapArtifactIds": [ - "rotation-boundary-sitecomp-current", - "rotation-boundary-sitecomp-lo" - ], + ] + }, + { + "observationId": "site-core:observation:v1:e90f28bbfe6504368011dcfad219c8103b931bcaa8e8b599c96c0ea6a04196f1", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["sitecomp-current"], "nextArtifacts": [ { "logicalName": "server-sitecomp", "role": "siteServer", - "reasonCode": "complete-logical-record-required", - "basenames": [ - "sitecomp.log", - "sitecomp.lo_" - ], - "rotations": [ - "current", - "loUnderscore" - ], - "maxArtifacts": 2, + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], + "maxArtifacts": 1, "scope": { - "rotationLineage": "sitecomp.log" + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" } } ] @@ -58,56 +62,105 @@ ], "coverageGaps": [ { - "artifactId": "rotation-boundary-sitecomp-current", + "artifactId": "b-sitecomp", + "sourceId": "server-sitecomp", "state": "parseFailed", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "rotation-boundary-sitecomp-current", - "entryId": "rotation-boundary-sitecomp-current:1-1", - "lineStart": 1, - "lineEnd": 1 - } + "reasonCode": "required-source-parse-failed", + "diagnosticMeaning": "coverageOnly" }, { - "artifactId": "rotation-boundary-sitecomp-lo", + "artifactId": "sitecomp-current", + "sourceId": "server-sitecomp", "state": "parseFailed", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "rotation-boundary-sitecomp-lo", - "entryId": "rotation-boundary-sitecomp-lo:1-1", - "lineStart": 1, - "lineEnd": 1 - } + "reasonCode": "required-source-parse-failed", + "diagnosticMeaning": "coverageOnly" } ], - "adversarialAssertions": { - "transactionCount": 0, - "phaseMayAdvance": false, - "terminalMayBeInferred": false, - "crossRotationFragmentJoin": false, - "highConfidenceCause": false - }, - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:35ab0d0fc326af948cdbeaa4e8510e8dc467bbfeca4d9de9726a9324871b0812", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "b-sitecomp", + "role": "siteServer", + "coverage": "parseFailed" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "sitecomp", + "role": "siteServer", + "reason": "Collect the complete sitecomp.log file." + } + ], + "subjectId": "site-core:observation:v1:451100e2fecf411dab719c39efac17c4a413cc9159154cbf7b8e852b2d5e0d2e", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:b526eeb6982092da44b2ade2f032b43f33dfb4910bb69b41c0b4c02f7c862a35", + "class": "insufficientEvidence", + "phase": "siteCoreCoverage", + "role": "siteServer", + "severity": "Warning", + "confidence": "none", + "title": "Site core coverage gap", + "summary": "The source is incomplete and cannot establish a component outcome.", + "evidence": [], + "terminalEvidence": [], + "coverageGaps": [ + { + "artifactId": "sitecomp-current", + "role": "siteServer", + "coverage": "parseFailed" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "sitecomp", + "role": "siteServer", + "reason": "Collect the complete sitecomp.log file." + } + ], + "subjectId": "site-core:observation:v1:e90f28bbfe6504368011dcfad219c8103b931bcaa8e8b599c96c0ea6a04196f1", + "lastSuccessfulPhase": null + } + ], + "artifactRequests": [ + { + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [ + { "basename": "sitecomp.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + }, + { + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json index 6c34b7d51..080f6fd49 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json @@ -1,56 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "rotation-boundary", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "rotation-boundary-sitecomp-current", - "syntheticFixture": true, - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current", - "fragmentComplete": false - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T15:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 216 }, { - "artifactId": "rotation-boundary-sitecomp-lo", - "syntheticFixture": true, - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "b-sitecomp", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.lo_", - "configuredPath": true, - "rotation": { - "kind": "loUnderscore", - "fragmentComplete": false - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "lo_", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T15:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_", "bytesCopied": 196 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json index b7ef1017c..7421e5277 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "status-processing-failure", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-STATUSFAIL-001", + "resultId": "site-core:result:v1:234575fad725c1b8673aaa8d9c02e5465aad0b919b0de5c4b52b4221098d0eb1", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-STATUSFAIL-001" }, @@ -23,32 +31,32 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "status-processing-failure-sitecomp-current", - "entryId": "status-processing-failure-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "status-processing-failure-sitecomp-current", - "entryId": "status-processing-failure-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "status-processing-failure-sitecomp-current", - "entryId": "status-processing-failure-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3 }, { - "artifactId": "status-processing-failure-statmgr-current", - "entryId": "status-processing-failure-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "status-processing-failure-statmgr-current", - "entryId": "status-processing-failure-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2, "terminal": true @@ -60,27 +68,66 @@ ], "unlinkedObservations": [], "coverageGaps": [], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:853fbf3e3970fd2b5acba277229471609617eb1f25b7932e01e7c67e5308f93d", + "class": "confirmedFailure", + "phase": "statusOrStateProcessing", + "role": "siteServer", + "severity": "Error", + "confidence": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is statusOrStateProcessing; later phases are bounded to cited evidence.", + "evidence": [ + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "terminalEvidence": [ + { + "reference": { + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + "kind": "observedFailure" + } + ], + "coverageGaps": [], + "correlationKeys": [], + "nextArtifacts": [], + "subjectId": "site-core:result:v1:234575fad725c1b8673aaa8d9c02e5465aad0b919b0de5c4b52b4221098d0eb1", + "lastSuccessfulPhase": "statusOrStateProcessing" + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "artifactRequests": [], + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json index 4e9e2a109..bd4375e95 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json @@ -1,52 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "status-processing-failure", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "status-processing-failure-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:31:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:31:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 995 }, { - "artifactId": "status-processing-failure-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:31:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T14:31:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 667 } ] diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs new file mode 100644 index 000000000..4cfaaeb88 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -0,0 +1,1852 @@ +use cmtraceopen_parser::sccm::server::windows::{ + analyze_site_core, assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeAssessment, + SccmSiteCoreAnalysis, SccmSiteCoreArtifactRequest, SccmSiteCoreConfidence, SccmSiteCorePhase, + SccmSiteCoreState, +}; +use cmtraceopen_parser::sccm::{ + SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, SccmTimeOrderingState, + SccmUnknownRotation, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const HEALTHY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const HEALTHY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const COMPONENT_FAILURE: &str = include_str!( + "fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const INBOX_BACKLOG: &str = include_str!( + "fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const STATUS_FAILURE_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const STATUS_FAILURE_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const RECOVERY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const RECOVERY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const CONTRADICTORY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const CONTRADICTORY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log" +); +const ROTATION_CURRENT_FRAGMENT: &str = include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log" +); +const ROTATION_LO_FRAGMENT: &str = include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_" +); +const OUT_OF_ORDER_SITECOMP: &str = concat!( + "\n", + "\n", + "\n", +); +const OUT_OF_ORDER_STATUS: &str = concat!( + "\n", + "\n", +); +const SUCCESS_AFTER_FAILURE: &str = concat!( + "\n", + "\n", + "\n", +); +const DEFERRED_THEN_ACCEPTED: &str = concat!( + "\n", + "\n", + "\n", + "\n", +); +const TERMINAL_FAILURE_WITHOUT_SUCCESS: &str = + "\n"; + +#[derive(Clone)] +struct Source<'a> { + artifact_id: &'static str, + source_id: &'static str, + basename: &'static str, + path_fingerprint: &'static str, + lineage_id: &'static str, + rotation_kind: &'static str, + rotation_value: Option, + content: Option<&'a str>, + capture_state: &'static str, + configured_state: &'static str, + path_class: Option<&'static str>, + encoding: Option<&'static str>, + limit_applied: bool, + truncated: Option, + fragment_complete: Option, +} + +impl<'a> Source<'a> { + fn sitecomp(content: &'a str) -> Self { + Self { + artifact_id: "sitecomp-current", + source_id: "server-sitecomp", + basename: "sitecomp.log", + path_fingerprint: "synthetic:path:site-default", + lineage_id: "sitecomp-lab", + rotation_kind: "current", + rotation_value: None, + content: Some(content), + capture_state: "captured", + configured_state: "configured", + path_class: None, + encoding: Some("utf-8"), + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn status(content: &'a str) -> Self { + Self { + artifact_id: "z-site-status", + source_id: "server-status", + basename: "statmgr.log", + path_fingerprint: "synthetic:path:z-site", + lineage_id: "site-status-z", + rotation_kind: "current", + rotation_value: None, + content: Some(content), + capture_state: "captured", + configured_state: "configured", + path_class: None, + encoding: Some("utf-8"), + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn absent_status() -> Self { + Self { + artifact_id: "z-site-status", + source_id: "server-status", + basename: "statmgr.log", + path_fingerprint: "synthetic:path:z-site", + lineage_id: "site-status-z", + rotation_kind: "current", + rotation_value: None, + content: None, + capture_state: "absent", + configured_state: "defaultCandidate", + path_class: None, + encoding: None, + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn default_sitecomp_candidate() -> Self { + Self { + artifact_id: "b-sitecomp", + source_id: "server-sitecomp", + basename: "sitecomp.log", + path_fingerprint: "synthetic:path:a-site", + lineage_id: "sitecomp-a", + rotation_kind: "current", + rotation_value: None, + content: None, + capture_state: "absent", + configured_state: "defaultCandidate", + path_class: None, + encoding: None, + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn capped_sitecomp(content: &'a str) -> Self { + let mut source = Self::sitecomp(content); + source.capture_state = "capped"; + source.limit_applied = true; + source.truncated = Some(true); + source.fragment_complete = Some(false); + source + } + + fn sitecomp_lo_fragment(content: &'a str) -> Self { + Self { + artifact_id: "b-sitecomp", + source_id: "server-sitecomp", + basename: "sitecomp.lo_", + path_fingerprint: "synthetic:path:site-default", + lineage_id: "sitecomp-lab", + rotation_kind: "lo_", + rotation_value: None, + content: Some(content), + capture_state: "captured", + configured_state: "configured", + path_class: None, + encoding: Some("utf-8"), + limit_applied: false, + truncated: None, + fragment_complete: None, + } + } + + fn numbered_status(content: &'a str) -> Self { + let mut source = Self::status(content); + source.basename = "statmgr.log.2"; + source.rotation_kind = "numbered"; + source.rotation_value = Some(json!(2)); + source + } + + fn relative_path(&self) -> Option { + self.content.map(|_| { + let rotation = match self.rotation_kind { + "current" => "current", + "lo_" => "lo_", + "numbered" => "numbered-2", + other => panic!("unsupported test rotation {other}"), + }; + format!( + "evidence/sccm/server/site-server/{}/{rotation}/{}", + self.source_id, self.basename + ) + }) + } + + fn manifest_artifact(&self) -> Value { + let bytes_copied = self.content.map_or(0, |content| content.len() as u64); + let collection_limit = self.content.map(|_| { + json!({ + "byteLimit": if self.limit_applied { bytes_copied } else { bytes_copied.max(4096) }, + "limitApplied": self.limit_applied, + }) + }); + json!({ + "artifactId": self.artifact_id, + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": self.source_id, + "sourceKind": "ccmLog", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": self.basename, + "configuredPathProvenance": { + "state": self.configured_state, + "pathClass": self.path_class, + "pathFingerprint": self.path_fingerprint, + }, + "defaultCandidateState": if self.configured_state == "defaultCandidate" { + Some("absentCandidateOnly") + } else { + None + }, + "rotation": { + "kind": self.rotation_kind, + "value": self.rotation_value, + "lineageId": self.lineage_id, + }, + "captureState": self.capture_state, + "encoding": self.encoding, + "collectionLimit": collection_limit, + "truncated": self.truncated, + "fragmentComplete": self.fragment_complete, + "collectedUtc": "2026-07-30T16:00:00Z", + "relativePath": self.relative_path(), + "bytesCopied": bytes_copied, + }) + } +} + +fn assess(sources: &[Source<'_>]) -> SccmServerIntakeAssessment { + let manifest = json!({ + "sccmManifestVersion": 1, + "syntheticFixture": true, + "proposalOnly": true, + "privacy": {"synthetic": true, "rawPaths": "redacted"}, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": ["siteServer"], + }, + "artifacts": sources.iter().map(Source::manifest_artifact).collect::>(), + }); + let payloads = sources + .iter() + .filter_map(|source| { + source.content.map(|content| SccmServerArtifactPayload { + manifest_artifact_id: source.artifact_id.to_owned(), + bytes: content.as_bytes().to_vec(), + }) + }) + .collect::>(); + + assess_server_intake(&manifest.to_string(), &payloads) + .expect("site-core test manifest must pass the shared server intake") +} + +fn replace_source_artifact_id( + assessment: &mut SccmServerIntakeAssessment, + source_id: &str, + replacement: &str, +) { + let artifact = assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == source_id) + .expect("source artifact"); + let original = std::mem::replace(&mut artifact.artifact_id, replacement.to_owned()); + for coverage in &mut assessment.coverage { + for artifact_id in &mut coverage.artifact_ids { + if artifact_id == &original { + *artifact_id = replacement.to_owned(); + } + } + } + for evidence in &mut assessment.evidence { + if evidence.reference.artifact_id == original { + evidence.reference.artifact_id = replacement.to_owned(); + } + } +} + +fn assert_bounded_request_has_specific_scope(request: &SccmSiteCoreArtifactRequest) { + assert!((1..=2).contains(&request.max_artifacts)); + assert!(!request.candidates.is_empty()); + assert!(request.candidates.len() <= request.max_artifacts); + assert!(request.candidates.iter().all(|candidate| { + !candidate.basename.trim().is_empty() && !candidate.rotation.trim().is_empty() + })); + assert!( + request + .scope + .producer_host_handle + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .scope + .component_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .scope + .work_item_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .scope + .rotation_lineage_handle + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + "request {} serialized an empty or unusable scope", + request.logical_name + ); +} + +fn assert_malformed_peer_source_fails_closed( + analysis: &SccmSiteCoreAnalysis, + malformed_id: &str, + required_source_id: &str, + required_reason_code: &str, +) { + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Incomplete); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + assert!(!result.evidence.is_empty()); + + let synthetic_gap = analysis + .coverage_gaps + .iter() + .find(|gap| { + gap.source_id == required_source_id + && gap.state == SccmCoverageState::Absent + && gap.reason_code == required_reason_code + }) + .expect("ineligible peer leaves a synthetic missing-source gap"); + assert!(synthetic_gap + .artifact_id + .starts_with("site-core:missing-source:v1:")); + assert_eq!( + result.coverage_gap_artifact_ids, + vec![synthetic_gap.artifact_id.clone()] + ); + + let rejected_gap = analysis + .coverage_gaps + .iter() + .find(|gap| { + gap.source_id == required_source_id + && gap.state == SccmCoverageState::ParseFailed + && gap.reason_code == "evidence-reference-rejected" + }) + .expect("malformed peer remains explicit rejected coverage"); + assert!(rejected_gap + .artifact_id + .starts_with("site-core:rejected-artifact:v1:")); + assert_ne!(rejected_gap.artifact_id, malformed_id); + assert!(analysis.coverage_gaps.iter().all(|gap| { + gap.artifact_id != malformed_id + && !gap.artifact_id.is_empty() + && gap.artifact_id.len() <= 256 + && gap.artifact_id.trim() == gap.artifact_id + && gap.artifact_id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-') + }) + })); + + let result_finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("malformed peer retains a validated result finding"); + assert_eq!( + result_finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert_eq!(result_finding.finding.coverage_gaps.len(), 1); + assert_eq!( + result_finding.finding.coverage_gaps[0].artifact_id, + synthetic_gap.artifact_id + ); + + for gap in &analysis.coverage_gaps { + let observation = analysis + .unlinked_observations + .iter() + .find(|observation| observation.coverage_gap_artifact_ids == [gap.artifact_id.clone()]) + .expect("each gap has an explicit coverage observation"); + let finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == observation.observation_id) + .expect("each gap has a validated coverage finding"); + assert_eq!( + finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert!(finding + .finding + .coverage_gaps + .iter() + .any(|finding_gap| finding_gap.artifact_id == gap.artifact_id)); + } + + let requests = analysis + .artifact_requests + .iter() + .filter(|request| request.logical_name == required_source_id) + .collect::>(); + assert!(!requests.is_empty()); + for request in requests { + assert_bounded_request_has_specific_scope(request); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + } + assert!(!analysis.cross_side_correlation_performed); +} + +fn assert_explicit_gap_and_request( + analysis: &SccmSiteCoreAnalysis, + artifact_id: &str, + source_id: &str, +) { + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == artifact_id && gap.state == SccmCoverageState::ParseFailed + })); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::InsufficientEvidence + && observation + .coverage_gap_artifact_ids + .iter() + .any(|candidate| candidate == artifact_id) + })); + let request = analysis + .artifact_requests + .iter() + .find(|request| request.logical_name == source_id) + .expect("coverage gap has a source-specific artifact request"); + assert_bounded_request_has_specific_scope(request); + for request in &analysis.artifact_requests { + assert_bounded_request_has_specific_scope(request); + } +} + +fn assert_gap_reason(analysis: &SccmSiteCoreAnalysis, artifact_id: &str, reason_code: &str) { + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == artifact_id + && gap.state == SccmCoverageState::ParseFailed + && gap.reason_code == reason_code + })); +} + +fn assert_delimiter_attached_unknown_label_fails_closed(delimiter: char) { + for (position, outcome_field) in [ + ( + "after-known", + format!("outcome=success{delimiter}unreviewed=x"), + ), + ( + "before-known", + format!("unreviewed=x{delimiter}outcome=success"), + ), + ] { + let sitecomp = HEALTHY_SITECOMP.replace("outcome=success", &outcome_field); + let status = HEALTHY_STATUS.replace("outcome=success", &outcome_field); + let assessment = assess(&[Source::sitecomp(&sitecomp), Source::status(&status)]); + let expected_observations = assessment.evidence.len(); + let analysis = analyze_site_core(&assessment); + + assert!( + analysis.results.is_empty(), + "delimiter {delimiter:?} {position} must not create a transaction" + ); + assert_eq!( + analysis.unlinked_observations.len(), + expected_observations, + "delimiter {delimiter:?} {position} must retain every rejected record" + ); + assert_eq!(analysis.findings.len(), expected_observations); + assert!(analysis.unlinked_observations.iter().all(|observation| { + observation.finding_class == SccmFindingClass::Symptom + && observation.evidence.len() == 1 + && observation.next_artifacts.len() == 1 + && observation.next_artifacts[0].candidates.len() == 1 + })); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class == SccmFindingClass::Symptom)); + } +} + +fn site_core_corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/server/site_core") +} + +fn load_corpus_scenario(scenario: &str) -> (SccmServerIntakeAssessment, Value) { + let root = site_core_corpus_root().join(scenario); + let manifest_path = root.join("manifest.json"); + let manifest_json = fs::read_to_string(&manifest_path) + .unwrap_or_else(|error| panic!("read {}: {error}", manifest_path.display())); + let manifest: Value = serde_json::from_str(&manifest_json) + .unwrap_or_else(|error| panic!("parse {}: {error}", manifest_path.display())); + let payloads = manifest["artifacts"] + .as_array() + .expect("corpus manifest artifacts") + .iter() + .filter_map(|artifact| { + let relative_path = artifact["relativePath"].as_str()?; + let artifact_id = artifact["artifactId"].as_str().expect("corpus artifact id"); + let evidence_path = root.join(relative_path); + Some(SccmServerArtifactPayload { + manifest_artifact_id: artifact_id.to_owned(), + bytes: fs::read(&evidence_path) + .unwrap_or_else(|error| panic!("read {}: {error}", evidence_path.display())), + }) + }) + .collect::>(); + let assessment = assess_server_intake(&manifest_json, &payloads) + .unwrap_or_else(|error| panic!("assess corpus scenario {scenario}: {error}")); + let expected_path = root.join("expected.json"); + let expected = serde_json::from_str( + &fs::read_to_string(&expected_path) + .unwrap_or_else(|error| panic!("read {}: {error}", expected_path.display())), + ) + .unwrap_or_else(|error| panic!("parse {}: {error}", expected_path.display())); + (assessment, expected) +} + +#[test] +fn healthy_site_core_is_reduced_from_server_intake_without_raw_site_identity() { + let assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let analysis = analyze_site_core(&assessment); + + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Healthy); + assert_eq!( + result.last_successful_phase, + Some(SccmSiteCorePhase::HealthyOrTerminal) + ); + assert_eq!(result.confidence, SccmSiteCoreConfidence::High); + assert_eq!(result.transaction_key.site_handle, "synthetic:site:lab"); + assert_eq!( + result.transaction_key.producer_host_handle, + "synthetic:host:site-01" + ); + assert!(analysis.findings.is_empty()); + + let wire = serde_json::to_string(&analysis).expect("site-core analysis serializes"); + assert!(!wire.contains("siteCode")); + assert!(!wire.contains("\"LAB\"")); + assert!(!wire.contains("/LAB/")); + assert!(!wire.contains("clientImpact")); + assert!(!analysis.cross_side_correlation_performed); +} + +#[test] +fn configured_nondefault_sources_supersede_absent_default_candidates() { + let mut sitecomp = Source::sitecomp(HEALTHY_SITECOMP); + sitecomp.path_class = Some("nonDefault"); + let mut status = Source::status(HEALTHY_STATUS); + status.path_class = Some("nonDefault"); + let assessment = assess(&[Source::default_sitecomp_candidate(), status, sitecomp]); + assert!(assessment.artifacts.iter().any(|artifact| { + artifact.configured_path_class + == Some( + cmtraceopen_parser::sccm::server::windows::SccmServerConfiguredPathClass::NonDefault, + ) + })); + + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 1); + assert_eq!(analysis.results[0].state, SccmSiteCoreState::Healthy); + assert_eq!(analysis.results[0].confidence, SccmSiteCoreConfidence::High); + assert!(analysis.coverage_gaps.is_empty()); + assert!(analysis.findings.is_empty()); + assert!(analysis.artifact_requests.is_empty()); +} + +#[test] +fn complete_catalogued_rotations_remain_profile_usable() { + let assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::numbered_status(HEALTHY_STATUS), + ]); + let analysis = analyze_site_core(&assessment); + + assert_eq!(analysis.results.len(), 1); + assert_eq!(analysis.results[0].state, SccmSiteCoreState::Healthy); + assert_eq!(analysis.results[0].confidence, SccmSiteCoreConfidence::High); +} + +#[test] +fn phase_order_and_post_terminal_evidence_fail_closed() { + let out_of_order = analyze_site_core(&assess(&[ + Source::sitecomp(OUT_OF_ORDER_SITECOMP), + Source::status(OUT_OF_ORDER_STATUS), + ])); + assert_eq!(out_of_order.results.len(), 1); + assert!(out_of_order.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + && result.confidence != SccmSiteCoreConfidence::High + })); + + let later_success = analyze_site_core(&assess(&[ + Source::sitecomp(SUCCESS_AFTER_FAILURE), + Source::absent_status(), + ])); + assert_eq!(later_success.results.len(), 1); + assert!(later_success.results.iter().all(|result| { + result.finding_class != Some(SccmFindingClass::ConfirmedFailure) + && result.confidence != SccmSiteCoreConfidence::High + })); +} + +#[test] +fn terminal_component_and_status_outcomes_require_exact_cited_facts() { + let component = analyze_site_core(&assess(&[ + Source::sitecomp(COMPONENT_FAILURE), + Source::absent_status(), + ])); + assert_eq!(component.results.len(), 1); + assert_eq!( + component.results[0].state, + SccmSiteCoreState::TerminalFailure + ); + assert_eq!( + component.results[0].last_successful_phase, + Some(SccmSiteCorePhase::ComponentWork) + ); + assert_eq!( + component.results[0].finding_class, + Some(SccmFindingClass::ConfirmedFailure) + ); + assert_eq!(component.findings.len(), 2); + let component_failure = component + .findings + .iter() + .find(|finding| finding.finding.class == SccmFindingClass::ConfirmedFailure) + .expect("confirmed component failure finding"); + assert_eq!(component_failure.finding.terminal_evidence.len(), 1); + assert!(component.results[0] + .evidence + .iter() + .any(|evidence| evidence.terminal == Some(true))); + + let status = analyze_site_core(&assess(&[ + Source::sitecomp(STATUS_FAILURE_SITECOMP), + Source::status(STATUS_FAILURE_STATUS), + ])); + assert_eq!(status.results.len(), 1); + assert_eq!(status.results[0].state, SccmSiteCoreState::TerminalFailure); + assert_eq!( + status.results[0].last_successful_phase, + Some(SccmSiteCorePhase::StatusOrStateProcessing) + ); + assert_eq!( + status.results[0].finding_class, + Some(SccmFindingClass::ConfirmedFailure) + ); + assert_eq!(status.findings.len(), 1); + assert_eq!(status.findings[0].finding.terminal_evidence.len(), 1); +} + +#[test] +fn backlog_is_deferred_and_same_component_terminal_recovery_is_cited() { + let backlog = analyze_site_core(&assess(&[ + Source::sitecomp(INBOX_BACKLOG), + Source::absent_status(), + ])); + assert_eq!(backlog.results.len(), 1); + assert_eq!( + backlog.results[0].state, + SccmSiteCoreState::BlockedOrDeferred + ); + assert_eq!(backlog.results[0].confidence, SccmSiteCoreConfidence::Low); + assert_eq!( + backlog.results[0].last_successful_phase, + Some(SccmSiteCorePhase::ComponentWork) + ); + assert!(backlog.results[0] + .next_artifacts + .iter() + .any(|request| request.logical_name == "server-status")); + + let recovery = analyze_site_core(&assess(&[ + Source::sitecomp(RECOVERY_SITECOMP), + Source::status(RECOVERY_STATUS), + ])); + assert_eq!(recovery.results.len(), 1); + assert_eq!(recovery.results[0].state, SccmSiteCoreState::Recovered); + assert_eq!( + recovery.results[0].finding_class, + Some(SccmFindingClass::Symptom) + ); + assert!(recovery.results[0] + .evidence + .iter() + .any(|evidence| evidence.recovery == Some(true))); +} + +#[test] +fn result_without_confirmed_success_uses_unconfirmed_finding_phase() { + let analysis = analyze_site_core(&assess(&[ + Source::sitecomp(TERMINAL_FAILURE_WITHOUT_SUCCESS), + Source::absent_status(), + ])); + + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.last_successful_phase, None); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + let finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("unconfirmed result has a conservative finding"); + assert_eq!( + serde_json::to_value(&finding.finding).expect("finding serializes")["phase"], + "siteCoreUnconfirmed" + ); +} + +#[test] +fn unrelated_same_minute_components_and_producer_hosts_never_merge() { + let assessment = assess(&[ + Source::sitecomp(CONTRADICTORY_SITECOMP), + Source::status(CONTRADICTORY_STATUS), + ]); + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 2); + assert_ne!( + analysis.results[0].transaction_key.component_id, + analysis.results[1].transaction_key.component_id + ); + assert!(analysis + .results + .iter() + .any(|result| result.state == SccmSiteCoreState::Healthy)); + assert!(analysis + .results + .iter() + .any(|result| result.state == SccmSiteCoreState::TerminalFailure)); + + let mut split_hosts = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + split_hosts + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + let split = analyze_site_core(&split_hosts); + assert_eq!(split.results.len(), 2); + assert_ne!( + split.results[0].transaction_key.producer_host_handle, + split.results[1].transaction_key.producer_host_handle + ); + assert!(split.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + assert!(split + .results + .iter() + .any(|result| { result.transaction_key.producer_host_handle == "synthetic:host:site-01" })); + assert!(split + .results + .iter() + .any(|result| { result.transaction_key.producer_host_handle == "synthetic:host:site-02" })); + + let mut foreign_gap = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); + foreign_gap + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + let foreign_gap_analysis = analyze_site_core(&foreign_gap); + assert_eq!(foreign_gap_analysis.results.len(), 1); + assert_eq!( + foreign_gap_analysis.results[0] + .coverage_gap_artifact_ids + .len(), + 1 + ); + let local_gap_id = &foreign_gap_analysis.results[0].coverage_gap_artifact_ids[0]; + assert!(local_gap_id.starts_with("site-core:missing-source:v1:")); + assert_ne!(local_gap_id, "z-site-status"); + assert!(foreign_gap_analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == *local_gap_id + && gap.source_id == "server-status" + && gap.state == SccmCoverageState::Absent + && gap.reason_code == "required-status-source-not-declared" + })); + assert!(!foreign_gap_analysis.results[0].next_artifacts.is_empty()); + assert!(foreign_gap_analysis.results[0] + .next_artifacts + .iter() + .all(|request| request.scope.producer_host_handle.as_deref() + == Some("synthetic:host:site-01"))); + for request in &foreign_gap_analysis.results[0].next_artifacts { + assert_bounded_request_has_specific_scope(request); + } +} + +#[test] +fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { + let mut wrong_encoding = Source::sitecomp(COMPONENT_FAILURE); + wrong_encoding.encoding = Some("windows-1252"); + let encoding = assess(&[wrong_encoding, Source::absent_status()]); + + let mut unknown_profile = + assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + let profiled = unknown_profile + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact"); + profiled.profile_eligible = false; + profiled.source_version = Some( + "cmtraceopen.version.sha256.v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_owned(), + ); + + let mut denied = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + denied + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .state = SccmCoverageState::AccessDenied; + + let mut incomplete_fragment = + assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + incomplete_fragment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .fragment_complete = Some(false); + + let mut missing_content_provenance = + assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + let missing_content = missing_content_provenance + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact"); + missing_content.content_sha256 = None; + missing_content.relative_path = None; + + let capped = assess(&[ + Source::capped_sitecomp(COMPONENT_FAILURE), + Source::absent_status(), + ]); + + let mut invalid_time = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + for evidence in &mut invalid_time.evidence { + evidence.timestamp.offset_minutes = None; + evidence.timestamp.utc_millis = None; + evidence.timestamp.ordering_state = SccmTimeOrderingState::OffsetInvalid; + } + + for (name, assessment) in [ + ("encoding", encoding), + ("profile", unknown_profile), + ("coverage", denied), + ("fragment", incomplete_fragment), + ("content", missing_content_provenance), + ("cap", capped), + ("time", invalid_time), + ] { + let analysis = analyze_site_core(&assessment); + assert!( + !analysis.coverage_gaps.is_empty(), + "{name} provenance must remain an explicit coverage gap" + ); + assert!( + !analysis.unlinked_observations.is_empty(), + "{name} provenance must remain an explicit observation" + ); + assert!( + !analysis.artifact_requests.is_empty(), + "{name} provenance must retain an actionable request" + ); + assert!( + !analysis.findings.is_empty(), + "{name} provenance must retain a conservative finding" + ); + assert!( + analysis.results.iter().all(|result| { + result.finding_class != Some(SccmFindingClass::ConfirmedFailure) + && result.confidence != SccmSiteCoreConfidence::High + }), + "{name} provenance produced a high-confidence terminal outcome" + ); + assert!(analysis.findings.iter().all(|finding| { + finding.finding.class != SccmFindingClass::ConfirmedFailure + || finding.finding.confidence != cmtraceopen_parser::sccm::SccmConfidence::High + })); + } +} + +#[test] +fn rotation_split_fragments_are_coverage_not_a_terminal_transaction() { + let assessment = assess(&[ + Source::sitecomp(ROTATION_CURRENT_FRAGMENT), + Source::sitecomp_lo_fragment(ROTATION_LO_FRAGMENT), + ]); + assert_eq!(assessment.artifacts.len(), 2); + assert!(assessment + .artifacts + .iter() + .all(|artifact| artifact.state == SccmCoverageState::ParseFailed)); + + let analysis = analyze_site_core(&assessment); + assert!(analysis.results.is_empty()); + assert_eq!(analysis.coverage_gaps.len(), 2); + assert!(analysis + .coverage_gaps + .iter() + .all(|gap| gap.state == SccmCoverageState::ParseFailed)); + assert_eq!(analysis.findings.len(), 2); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class != SccmFindingClass::ConfirmedFailure)); +} + +#[test] +fn incomplete_sources_are_coverage_states_not_role_health_claims() { + let analysis = analyze_site_core(&assess(&[ + Source::capped_sitecomp(HEALTHY_SITECOMP), + Source::absent_status(), + ])); + + assert!(analysis.results.is_empty()); + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == "sitecomp-current" && gap.state == SccmCoverageState::Capped + })); + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == "z-site-status" && gap.state == SccmCoverageState::Absent + })); + assert!(!analysis.findings.is_empty()); + assert!(analysis + .findings + .iter() + .all(|finding| finding.finding.class != SccmFindingClass::ConfirmedFailure)); +} + +#[test] +fn undeclared_status_source_is_an_explicit_host_scoped_coverage_gap() { + let assessment = assess(&[Source::sitecomp(HEALTHY_SITECOMP)]); + assert!(assessment + .artifacts + .iter() + .all(|artifact| artifact.source_id != "server-status")); + assert!(!assessment.evidence.is_empty()); + + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Incomplete); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + assert!(!result.evidence.is_empty()); + assert!(!analysis.cross_side_correlation_performed); + + assert_eq!(analysis.coverage_gaps.len(), 1); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.source_id, "server-status"); + assert_eq!(gap.state, SccmCoverageState::Absent); + assert_eq!(gap.reason_code, "required-status-source-not-declared"); + assert!(gap.artifact_id.starts_with("site-core:missing-source:v1:")); + assert_eq!( + result.coverage_gap_artifact_ids, + vec![gap.artifact_id.clone()] + ); + + let result_finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("insufficient-evidence result has a validated finding"); + assert_eq!( + result_finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert!(result_finding + .finding + .coverage_gaps + .iter() + .any(|finding_gap| finding_gap.artifact_id == gap.artifact_id)); + + let status_requests = analysis + .artifact_requests + .iter() + .filter(|request| request.logical_name == "server-status") + .collect::>(); + assert!(!status_requests.is_empty()); + for request in status_requests { + assert_bounded_request_has_specific_scope(request); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + } +} + +#[test] +fn undeclared_component_source_is_an_explicit_host_component_work_item_scoped_coverage_gap() { + let assessment = assess(&[Source::status(HEALTHY_STATUS)]); + assert!(assessment + .artifacts + .iter() + .all(|artifact| artifact.source_id != "server-sitecomp")); + assert!(!assessment.evidence.is_empty()); + + let analysis = analyze_site_core(&assessment); + assert_eq!(analysis.results.len(), 1); + let result = &analysis.results[0]; + assert_eq!(result.state, SccmSiteCoreState::Incomplete); + assert_eq!( + result.finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + assert!(!result.evidence.is_empty()); + + assert_eq!(analysis.coverage_gaps.len(), 1); + let gap = &analysis.coverage_gaps[0]; + assert_eq!(gap.source_id, "server-sitecomp"); + assert_eq!(gap.state, SccmCoverageState::Absent); + assert_eq!(gap.reason_code, "required-component-source-not-declared"); + assert!(gap.artifact_id.starts_with("site-core:missing-source:v1:")); + assert!(!gap.artifact_id.contains("site-01")); + assert_eq!( + result.coverage_gap_artifact_ids, + vec![gap.artifact_id.clone()] + ); + + let result_finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("insufficient-evidence result has a validated finding"); + assert_eq!( + result_finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert_eq!(result_finding.finding.coverage_gaps.len(), 1); + assert_eq!( + result_finding.finding.coverage_gaps[0].artifact_id, + gap.artifact_id + ); + + let component_requests = analysis + .artifact_requests + .iter() + .filter(|request| { + request.logical_name == "server-sitecomp" + && request.scope.producer_host_handle.as_deref() == Some("synthetic:host:site-01") + && request.scope.component_id.as_deref() == Some("SMS_EXECUTIVE") + && request.scope.work_item_id.as_deref() == Some("SC-HEALTH-001") + }) + .collect::>(); + assert_eq!(component_requests.len(), 1); + let request = component_requests[0]; + assert_eq!(request.reason_code, "matching-component-evidence-missing"); + assert_eq!(request.max_artifacts, 2); + assert_eq!( + request + .candidates + .iter() + .map(|candidate| (candidate.basename.as_str(), candidate.rotation.as_str())) + .collect::>(), + vec![ + ("sitecomp.log", "current"), + ("sitecomp.lo_", "loUnderscore") + ] + ); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + assert_eq!(request.scope.component_id.as_deref(), Some("SMS_EXECUTIVE")); + assert_eq!(request.scope.work_item_id.as_deref(), Some("SC-HEALTH-001")); + assert_eq!(request.scope.rotation_lineage_handle, None); +} + +#[test] +fn malformed_status_peer_cannot_hide_required_status_coverage() { + for malformed_id in ["a".repeat(300), "invalid/status".to_owned()] { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + replace_source_artifact_id(&mut assessment, "server-status", &malformed_id); + + assert_malformed_peer_source_fails_closed( + &analyze_site_core(&assessment), + &malformed_id, + "server-status", + "required-status-source-not-declared", + ); + } +} + +#[test] +fn malformed_component_peer_cannot_hide_required_component_coverage() { + for malformed_id in ["a".repeat(300), "invalid/component".to_owned()] { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + replace_source_artifact_id(&mut assessment, "server-sitecomp", &malformed_id); + + assert_malformed_peer_source_fails_closed( + &analyze_site_core(&assessment), + &malformed_id, + "server-sitecomp", + "required-component-source-not-declared", + ); + } +} + +#[test] +fn undeclared_component_gap_requires_admitted_status_facts() { + let unrelated_status = HEALTHY_STATUS.replace("profileId=sccm-site-core", "profileId=other"); + let analysis = analyze_site_core(&assess(&[Source::status(&unrelated_status)])); + + assert!(analysis.results.is_empty()); + assert!(analysis.coverage_gaps.is_empty()); + assert!(!analysis.cross_side_correlation_performed); +} + +#[test] +fn undeclared_component_gap_is_deterministic_under_status_only_assessment_permutation() { + let assessment = assess(&[Source::status(HEALTHY_STATUS)]); + let mut reordered = assessment.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.next_artifact_requests.reverse(); + + assert_eq!( + serde_json::to_vec(&analyze_site_core(&assessment)).expect("analysis serializes"), + serde_json::to_vec(&analyze_site_core(&reordered)).expect("analysis serializes") + ); +} + +#[test] +fn undeclared_component_gap_does_not_attach_across_producer_hosts() { + let mut assessment = assess(&[ + Source::status(HEALTHY_STATUS), + Source::sitecomp(HEALTHY_SITECOMP), + ]); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("component artifact") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + + let analysis = analyze_site_core(&assessment); + let status_only_result = analysis + .results + .iter() + .find(|result| result.transaction_key.producer_host_handle == "synthetic:host:site-01") + .expect("status-only host result"); + assert_eq!(status_only_result.coverage_gap_artifact_ids.len(), 1); + let local_gap_id = &status_only_result.coverage_gap_artifact_ids[0]; + let local_gap = analysis + .coverage_gaps + .iter() + .find(|gap| gap.artifact_id == *local_gap_id) + .expect("status-only host component gap"); + assert_eq!(local_gap.source_id, "server-sitecomp"); + assert_eq!( + local_gap.reason_code, + "required-component-source-not-declared" + ); + assert!(status_only_result.next_artifacts.iter().all(|request| { + request.scope.producer_host_handle.as_deref() == Some("synthetic:host:site-01") + })); + + let foreign_component_result = analysis + .results + .iter() + .find(|result| result.transaction_key.producer_host_handle == "synthetic:host:site-02") + .expect("foreign component host result"); + assert!(foreign_component_result + .coverage_gap_artifact_ids + .iter() + .all(|gap_id| gap_id != local_gap_id)); +} + +#[test] +fn site_core_output_is_byte_identical_after_assessment_reordering() { + let assessment = assess(&[ + Source::sitecomp(CONTRADICTORY_SITECOMP), + Source::status(CONTRADICTORY_STATUS), + ]); + let mut reordered = assessment.clone(); + reordered.artifacts.reverse(); + reordered.coverage.reverse(); + reordered.evidence.reverse(); + reordered.next_artifact_requests.reverse(); + + assert_eq!( + serde_json::to_vec(&analyze_site_core(&assessment)).expect("analysis serializes"), + serde_json::to_vec(&analyze_site_core(&reordered)).expect("analysis serializes") + ); +} + +#[test] +fn no_provenance_mutation_can_reintroduce_a_confirmed_failure() { + let mut assessment = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .capture_provenance + .as_mut() + .expect("captured source provenance") + .limit_applied = true; + + let analysis = analyze_site_core(&assessment); + assert!(analysis.results.is_empty()); + assert!(analysis + .coverage_gaps + .iter() + .any(|gap| gap.artifact_id == "sitecomp-current")); + let request = analysis + .artifact_requests + .iter() + .find(|request| request.logical_name == "server-sitecomp") + .expect("provenance coverage gap has a request"); + assert_bounded_request_has_specific_scope(request); +} + +#[test] +fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { + let healthy = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + + let mut wrong_role = healthy.clone(); + wrong_role + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .producer_role = SccmRole::ManagementPoint; + assert_explicit_gap_and_request( + &analyze_site_core(&wrong_role), + "z-site-status", + "server-status", + ); + + let mut wrong_subject = healthy.clone(); + let sitecomp = wrong_subject + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact"); + sitecomp.workflow_subject_role = Some(SccmRole::Client); + sitecomp.workflow_subject_handle = Some("synthetic:subject:client-01".to_owned()); + assert_explicit_gap_and_request( + &analyze_site_core(&wrong_subject), + "sitecomp-current", + "server-sitecomp", + ); + + let mut duplicate = healthy; + let duplicate_sitecomp = duplicate + .artifacts + .iter() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .clone(); + duplicate.artifacts.push(duplicate_sitecomp); + assert_explicit_gap_and_request( + &analyze_site_core(&duplicate), + "sitecomp-current", + "server-sitecomp", + ); + + let mut rejected_shape = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + rejected_shape + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .original_basename = Some("future-status.bin".to_owned()); + assert_explicit_gap_and_request( + &analyze_site_core(&rejected_shape), + "z-site-status", + "server-status", + ); +} + +#[test] +fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { + let healthy = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + + let mut wrong_role = healthy.clone(); + wrong_role.evidence[0].role = SccmRole::ManagementPoint; + let analysis = analyze_site_core(&wrong_role); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_gap_reason(&analysis, "sitecomp-current", "evidence-role-rejected"); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom + && observation + .evidence + .iter() + .any(|evidence| evidence.entry_id == wrong_role.evidence[0].evidence_id) + })); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let mut incomplete_reference = healthy.clone(); + incomplete_reference.evidence[0].reference.line_end = None; + let analysis = analyze_site_core(&incomplete_reference); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_gap_reason(&analysis, "sitecomp-current", "evidence-reference-rejected"); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom && observation.evidence.is_empty() + })); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let mut cross_source_reference = healthy.clone(); + cross_source_reference.evidence[0].reference.artifact_id = "z-site-status".to_owned(); + cross_source_reference.evidence[0].reference.line_start = Some(10_001); + cross_source_reference.evidence[0].reference.line_end = Some(10_001); + let analysis = analyze_site_core(&cross_source_reference); + assert_explicit_gap_and_request(&analysis, "z-site-status", "server-status"); + assert_gap_reason( + &analysis, + "z-site-status", + "evidence-source-attribution-rejected", + ); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom + && observation + .evidence + .iter() + .any(|evidence| evidence.entry_id == cross_source_reference.evidence[0].evidence_id) + })); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let mut unresolved_reference = healthy; + unresolved_reference.evidence[0].reference.artifact_id = "orphan-sitecomp-record".to_owned(); + let analysis = analyze_site_core(&unresolved_reference); + assert_explicit_gap_and_request(&analysis, "orphan-sitecomp-record", "server-sitecomp"); + assert_gap_reason( + &analysis, + "orphan-sitecomp-record", + "evidence-source-unresolved", + ); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); +} + +#[test] +fn foreign_artifact_identity_cannot_scope_an_unresolved_site_core_request() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let mut foreign_artifact = assessment.artifacts[0].clone(); + foreign_artifact.artifact_id = "foreign-artifact".to_owned(); + foreign_artifact.source_id = "server-foreign".to_owned(); + foreign_artifact.producer_host_handle = Some("synthetic:host:foreign".to_owned()); + foreign_artifact.rotation_lineage_handle = "foreign-lineage".to_owned(); + assessment.artifacts.push(foreign_artifact); + assessment.evidence[0].reference.artifact_id = "foreign-artifact".to_owned(); + + let analysis = analyze_site_core(&assessment); + let gap = analysis + .coverage_gaps + .iter() + .find(|gap| { + gap.source_id == "server-sitecomp" && gap.reason_code == "evidence-source-unresolved" + }) + .expect("foreign attribution becomes a site-core coverage gap"); + assert_ne!(gap.artifact_id, "foreign-artifact"); + assert!(gap + .artifact_id + .starts_with("site-core:rejected-artifact:v1:")); + let request = analysis + .artifact_requests + .iter() + .find(|request| request.logical_name == "server-sitecomp") + .expect("unresolved site-core evidence has a bounded request"); + assert_bounded_request_has_specific_scope(request); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + assert_ne!( + request.scope.rotation_lineage_handle.as_deref(), + Some("foreign-lineage") + ); +} + +#[test] +fn rejected_nonprofile_prose_is_coverage_not_a_profile_symptom() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment.evidence[0].message = "ordinary non-profile source prose".to_owned(); + assessment.evidence[0].role = SccmRole::ManagementPoint; + + let analysis = analyze_site_core(&assessment); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_gap_reason(&analysis, "sitecomp-current", "evidence-role-rejected"); + assert_eq!(analysis.unlinked_observations.len(), 1); + assert_eq!( + analysis.unlinked_observations[0].finding_class, + SccmFindingClass::InsufficientEvidence + ); + assert!(analysis.unlinked_observations[0].evidence.is_empty()); +} + +#[test] +fn colliding_evidence_identities_are_parse_gaps_not_silent_drops() { + let mut assessment = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); + let duplicate = assessment.evidence[0].clone(); + assessment.evidence.push(duplicate); + + let analysis = analyze_site_core(&assessment); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert!(analysis.results.is_empty()); +} + +#[test] +fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() { + let mut arbitrary_work = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + for evidence in &mut arbitrary_work.evidence { + evidence.message = evidence + .message + .replace("workItemId=SC-HEALTH-001", "workItemId=ARBITRARY-001"); + } + let arbitrary = analyze_site_core(&arbitrary_work); + assert!(arbitrary.results.is_empty()); + assert_eq!( + arbitrary.unlinked_observations.len(), + arbitrary_work.evidence.len() + ); + let arbitrary_wire = serde_json::to_string(&arbitrary).expect("analysis serializes"); + assert!(arbitrary_work + .evidence + .iter() + .all(|evidence| arbitrary_wire.contains(&evidence.evidence_id))); + + let mut unknown_status = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let rejected_id = unknown_status.evidence[0].evidence_id.clone(); + unknown_status.evidence[0].message = unknown_status.evidence[0] + .message + .replace("SC_COMPONENT_START_OK", "SC_UNREVIEWED_STATUS"); + let unknown = analyze_site_core(&unknown_status); + let unknown_wire = serde_json::to_string(&unknown).expect("analysis serializes"); + assert!(unknown_wire.contains(&rejected_id)); + let observation = unknown + .unlinked_observations + .iter() + .find(|observation| { + observation + .evidence + .iter() + .any(|evidence| evidence.entry_id == rejected_id) + }) + .expect("rejected record remains a source-local observation"); + assert_eq!(observation.finding_class, SccmFindingClass::Symptom); + assert_eq!(observation.coverage_gap_artifact_ids, Vec::::new()); + assert_eq!(observation.next_artifacts.len(), 1); + assert_eq!(observation.next_artifacts[0].candidates.len(), 1); + assert_eq!( + observation.next_artifacts[0].candidates[0].basename, + "sitecomp.log" + ); + assert_eq!( + observation.next_artifacts[0].candidates[0].rotation, + "current" + ); + let finding = unknown + .findings + .iter() + .find(|finding| finding.subject_id == observation.observation_id) + .expect("rejected record has a conservative finding"); + assert_eq!( + finding.finding.title, + "Unrecognized site core profile record" + ); + assert!(finding.finding.coverage_gaps.is_empty()); +} + +#[test] +fn semicolon_attached_unknown_profile_labels_fail_closed_in_both_orders() { + assert_delimiter_attached_unknown_label_fails_closed(';'); +} + +#[test] +fn comma_attached_unknown_profile_labels_fail_closed_in_both_orders() { + assert_delimiter_attached_unknown_label_fails_closed(','); +} + +#[test] +fn ampersand_attached_unknown_profile_labels_fail_closed_in_both_orders() { + assert_delimiter_attached_unknown_label_fails_closed('&'); +} + +#[test] +fn delimiter_separated_known_profile_labels_and_safe_prose_remain_accepted() { + for delimiter in [';', ',', '&'] { + let joined_fields = + format!("outcome=success{delimiter}terminal=false harmless prose tokens"); + let sitecomp = HEALTHY_SITECOMP.replace("outcome=success terminal=false", &joined_fields); + let status = HEALTHY_STATUS.replace("outcome=success terminal=false", &joined_fields); + let analysis = analyze_site_core(&assess(&[ + Source::sitecomp(&sitecomp), + Source::status(&status), + ])); + + assert_eq!(analysis.results.len(), 1, "delimiter {delimiter:?}"); + assert_eq!( + analysis.results[0].state, + SccmSiteCoreState::Healthy, + "delimiter {delimiter:?}" + ); + assert!( + analysis.unlinked_observations.is_empty(), + "delimiter {delimiter:?}" + ); + } +} + +#[test] +fn every_required_source_coverage_state_emits_insufficient_evidence_and_a_request() { + for state in [ + SccmCoverageState::Absent, + SccmCoverageState::AccessDenied, + SccmCoverageState::Skipped, + SccmCoverageState::Unsupported, + ] { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .state = state.clone(); + assessment + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-sitecomp") + .expect("sitecomp coverage") + .state = state.clone(); + + let analysis = analyze_site_core(&assessment); + assert!(analysis + .coverage_gaps + .iter() + .any(|gap| { gap.artifact_id == "sitecomp-current" && gap.state == state })); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::InsufficientEvidence + && observation + .coverage_gap_artifact_ids + .contains(&"sitecomp-current".to_owned()) + })); + assert!(analysis + .artifact_requests + .iter() + .any(|request| request.logical_name == "server-sitecomp")); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + && result.finding_class != Some(SccmFindingClass::ConfirmedFailure) + })); + } +} + +#[test] +fn generated_result_and_finding_ids_are_bounded_stable_and_opaque() { + let analysis = analyze_site_core(&assess(&[ + Source::sitecomp(COMPONENT_FAILURE), + Source::absent_status(), + ])); + assert_eq!(analysis.results.len(), 1); + assert_eq!(analysis.findings.len(), 2); + let result = &analysis.results[0]; + assert!(result.result_id.starts_with("site-core:result:v1:")); + assert_eq!(result.result_id.len(), "site-core:result:v1:".len() + 64); + assert!(!result + .result_id + .contains(&result.transaction_key.component_id)); + assert!(!result + .result_id + .contains(&result.transaction_key.work_item_id)); + assert!(analysis.findings.iter().all(|finding| { + finding + .finding + .finding_id + .starts_with("site-core:finding:v1:") + && finding.finding.finding_id.len() == "site-core:finding:v1:".len() + 64 + })); +} + +#[test] +fn invalid_finding_inputs_become_explicit_gaps_instead_of_clearing_class() { + let mut assessment = assess(&[Source::sitecomp(COMPONENT_FAILURE), Source::absent_status()]); + let oversized_id = "a".repeat(300); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .artifact_id = oversized_id.clone(); + for coverage in &mut assessment.coverage { + for artifact_id in &mut coverage.artifact_ids { + if artifact_id == "sitecomp-current" { + *artifact_id = oversized_id.clone(); + } + } + } + for evidence in &mut assessment.evidence { + if evidence.reference.artifact_id == "sitecomp-current" { + evidence.reference.artifact_id = oversized_id.clone(); + evidence.evidence_id = format!("{oversized_id}:{}", evidence.evidence_id); + evidence.reference.entry_id = evidence.evidence_id.clone(); + } + } + + let analysis = analyze_site_core(&assessment); + assert!(analysis.results.is_empty()); + assert!(!analysis.unlinked_observations.is_empty()); + assert!(!analysis.artifact_requests.is_empty()); +} + +#[test] +fn committed_site_core_corpus_exactly_matches_every_serialized_output() { + let scenarios = [ + "healthy", + "component-failure", + "inbox-backlog", + "status-processing-failure", + "recovery", + "contradictory", + "rotation-boundary", + "incomplete", + "malformed", + ]; + let corpus = scenarios + .iter() + .map(|scenario| load_corpus_scenario(scenario)) + .collect::>(); + for (scenario, (assessment, expected)) in scenarios.into_iter().zip(corpus) { + assert_eq!( + serde_json::to_value(analyze_site_core(&assessment)) + .expect("site-core analysis serializes"), + expected, + "corpus scenario {scenario} diverged" + ); + } +} + +#[test] +fn later_same_phase_success_clears_deferred_but_unrecovered_deferred_remains() { + let cleared = analyze_site_core(&assess(&[ + Source::sitecomp(DEFERRED_THEN_ACCEPTED), + Source::absent_status(), + ])); + assert_eq!(cleared.results.len(), 1); + assert_eq!(cleared.results[0].state, SccmSiteCoreState::Incomplete); + assert_eq!( + cleared.results[0].finding_class, + Some(SccmFindingClass::InsufficientEvidence) + ); + + let pending = analyze_site_core(&assess(&[ + Source::sitecomp(INBOX_BACKLOG), + Source::absent_status(), + ])); + assert_eq!(pending.results.len(), 1); + assert_eq!( + pending.results[0].state, + SccmSiteCoreState::BlockedOrDeferred + ); +} + +#[test] +fn rotation_provenance_must_match_classification_and_requests_use_exact_pairs() { + let mut mismatch = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + mismatch + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .rotation = Some(SccmRotation::LoUnderscore); + let rejected = analyze_site_core(&mismatch); + assert_explicit_gap_and_request(&rejected, "sitecomp-current", "server-sitecomp"); + assert_eq!(rejected.results.len(), 1); + assert!(rejected.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let backlog = analyze_site_core(&assess(&[ + Source::sitecomp(INBOX_BACKLOG), + Source::absent_status(), + ])); + let request = backlog + .artifact_requests + .iter() + .find(|request| request.logical_name == "server-status") + .expect("bounded status request"); + let request_wire = serde_json::to_value(request).expect("request serializes"); + assert_eq!( + request_wire["candidates"], + json!([ + {"basename": "statmgr.log", "rotation": "current"}, + {"basename": "statmgr.lo_", "rotation": "loUnderscore"} + ]) + ); + assert!(request_wire.get("basenames").is_none()); + assert!(request_wire.get("rotations").is_none()); + + let mut unknown_rotation = assess(&[ + Source::capped_sitecomp(HEALTHY_SITECOMP), + Source::absent_status(), + ]); + unknown_rotation + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .rotation = Some(SccmRotation::Unknown(SccmUnknownRotation { + kind: "future".to_owned(), + value: None, + })); + let unknown = analyze_site_core(&unknown_rotation); + assert!(!unknown.artifact_requests.is_empty()); + for request in &unknown.artifact_requests { + assert_bounded_request_has_specific_scope(request); + } +} + +#[test] +fn intake_coverage_must_be_congruent_before_facts_can_shape_results() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment.coverage.clear(); + + let analysis = analyze_site_core(&assessment); + assert!(analysis.results.is_empty()); + assert!(!analysis.coverage_gaps.is_empty()); + assert!(!analysis.unlinked_observations.is_empty()); + assert!(!analysis.artifact_requests.is_empty()); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs index fc0cae2ec..c9173af89 100644 --- a/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs @@ -34,7 +34,7 @@ fn coverage_contract_failures(artifact: &Value) -> Vec { if matches!( state, "absent" | "accessDenied" | "capped" | "skipped" | "unsupported" | "parseFailed" - ) && artifact["rotation"]["fragmentComplete"] == true + ) && artifact["fragmentComplete"] == true { vec![format!( "{state} artifact {} cannot be a complete fragment", @@ -179,7 +179,7 @@ fn site_core_uses_canonical_rotation_and_coverage_contracts() { .as_array() .expect("rotation artifacts are an array") .iter() - .find(|artifact| artifact["rotation"]["kind"] == "loUnderscore") + .find(|artifact| artifact["rotation"]["kind"] == "lo_") .expect("rotation corpus has a .lo_ artifact"); let basename = rollover["originalBasename"] @@ -202,18 +202,31 @@ fn site_core_uses_canonical_rotation_and_coverage_contracts() { "fixtures/sccm/server/site_core/rotation-boundary/expected.json" )) .expect("rotation expected output is JSON"); - let requested_basenames = expected["unlinkedObservations"][0]["nextArtifacts"][0]["basenames"] + let requested_candidates = expected["unlinkedObservations"] .as_array() - .expect("rotation request has basenames"); - if requested_basenames + .expect("rotation output has observations") .iter() - .any(|value| value.as_str() == Some("sitecomp.log.lo_")) - || !requested_basenames - .iter() - .any(|value| value.as_str() == Some("sitecomp.lo_")) + .flat_map(|observation| { + observation["nextArtifacts"] + .as_array() + .into_iter() + .flatten() + }) + .flat_map(|request| request["candidates"].as_array().into_iter().flatten()) + .collect::>(); + let rollover_candidates = requested_candidates + .iter() + .filter(|candidate| candidate["basename"] == "sitecomp.lo_") + .copied() + .collect::>(); + if requested_candidates + .iter() + .any(|candidate| candidate["basename"] == "sitecomp.log.lo_") + || rollover_candidates.len() != 1 + || rollover_candidates[0]["rotation"] != "loUnderscore" { failures.push(format!( - "rotation-boundary: expected request must use sitecomp.lo_, got {requested_basenames:?}" + "rotation-boundary: expected request must use exactly paired sitecomp.lo_/loUnderscore, got {requested_candidates:?}" )); } @@ -225,7 +238,8 @@ fn capped_artifact_cannot_claim_a_complete_fragment() { let artifact = serde_json::json!({ "artifactId": "capped-probe", "captureState": "capped", - "rotation": {"kind": "current", "fragmentComplete": true} + "rotation": {"kind": "current"}, + "fragmentComplete": true }); assert_eq!(coverage_contract_failures(&artifact).len(), 1); @@ -287,7 +301,8 @@ fn nonphysical_states_cannot_claim_files_or_complete_fragments() { "captureState": state, "relativePath": "evidence/placeholder.log", "bytesCopied": 1, - "rotation": {"kind": "current", "fragmentComplete": true} + "rotation": {"kind": "current"}, + "fragmentComplete": true }); assert_eq!(