diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs
new file mode 100644
index 00000000..a669670d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs
@@ -0,0 +1,112 @@
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo;
+
+///
+/// Pins the "no Autopilot profile" detection that drives the autopilot_profile_missing
+/// warning event. Evidence-based: only an explicit ProfileAvailable=0 counts as missing —
+/// an absent value is no evidence either way and must NOT trigger the event (a device
+/// with a real profile has ProfileAvailable=1; older builds may not write the value).
+/// Payload sample below mirrors the real-world stub Windows caches when the ZTD service
+/// returns no profile (session 423b5360: empty ZeroTouchConfig, no tenant domain).
+///
+public class DeviceInfoCollectorAutopilotProfileMissingTests
+{
+ // Real-world AutopilotPolicyCache stub of a device without an Autopilot profile:
+ // PolicyJsonCache contains CloudAssignedAadServerData as an escaped JSON string
+ // whose ZeroTouchConfig has an empty CloudAssignedTenantDomain.
+ private const string PolicyJsonCacheWithoutProfile =
+ "{\r\n \"AutopilotCreationDate\": \"2026-07-16T13:48:31Z\",\r\n \"AutopilotUpdateTimeout\": 1800000,\r\n \"AutopilotMode\": 0,\r\n \"AutopilotCorrelationVector\": \"W1D16N3gSUORhUV2.0\",\r\n \"CloudAssignedAadServerData\": \"{\\u0022ZeroTouchConfig\\u0022:{\\u0022CloudAssignedTenantDomain\\u0022:\\u0022\\u0022,\\u0022CloudAssignedTenantUpn\\u0022:\\u0022\\u0022,\\u0022ForcedEnrollment\\u0022:0}}\"\r\n}";
+
+ private const string PolicyJsonCacheWithProfile =
+ "{\"AutopilotMode\": 0, \"CloudAssignedAadServerData\": \"{\\u0022ZeroTouchConfig\\u0022:{\\u0022CloudAssignedTenantDomain\\u0022:\\u0022contoso.com\\u0022,\\u0022ForcedEnrollment\\u0022:1}}\"}";
+
+ [Fact]
+ public void IsAutopilotProfileMissing_ProfileAvailableZero_ReturnsTrue()
+ {
+ var data = new Dictionary
+ {
+ { "ProfileAvailable", "0" },
+ { "PolicyJsonCache", PolicyJsonCacheWithoutProfile }
+ };
+
+ Assert.True(DeviceInfoCollector.IsAutopilotProfileMissing(data));
+ }
+
+ [Fact]
+ public void IsAutopilotProfileMissing_ProfileAvailableOne_ReturnsFalse()
+ {
+ var data = new Dictionary { { "ProfileAvailable", "1" } };
+
+ Assert.False(DeviceInfoCollector.IsAutopilotProfileMissing(data));
+ }
+
+ [Fact]
+ public void IsAutopilotProfileMissing_ValueAbsent_ReturnsFalse()
+ {
+ // No ProfileAvailable value = no evidence — must not claim the profile is missing.
+ var data = new Dictionary { { "PolicyJsonCache", PolicyJsonCacheWithProfile } };
+
+ Assert.False(DeviceInfoCollector.IsAutopilotProfileMissing(data));
+ }
+
+ [Fact]
+ public void IsAutopilotProfileMissing_NullData_ReturnsFalse()
+ {
+ Assert.False(DeviceInfoCollector.IsAutopilotProfileMissing(null));
+ }
+
+ [Fact]
+ public void TryExtractZeroTouchTenantDomain_EmbeddedInPolicyJsonCache_EmptyDomain()
+ {
+ var data = new Dictionary { { "PolicyJsonCache", PolicyJsonCacheWithoutProfile } };
+
+ Assert.True(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain));
+ Assert.Equal(string.Empty, domain);
+ }
+
+ [Fact]
+ public void TryExtractZeroTouchTenantDomain_EmbeddedInPolicyJsonCache_RealDomain()
+ {
+ var data = new Dictionary { { "PolicyJsonCache", PolicyJsonCacheWithProfile } };
+
+ Assert.True(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain));
+ Assert.Equal("contoso.com", domain);
+ }
+
+ [Fact]
+ public void TryExtractZeroTouchTenantDomain_TopLevelServerData_Wins()
+ {
+ // When the registry exposes CloudAssignedAadServerData as its own value it is
+ // preferred over the PolicyJsonCache-embedded copy.
+ var data = new Dictionary
+ {
+ { "CloudAssignedAadServerData", "{\"ZeroTouchConfig\":{\"CloudAssignedTenantDomain\":\"fabrikam.com\"}}" },
+ { "PolicyJsonCache", PolicyJsonCacheWithProfile }
+ };
+
+ Assert.True(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain));
+ Assert.Equal("fabrikam.com", domain);
+ }
+
+ [Theory]
+ [InlineData("not json at all")]
+ [InlineData("{\"CloudAssignedAadServerData\": 42}")] // wrong type
+ [InlineData("{\"SomethingElse\": true}")] // property absent
+ public void TryExtractZeroTouchTenantDomain_UnusablePolicyJsonCache_ReturnsFalse(string policyJsonCache)
+ {
+ var data = new Dictionary { { "PolicyJsonCache", policyJsonCache } };
+
+ Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(data, out var domain));
+ Assert.Null(domain);
+ }
+
+ [Fact]
+ public void TryExtractZeroTouchTenantDomain_NullData_ReturnsFalse()
+ {
+ Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain));
+ Assert.Null(domain);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs
index bd7bfce6..1334c9c4 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs
@@ -286,6 +286,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
+ // ProfileAvailable=0 means the ZTD service returned no profile for this device
+ // during OOBE (not Autopilot-registered, profile assignment not propagated yet,
+ // or device deleted from Autopilot). Such a device still enrolls via manual
+ // Entra join + MDM auto-enrollment and would otherwise look like a regular
+ // Autopilot session — surface the edge case explicitly.
+ if (IsAutopilotProfileMissing(data))
+ {
+ var missingData = new Dictionary
+ {
+ { "profileAvailable", "0" },
+ { "likelyCauses", "not_registered_in_autopilot,profile_assignment_not_propagated,deleted_from_autopilot" }
+ };
+ if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain))
+ {
+ missingData["zeroTouchTenantDomain"] = ztdTenantDomain ?? "";
+ missingData["zeroTouchTenantDomainEmpty"] = string.IsNullOrEmpty(ztdTenantDomain);
+ }
+
+ EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing,
+ "No Autopilot profile cached on this device (ProfileAvailable=0) — the device was likely not Autopilot-registered when OOBE ran; this enrollment appears to be a manual Entra ID join, not an Autopilot deployment.",
+ missingData,
+ EventSeverity.Warning);
+
+ _logger.Warning("EnrollmentTracker: no Autopilot profile cached (ProfileAvailable=0) — non-Autopilot OOBE enrollment suspected");
+ }
+
// Emit dedicated enrollment_type_detected event for easy filtering. Routed through
// EmitDeviceInfoEvent (same Severity/Source/Phase) so the restart dedup applies.
EmitDeviceInfoEvent(Constants.EventTypes.EnrollmentTypeDetected,
@@ -314,6 +340,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
return (detectedType, isHybridJoin, detectedAutopilotMode);
}
+ ///
+ /// True when the AutopilotPolicyCache explicitly recorded that no Autopilot profile
+ /// is available (ProfileAvailable=0). Windows writes this when the ZTD service
+ /// returned no profile for the device's hardware hash during OOBE. An absent
+ /// ProfileAvailable value is NOT treated as missing (no evidence either way).
+ ///
+ internal static bool IsAutopilotProfileMissing(Dictionary data)
+ {
+ return data != null
+ && data.TryGetValue("ProfileAvailable", out var profileAvailable)
+ && profileAvailable?.ToString() == "0";
+ }
+
+ ///
+ /// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data.
+ /// The value lives in the CloudAssignedAadServerData JSON — either as a top-level
+ /// registry value or embedded as a string property inside PolicyJsonCache.
+ /// Returns false when it cannot be located (missing values, malformed JSON).
+ ///
+ internal static bool TryExtractZeroTouchTenantDomain(Dictionary data, out string tenantDomain)
+ {
+ tenantDomain = null;
+ if (data == null) return false;
+
+ try
+ {
+ string serverDataJson = null;
+ if (data.TryGetValue("CloudAssignedAadServerData", out var topLevel))
+ {
+ serverDataJson = topLevel?.ToString();
+ }
+ else if (data.TryGetValue("PolicyJsonCache", out var policyCache))
+ {
+ var policyRaw = policyCache?.ToString();
+ if (!string.IsNullOrWhiteSpace(policyRaw))
+ {
+ using (var pDoc = System.Text.Json.JsonDocument.Parse(policyRaw))
+ {
+ if (pDoc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object &&
+ pDoc.RootElement.TryGetProperty("CloudAssignedAadServerData", out var serverDataProp) &&
+ serverDataProp.ValueKind == System.Text.Json.JsonValueKind.String)
+ {
+ serverDataJson = serverDataProp.GetString();
+ }
+ }
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(serverDataJson)) return false;
+
+ using (var doc = System.Text.Json.JsonDocument.Parse(serverDataJson))
+ {
+ if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object &&
+ doc.RootElement.TryGetProperty("ZeroTouchConfig", out var ztc) &&
+ ztc.ValueKind == System.Text.Json.JsonValueKind.Object &&
+ ztc.TryGetProperty("CloudAssignedTenantDomain", out var domainProp) &&
+ domainProp.ValueKind == System.Text.Json.JsonValueKind.String)
+ {
+ tenantDomain = domainProp.GetString();
+ return true;
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Malformed cache JSON — treat as not extractable.
+ }
+
+ return false;
+ }
+
private void CollectSecureBootStatus()
{
try
@@ -768,7 +865,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
- private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data)
+ private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data,
+ EventSeverity severity = EventSeverity.Info)
{
if (!ShouldEmitDeviceInfoEvent(eventType, data)) return;
@@ -777,7 +875,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
- Severity = EventSeverity.Info,
+ Severity = severity,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 7ae5be65..3f3c9366 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-1329
+1344
diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
index c1fb26d8..5c389773 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
@@ -94,6 +94,7 @@ export const EVENT_TYPES_CATALOG = {
'secureboot_status',
'bitlocker_status',
'autopilot_profile',
+ 'autopilot_profile_missing',
'enrollment_type_detected',
'aad_join_status',
'cert_validation',
@@ -257,6 +258,11 @@ export const DEVICE_PROPERTIES_CATALOG = {
'autopilot_profile.CloudAssignedDeviceName': { type: 'string', description: 'Cloud-assigned device name template' },
'autopilot_profile.CloudAssignedLanguage': { type: 'string', description: 'Cloud-assigned language/locale' },
},
+ autopilot_profile_missing: {
+ 'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' },
+ 'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' },
+ 'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (not_registered_in_autopilot, profile_assignment_not_propagated, deleted_from_autopilot)' },
+ },
hardware_spec: {
'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' },
'hardware_spec.cpuCores': { type: 'number', description: 'Number of physical CPU cores' },
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 5f5b1dbe..9f54cf10 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -436,6 +436,7 @@ namespace AutopilotMonitor.Shared
public const string DnsConfiguration = "dns_configuration";
public const string ProxyConfiguration = "proxy_configuration";
public const string AutopilotProfile = "autopilot_profile";
+ public const string AutopilotProfileMissing = "autopilot_profile_missing";
public const string EnrollmentTypeDetected = "enrollment_type_detected";
public const string SecureBootStatus = "secureboot_status";
public const string BitLockerStatus = "bitlocker_status";
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx
index f284ce9e..410670bc 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx
@@ -147,6 +147,11 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
const networkInterfaceInfo = getEventData("network_interface_info");
const wifiSignalInfo = getEventData("wifi_signal_info");
const autopilotProfile = normalizeAutopilotProfile(getEventData("autopilot_profile"));
+ // Explicit agent signal (new sessions) OR ProfileAvailable=0 in the cached profile
+ // (also covers sessions recorded before autopilot_profile_missing existed).
+ const autopilotProfileMissing =
+ getEventData("autopilot_profile_missing") !== null ||
+ (autopilotProfile !== null && `${autopilotProfile.ProfileAvailable}` === "0");
const aadJoinStatus = getEventData("aad_join_status");
const imeVersion = getEventData("ime_agent_version");
const realmJoinInfo = getEventData("realmjoin_detected");
@@ -379,6 +384,21 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
{/* Autopilot Profile */}
{autopilotProfile && (
+ {autopilotProfileMissing && (
+
+
+
+
+ No Autopilot profile found on this device.{" "}
+ It was likely not registered for Windows Autopilot when OOBE ran — this
+ enrollment appears to be a manual Entra ID join during OOBE, not an
+ Autopilot deployment.
+
+