From 4b015ca9e7177a2555e4330355059a693b52bea0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:49:04 -0400 Subject: [PATCH 1/8] fix(intune): make typed Store assignment intent authoritative over named_data Reducer Framework v1 Store pilot Phase 3, cluster 1 (typed intent authority; ADR-001, inventory row 1, refs #358). reduce_group previously re-read "IntuneIntent" from every group member's caller-writable named_data, so the last writer won over the typed StoreAssignment.intent field: a package inventory fact carrying named_data IntuneIntent=notTargeted flipped a typed Required assignment into a NotTargeted transaction. The typed assignment intent now travels as a typed field: - StoreClassification and StoreObservation gain typed_intent, set only by collect_from_assignment from the typed StoreAssignment.intent. The field is skip-serialized when absent, so the wire shape of non-assignment observations (and the expected-full.json golden) is unchanged. - reduce_group folds intent exclusively from typed_intent. Two typed assignments stating different intents are an unresolved contradiction and reduce to Unknown instead of letting member order pick a winner. - The reducer no longer writes a synthetic "IntuneIntent" pair into the assignment observation's named_data; caller named_data passes through untouched and is inert for intent. Un-ignores typed_required_intent_survives_caller_writable_named_data. No fixture expectation changed. Co-Authored-By: Claude Fable 5 --- .../apps/windows/microsoft_store/models.rs | 6 +++ .../apps/windows/microsoft_store/reducer.rs | 52 +++++++++---------- .../apps/windows/microsoft_store/rules.rs | 9 +++- ...ntune_windows_microsoft_store_semantics.rs | 1 - 4 files changed, 37 insertions(+), 31 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs index 0cfe45857..4a99b6f21 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs @@ -434,6 +434,12 @@ pub struct StoreObservation { pub app_id: Option, pub execution_context: StoreExecutionContext, pub action: StoreDeploymentAction, + /// Intune's typed assignment intent, present only when this observation is + /// a typed assignment. This is the authoritative statement of intent: the + /// reducer reads intent from here and never from caller-writable + /// `named_data` (ADR-001). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub typed_intent: Option, pub error: Option, /// True when the record came from a recognized provider but an event id or /// schema version this build has no rule for. diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 6d0b9564f..730c62b55 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -243,6 +243,7 @@ fn collect_from_fact(fact: &StorePackageFact, observations: &mut Vec &'static str { - match intent { - StoreAssignmentIntent::Required => "required", - StoreAssignmentIntent::Available => "available", - StoreAssignmentIntent::Uninstall => "uninstall", - StoreAssignmentIntent::NotTargeted => "notTargeted", - StoreAssignmentIntent::Unknown => "unknown", - } -} - fn collect_from_installer_outcome( outcome: &StoreInstallerOutcome, observations: &mut Vec, @@ -323,6 +313,7 @@ fn collect_from_installer_outcome( // package; that is what makes it installer-native. installer_family: StoreInstallerFamily::StoreWin32, family_declared: true, + typed_intent: None, error: outcome.exit_code.clone(), unknown_version: false, recognized: true, @@ -386,6 +377,7 @@ fn push_observation( app_id: classification.app_id, execution_context: classification.execution_context, action: classification.action, + typed_intent: classification.typed_intent, error: classification.error, unknown_version: classification.unknown_version, named_data, @@ -603,6 +595,7 @@ fn reduce_group( let mut family_basis = StoreFamilyBasis::Unvalidated; let mut action = StoreDeploymentAction::Unknown; let mut intent = StoreAssignmentIntent::Unknown; + let mut intent_conflict = false; let mut last_confirmed_phase: Option = None; let mut state = StoreTransactionState::InsufficientEvidence; let mut error: Option = None; @@ -634,18 +627,21 @@ fn reduce_group( if action == StoreDeploymentAction::Unknown { action = observation.action; } - if let Some(value) = observation - .named_data - .iter() - .find(|entry| entry.name == "IntuneIntent") - { - intent = match value.value.as_str() { - "required" => StoreAssignmentIntent::Required, - "available" => StoreAssignmentIntent::Available, - "uninstall" => StoreAssignmentIntent::Uninstall, - "notTargeted" => StoreAssignmentIntent::NotTargeted, - _ => StoreAssignmentIntent::Unknown, - }; + // Typed assignment intent is authoritative (ADR-001). It is read only + // from the typed field a real assignment carried; a caller-writable + // `named_data` pair on a package or installer observation is raw + // metadata and can never state or override an intent. Two typed + // assignments stating different intents are an unresolved contradiction + // and stay `Unknown` rather than letting input order pick a winner. + if let Some(typed) = observation.typed_intent { + if typed != StoreAssignmentIntent::Unknown { + if intent == StoreAssignmentIntent::Unknown && !intent_conflict { + intent = typed; + } else if intent != typed { + intent = StoreAssignmentIntent::Unknown; + intent_conflict = true; + } + } } has_intune_intent |= observation.origin.is_intune_intent(); diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs index a39f696d1..bb20fd00b 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs @@ -20,8 +20,8 @@ use crate::intune::evidence::{IntuneErrorCode, IntuneNamedValue}; use crate::intune::normalized::{NormalizedEventLevel, NormalizedWindowsEvent}; use super::models::{ - StoreDeploymentAction, StoreExecutionContext, StoreInstallerFamily, StorePackageIdentity, - StoreSignal, + StoreAssignmentIntent, StoreDeploymentAction, StoreExecutionContext, StoreInstallerFamily, + StorePackageIdentity, StoreSignal, }; use super::sources::{classify_event_source, StoreEventSource}; @@ -205,6 +205,10 @@ pub struct StoreClassification { pub installer_family: StoreInstallerFamily, /// True when the record stated the family rather than leaving it open. pub family_declared: bool, + /// Intune's typed assignment intent, present only when the record *is* a + /// typed assignment. Free-text or caller-writable metadata never sets it: + /// per ADR-001, untyped data is not authoritative intent. + pub typed_intent: Option, pub error: Option, /// True when a recognized provider emitted an event id or schema version /// this build has no rule for. @@ -227,6 +231,7 @@ impl Default for StoreClassification { action: StoreDeploymentAction::Unknown, installer_family: StoreInstallerFamily::Unknown, family_declared: false, + typed_intent: None, error: None, unknown_version: false, recognized: true, diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs index 4172d83b4..26c5ed132 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs @@ -149,7 +149,6 @@ fn appx_event( /// package fact arrives after the typed assignment's own entry, so the last /// writer wins and the forced `NotTargeted` state override fires. #[test] -#[ignore = "RED (Framework v1 Phase 2): fails against current reducer; fixed in Store pilot Phase 3"] fn typed_required_intent_survives_caller_writable_named_data() { let assignment = StoreAssignment { context: context("assignment", 1, IntuneSourceKind::SuppliedFact), From 81310702b061495db12cc725c290a0b914cdfe50 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:51:20 -0400 Subject: [PATCH 2/8] fix(intune): resolve Store terminal state by evidence, not input order Reducer Framework v1 Store pilot Phase 3, cluster 2 (input order and chronology; ADR-003, inventory row 2, refs #358). reduce_group folded transaction state online with state_rank(candidate) >= state_rank(state), so equal-ranked terminal statements were resolved by whichever record the caller supplied last: reversing the artifact vector flipped InstallCompleted into RegistrationFailure with no change in evidence. State is now resolved over all state-bearing observations at once: - the highest lifecycle rank still wins; - within that rank, a record superseded by a later record from the same sequenced source artifact (event-log record ids, CCM record order) is dropped, which is the explicit source-native ordering that lets a linked retry land on its final outcome; - if the surviving records still state more than one distinct state, the contradiction is unresolved and the reduction stays conservative at InsufficientEvidence (the module's existing no-single-conclusion state) instead of crowning an arbitrary winner. Supplied facts carry caller-chosen record numbers and are never sequenced against anything. Un-ignores equivalent_input_permutation_does_not_change_the_reduction. No fixture expectation changed: no fixture encodes an equal-ranked cross-artifact contradiction. Co-Authored-By: Claude Fable 5 --- .../apps/windows/microsoft_store/reducer.rs | 105 ++++++++++++++++-- ...ntune_windows_microsoft_store_semantics.rs | 1 - 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 730c62b55..94d374df6 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -561,9 +561,9 @@ fn state_for(signal: StoreSignal) -> Option { } } -/// Lifecycle rank. A higher-ranked state replaces a lower one; equal ranks -/// resolve to the later observation, which is how a retry that finally succeeds -/// lands on its success rather than on its first failure. +/// Lifecycle rank. A higher-ranked state replaces a lower one. Equal ranks are +/// resolved by [`resolve_state`], never by the order the caller supplied the +/// observations in (ADR-003). fn state_rank(state: StoreTransactionState) -> u8 { match state { StoreTransactionState::InsufficientEvidence => 0, @@ -583,6 +583,85 @@ fn state_rank(state: StoreTransactionState) -> u8 { } } +/// One observation's statement about the transaction state, with the provenance +/// needed to order it against other statements. +struct StateCandidate<'a> { + state: StoreTransactionState, + error: Option<&'a IntuneErrorCode>, + source_artifact_id: &'a str, + record_number: Option, + /// True when the observation came from a source whose own record numbering + /// is a monotonic write order (an event log's record ids, a CCM log's + /// records). Supplied facts carry a caller-chosen record number that states + /// nothing about time, so they are never sequenced against anything. + sequenced: bool, +} + +/// Whether `later` supersedes `earlier` by the source's own ordering. +/// +/// Only records from the *same* sequenced source artifact are comparable: +/// record numbers from two different artifacts, or from supplied facts, share +/// no clock and no counter. Incomparable records stay ambiguous (ADR-003). +fn supersedes(later: &StateCandidate<'_>, earlier: &StateCandidate<'_>) -> bool { + later.sequenced + && earlier.sequenced + && later.source_artifact_id == earlier.source_artifact_id + && matches!( + (later.record_number, earlier.record_number), + (Some(later), Some(earlier)) if later > earlier + ) +} + +/// Resolve the transaction state from every state-bearing observation at once. +/// +/// Caller vector order is an acquisition detail, not chronology, so this +/// resolution must give the same answer for any permutation of the same +/// observations (ADR-003): +/// +/// 1. the highest lifecycle rank wins, as before; +/// 2. within that rank, a record superseded by a later record from the same +/// sequenced source artifact is dropped — this is what lets an explicitly +/// ordered retry land on its final outcome instead of its first failure; +/// 3. if the surviving records still state more than one distinct state, the +/// evidence is an unresolved authoritative contradiction and the reduction +/// stays conservative: `InsufficientEvidence` for a single terminal +/// conclusion, with every contributing observation left visible, rather +/// than whichever record the caller happened to list last. +fn resolve_state( + candidates: &[StateCandidate<'_>], +) -> (StoreTransactionState, Option) { + let Some(top_rank) = candidates + .iter() + .map(|candidate| state_rank(candidate.state)) + .max() + else { + return (StoreTransactionState::InsufficientEvidence, None); + }; + let top: Vec<&StateCandidate<'_>> = candidates + .iter() + .filter(|candidate| state_rank(candidate.state) == top_rank) + .collect(); + let mut surviving: Vec<&StateCandidate<'_>> = top + .iter() + .filter(|candidate| !top.iter().any(|other| supersedes(other, candidate))) + .copied() + .collect(); + // Canonical evidence order, independent of the caller's input order. + surviving.sort_by_key(|candidate| (candidate.source_artifact_id, candidate.record_number)); + + let state = surviving[0].state; + if surviving + .iter() + .any(|candidate| candidate.state != state) + { + return (StoreTransactionState::InsufficientEvidence, None); + } + let error = surviving + .iter() + .find_map(|candidate| candidate.error.cloned()); + (state, error) +} + fn reduce_group( index: usize, members: &[usize], @@ -597,8 +676,7 @@ fn reduce_group( let mut intent = StoreAssignmentIntent::Unknown; let mut intent_conflict = false; let mut last_confirmed_phase: Option = None; - let mut state = StoreTransactionState::InsufficientEvidence; - let mut error: Option = None; + let mut state_candidates: Vec> = Vec::new(); let mut has_intune_intent = false; let mut has_device_evidence = false; let mut unknown_version_observed = false; @@ -658,13 +736,22 @@ fn reduce_group( } if let Some(candidate) = state_for(observation.signal) { - if state_rank(candidate) >= state_rank(state) { - state = candidate; - error.clone_from(&observation.error); - } + state_candidates.push(StateCandidate { + state: candidate, + error: observation.error.as_ref(), + source_artifact_id: &observation.context.evidence_ref.source_artifact_id, + record_number: observation.context.provenance.record_number, + sequenced: matches!( + observation.origin, + StoreEvidenceOrigin::WindowsEvent + | StoreEvidenceOrigin::IntuneManagementExtension + ), + }); } } + let (mut state, mut error) = resolve_state(&state_candidates); + if intent == StoreAssignmentIntent::NotTargeted { state = StoreTransactionState::NotTargeted; } diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs index 26c5ed132..8fa798173 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs @@ -225,7 +225,6 @@ fn typed_required_intent_survives_caller_writable_named_data() { /// `>=` over members iterated in input order, so whichever terminal record the /// caller supplied last silently wins the transaction state (and its error). #[test] -#[ignore = "RED (Framework v1 Phase 2): fails against current reducer; fixed in Store pilot Phase 3"] fn equivalent_input_permutation_does_not_change_the_reduction() { // Two equal-ranked terminal statements about the same per-user // registration. Neither event carries a source timestamp here, and the From ec0f538710cdba5bc1d0fa5939e221faaa38b583 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:52:37 -0400 Subject: [PATCH 3/8] fix(intune): stop app_id-only matches joining package-identified Store transactions Reducer Framework v1 Store pilot Phase 3, cluster 3 (identity and correlation; ADR-002, inventory row 3, refs #358). joinable() accepted a bare Intune app id match as a full join, so an installer failure that named no package at all was merged into the product-identified transaction that shared its app id, and its Win32InstallerFailed signal became that package's terminal InstallerFailure at High confidence. An app id match now groups observations only while neither the observation nor the group claims any package correlation token. The rule is symmetric: an identity-free record cannot be merged into a package-identified transaction, and a package-identified record cannot donate its identity to a group built from identity-free evidence. Identity-free evidence sharing an app id still reduces - as its own Intune-app-level transaction that never names a package. Un-ignores an_app_id_match_without_package_identity_cannot_drive_a_package_terminal_outcome; the Phase 2 semantics file now runs with zero ignored tests. No fixture expectation changed: every fixture observation carrying an app id also carries a package token. Co-Authored-By: Claude Fable 5 --- .../apps/windows/microsoft_store/reducer.rs | 22 ++++++++++++++++--- ...ntune_windows_microsoft_store_semantics.rs | 1 - 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 94d374df6..966208c57 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -14,6 +14,11 @@ //! keeps a per-user registration from being closed out by a machine-wide //! provisioning event. //! +//! A shared Intune app id alone is an Intune-level association, not a package +//! join (ADR-002): it may group observations only while neither side claims a +//! package identity, so an identity-free record can never be merged into a +//! package-identified transaction or drive a package-specific terminal outcome. +//! //! An observation carrying no correlation token stays unkeyed. It remains //! visible in [`StoreAnalysis::unkeyed_observations`] and can never terminate //! somebody else's transaction. @@ -415,13 +420,24 @@ fn identities_conflict(left: &[String], right: &[String]) -> bool { } /// Whether an observation may join a group. +/// +/// A shared package correlation token is a join. A shared Intune app id alone +/// is not: per ADR-002 it is an Intune-level association, at most weak or +/// moderate, so it may only group observations while *neither* side claims a +/// package identity. An identity-free record must never be merged into a +/// package-identified transaction (where its terminal signal would become a +/// package-specific conclusion), and a package-identified record must never +/// donate its identity to a group built from identity-free evidence. fn joinable(group: &Group, tokens: &[String], app_id: Option<&String>) -> bool { if identities_conflict(tokens, &group.tokens) { return false; } - let shares_identifier = tokens.iter().any(|token| group.tokens.contains(token)) - || app_id.is_some_and(|app| group.app_ids.contains(app)); - shares_identifier + if tokens.iter().any(|token| group.tokens.contains(token)) { + return true; + } + app_id.is_some_and(|app| group.app_ids.contains(app)) + && tokens.is_empty() + && group.tokens.is_empty() } /// Partition observations into transaction groups. diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs index 8fa798173..726013664 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs @@ -304,7 +304,6 @@ fn equivalent_input_permutation_does_not_change_the_reduction() { /// so the identity-free installer outcome lands in the package's group and its /// `Win32InstallerFailed` signal becomes the transaction's terminal state. #[test] -#[ignore = "RED (Framework v1 Phase 2): fails against current reducer; fixed in Store pilot Phase 3"] fn an_app_id_match_without_package_identity_cannot_drive_a_package_terminal_outcome() { let assignment = StoreAssignment { context: context("assignment", 1, IntuneSourceKind::SuppliedFact), From 474b1c696f3e2de7439aa969b294354e9f7f2cb8 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 09:57:51 -0400 Subject: [PATCH 4/8] fix(intune): harden Store source classification, degradation reasons, and Win32 failure findings Reducer Framework v1 Store pilot Phase 3, adversarial production hardening for three inventory clusters (refs #358): Source classification (inventory row 8): classify_event_source matched channels by case-insensitive substring, so an exact recognized provider name paired with 'Backup-...AppXDeploymentServer/...' or 'Contoso-StoreAgent-Archive/Old' was classified as the OS channel. Channels now match only an approved base exactly or with a '/' stream suffix; prefixed, suffixed, archive, and unrelated channels stay Unknown. Unit tests pin both directions. Evidence degradation (inventory row 7): a known failure event id logged at Information level was folded into unknown_version, conflating 'this build does not recognize the dialect' with 'the record contradicts its own level'. StoreClassification/StoreObservation now carry a separate level_mismatch flag (skip-serialized when false, so the wire golden is unchanged), both flags independently cap transaction confidence at Low, and a new store-event-level-mismatch finding documents the second reason distinctly from store-unknown-event-version. Installer-family isolation (inventory row 5): the InstallerFailure terminal state had no finding rule at all, so a Store-delivered Win32 installer failure produced findings silence while AppX failures were reported. New store-win32-installer-failed finding with Win32-native remediation, kept apart from the AppX registration rule. No fixture expectation changed. Co-Authored-By: Claude Fable 5 --- .../apps/windows/microsoft_store/findings.rs | 66 ++++++++++++++++ .../apps/windows/microsoft_store/models.rs | 7 ++ .../apps/windows/microsoft_store/reducer.rs | 12 ++- .../apps/windows/microsoft_store/rules.rs | 43 ++++++++++- .../apps/windows/microsoft_store/sources.rs | 77 ++++++++++++++++--- 5 files changed, 191 insertions(+), 14 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs index 6645fc301..e077fe451 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs @@ -31,6 +31,7 @@ pub fn derive_findings(snapshot: &StoreAnalysis) -> Vec { push_license_failure(snapshot, &mut findings); push_download_failure(snapshot, &mut findings); push_registration_failure(snapshot, &mut findings); + push_installer_failure(snapshot, &mut findings); push_provisioning_failure(snapshot, &mut findings); push_uninstall_failure(snapshot, &mut findings); push_no_interactive_user(snapshot, &mut findings); @@ -40,6 +41,7 @@ pub fn derive_findings(snapshot: &StoreAnalysis) -> Vec { push_device_failure_without_intune_intent(snapshot, &mut findings); push_ambiguous_display_name(snapshot, &mut findings); push_unknown_event_version(snapshot, &mut findings); + push_event_level_mismatch(snapshot, &mut findings); push_malformed_source(snapshot, &mut findings); push_evidence_coverage_gap(snapshot, &mut findings); push_install_completed(snapshot, &mut findings); @@ -252,6 +254,37 @@ fn push_registration_failure(snapshot: &StoreAnalysis, findings: &mut Vec) { + let affected = transactions_in_state(snapshot, StoreTransactionState::InstallerFailure); + if affected.is_empty() { + return; + } + push_finding( + findings, + "store-win32-installer-failed", + IntuneFindingSeverity::Error, + IntuneFindingConfidence::High, + "Store-delivered Win32 installer reported failure", + format!( + "The installer for {} ran and reported failure. This is the package's own Windows installer, not an AppX deployment stage.{}", + labels(&affected), + error_suffix(&affected) + ), + &[ + "Read the cited exit code against the installer's own documentation (MSI or setup engine), not against AppX deployment errors", + "Collect the installer's own log for the failing run", + ], + evidence_of(&affected), + Vec::new(), + ); +} + fn push_provisioning_failure(snapshot: &StoreAnalysis, findings: &mut Vec) { let affected = transactions_in_state(snapshot, StoreTransactionState::ProvisioningFailure); if affected.is_empty() { @@ -525,6 +558,39 @@ fn push_unknown_event_version(snapshot: &StoreAnalysis, findings: &mut Vec) { + let evidence = snapshot + .observations + .iter() + .filter(|observation| observation.level_mismatch) + .map(|observation| observation.context.evidence_ref.clone()) + .collect::>(); + if evidence.is_empty() { + return; + } + push_finding( + findings, + "store-event-level-mismatch", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "A known event's level contradicts its stated outcome", + format!( + "{} record(s) carry a recognized failure event id but were logged at Information level. The stated outcome was kept and the level was not promoted into evidence; any transaction they touch is reported at reduced confidence.", + evidence.len() + ), + &["Compare the rendered event text with the event id's documented meaning; the export may have altered the level"], + evidence, + Vec::new(), + ); +} + fn push_malformed_source(snapshot: &StoreAnalysis, findings: &mut Vec) { let gaps = snapshot .coverage diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs index 4a99b6f21..1913a118a 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs @@ -444,6 +444,13 @@ pub struct StoreObservation { /// True when the record came from a recognized provider but an event id or /// schema version this build has no rule for. pub unknown_version: bool, + /// True when a *known* event's level contradicts the outcome its event id + /// states (a failure id logged at Information level). Deliberately a + /// separate flag from [`Self::unknown_version`]: an unrecognized dialect + /// and a self-contradictory known record degrade confidence for distinct + /// reasons, and conflating them would hide which one happened. + #[serde(default, skip_serializing_if = "core::ops::Not::not")] + pub level_mismatch: bool, #[serde(default)] pub named_data: Vec, /// Verbatim record or rendered event text. Redacted by the export diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 966208c57..d4100ac8b 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -251,6 +251,7 @@ fn collect_from_fact(fact: &StorePackageFact, observations: &mut Vec StoreClassification { classification.signal = signal_for(operation, outcome); // An event that reports failure without an error code is still a failure, - // but the level must never be promoted into one. + // but the level must never be promoted into one. A failure id logged at + // Information level is a *known* record contradicting itself, which is a + // different degradation than an unrecognized dialect, so it is flagged + // separately rather than folded into `unknown_version`. if outcome == EventOutcome::Failed && event.level == NormalizedEventLevel::Information { - classification.unknown_version = true; + classification.level_mismatch = true; } classification @@ -664,10 +672,41 @@ mod tests { )); assert_eq!(classification.signal, StoreSignal::Unclassified); assert!(classification.unknown_version); + assert!( + !classification.level_mismatch, + "an unrecognized dialect is not a level contradiction" + ); // Identity survives so the record stays correlatable. assert!(!classification.identity.is_empty()); } + /// A *known* failure id logged at Information level is the record + /// contradicting itself, which is a distinct degradation from an + /// unrecognized dialect and must be flagged as such (evidence-degradation + /// cluster, inventory row 7). + #[test] + fn a_known_failure_id_at_information_level_is_a_level_mismatch_not_an_unknown_version() { + let mut contradictory = event( + 404, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Contoso.SynthApp_9abcdef01234h"), + ("ErrorCode", "0x80073CF9"), + ], + ); + contradictory.level = NormalizedEventLevel::Information; + let classification = classify_event(&contradictory); + // The stated outcome is kept; the level is never promoted into one. + assert_eq!(classification.signal, StoreSignal::RegistrationFailed); + assert!(classification.level_mismatch); + assert!( + !classification.unknown_version, + "the dialect is fully recognized; conflating the two reasons would \ + hide which degradation happened" + ); + } + #[test] fn an_unknown_provider_yields_no_signal_at_all() { let mut unknown = event(603, &[("DeploymentOperation", "Register")]); diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/sources.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/sources.rs index de5b1e4b0..9617c3eb3 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/sources.rs @@ -57,9 +57,19 @@ const APPX_DEPLOYMENT_PROVIDERS: &[&str] = &[ const APPX_PACKAGING_PROVIDERS: &[&str] = &["Microsoft-Windows-AppxPackagingOM"]; const STORE_PROVIDERS: &[&str] = &["Microsoft-Windows-StoreAgent", "Microsoft-Windows-Store"]; -const APPX_DEPLOYMENT_CHANNEL_HINTS: &[&str] = &["AppXDeployment", "AppXDeploymentServer"]; -const APPX_PACKAGING_CHANNEL_HINTS: &[&str] = &["AppxPackaging"]; -const STORE_CHANNEL_HINTS: &[&str] = &["StoreAgent", "Store/"]; +/// Approved channel bases. A channel matches when it *is* the base or is the +/// base followed by a `/` stream suffix (`.../Operational`, `.../Debug`). +/// +/// Substring matching is deliberately not used: `Backup-AppXDeploymentServer` +/// or `Contoso-StoreAgent-Archive/Old` contain the same words without being +/// the OS channel, and an archive or unrelated channel that merely *mentions* +/// a recognized name must classify as [`StoreEventSource::Unknown`]. +const APPX_DEPLOYMENT_CHANNELS: &[&str] = &[ + "Microsoft-Windows-AppXDeploymentServer", + "Microsoft-Windows-AppXDeployment", +]; +const APPX_PACKAGING_CHANNELS: &[&str] = &["Microsoft-Windows-AppxPackaging"]; +const STORE_CHANNELS: &[&str] = &["Microsoft-Windows-StoreAgent", "Microsoft-Windows-Store"]; /// Strip a rotation suffix and the `.log` extension. /// @@ -143,26 +153,33 @@ fn matches_any(value: &str, candidates: &[&str]) -> bool { .any(|candidate| candidate.eq_ignore_ascii_case(value)) } -fn contains_any(value: &str, hints: &[&str]) -> bool { - let lowered = value.to_ascii_lowercase(); - hints - .iter() - .any(|hint| lowered.contains(&hint.to_ascii_lowercase())) +/// Whether `channel` is one of the approved bases, exactly or with a `/` +/// stream suffix. Anything else — a prefixed, suffixed, archive, or unrelated +/// channel that merely contains an approved name — does not match. +fn channel_matches(channel: &str, bases: &[&str]) -> bool { + let channel = channel.trim(); + bases.iter().any(|base| { + channel + .split_at_checked(base.len()) + .is_some_and(|(head, tail)| { + head.eq_ignore_ascii_case(base) && (tail.is_empty() || tail.starts_with('/')) + }) + }) } /// Classify one normalized event by its provider and channel together. pub fn classify_event_source(provider: &str, channel: &str) -> StoreEventSource { if matches_any(provider, APPX_DEPLOYMENT_PROVIDERS) - && contains_any(channel, APPX_DEPLOYMENT_CHANNEL_HINTS) + && channel_matches(channel, APPX_DEPLOYMENT_CHANNELS) { return StoreEventSource::AppxDeployment; } if matches_any(provider, APPX_PACKAGING_PROVIDERS) - && contains_any(channel, APPX_PACKAGING_CHANNEL_HINTS) + && channel_matches(channel, APPX_PACKAGING_CHANNELS) { return StoreEventSource::AppxPackaging; } - if matches_any(provider, STORE_PROVIDERS) && contains_any(channel, STORE_CHANNEL_HINTS) { + if matches_any(provider, STORE_PROVIDERS) && channel_matches(channel, STORE_CHANNELS) { return StoreEventSource::StoreAgent; } StoreEventSource::Unknown @@ -254,6 +271,44 @@ mod tests { ); } + /// Only approved exact or `/`-suffixed channel forms match. A channel that + /// merely *contains* an approved name — prefixed, suffixed, or an archive + /// copy — is not the OS channel and stays Unknown. + #[test] + fn a_channel_that_merely_contains_an_approved_name_is_not_matched() { + for channel in [ + "Backup-Microsoft-Windows-AppXDeploymentServer/Operational", + "Microsoft-Windows-AppXDeploymentServer-Archive/Operational", + "Microsoft-Windows-AppXDeploymentServerX/Operational", + "Contoso-StoreAgent-Archive/Old", + "Microsoft-Windows-StoreAgent.bak", + ] { + assert_eq!( + classify_event_source("Microsoft-Windows-AppXDeployment-Server", channel), + StoreEventSource::Unknown, + "{channel} must not classify" + ); + assert_eq!( + classify_event_source("Microsoft-Windows-StoreAgent", channel), + StoreEventSource::Unknown, + "{channel} must not classify" + ); + } + } + + /// The exact base with no stream suffix still matches: some exports drop + /// the `/Operational` part while remaining the same channel. + #[test] + fn an_exact_channel_base_without_a_stream_suffix_matches() { + assert_eq!( + classify_event_source( + "Microsoft-Windows-AppXDeployment-Server", + "Microsoft-Windows-AppXDeploymentServer" + ), + StoreEventSource::AppxDeployment + ); + } + #[test] fn store_and_packaging_channels_are_distinguished() { assert_eq!( From 0b120df2fe4cd7aea53fbcf8127fae619f972927 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 10:05:31 -0400 Subject: [PATCH 5/8] fix(intune): make Store transaction grouping independent of artifact order Reducer Framework v1 Store pilot Phase 3, cluster 2 follow-up (ADR-003, refs #358). Found by the Phase 3 adversarial permutation test (next commit), not by the Phase 2 RED set. Greedy group discovery was order-dependent in two ways: - observations were scanned in input order, so which group was discovered first depended on the artifact vector; - an observation bridging two groups (sharing a family-name token with one and a product-id token with the other) was placed into whichever group matched first, and the other group stayed a separate transaction. Reordering the same three artifacts turned one transaction into two, with the bridge's Intune-intent evidence stranded on the second. Grouping now processes observations in the canonical order of their observation ids (identifiers of the evidence itself, stable under input permutation) and then merges groups to a fixpoint under the same join rules observations use: shared token merges; shared app id merges only while both sides are package-identity-free; conflicting identities, contexts, and installer families never merge. Transaction ids and cited evidence remain in source order. No fixture expectation changed. Co-Authored-By: Claude Fable 5 --- .../apps/windows/microsoft_store/reducer.rs | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index d4100ac8b..240f7cf5b 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -444,20 +444,92 @@ fn joinable(group: &Group, tokens: &[String], app_id: Option<&String>) -> bool { && group.tokens.is_empty() } +/// Whether two groups describe the same transaction and may merge. +/// +/// Mirrors [`joinable`] at the group level: a shared correlation token merges, +/// a shared app id merges only while both sides are package-identity-free, and +/// conflicting identities, contexts, or families never merge. +fn groups_may_merge(left: &Group, right: &Group) -> bool { + if identities_conflict(&left.tokens, &right.tokens) { + return false; + } + if !left + .execution_context + .is_compatible_with(right.execution_context) + || !left.installer_family.is_compatible_with(right.installer_family) + { + return false; + } + left.tokens.iter().any(|token| right.tokens.contains(token)) + || (left.tokens.is_empty() + && right.tokens.is_empty() + && left.app_ids.iter().any(|app| right.app_ids.contains(app))) +} + +/// Merge groups until no two remaining groups may merge. +/// +/// One observation can bridge two groups — sharing a family-name token with +/// one and a product-id token with the other. Which group the bridge was +/// *placed* in is a processing detail; without this fixpoint the other group +/// stayed a separate transaction, and the split depended on discovery order. +fn merge_groups_to_fixpoint(groups: &mut Vec) { + loop { + let Some((left, right)) = (0..groups.len()) + .flat_map(|left| ((left + 1)..groups.len()).map(move |right| (left, right))) + .find(|&(left, right)| groups_may_merge(&groups[left], &groups[right])) + else { + return; + }; + let absorbed = groups.remove(right); + let target = &mut groups[left]; + for token in absorbed.tokens { + if !target.tokens.contains(&token) { + target.tokens.push(token); + } + } + for app in absorbed.app_ids { + if !target.app_ids.contains(&app) { + target.app_ids.push(app); + } + } + if target.execution_context == StoreExecutionContext::Unknown { + target.execution_context = absorbed.execution_context; + } + if target.installer_family == StoreInstallerFamily::Unknown { + target.installer_family = absorbed.installer_family; + } + target.members.extend(absorbed.members); + } +} + /// Partition observations into transaction groups. /// /// Runs in two passes. Observations that *declare* an installer family are /// placed first, so the groups exist before the ambiguous records are assigned; /// without that, whether an IME intent record landed on the per-user or the /// provisioned transaction depended on which artifact the caller happened to -/// supply first. Members and groups are then returned in source order, so -/// transaction ids and cited evidence do not depend on the pass an observation +/// supply first. +/// +/// Within each pass, observations are processed in the canonical order of +/// their observation ids — identifiers of the evidence itself — so greedy +/// group discovery does not depend on the order the caller supplied the +/// artifacts in (ADR-003). Groups are then merged to a fixpoint, and members +/// and groups are returned in source order, so transaction ids and cited +/// evidence do not depend on the pass or the discovery order an observation /// was placed in. fn group_observations(observations: &[StoreObservation]) -> Vec> { let mut groups: Vec = Vec::new(); + let mut canonical: Vec = (0..observations.len()).collect(); + canonical.sort_by(|&left, &right| { + observations[left] + .observation_id + .cmp(&observations[right].observation_id) + }); + for declared_pass in [true, false] { - for (index, observation) in observations.iter().enumerate() { + for &index in &canonical { + let observation = &observations[index]; let declares_family = observation.installer_family != StoreInstallerFamily::Unknown; if declares_family != declared_pass { continue; @@ -513,6 +585,8 @@ fn group_observations(observations: &[StoreObservation]) -> Vec> { } } + merge_groups_to_fixpoint(&mut groups); + let mut members = groups .into_iter() .map(|group| { From fc570259699af9ecd4b7f0dbda62d5f835b78e08 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 10:05:48 -0400 Subject: [PATCH 6/8] test(intune): adversarial pilot coverage for the Store reducer clusters Reducer Framework v1 Store pilot Phase 3, adversarial pilot (refs #358). Active, passing tests over the fixed reducer for every remaining inventory cluster, exercised through the public analyze_store_bundle API: - terminal precedence/retries (ADR-003): a retry ordered by the same sequenced source's record numbers transitions failure->success (and the mirror image keeps a late failure); an inventory success fact that cannot be ordered against a failure event stays a conservative InsufficientEvidence contradiction at Low confidence with both records cited, under both input orders. - installer-family isolation: AppX and Store-Win32 failures for the same product stay two transactions; store-registration-failed cites only AppX evidence and store-win32-installer-failed cites only installer-native evidence. - source classification: an archive-suffixed channel with a recognized provider yields no observations, no transactions, and no terminal outcome - coverage only (exact/prefix matching itself is unit-tested in sources.rs). - evidence degradation: unknown dialect and known-event level mismatch each degrade to Low via their own flag and their own finding (store-unknown-event-version vs store-event-level-mismatch), and the level is never promoted into an outcome. - confidence (ADR-001): duplicating device-only evidence cannot cross Medium; six copies of an unassessable record are still Low and produce no terminal outcome; a coverage gap never raises confidence and is itself reported. - permutation/duplication/irrelevant evidence (ADR-002/003): over a realistic assignment+IME+event bundle, permuting artifacts changes no conclusion (this test flushed out the grouping fix in the previous commit), duplicating an artifact changes no conclusion, and an unrelated package's failure cannot alter another transaction. - redaction (ADR-004, provisional): pins the currently observed equality scope - the token is a pure function of the value alone, i.e. global equality with no caller key, recorded but not endorsed - plus same-value/same-token, distinct-value/distinct-token, restricted values absent from the export and findings, correlation grammar surviving, and redaction never altering reducer conclusions. No token API was added or changed. Also documents the Phase 3 status in the test file header; the Phase 2 RED recordings remain as history. Co-Authored-By: Claude Fable 5 --- ...ntune_windows_microsoft_store_semantics.rs | 918 +++++++++++++++++- 1 file changed, 906 insertions(+), 12 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs index 726013664..ce8091ce8 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs @@ -9,26 +9,30 @@ //! * inventory: `docs/architecture/reducer-framework-v1-store-inventory.md`; //! * contracts: `docs/architecture/decisions/ADR-001` through `ADR-004`. //! -//! Tests that fail against the current reducer are marked `#[ignore]` so CI -//! stays green while recording the real defect; the verbatim failure output is -//! preserved in each test's doc comment. Production behavior is intentionally -//! unchanged in this phase. The fixes land in Store pilot Phase 3, which -//! removes the `#[ignore]` markers. +//! Phase 2 recorded the three confirmed defects as `#[ignore]`d RED tests with +//! their verbatim failure output. Phase 3 (this branch) fixed the reducer, +//! removed the `#[ignore]` markers, and extended this file with the +//! adversarial pilot: targeted, active coverage for the remaining inventory +//! clusters — terminal precedence and retries, installer-family isolation, +//! source classification, evidence degradation, confidence propagation, +//! permutation/duplication/irrelevant-evidence invariants, and the +//! provisional redaction equality scope. The RED recordings are kept in the +//! doc comments as the historical record of what the reducer used to do. //! //! Inputs are built through the module's public API exactly the way a native //! collector would supply them: typed payloads inside `StoreSourceArtifact`, //! with observation contexts constructed by the test. use cmtraceopen_parser::intune::apps::windows::microsoft_store::{ - analyze_store_bundle, parse_error_code, StoreArtifactPayload, StoreAssignment, - StoreAssignmentIntent, StoreDeploymentAction, StoreExecutionContext, StoreInstallerFamily, - StoreInstallerOutcome, StorePackageFact, StorePackageIdentity, StoreSourceArtifact, - StoreTransactionState, + analyze_store_bundle, parse_error_code, redact_text, redacted_export_projection, + StoreAnalysis, StoreArtifactPayload, StoreAssignment, StoreAssignmentIntent, + StoreDeploymentAction, StoreExecutionContext, StoreInstallerFamily, StoreInstallerOutcome, + StorePackageFact, StorePackageIdentity, StoreSourceArtifact, StoreTransactionState, }; use cmtraceopen_parser::intune::evidence::{ - IntuneAccessState, IntuneArtifactStatus, IntuneEvidenceRef, IntuneNamedValue, - IntuneObservationContext, IntuneParseState, IntuneProvenance, IntuneSensitivity, - IntuneSourceKind, + IntuneAccessState, IntuneArtifactStatus, IntuneEvidenceRef, IntuneFindingConfidence, + IntuneNamedValue, IntuneObservationContext, IntuneParseState, IntuneProvenance, + IntuneSensitivity, IntuneSourceKind, }; use cmtraceopen_parser::intune::normalized::{NormalizedEventLevel, NormalizedWindowsEvent}; @@ -359,3 +363,893 @@ fn an_app_id_match_without_package_identity_cannot_drive_a_package_terminal_outc .unwrap(), ); } + +// ═══ Adversarial pilot (Phase 3) ════════════════════════════════════════════ +// +// Targeted, active coverage for the remaining inventory clusters, exercised +// over the fixed reducer through the same public API. + +/// The order-independent conclusions of an analysis: one canonical line per +/// transaction, sorted. Transaction ids and presentation order deliberately +/// follow input order, so invariance is asserted over the conclusions, not +/// over the serialized vector. +fn conclusions(analysis: &StoreAnalysis) -> Vec { + let mut lines = analysis + .transactions + .iter() + .map(|transaction| { + format!( + "tokens={:?} app={:?} family={:?} context={:?} state={:?} intent={:?} \ + confidence={:?} error={:?}", + transaction.identity.correlation_tokens(), + transaction.app_id, + transaction.installer_family, + transaction.execution_context, + transaction.state, + transaction.intent, + transaction.confidence, + transaction + .error + .as_ref() + .map(|error| error.hex.clone().unwrap_or_else(|| error.raw.clone())), + ) + }) + .collect::>(); + lines.sort(); + lines +} + +fn ime_artifact(id: &str, text: &str) -> StoreSourceArtifact { + StoreSourceArtifact { + artifact_id: id.to_owned(), + family: "ime".to_owned(), + source_kind: IntuneSourceKind::ImeLog, + status: IntuneArtifactStatus::Available, + detail: None, + observed_at_utc: "2026-07-31T00:00:00Z".to_owned(), + file_name: Some("IntuneManagementExtension.log".to_owned()), + file_path: None, + payload: Some(StoreArtifactPayload::ImeText { + text: text.to_owned(), + }), + } +} + +/// A CCM-framed IME record with a confirming component. +fn ccm_line(message: &str) -> String { + format!( + r#""# + ) +} + +// == Cluster 4: terminal precedence and retries ============================= +// +// Contract (ADR-003): a retry may transition failure into success only when +// the two records are explicitly ordered by the source's own sequencing — +// here, monotonic record numbers within the same event-log artifact. A +// success that cannot be ordered against the failure (a supplied inventory +// fact from another artifact) is ambiguous and must not overwrite it. + +#[test] +fn an_explicitly_ordered_retry_success_replaces_the_earlier_failure() { + let failed = appx_event( + "appx", + 3, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073CF9"), + ], + ); + let retried = appx_event( + "appx", + 7, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + + for events in [ + vec![failed.clone(), retried.clone()], + vec![retried, failed], + ] { + let analysis = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { events }, + )]); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].state, + StoreTransactionState::InstallCompleted, + "the later record of the same sequenced source is the explicit \ + retry linkage ADR-003 requires" + ); + assert!(analysis.transactions[0].error.is_none()); + } +} + +/// The mirror image: when the source order says the *failure* came last, the +/// failure stands, no matter how the caller arranged the vector. +#[test] +fn a_source_ordered_late_failure_is_not_hidden_by_an_earlier_success() { + let completed = appx_event( + "appx", + 3, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let failed_later = appx_event( + "appx", + 7, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073CF9"), + ], + ); + + for events in [ + vec![completed.clone(), failed_later.clone()], + vec![failed_later, completed], + ] { + let analysis = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { events }, + )]); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].state, + StoreTransactionState::RegistrationFailure + ); + assert_eq!( + analysis.transactions[0] + .error + .as_ref() + .and_then(|error| error.hex.as_deref()), + Some("0x80073CF9") + ); + } +} + +/// An inventory fact saying "installed" cannot be ordered against a failure +/// event from another artifact: they share no counter and no clock. The +/// contradiction is unresolved, so the reduction must stay conservative +/// (`InsufficientEvidence`, the module's no-single-conclusion state) instead +/// of letting the ambiguous success overwrite the failure — or the failure +/// silently swallow the fact. +#[test] +fn an_unlinked_success_fact_does_not_overwrite_a_failure_event() { + let failed = appx_event( + "appx", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073CF9"), + ], + ); + let installed_fact = StorePackageFact { + context: context("inventory", 1, IntuneSourceKind::SuppliedFact), + identity: StorePackageIdentity { + package_family_name: Some(PACKAGE_FAMILY.to_owned()), + ..StorePackageIdentity::default() + }, + installer_family: StoreInstallerFamily::UwpUserContext, + execution_context: StoreExecutionContext::User, + installed: true, + provisioned: None, + named_data: Vec::new(), + }; + + let event_artifact = artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![failed], + }, + ); + let fact_artifact = artifact( + "inventory", + "inventory", + IntuneSourceKind::SuppliedFact, + StoreArtifactPayload::PackageFacts { + facts: vec![installed_fact], + }, + ); + + let forward = analyze_store_bundle(&[event_artifact.clone(), fact_artifact.clone()]); + let reversed = analyze_store_bundle(&[fact_artifact, event_artifact]); + + for analysis in [&forward, &reversed] { + assert_eq!(analysis.transactions.len(), 1); + let transaction = &analysis.transactions[0]; + assert_ne!( + transaction.state, + StoreTransactionState::InstallCompleted, + "an unlinked success must not overwrite a failure (ADR-003)" + ); + assert_eq!( + transaction.state, + StoreTransactionState::InsufficientEvidence, + "an unresolved authoritative contradiction stays conservative" + ); + assert_eq!(transaction.confidence, IntuneFindingConfidence::Low); + assert_eq!( + transaction.evidence.len(), + 2, + "both contradicting records stay visible on the transaction" + ); + } + assert_eq!(conclusions(&forward), conclusions(&reversed)); +} + +// == Cluster 5: installer-family isolation ================================== +// +// Contract: AppX/UWP and Store-Win32 observations about the same product are +// different installer grammars and stay separate transactions, each with +// family-appropriate findings; neither failure may inherit the other's +// terminal semantics or remediation. + +#[test] +fn mixed_appx_and_win32_observations_stay_separate_with_family_appropriate_findings() { + let appx_failure = appx_event( + "appx", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("StoreProductId", PRODUCT_ID), + ("ErrorCode", "0x80073CF9"), + ], + ); + let win32_failure = StoreInstallerOutcome { + context: context("installer-outcome", 1, IntuneSourceKind::SuppliedFact), + app_id: None, + identity: product_identity(), + action: StoreDeploymentAction::Install, + exit_code: Some(parse_error_code("1603")), + succeeded: Some(false), + named_data: Vec::new(), + }; + + let analysis = analyze_store_bundle(&[ + artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![appx_failure], + }, + ), + artifact( + "installer-outcome", + "installerOutcomes", + IntuneSourceKind::SuppliedFact, + StoreArtifactPayload::InstallerOutcomes { + outcomes: vec![win32_failure], + }, + ), + ]); + + assert_eq!( + analysis.transactions.len(), + 2, + "two concrete installer families never merge, even over one product id" + ); + let appx = analysis + .transactions + .iter() + .find(|transaction| transaction.installer_family == StoreInstallerFamily::UwpUserContext) + .expect("AppX transaction"); + let win32 = analysis + .transactions + .iter() + .find(|transaction| transaction.installer_family == StoreInstallerFamily::StoreWin32) + .expect("Win32 transaction"); + assert_eq!(appx.state, StoreTransactionState::RegistrationFailure); + assert_eq!(win32.state, StoreTransactionState::InstallerFailure); + + let registration = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-registration-failed") + .expect("AppX registration finding"); + let installer = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-win32-installer-failed") + .expect("Win32 installer finding"); + assert!( + registration + .evidence + .iter() + .all(|reference| reference.source_artifact_id == "appx"), + "the AppX finding must cite only AppX evidence" + ); + assert!( + installer + .evidence + .iter() + .all(|reference| reference.source_artifact_id == "installer-outcome"), + "the Win32 finding must cite only installer-native evidence" + ); +} + +// == Cluster 6: source classification ======================================= +// +// Contract: an approved provider name pasted next to an archive or otherwise +// decorated channel is not the OS channel. Such records never become Store +// evidence, so they cannot manufacture observations, transactions, or +// terminal outcomes. (Exact/prefix matching itself is pinned by unit tests in +// `sources.rs`.) + +#[test] +fn an_archive_suffixed_channel_never_becomes_store_evidence() { + let mut archived = appx_event( + "archive", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073CF9"), + ], + ); + archived.channel = "Microsoft-Windows-AppXDeploymentServer-Archive/Operational".to_owned(); + + let analysis = analyze_store_bundle(&[artifact( + "archive", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![archived], + }, + )]); + + assert!( + analysis.transactions.is_empty(), + "an unapproved channel must not drive any terminal outcome" + ); + assert!(analysis.observations.is_empty()); + assert_eq!( + analysis.coverage.len(), + 1, + "the artifact stays declared as coverage" + ); +} + +// == Cluster 7: evidence degradation ======================================== +// +// Contract (inventory row 7): an unrecognized dialect and a known record +// contradicting its own level both degrade confidence, but for distinct, +// distinctly documented reasons — not one conflated flag. + +#[test] +fn unknown_version_and_level_mismatch_degrade_for_distinct_documented_reasons() { + // A recognized provider speaking an unknown dialect. + let unknown_dialect = appx_event( + "appx-unknown", + 1, + 9999, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + // A fully recognized failure id logged at Information level. + let contradicting = appx_event( + "appx-mismatch", + 1, + 404, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Fabrikam.OtherApp_8synthh0abcd3"), + ("ErrorCode", "0x80073CF9"), + ], + ); + + let analysis = analyze_store_bundle(&[ + artifact( + "appx-unknown", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![unknown_dialect], + }, + ), + artifact( + "appx-mismatch", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![contradicting], + }, + ), + ]); + + // Both transactions are degraded to Low confidence... + for transaction in &analysis.transactions { + assert_eq!(transaction.confidence, IntuneFindingConfidence::Low); + } + // ...but each for its own documented reason. + let unknown = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-unknown-event-version") + .expect("unknown-version finding"); + let mismatch = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-event-level-mismatch") + .expect("level-mismatch finding"); + assert!(unknown + .evidence + .iter() + .all(|reference| reference.source_artifact_id == "appx-unknown")); + assert!(mismatch + .evidence + .iter() + .all(|reference| reference.source_artifact_id == "appx-mismatch")); + + // The level was not promoted into evidence: the stated failure stands. + let mismatched_transaction = analysis + .transactions + .iter() + .find(|transaction| { + transaction.identity.package_family_name.as_deref() + == Some("Fabrikam.OtherApp_8synthh0abcd3") + }) + .expect("mismatch transaction"); + assert_eq!( + mismatched_transaction.state, + StoreTransactionState::RegistrationFailure + ); +} + +// == Cluster 8: confidence propagation ====================================== +// +// Contract (ADR-001): repetition cannot promote weak evidence, and coverage +// gaps cannot raise confidence. + +#[test] +fn duplicating_device_only_evidence_cannot_raise_confidence() { + let completed = appx_event( + "appx", + 1, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let single = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![completed.clone()], + }, + )]); + // The same statement five more times, from a second copy of the artifact. + let mut copy_events = Vec::new(); + for _ in 0..5 { + copy_events.push(completed.clone()); + } + let duplicated = analyze_store_bundle(&[ + artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![completed.clone()], + }, + ), + artifact( + "appx-copy", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: copy_events, + }, + ), + ]); + + assert_eq!( + single.transactions[0].confidence, + IntuneFindingConfidence::Medium, + "device evidence without Intune intent is capped below High" + ); + assert_eq!( + duplicated.transactions[0].confidence, + single.transactions[0].confidence, + "ADR-001: duplication cannot promote evidence" + ); + assert_eq!(duplicated.transactions[0].state, single.transactions[0].state); +} + +#[test] +fn duplicating_degraded_evidence_cannot_lift_the_degradation() { + let unknown_dialect = appx_event( + "appx", + 1, + 9999, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let mut events = Vec::new(); + for _ in 0..6 { + events.push(unknown_dialect.clone()); + } + let analysis = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { events }, + )]); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].confidence, + IntuneFindingConfidence::Low, + "six copies of an unassessable record are still unassessable" + ); + assert_eq!( + analysis.transactions[0].state, + StoreTransactionState::InsufficientEvidence, + "no terminal outcome from non-assessable evidence" + ); +} + +#[test] +fn coverage_gaps_cannot_raise_confidence() { + let completed = appx_event( + "appx", + 1, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let event_artifact = artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![completed], + }, + ); + let mut missing = artifact( + "ime-log", + "ime", + IntuneSourceKind::ImeLog, + StoreArtifactPayload::ImeText { + text: String::new(), + }, + ); + missing.status = IntuneArtifactStatus::Missing; + missing.payload = None; + + let without_gap = analyze_store_bundle(std::slice::from_ref(&event_artifact)); + let with_gap = analyze_store_bundle(&[event_artifact, missing]); + + assert_eq!( + with_gap.transactions[0].confidence, + without_gap.transactions[0].confidence, + "ADR-001: a coverage gap is a gap, not corroboration" + ); + assert!( + with_gap + .findings + .iter() + .any(|finding| finding.finding_id == "store-evidence-coverage-gap"), + "the gap itself is reported" + ); +} + +// == Cluster 9: permutation, duplication, irrelevant evidence =============== +// +// Contract (ADR-002/ADR-003 executable invariants), over a realistic bundle: +// permuting artifacts changes nothing, duplicating an artifact changes no +// conclusion, and an unrelated package's evidence cannot alter another +// transaction. + +fn realistic_bundle() -> Vec { + let assignment = StoreAssignment { + context: context("assignment", 1, IntuneSourceKind::SuppliedFact), + app_id: Some(APP_ID.to_owned()), + identity: StorePackageIdentity { + store_product_id: Some(PRODUCT_ID.to_owned()), + package_family_name: Some(PACKAGE_FAMILY.to_owned()), + ..StorePackageIdentity::default() + }, + intent: StoreAssignmentIntent::Required, + target_context: StoreExecutionContext::User, + named_data: Vec::new(), + }; + let staged = appx_event( + "appx", + 1, + 400, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Stage"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let registered = appx_event( + "appx", + 2, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let ime = ime_artifact( + "ime-log", + &format!( + "{}\n{}\n", + ccm_line(&format!( + "Store app AppId={APP_ID} StoreProductId={PRODUCT_ID} is targeted for this device" + )), + ccm_line(&format!( + "Requesting store acquisition for StoreProductId={PRODUCT_ID}" + )), + ), + ); + vec![ + artifact( + "assignment", + "assignments", + IntuneSourceKind::SuppliedFact, + StoreArtifactPayload::Assignments { + assignments: vec![assignment], + }, + ), + ime, + artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![staged, registered], + }, + ), + ] +} + +#[test] +fn permuting_a_realistic_bundle_changes_no_conclusion() { + let bundle = realistic_bundle(); + let baseline = analyze_store_bundle(&bundle); + assert_eq!(baseline.transactions.len(), 1); + assert_eq!( + baseline.transactions[0].state, + StoreTransactionState::InstallCompleted + ); + assert_eq!( + baseline.transactions[0].confidence, + IntuneFindingConfidence::High + ); + + let mut reversed = bundle.clone(); + reversed.reverse(); + let mut rotated = bundle.clone(); + rotated.rotate_left(1); + for permutation in [reversed, rotated] { + assert_eq!( + conclusions(&analyze_store_bundle(&permutation)), + conclusions(&baseline), + "ADR-003: artifact order is an acquisition detail" + ); + } +} + +#[test] +fn duplicating_an_artifact_changes_no_conclusion() { + let bundle = realistic_bundle(); + let baseline = analyze_store_bundle(&bundle); + + let mut duplicated = bundle.clone(); + let mut copy = bundle[2].clone(); + copy.artifact_id = "appx-copy".to_owned(); + duplicated.push(copy); + + assert_eq!( + conclusions(&analyze_store_bundle(&duplicated)), + conclusions(&baseline), + "a re-collected copy of the same evidence adds nothing" + ); +} + +#[test] +fn an_unrelated_packages_failure_cannot_alter_another_transaction() { + let bundle = realistic_bundle(); + let baseline = analyze_store_bundle(&bundle); + + let unrelated_failure = appx_event( + "appx-unrelated", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Fabrikam.OtherApp_8synthh0abcd3"), + ("ErrorCode", "0x80073CF9"), + ], + ); + let mut widened = bundle.clone(); + widened.push(artifact( + "appx-unrelated", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![unrelated_failure], + }, + )); + let widened_analysis = analyze_store_bundle(&widened); + + // The original transaction's conclusion line is still present, unchanged. + let baseline_lines = conclusions(&baseline); + let widened_lines = conclusions(&widened_analysis); + for line in &baseline_lines { + assert!( + widened_lines.contains(line), + "adding unrelated evidence altered an existing conclusion:\n\ + missing {line}\nfrom {widened_lines:?}" + ); + } + assert_eq!( + widened_analysis.transactions.len(), + 2, + "the unrelated failure is its own transaction, not a merge" + ); +} + +// == Cluster 10: redaction scope (ADR-004, provisional) ===================== +// +// ADR-004 accepts the architecture boundary but leaves the token/equality +// scope PROVISIONAL. These tests deliberately do not add or change any token +// API; they pin the behavior the current implementation actually has so that +// a future contract change is a visible, reviewed diff. +// +// Observed equality scope, as pinned below: the token is a pure function of +// the masked value alone. The same restricted value therefore yields the same +// token across observations, artifacts, findings, separate analyses, and +// separate exports — a *global* equality scope with no caller-controlled key. +// Per ADR-004 this global scope must not be relied on for cross-artifact, +// cross-session, or cross-export correlation; it is recorded here, not +// endorsed. + +#[test] +fn same_scope_redaction_preserves_equality_and_distinct_values_stay_distinct() { + let ime = ime_artifact( + "ime-log", + &format!( + "{}\n{}\n{}\n", + ccm_line(&format!( + "Store app StoreProductId={PRODUCT_ID} is targeted for this device for synthetic.user@example.invalid" + )), + ccm_line(&format!( + "Requesting store acquisition for StoreProductId={PRODUCT_ID} for synthetic.user@example.invalid" + )), + ccm_line(&format!( + "Store app StoreProductId={PRODUCT_ID} is targeted for this device for other.user@example.invalid" + )), + ), + ); + let analysis = analyze_store_bundle(&[ime]); + let export = redacted_export_projection(&analysis); + + let same_token = redact_text("synthetic.user@example.invalid"); + let other_token = redact_text("other.user@example.invalid"); + assert_ne!( + same_token, other_token, + "different restricted values must not accidentally become equal" + ); + + let masked_messages = export + .observations + .iter() + .filter_map(|observation| observation.message.clone()) + .collect::>(); + assert_eq!( + masked_messages + .iter() + .filter(|message| message.contains(&same_token)) + .count(), + 2, + "the same value masks to the same token in every record (same-scope \ + equality preserved): {masked_messages:?}" + ); + assert_eq!( + masked_messages + .iter() + .filter(|message| message.contains(&other_token)) + .count(), + 1 + ); +} + +#[test] +fn restricted_values_are_absent_from_the_redacted_export_including_findings() { + let ime = ime_artifact( + "ime-log", + &format!( + "{}\n", + ccm_line(&format!( + r"Store app StoreProductId={PRODUCT_ID} is targeted for synthetic.user@example.invalid payload C:\Users\Synthetic User\AppData\Local\Temp\pkg" + )), + ), + ); + let analysis = analyze_store_bundle(&[ime]); + let export = redacted_export_projection(&analysis); + let serialized = serde_json::to_string(&export).expect("export serializes"); + + for restricted in ["synthetic.user@example.invalid", "Synthetic User"] { + assert!( + !serialized.contains(restricted), + "restricted value {restricted:?} leaked into the redacted export" + ); + } + let findings_only = + serde_json::to_string(&export.findings).expect("findings serialize"); + assert!(!findings_only.contains("synthetic.user@example.invalid")); + + // The correlation grammar survives redaction. + assert!(serialized.contains(PRODUCT_ID)); +} + +/// Redaction is a projection for export: it must not change any non-sensitive +/// reducer conclusion within the analysis (ADR-004). +#[test] +fn redaction_does_not_alter_reducer_conclusions() { + let analysis = analyze_store_bundle(&realistic_bundle()); + let export = redacted_export_projection(&analysis); + assert_eq!(export.transactions, analysis.transactions); + assert_eq!(export.coverage, analysis.coverage); + assert_eq!(conclusions(&export), conclusions(&analysis)); +} From 4b9af9550098af01620cf6f6936c136f45b4cfcf Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 17:54:54 -0400 Subject: [PATCH 7/8] fix(intune): address the Hermes charter review on the Store reducer - classify_event consumes the typed event_version: an unsupported template revision degrades to unknown-version coverage and cannot drive a terminal outcome (ADR-001) - a later success supersedes a failure only when the ETW activity id links both records to one operation; unlinked contradictions stay conservative (ADR-003 separates chronology from retry linkage) - state-candidate ordering breaks (artifact, record) ties on the error token so duplicate observations cannot make error selection depend on caller order - the unknown-version and level-mismatch findings normalize (sort and dedupe) their citations before counting or emitting Co-Authored-By: Claude Opus 4.8 --- .../apps/windows/microsoft_store/findings.rs | 36 ++- .../apps/windows/microsoft_store/models.rs | 8 + .../apps/windows/microsoft_store/reducer.rs | 54 +++- .../apps/windows/microsoft_store/rules.rs | 58 ++++ ...ntune_windows_microsoft_store_semantics.rs | 306 +++++++++++++++++- 5 files changed, 433 insertions(+), 29 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs index e077fe451..d575777e0 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs @@ -532,13 +532,24 @@ fn push_ambiguous_display_name(snapshot: &StoreAnalysis, findings: &mut Vec) -> Vec { + let mut evidence: Vec = refs.collect(); + evidence.sort(); + evidence.dedup(); + evidence +} + fn push_unknown_event_version(snapshot: &StoreAnalysis, findings: &mut Vec) { - let evidence = snapshot - .observations - .iter() - .filter(|observation| observation.unknown_version) - .map(|observation| observation.context.evidence_ref.clone()) - .collect::>(); + let evidence = normalized_refs( + snapshot + .observations + .iter() + .filter(|observation| observation.unknown_version) + .map(|observation| observation.context.evidence_ref.clone()), + ); if evidence.is_empty() { return; } @@ -566,12 +577,13 @@ fn push_unknown_event_version(snapshot: &StoreAnalysis, findings: &mut Vec) { - let evidence = snapshot - .observations - .iter() - .filter(|observation| observation.level_mismatch) - .map(|observation| observation.context.evidence_ref.clone()) - .collect::>(); + let evidence = normalized_refs( + snapshot + .observations + .iter() + .filter(|observation| observation.level_mismatch) + .map(|observation| observation.context.evidence_ref.clone()), + ); if evidence.is_empty() { return; } diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs index 1913a118a..d669f8546 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/models.rs @@ -434,6 +434,14 @@ pub struct StoreObservation { pub app_id: Option, pub execution_context: StoreExecutionContext, pub action: StoreDeploymentAction, + /// The source's own operation-correlation token — for a Windows event, the + /// ETW activity id. This is the only linkage that can tie a success record + /// to an earlier failure as one operation: record order alone proves + /// chronology, not linkage (ADR-003), so the reducer requires a shared + /// activity id before a later success may supersede a failure. Absent for + /// sources whose grammar carries no such token (IME text, supplied facts). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity_id: Option, /// Intune's typed assignment intent, present only when this observation is /// a typed assignment. This is the authoritative statement of intent: the /// reducer reads intent from here and never from caller-writable diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 240f7cf5b..09dd83b54 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -126,6 +126,7 @@ fn collect_from_artifact(artifact: &StoreSourceArtifact, observations: &mut Vec< event.context.clone(), StoreEvidenceOrigin::WindowsEvent, classification, + event.activity_id.clone(), event.message.clone(), event.named_data.clone(), ); @@ -229,6 +230,8 @@ fn collect_from_ime_text( context, StoreEvidenceOrigin::IntuneManagementExtension, classification, + // The CCM text grammar carries no operation-correlation token. + None, Some(record.message.clone()), Vec::new(), ); @@ -260,6 +263,7 @@ fn collect_from_fact(fact: &StorePackageFact, observations: &mut Vec, message: Option, named_data: Vec, ) { @@ -374,6 +381,7 @@ fn push_observation( observation_id, context, origin, + activity_id, signal: classification.signal, installer_family: classification.installer_family, family_basis: if classification.family_declared { @@ -684,6 +692,9 @@ struct StateCandidate<'a> { error: Option<&'a IntuneErrorCode>, source_artifact_id: &'a str, record_number: Option, + /// The source's own operation-correlation token (the ETW activity id for a + /// Windows event). Required linkage for a success to supersede a failure. + activity_id: Option<&'a str>, /// True when the observation came from a source whose own record numbering /// is a monotonic write order (an event log's record ids, a CCM log's /// records). Supplied facts carry a caller-chosen record number that states @@ -696,14 +707,37 @@ struct StateCandidate<'a> { /// Only records from the *same* sequenced source artifact are comparable: /// record numbers from two different artifacts, or from supplied facts, share /// no clock and no counter. Incomparable records stay ambiguous (ADR-003). +/// +/// ADR-003 additionally separates chronology from retry linkage: a later +/// record number proves the success was *written* later, not that it retried +/// this failure — it may belong to an unrelated deployment attempt for the +/// same package. The Store event grammar's explicit linkage token is the ETW +/// activity id (`NormalizedWindowsEvent::activity_id`, "correlates a +/// multi-event operation"), so a success replaces a failure only when both +/// records carry the same activity id; without that link the contradiction is +/// preserved and [`resolve_state`] stays conservative. The gate is +/// deliberately asymmetric: a later *failure* after a success is not the +/// retry-success case ADR-003 restricts, and hiding it would suppress a +/// device-reported failure, so plain source-native chronology still applies +/// there (and to every same-outcome refinement). fn supersedes(later: &StateCandidate<'_>, earlier: &StateCandidate<'_>) -> bool { - later.sequenced + let source_ordered = later.sequenced && earlier.sequenced && later.source_artifact_id == earlier.source_artifact_id && matches!( (later.record_number, earlier.record_number), (Some(later), Some(earlier)) if later > earlier - ) + ); + if !source_ordered { + return false; + } + if earlier.state.is_failure() && !later.state.is_failure() { + return matches!( + (later.activity_id, earlier.activity_id), + (Some(later), Some(earlier)) if later == earlier + ); + } + true } /// Resolve the transaction state from every state-bearing observation at once. @@ -740,8 +774,19 @@ fn resolve_state( .filter(|candidate| !top.iter().any(|other| supersedes(other, candidate))) .copied() .collect(); - // Canonical evidence order, independent of the caller's input order. - surviving.sort_by_key(|candidate| (candidate.source_artifact_id, candidate.record_number)); + // Canonical evidence order, independent of the caller's input order. The + // provenance key alone cannot break a tie between duplicate observations + // of the same record, so the error token — the only candidate content + // that can still differ once the states are checked equal below — is the + // final component; without it, `find_map` would report whichever + // duplicate the caller listed first. + surviving.sort_by_key(|candidate| { + ( + candidate.source_artifact_id, + candidate.record_number, + candidate.error.map(|error| error.raw.as_str()), + ) + }); let state = surviving[0].state; if surviving @@ -837,6 +882,7 @@ fn reduce_group( error: observation.error.as_ref(), source_artifact_id: &observation.context.evidence_ref.source_artifact_id, record_number: observation.context.provenance.record_number, + activity_id: observation.activity_id.as_deref(), sequenced: matches!( observation.origin, StoreEvidenceOrigin::WindowsEvent diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs index 13b698e16..fe6aac35b 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs @@ -519,6 +519,21 @@ pub fn classify_event(event: &NormalizedWindowsEvent) -> StoreClassification { return classification; }; + // The typed template version states which payload contract the record + // follows. The event-id table above was written against the baseline + // template revisions (0 and 1); a later revision redefines what the + // payload's fields mean, so nothing extracted from it is assessable under + // these rules. Non-assessable evidence cannot produce a terminal + // conclusion (ADR-001): the record degrades to unknown-version coverage, + // the same conservative route as an unrecognized event id. This is + // deliberately stricter than the named `Version` payload check below — + // there the template itself is understood and only a payload detail is + // newer, so the stated outcome is kept at reduced confidence. + if event.event_version.is_some_and(|version| version > 1) { + classification.unknown_version = true; + return classification; + } + // A payload version beyond the known set is the same problem arriving by a // different route. if let Some(version) = named(named_data, "Version") { @@ -707,6 +722,49 @@ mod tests { ); } + /// The typed route to the unknown-version degradation: a known failure + /// event id under an unsupported ETW template version is non-assessable + /// and must not classify as a failure (ADR-001). + #[test] + fn an_unsupported_typed_event_version_is_surfaced_not_interpreted() { + let mut versioned = event( + 404, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Contoso.SynthApp_9abcdef01234h"), + ("ErrorCode", "0x80073CF9"), + ], + ); + versioned.event_version = Some(2); + let classification = classify_event(&versioned); + assert_eq!(classification.signal, StoreSignal::Unclassified); + assert!(classification.unknown_version); + assert!( + !classification.level_mismatch, + "nothing was interpreted, so nothing can contradict itself" + ); + // Identity survives so the record stays correlatable. + assert!(!classification.identity.is_empty()); + + // The baseline template revisions stay assessable. + for supported in [0, 1] { + let mut baseline = event( + 404, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Contoso.SynthApp_9abcdef01234h"), + ("ErrorCode", "0x80073CF9"), + ], + ); + baseline.event_version = Some(supported); + let classification = classify_event(&baseline); + assert_eq!(classification.signal, StoreSignal::RegistrationFailed); + assert!(!classification.unknown_version); + } + } + #[test] fn an_unknown_provider_yields_no_signal_at_all() { let mut unknown = event(603, &[("DeploymentOperation", "Register")]); diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs index ce8091ce8..85eca35f1 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs @@ -424,15 +424,21 @@ fn ccm_line(message: &str) -> String { // == Cluster 4: terminal precedence and retries ============================= // -// Contract (ADR-003): a retry may transition failure into success only when -// the two records are explicitly ordered by the source's own sequencing — -// here, monotonic record numbers within the same event-log artifact. A -// success that cannot be ordered against the failure (a supplied inventory -// fact from another artifact) is ambiguous and must not overwrite it. +// Contract (ADR-003): chronology and retry linkage are separate requirements. +// Record numbers within the same event-log artifact prove which record was +// written later; they do not prove the later success belongs to the same +// operation as the earlier failure. The AppX event grammar's own linkage key +// is the ETW activity id ("correlates a multi-event operation"): a success +// replaces a failure only when both records carry the same activity id. A +// success that is merely later — or that cannot be ordered at all (a supplied +// inventory fact from another artifact) — must not overwrite the failure. + +const ACTIVITY: &str = "cccccccc-dddd-4eee-8fff-000000000001"; +const OTHER_ACTIVITY: &str = "cccccccc-dddd-4eee-8fff-000000000002"; #[test] -fn an_explicitly_ordered_retry_success_replaces_the_earlier_failure() { - let failed = appx_event( +fn a_success_linked_by_activity_id_replaces_the_earlier_failure() { + let mut failed = appx_event( "appx", 3, 404, @@ -444,7 +450,8 @@ fn an_explicitly_ordered_retry_success_replaces_the_earlier_failure() { ("ErrorCode", "0x80073CF9"), ], ); - let retried = appx_event( + failed.activity_id = Some(ACTIVITY.to_owned()); + let mut retried = appx_event( "appx", 7, 603, @@ -455,11 +462,9 @@ fn an_explicitly_ordered_retry_success_replaces_the_earlier_failure() { ("PackageFamilyName", PACKAGE_FAMILY), ], ); + retried.activity_id = Some(ACTIVITY.to_owned()); - for events in [ - vec![failed.clone(), retried.clone()], - vec![retried, failed], - ] { + for events in [vec![failed.clone(), retried.clone()], vec![retried, failed]] { let analysis = analyze_store_bundle(&[artifact( "appx", "appxDeployment", @@ -470,13 +475,88 @@ fn an_explicitly_ordered_retry_success_replaces_the_earlier_failure() { assert_eq!( analysis.transactions[0].state, StoreTransactionState::InstallCompleted, - "the later record of the same sequenced source is the explicit \ + "a shared activity id plus source-native order is the explicit \ retry linkage ADR-003 requires" ); assert!(analysis.transactions[0].error.is_none()); } } +/// The adversarial case ADR-003 exists for: two attempts with no explicit +/// link. A failure at record 3 and a success at record 7 in the same artifact +/// may be two unrelated deployment attempts; record order proves only that +/// the success was *written* later. Without a shared activity id the failure +/// must not be silently replaced — the contradiction stays conservative. +#[test] +fn a_later_unlinked_success_does_not_replace_a_same_artifact_failure() { + let failed = appx_event( + "appx", + 3, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073CF9"), + ], + ); + let later_success = appx_event( + "appx", + 7, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + + // No activity ids at all, and two *different* activity ids: in neither + // shape does the source link the success to the failure. + let mut cross_activity_failed = failed.clone(); + cross_activity_failed.activity_id = Some(ACTIVITY.to_owned()); + let mut cross_activity_success = later_success.clone(); + cross_activity_success.activity_id = Some(OTHER_ACTIVITY.to_owned()); + + for (failed, success) in [ + (failed, later_success), + (cross_activity_failed, cross_activity_success), + ] { + for events in [ + vec![failed.clone(), success.clone()], + vec![success.clone(), failed.clone()], + ] { + let analysis = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { events }, + )]); + assert_eq!(analysis.transactions.len(), 1); + let transaction = &analysis.transactions[0]; + assert_ne!( + transaction.state, + StoreTransactionState::InstallCompleted, + "ADR-003: chronology alone is not retry linkage; an unlinked \ + later success must not overwrite the failure" + ); + assert_eq!( + transaction.state, + StoreTransactionState::InsufficientEvidence, + "an unresolved authoritative contradiction stays conservative" + ); + assert_eq!(transaction.confidence, IntuneFindingConfidence::Low); + assert_eq!( + transaction.evidence.len(), + 2, + "both contradicting records stay visible on the transaction" + ); + } + } +} + /// The mirror image: when the source order says the *failure* came last, the /// failure stands, no matter how the caller arranged the vector. #[test] @@ -530,6 +610,69 @@ fn a_source_ordered_late_failure_is_not_hidden_by_an_earlier_success() { } } +/// Duplicate provenance is the tie the canonical sort cannot break from +/// `(source_artifact_id, record_number)` alone: two observations carrying the +/// same artifact and record number but different error tokens would otherwise +/// leave error selection to caller order. The tie-break must come from the +/// candidates' own content, so any permutation reports the same error. +#[test] +fn tied_provenance_duplicates_select_the_error_deterministically() { + let first = appx_event( + "appx", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073D01"), + ], + ); + let second = appx_event( + "appx", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80070005"), + ], + ); + + let mut selected = Vec::new(); + for events in [vec![first.clone(), second.clone()], vec![second, first]] { + let analysis = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { events }, + )]); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].state, + StoreTransactionState::RegistrationFailure + ); + selected.push( + analysis.transactions[0] + .error + .as_ref() + .map(|error| error.raw.clone()), + ); + } + assert_eq!( + selected[0], selected[1], + "the reported error must not depend on the caller's input order" + ); + assert_eq!( + selected[0].as_deref(), + Some("0x80070005"), + "the tie-break is canonical over candidate content, not first-seen" + ); +} + /// An inventory fact saying "installed" cannot be ordered against a failure /// event from another artifact: they share no counter and no clock. The /// contradiction is unresolved, so the reduction must stay conservative @@ -838,6 +981,143 @@ fn unknown_version_and_level_mismatch_degrade_for_distinct_documented_reasons() ); } +/// The typed route to the same degradation: `NormalizedWindowsEvent` carries +/// its ETW template version as `event_version`, and an unsupported template +/// revision means the payload contract these rules were written against no +/// longer holds. Per ADR-001 non-assessable evidence cannot produce a +/// terminal conclusion, so a known failure event id under an unsupported +/// typed version must degrade to unknown-version coverage — never classify +/// as `RegistrationFailed`. +#[test] +fn an_unsupported_typed_event_version_cannot_drive_a_terminal_outcome() { + let mut versioned = appx_event( + "appx", + 1, + 404, + NormalizedEventLevel::Error, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ("ErrorCode", "0x80073CF9"), + ], + ); + versioned.event_version = Some(2); + + let analysis = analyze_store_bundle(&[artifact( + "appx", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![versioned], + }, + )]); + + assert_eq!(analysis.transactions.len(), 1); + let transaction = &analysis.transactions[0]; + assert_ne!( + transaction.state, + StoreTransactionState::RegistrationFailure, + "ADR-001: an unsupported typed event version is non-assessable and \ + cannot drive a terminal outcome" + ); + assert_eq!( + transaction.state, + StoreTransactionState::InsufficientEvidence + ); + assert!(transaction.unknown_version_observed); + assert_eq!(transaction.confidence, IntuneFindingConfidence::Low); + assert!( + analysis + .findings + .iter() + .any(|finding| finding.finding_id == "store-unknown-event-version"), + "the unsupported version is surfaced as coverage" + ); + assert!( + !analysis + .findings + .iter() + .any(|finding| finding.finding_id == "store-registration-failed"), + "no terminal failure finding from an unsupported version" + ); +} + +/// Coverage findings must be duplicate-invariant: the same degraded record +/// supplied twice is one piece of evidence, not two, and its citation list +/// must be normalized (sorted, de-duplicated) before it is counted or +/// emitted. +#[test] +fn duplicated_degraded_observations_do_not_inflate_coverage_finding_citations() { + let unknown_dialect = appx_event( + "appx-unknown", + 1, + 9999, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let contradicting = appx_event( + "appx-mismatch", + 1, + 404, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Fabrikam.OtherApp_8synthh0abcd3"), + ("ErrorCode", "0x80073CF9"), + ], + ); + + let analysis = analyze_store_bundle(&[ + artifact( + "appx-unknown", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![unknown_dialect.clone(), unknown_dialect], + }, + ), + artifact( + "appx-mismatch", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![contradicting.clone(), contradicting], + }, + ), + ]); + + let unknown = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-unknown-event-version") + .expect("unknown-version finding"); + assert_eq!( + unknown.evidence.len(), + 1, + "the duplicated record is one citation, not two: {:?}", + unknown.evidence + ); + assert!(unknown.summary.starts_with("1 record(s)")); + + let mismatch = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-event-level-mismatch") + .expect("level-mismatch finding"); + assert_eq!( + mismatch.evidence.len(), + 1, + "the duplicated record is one citation, not two: {:?}", + mismatch.evidence + ); + assert!(mismatch.summary.starts_with("1 record(s)")); +} + // == Cluster 8: confidence propagation ====================================== // // Contract (ADR-001): repetition cannot promote weak evidence, and coverage From 4d3dd40698b9407c52c74b21e7a6cfca5bfafcd4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 8 Aug 2026 18:51:31 -0400 Subject: [PATCH 8/8] fix(intune): address the CodeRabbit review on the Store reducer Three review threads, all verified against the code and fixed: - Outcome findings no longer overstate degraded evidence. Every rule that asserts a transaction outcome (the six failure rules, no-interactive-user, and the two completion rules) now inherits the weakest confidence among its affected transactions instead of hard-coding High, so a transaction the reducer capped at Low for an unknown dialect, a level mismatch, or a malformed contributor caps every finding built on it (ADR-001). Gap and attribution rules keep their own deliberate confidence: their claim is about what is missing, not about an outcome. - The two degradation causes stay distinguishable on the wire. StoreTransaction now carries levelMismatchObserved beside unknownVersionObserved, and StoreObservation serializes levelMismatch unconditionally exactly like unknownVersion, so a consumer reading a Low transaction can tell which cause fired without replaying observations. - duplicating_an_artifact_changes_no_conclusion now rebuilds the copied artifact's events under the new artifact id, giving the copy its own source_artifact_id and evidence ids and actually exercising the cross-artifact reduction path the test claims to cover. Fixtures updated accordingly: every transaction states levelMismatchObserved explicitly (pinned in the fixture contract), the three outcome findings over degraded or one-sided transactions now state their honest confidence, and the redacted-export golden was regenerated. Co-Authored-By: Claude Opus 4.8 --- .../apps/windows/microsoft_store/findings.rs | 126 +++++++++++++-- .../apps/windows/microsoft_store/models.rs | 12 +- .../apps/windows/microsoft_store/reducer.rs | 1 + .../acquisition-license-failure/expected.json | 1 + .../expected.json | 1 + .../download-staging-failure/expected.json | 1 + .../expected.json | 1 + .../expected.json | 1 + .../expected.json | 1 + .../no-interactive-user/expected.json | 3 +- .../expected.json | 3 +- .../expected.json | 1 + .../provisioning-failure/expected.json | 1 + .../expected.json | 2 + .../store-win32-handoff-success/expected.json | 1 + .../uninstall-failure/expected.json | 1 + .../uninstall-success/expected.json | 1 + .../unknown-event-version/expected.json | 3 +- .../expected-full.json | 6 + .../expected.json | 1 + .../tests/intune_windows_microsoft_store.rs | 1 + ...ntune_windows_microsoft_store_semantics.rs | 145 +++++++++++++++++- 22 files changed, 298 insertions(+), 16 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs index d575777e0..4611e4a12 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/findings.rs @@ -131,6 +131,32 @@ fn transactions_in_state( .collect() } +/// The weakest confidence among the affected transactions. +/// +/// An outcome-asserting finding (a failure, a completion, a scheduling +/// condition) claims exactly what its transactions claim, so it may not claim +/// it more strongly than the reducer did: a transaction capped at `Low` for an +/// unknown dialect, a level mismatch, or a malformed contributor caps every +/// finding built on it (ADR-001). Rules that instead describe an evidence gap +/// or an attribution boundary keep their own deliberate confidence, because +/// their claim is about what is missing, not about an outcome. +fn weakest_confidence(transactions: &[&StoreTransaction]) -> IntuneFindingConfidence { + fn rank(confidence: &IntuneFindingConfidence) -> u8 { + match confidence { + IntuneFindingConfidence::Low => 0, + IntuneFindingConfidence::Medium => 1, + IntuneFindingConfidence::High => 2, + } + } + let mut weakest = IntuneFindingConfidence::High; + for transaction in transactions { + if rank(&transaction.confidence) < rank(&weakest) { + weakest = transaction.confidence.clone(); + } + } + weakest +} + #[allow(clippy::too_many_arguments)] fn push_finding( findings: &mut Vec, @@ -181,7 +207,7 @@ fn push_license_failure(snapshot: &StoreAnalysis, findings: &mut Vec, @@ -488,6 +489,13 @@ pub struct StoreTransaction { /// True when device-side OS evidence (event, inventory fact, installer) is present. pub has_device_evidence: bool, pub unknown_version_observed: bool, + /// True when any contributing observation carried + /// [`StoreObservation::level_mismatch`]. Kept beside + /// [`Self::unknown_version_observed`] with the same always-present shape: + /// the two degradations cap confidence identically but for distinct + /// reasons, and a consumer reading a `Low` transaction must be able to + /// tell which one happened without replaying the observations. + pub level_mismatch_observed: bool, pub observations: Vec, pub evidence: Vec, /// The smallest artifact that would advance this diagnosis. diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 09dd83b54..58ad36f89 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -937,6 +937,7 @@ fn reduce_group( has_intune_intent, has_device_evidence, unknown_version_observed, + level_mismatch_observed, next_evidence_request: next_evidence_request( installer_family, state, diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/acquisition-license-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/acquisition-license-failure/expected.json index 316f45c27..8911da424 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/acquisition-license-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/acquisition-license-failure/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/deployment-registration-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/deployment-registration-failure/expected.json index 63f32ab96..2eb9a551c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/deployment-registration-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/deployment-registration-failure/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/download-staging-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/download-staging-failure/expected.json index df20c3af4..6d9935447 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/download-staging-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/download-staging-failure/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/incomplete-event-channel-coverage/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/incomplete-event-channel-coverage/expected.json index c4cb2830b..644fc4109 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/incomplete-event-channel-coverage/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/incomplete-event-channel-coverage/expected.json @@ -34,6 +34,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": "Collect the AppX deployment scope or package provisioning state to establish the installer family", "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/intune-intent-without-os-event/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/intune-intent-without-os-event/expected.json index f17df2c02..22b259bed 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/intune-intent-without-os-event/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/intune-intent-without-os-event/expected.json @@ -34,6 +34,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": false, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": "Collect Microsoft-Windows-AppXDeploymentServer/Operational and Microsoft-Windows-StoreAgent events for this package", "evidence": [ "ime-log:2" diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/malformed-export-and-redaction/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/malformed-export-and-redaction/expected.json index d575911a8..f5827b07b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/malformed-export-and-redaction/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/malformed-export-and-redaction/expected.json @@ -34,6 +34,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/no-interactive-user/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/no-interactive-user/expected.json index bc0d03623..a06c65ff1 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/no-interactive-user/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/no-interactive-user/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": false, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": "Collect Microsoft-Windows-AppXDeploymentServer/Operational and Microsoft-Windows-StoreAgent events for this package", "evidence": [ "ime-log:2", @@ -41,7 +42,7 @@ { "findingId": "store-no-interactive-user", "severity": "warning", - "confidence": "high", + "confidence": "medium", "evidence": [ "ime-log:2", "ime-log:3" diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/os-error-without-intune-intent/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/os-error-without-intune-intent/expected.json index 8c014981d..4b40af2d5 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/os-error-without-intune-intent/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/os-error-without-intune-intent/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": false, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": "Collect IntuneManagementExtension.log or the app assignment for this package", "evidence": [ "appx-operational:1", @@ -41,7 +42,7 @@ { "findingId": "store-registration-failed", "severity": "error", - "confidence": "high", + "confidence": "medium", "evidence": [ "appx-operational:1", "appx-operational:2" diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioned-package-install-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioned-package-install-success/expected.json index 63d3dc718..56e768940 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioned-package-install-success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioned-package-install-success/expected.json @@ -34,6 +34,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioning-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioning-failure/expected.json index 7a6ae3b4e..a5f54b047 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioning-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/provisioning-failure/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/same-display-name-different-package-family/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/same-display-name-different-package-family/expected.json index e8f5a999f..17049e43e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/same-display-name-different-package-family/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/same-display-name-different-package-family/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "assignments:1", @@ -54,6 +55,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "assignments:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/store-win32-handoff-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/store-win32-handoff-success/expected.json index 549f5c9bb..7ea2c4c7d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/store-win32-handoff-success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/store-win32-handoff-success/expected.json @@ -34,6 +34,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-failure/expected.json index befc9435a..e2c545eb0 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-failure/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "assignments:1", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-success/expected.json index d81ea4849..70d3d144b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/uninstall-success/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "assignments:1", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/unknown-event-version/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/unknown-event-version/expected.json index 7ac4a0aff..7e8681c11 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/unknown-event-version/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/unknown-event-version/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": true, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", @@ -52,7 +53,7 @@ { "findingId": "store-install-completed", "severity": "info", - "confidence": "high", + "confidence": "low", "evidence": [ "appx-operational:1", "appx-operational:2", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected-full.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected-full.json index d0f0f6e84..13aa0db65 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected-full.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected-full.json @@ -22,6 +22,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "observations": [ "ime-log:2", "ime-log:3", @@ -97,6 +98,7 @@ "action": "install", "error": null, "unknownVersion": false, + "levelMismatch": false, "namedData": [], "message": "Store app AppId=11111111-2222-4333-8444-555555555555 StoreProductId=9WZSYNTH0001 PackageFamilyName=Contoso.SynthStoreApp_9synth0abcd1h is targeted for this device" }, @@ -142,6 +144,7 @@ "action": "install", "error": null, "unknownVersion": false, + "levelMismatch": false, "namedData": [], "message": "Requesting store acquisition for StoreProductId=9WZSYNTH0001" }, @@ -192,6 +195,7 @@ "action": "install", "error": null, "unknownVersion": false, + "levelMismatch": false, "namedData": [ { "name": "DeploymentOperation", @@ -259,6 +263,7 @@ "action": "install", "error": null, "unknownVersion": false, + "levelMismatch": false, "namedData": [ { "name": "DeploymentOperation", @@ -326,6 +331,7 @@ "action": "install", "error": null, "unknownVersion": false, + "levelMismatch": false, "namedData": [ { "name": "DeploymentOperation", diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected.json index ef89acdca..9f2a83185 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/apps/windows/microsoft-store/user-context-uwp-install-success/expected.json @@ -30,6 +30,7 @@ "hasIntuneIntent": true, "hasDeviceEvidence": true, "unknownVersionObserved": false, + "levelMismatchObserved": false, "nextEvidenceRequest": null, "evidence": [ "ime-log:2", diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs index 7d138995f..2f9408735 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs @@ -221,6 +221,7 @@ fn assert_transactions(scenario: &str, actual: &Value, expected: &Value) { "hasIntuneIntent", "hasDeviceEvidence", "unknownVersionObserved", + "levelMismatchObserved", "nextEvidenceRequest", ] { assert_eq!(got[key], want[key], "{at}: {key}"); diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs index 85eca35f1..117d12fa6 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store_semantics.rs @@ -981,6 +981,112 @@ fn unknown_version_and_level_mismatch_degrade_for_distinct_documented_reasons() ); } +/// The wire keeps the two degradation causes distinguishable end to end. +/// +/// A transaction capped at `Low` must say *why* without the consumer replaying +/// the observations: `unknownVersionObserved` and `levelMismatchObserved` are +/// both always present on every serialized transaction, exactly as +/// `unknownVersion` and `levelMismatch` are on every serialized observation. +/// An outcome finding over the degraded transaction inherits the cap. +#[test] +fn both_degradation_causes_stay_distinguishable_on_the_wire() { + let unknown_dialect = appx_event( + "appx-unknown", + 1, + 9999, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ); + let contradicting = appx_event( + "appx-mismatch", + 1, + 404, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", "Fabrikam.OtherApp_8synthh0abcd3"), + ("ErrorCode", "0x80073CF9"), + ], + ); + + let analysis = analyze_store_bundle(&[ + artifact( + "appx-unknown", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![unknown_dialect], + }, + ), + artifact( + "appx-mismatch", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![contradicting], + }, + ), + ]); + + let mismatched = analysis + .transactions + .iter() + .find(|transaction| { + transaction.identity.package_family_name.as_deref() + == Some("Fabrikam.OtherApp_8synthh0abcd3") + }) + .expect("mismatch transaction"); + assert!(mismatched.level_mismatch_observed); + assert!( + !mismatched.unknown_version_observed, + "the mismatch transaction must not claim the other degradation cause" + ); + let unknown = analysis + .transactions + .iter() + .find(|transaction| { + transaction.identity.package_family_name.as_deref() == Some(PACKAGE_FAMILY) + }) + .expect("unknown-dialect transaction"); + assert!(unknown.unknown_version_observed); + assert!(!unknown.level_mismatch_observed); + + // The degraded RegistrationFailure may not surface as a High-confidence + // failure finding: the outcome finding inherits the transaction's cap. + let failure = analysis + .findings + .iter() + .find(|finding| finding.finding_id == "store-registration-failed") + .expect("registration failure finding"); + assert_eq!(failure.confidence, IntuneFindingConfidence::Low); + + // Serialization parity: both cause flags are always on the wire, on every + // transaction and every observation, so `false` is a statement, not an + // absence a consumer must guess about. + let serialized = serde_json::to_value(&analysis).expect("analysis serializes"); + for transaction in serialized["transactions"].as_array().expect("transactions") { + for key in ["unknownVersionObserved", "levelMismatchObserved"] { + assert!( + transaction.get(key).is_some_and(serde_json::Value::is_boolean), + "every serialized transaction carries {key}: {transaction}" + ); + } + } + for observation in serialized["observations"].as_array().expect("observations") { + for key in ["unknownVersion", "levelMismatch"] { + assert!( + observation.get(key).is_some_and(serde_json::Value::is_boolean), + "every serialized observation carries {key}: {observation}" + ); + } + } +} + /// The typed route to the same degradation: `NormalizedWindowsEvent` carries /// its ETW template version as `event_version`, and an unsupported template /// revision means the payload contract these rules were written against no @@ -1372,9 +1478,44 @@ fn duplicating_an_artifact_changes_no_conclusion() { let bundle = realistic_bundle(); let baseline = analyze_store_bundle(&bundle); + // A genuinely re-collected copy: same records, but carried by a distinct + // artifact whose events cite their own artifact id, so every observation + // from the copy has its own `source_artifact_id` and evidence ids. This is + // what exercises the cross-artifact reduction path; renaming only the + // containing artifact while its events still cite "appx" would collapse + // back into the original evidence and test nothing. let mut duplicated = bundle.clone(); - let mut copy = bundle[2].clone(); - copy.artifact_id = "appx-copy".to_owned(); + let copy = artifact( + "appx-copy", + "appxDeployment", + IntuneSourceKind::EventLog, + StoreArtifactPayload::WindowsEvents { + events: vec![ + appx_event( + "appx-copy", + 1, + 400, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Stage"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ), + appx_event( + "appx-copy", + 2, + 603, + NormalizedEventLevel::Information, + &[ + ("DeploymentOperation", "Register"), + ("DeploymentScope", "User"), + ("PackageFamilyName", PACKAGE_FAMILY), + ], + ), + ], + }, + ); duplicated.push(copy); assert_eq!(