diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs
new file mode 100644
index 00000000..cfcf6150
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs
@@ -0,0 +1,175 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Persistence;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
+{
+ /// The V1 confidence score of .
+ public sealed class ConfigMgrClientScoreTests
+ {
+ [Fact]
+ public void Nothing_found_scores_zero_and_is_not_detected()
+ {
+ Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
+ Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
+ }
+
+ [Fact]
+ public void A_registered_running_client_scores_100()
+ {
+ var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
+ Assert.Equal(100, snapshot.ConfidenceScore);
+ }
+
+ [Fact]
+ public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
+ {
+ var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
+ Assert.True(snapshot.Detected);
+ Assert.Equal(10, snapshot.ConfidenceScore);
+ }
+
+ [Fact]
+ public void A_stopped_service_counts_without_the_running_bonus()
+ {
+ var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
+ Assert.Equal(80, snapshot.ConfidenceScore);
+ }
+ }
+
+ ///
+ /// Emission surface of via the real
+ /// . Static probe override → serial collection.
+ ///
+ [Collection("SerialThreading")]
+ public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
+ {
+ private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
+
+ private readonly TempDirectory _tmp = new TempDirectory();
+
+ public void Dispose() => _tmp.Dispose();
+
+ private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
+ {
+ var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
+ var sink = new FakeSignalIngressSink();
+ var clock = new VirtualClock(At);
+ var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
+ return (sut, sink);
+ }
+
+ private static IReadOnlyList Detected(FakeSignalIngressSink sink) =>
+ sink.Posted.Where(p =>
+ p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == "configmgr_client_detected").ToList();
+
+ private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
+ new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
+
+ [Fact]
+ public void No_client_never_emits()
+ {
+ using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
+ var (sut, sink) = BuildDetector();
+
+ sut.Check();
+ sut.Check();
+
+ Assert.Empty(Detected(sink));
+ }
+
+ [Fact]
+ public void First_detection_emits_the_v1_payload()
+ {
+ using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
+ var (sut, sink) = BuildDetector();
+
+ sut.Check();
+
+ var evt = Assert.Single(Detected(sink));
+ Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
+ Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
+ Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
+
+ var data = Assert.IsAssignableFrom>(evt.TypedPayload);
+ Assert.Equal(true, data["ccmRegistryFound"]);
+ Assert.Equal(true, data["ccmDirectoryFound"]);
+ Assert.Equal("Running", data["ccmServiceState"]);
+ Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
+ Assert.Equal("ABC", data["siteCode"]);
+ Assert.Equal(100, data["confidenceScore"]);
+ Assert.Equal("startup", data["detectedAt"]);
+ }
+
+ [Fact]
+ public void An_unchanged_state_emits_once_a_changed_state_again()
+ {
+ var current = Client("Stopped", siteCode: null);
+ using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
+ var (sut, sink) = BuildDetector();
+
+ sut.Check();
+ sut.Check();
+ Assert.Single(Detected(sink));
+
+ current = Client("Running");
+ sut.Check();
+
+ var events = Detected(sink);
+ Assert.Equal(2, events.Count);
+ var data = Assert.IsAssignableFrom>(events[0].TypedPayload);
+ Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
+ var latest = Assert.IsAssignableFrom>(events[1].TypedPayload);
+ Assert.Equal("Running", latest["ccmServiceState"]);
+ Assert.Equal("poll", latest["detectedAt"]);
+ }
+
+ [Fact]
+ public void A_restart_into_the_same_state_stays_silent()
+ {
+ var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
+ var current = Client("Running");
+ using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
+
+ var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
+ run1.Check();
+ Assert.Single(Detected(sink1));
+
+ var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
+ run2.Check();
+ Assert.Empty(Detected(sink2));
+
+ current = Client("Stopped");
+ run2.Check();
+ Assert.Single(Detected(sink2));
+ }
+
+ [Fact]
+ public void Emissions_are_capped_per_run()
+ {
+ var running = true;
+ using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
+ var (sut, sink) = BuildDetector();
+
+ for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
+ {
+ sut.Check();
+ running = !running;
+ }
+
+ Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs
new file mode 100644
index 00000000..5aa350e3
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs
@@ -0,0 +1,105 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Persistence;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
+{
+ ///
+ /// The V1 ConfigMgr client check, run on every poll: emits configmgr_client_detected
+ /// with the V1 payload when the client is first seen, and again when what it reports changes
+ /// (the service starts, a site code is assigned) — the client often installs after the agent
+ /// started. A fingerprint keeps a restart into the same state
+ /// silent. Observational only: emission goes through .
+ ///
+ internal sealed class ConfigMgrClientDetector
+ {
+ internal const string SourceLabel = "ConfigMgrClientDetector";
+
+ /// Emissions per agent run; a flapping service must not flood the timeline.
+ internal const int MaxEmissionsPerRun = 10;
+
+ private readonly string _sessionId;
+ private readonly string _tenantId;
+ private readonly InformationalEventPost _post;
+ private readonly AgentLogger _logger;
+ private readonly IClock _clock;
+ private readonly StartupEventGate? _startupGate;
+
+ private string? _lastFingerprint;
+ private int _emissions;
+ private bool _checkedOnce;
+
+ public ConfigMgrClientDetector(
+ string sessionId,
+ string tenantId,
+ InformationalEventPost post,
+ AgentLogger logger,
+ IClock clock,
+ StartupEventGate? startupGate = null)
+ {
+ _sessionId = sessionId;
+ _tenantId = tenantId;
+ _post = post ?? throw new ArgumentNullException(nameof(post));
+ _logger = logger;
+ _clock = clock ?? throw new ArgumentNullException(nameof(clock));
+ _startupGate = startupGate;
+ }
+
+ /// Host timer callback and test seam — one probe round.
+ internal void Check()
+ {
+ var trigger = _checkedOnce ? "poll" : "startup";
+ _checkedOnce = true;
+
+ var snapshot = ConfigMgrClientProbe.Read(_logger);
+ if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
+ return;
+
+ _lastFingerprint = snapshot.Fingerprint;
+ if (_startupGate != null && !_startupGate.ShouldEmit(
+ Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
+ {
+ _logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
+ return;
+ }
+
+ _emissions++;
+ var score = snapshot.ConfidenceScore;
+ var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
+ var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
+
+ _post.Emit(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = _clock.UtcNow,
+ EventType = Constants.EventTypes.ConfigMgrClientDetected,
+ Severity = EventSeverity.Info,
+ Source = SourceLabel,
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
+ Data = new Dictionary
+ {
+ ["ccmRegistryFound"] = snapshot.RegistryFound,
+ ["ccmDirectoryFound"] = snapshot.DirectoryFound,
+ ["ccmServiceState"] = snapshot.ServiceState,
+ ["ccmVersion"] = snapshot.Version ?? "unknown",
+ ["siteCode"] = snapshot.SiteCode ?? "unknown",
+ ["confidenceScore"] = score,
+ ["detectedAt"] = trigger,
+ }
+ });
+ _startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
+
+ _logger?.Info(
+ $"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
+ $"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs
new file mode 100644
index 00000000..aa203b26
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs
@@ -0,0 +1,146 @@
+#nullable enable
+using System;
+using System.IO;
+using System.ServiceProcess;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
+{
+ ///
+ /// Reads the Configuration Manager client's footprint the way the V1 agent did: the
+ /// CCM key, the version from CCMSetup, the assigned site code, the
+ /// %windir%\CCM folder and the CcmExec service. Fail-soft: any failure yields
+ /// . The service is queried only when its key
+ /// exists, so devices without the client never throw on every poll.
+ ///
+ internal static class ConfigMgrClientProbe
+ {
+ internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
+ internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
+ internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
+ internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
+ internal const string CcmExecServiceName = "CcmExec";
+
+ /// Service state when CcmExec is not installed (the V1 payload value).
+ internal const string ServiceNotFound = "not_found";
+
+ /// Test seam; use to guarantee cleanup.
+ internal static Func? TestOverride;
+
+ public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
+ {
+ var probe = TestOverride;
+ if (probe != null) return probe();
+
+ try
+ {
+ // The agent is AnyCPU; the client writes the 64-bit view.
+ using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
+
+ bool registryFound;
+ using (var ccm = hklm.OpenSubKey(CcmKeyPath))
+ registryFound = ccm != null;
+
+ string? version;
+ using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
+ version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
+
+ string? siteCode;
+ using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
+ siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
+
+ bool serviceInstalled;
+ using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
+ serviceInstalled = service != null;
+
+ var directoryFound = Directory.Exists(
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
+
+ return new ConfigMgrClientSnapshot(
+ registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
+ }
+ catch (Exception ex)
+ {
+ logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
+ return ConfigMgrClientSnapshot.Empty;
+ }
+ }
+
+ private static string ReadServiceState()
+ {
+ try
+ {
+ using (var controller = new ServiceController(CcmExecServiceName))
+ return controller.Status.ToString();
+ }
+ catch (InvalidOperationException)
+ {
+ return ServiceNotFound; // removed between the key read and the query
+ }
+ }
+
+ private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
+
+ /// Sets for the scope and restores the previous value on Dispose.
+ internal sealed class ScopedOverride : IDisposable
+ {
+ private readonly Func? _previous;
+ private int _disposed;
+
+ public ScopedOverride(Func probe)
+ {
+ _previous = TestOverride;
+ TestOverride = probe;
+ }
+
+ public void Dispose()
+ {
+ if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
+ TestOverride = _previous;
+ }
+ }
+ }
+
+ /// One read of the client's footprint, with the V1 confidence score.
+ internal sealed class ConfigMgrClientSnapshot
+ {
+ public static readonly ConfigMgrClientSnapshot Empty =
+ new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
+
+ public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
+ {
+ RegistryFound = registryFound;
+ DirectoryFound = directoryFound;
+ ServiceState = serviceState;
+ Version = version;
+ SiteCode = siteCode;
+ }
+
+ public bool RegistryFound { get; }
+ public bool DirectoryFound { get; }
+
+ /// name, or .
+ public string ServiceState { get; }
+
+ public string? Version { get; }
+ public string? SiteCode { get; }
+
+ ///
+ /// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
+ /// detection from 50 on.
+ ///
+ public int ConfidenceScore =>
+ (DirectoryFound ? 10 : 0)
+ + (RegistryFound ? 20 : 0)
+ + (Version != null ? 15 : 0)
+ + (SiteCode != null ? 15 : 0)
+ + (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
+ + (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
+
+ public bool Detected => ConfidenceScore > 0;
+
+ /// Everything the event reports; a change re-emits.
+ public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
index 3a309831..e1afe325 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
@@ -517,6 +517,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
startupGate: _startupEventGate));
}
+ // ConfigMgr client presence (the V1 check) — always-on, no config gate, on every flow.
+ hosts.Add(new ConfigMgrClientHost(
+ sessionId: sessionId,
+ tenantId: tenantId,
+ logger: logger,
+ ingress: ingress,
+ clock: clock,
+ startupGate: _startupEventGate));
+
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs
new file mode 100644
index 00000000..9a979c80
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs
@@ -0,0 +1,90 @@
+#nullable enable
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
+using AutopilotMonitor.Agent.V2.Core.Persistence;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+
+namespace AutopilotMonitor.Agent.V2.Core.Orchestration
+{
+ ///
+ /// Drives the : a first check shortly after start (the
+ /// client may already be installed) and one per minute after that (it may arrive later, e.g.
+ /// as a Win32 app). Always-on, no config gate — like the V1 check it replaces.
+ ///
+ internal sealed class ConfigMgrClientHost : ICollectorHost
+ {
+ public string Name => "ConfigMgrClientDetector";
+
+ private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
+
+ private readonly ConfigMgrClientDetector _detector;
+ private readonly AgentLogger _logger;
+ private Timer? _tickTimer;
+ private int _checking;
+ private int _disposed;
+
+ public ConfigMgrClientHost(
+ string sessionId,
+ string tenantId,
+ AgentLogger logger,
+ ISignalIngressSink ingress,
+ IClock clock,
+ StartupEventGate? startupGate)
+ {
+ if (ingress == null) throw new ArgumentNullException(nameof(ingress));
+ if (clock == null) throw new ArgumentNullException(nameof(clock));
+ _logger = logger;
+ _detector = new ConfigMgrClientDetector(
+ sessionId: sessionId,
+ tenantId: tenantId,
+ post: new InformationalEventPost(ingress, clock),
+ logger: logger,
+ clock: clock,
+ startupGate: startupGate);
+ }
+
+ public void Start()
+ {
+ _tickTimer = new Timer(_ => SafeCheck(), state: null, dueTime: FirstCheckDelay, period: PollInterval);
+ _logger.Info($"ConfigMgrClientHost: started (check every {PollInterval.TotalSeconds}s).");
+ }
+
+ public void Stop()
+ {
+ try
+ {
+ _tickTimer?.Dispose();
+ _tickTimer = null;
+ }
+ catch (Exception ex) { _logger.Warning($"ConfigMgrClientHost: timer dispose failed: {ex.Message}"); }
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
+ Stop();
+ }
+
+ private void SafeCheck()
+ {
+ // A slow service query must not stack a second check on top of the first.
+ if (Interlocked.Exchange(ref _checking, 1) == 1) return;
+ try
+ {
+ _detector.Check();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("ConfigMgrClientHost: check failed.", ex);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _checking, 0);
+ }
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 41fcc72d..a9c3fff2 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -742,6 +742,7 @@ namespace AutopilotMonitor.Shared
public const string SessionParkedWithoutDeadline = "session_parked_without_deadline"; // One-shot Warning tripwire (liveness plan PR1): a non-terminal session entered the post-AccountSetup dead-end zone (ESP final exit or advisory failure) with NO resolution-capable deadline armed. After the 2026-06-12 dead-end fixes this must never fire in production — every occurrence is a bug report for an unknown parking variant.
public const string DiskSpaceLow = "disk_space_low"; // One-shot Warning (state-change-only, 1 GB hysteresis — NOT periodic) when the system drive drops below 2 GB free. Review MON-B10: disk-full was previously only visible inside Debug performance_snapshot payloads (filtered out of timelines), so a disk-starved enrollment carried no actionable threshold signal.
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3} while device preparation is still open). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
+ public const string ConfigMgrClientDetected = "configmgr_client_detected"; // Info when the ConfigMgr client is first seen and again when its reported state changes (CCM key, CCMSetup version, assigned site code, %windir%\CCM, CcmExec state; V1 confidence score), capped per run. The V1 check, restored in V2 (D-382).
public const string KeepAwakeEngaged = "keep_awake_engaged"; // Info (state-change, NOT periodic) emitted by the opt-in UserEspKeepAwake host when it holds the device awake (system + display) on entering the User-ESP/AccountSetup phase, so idle standby cannot stall app installs / account provisioning. Reboots are unaffected. Per-tenant toggle (AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off).
public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = user_esp_exited (Shell-Core 62407 after AccountSetup progress, i.e. the user-ESP page closed; agents before 2026-09-04 wrote account_setup_complete for the synthesized completion, which fired minutes early) | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot.
@@ -1046,7 +1047,6 @@ namespace AutopilotMonitor.Shared
// search/catalog stay complete for historical V1 session analysis. V1 emit
// sites are intentionally NOT refactored to use these (V1 is going away).
// ---------------------------------------------------------------------
- public const string ConfigMgrClientDetected = "configmgr_client_detected";
public const string EnrollmentTypeMismatch = "enrollment_type_mismatch";
public const string DecisionProcessCompletion = "decision_process_completion";
public const string WhiteGloveClassification = "whiteglove_classification";
diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx
index 64ccc969..aa0f101b 100644
--- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/lib/config";
-const LAST_UPDATED = "20 October 2026";
+const LAST_UPDATED = "23 October 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
@@ -71,7 +71,7 @@ export default function PrivacyPage() {
- Device identity — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into
- Enrollment progress — phases, ESP stages, application and script results, policy activity, Windows updates installed during the enrollment, reboots, timings, and failure codes
- - Device context — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request
+ - Device context — OS build, hardware characteristics, disk and network state, whether the Configuration Manager client is installed (service state, version, site code), plus whatever your own gather rules request
- Approximate location — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
index cca1215e..ac89180b 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
@@ -146,7 +146,6 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
{configMgrDetected.ccmServiceState && configMgrDetected.ccmServiceState !== "not_found" && <> — {configMgrDetected.ccmServiceState}>}
{configMgrDetected.ccmVersion && configMgrDetected.ccmVersion !== "unknown" && <>, v{configMgrDetected.ccmVersion}>}
{configMgrDetected.siteCode && configMgrDetected.siteCode !== "unknown" && <>, site {configMgrDetected.siteCode}>}.
- {" "}The SCCM agent may trigger reboots during ESP that disrupt the enrollment flow and cause unexpected event sequences (e.g., device events appearing in the account phase, AccountSetup never starting).
)}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts
index 9b381813..6b2d26c3 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts
+++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts
@@ -109,11 +109,12 @@ export function useSessionDerivedData(
};
}, [events]);
- // Extract ConfigMgr co-management detection (if present)
+ // Latest ConfigMgr client detection — the agent reports again when the client's state changes.
// Only show badge when confidence >= 50 (directory-only is too weak).
// Default to 100 for backward compat with old agent events that lack confidenceScore.
const configMgrDetected = useMemo(() => {
- const evt = events.find(e => e.eventType === "configmgr_client_detected");
+ const detections = events.filter(e => e.eventType === "configmgr_client_detected");
+ const evt = detections[detections.length - 1];
if (!evt?.data) return null;
const confidence = (evt.data.confidenceScore as number) ?? 100;
if (confidence < 50) return null;