diff --git a/.gitignore b/.gitignore
index 4ec851f7..e4d4edca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,4 @@ scripts/Deployment/build_and_upload_release_agent_build.ps1
.vscode/
TODO.txt
tools/orphan-analysis.py
+tasks/todo.md
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs
new file mode 100644
index 00000000..78bfeeef
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs
@@ -0,0 +1,40 @@
+using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Collectors
+{
+ public class AadJoinInfoTests
+ {
+ [Theory]
+ [InlineData("foouser@contoso.onmicrosoft.com", true)]
+ [InlineData("FOOUSER@CONTOSO.onmicrosoft.com", true)]
+ [InlineData("FooUser@tenant.com", true)]
+ [InlineData("autopilot@contoso.onmicrosoft.com", true)]
+ [InlineData("AutoPilot@tenant.com", true)]
+ [InlineData("AUTOPILOT@xyz.com", true)]
+ public void IsPlaceholderUserEmail_KnownPatterns_ReturnsTrue(string email, bool expected)
+ {
+ Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email));
+ }
+
+ [Theory]
+ [InlineData("alice@contoso.onmicrosoft.com")]
+ [InlineData("bob.smith@company.com")]
+ [InlineData("foo@bar.com")]
+ [InlineData("autopilotadmin@company.com")]
+ [InlineData("myfoouser@company.com")]
+ public void IsPlaceholderUserEmail_RealEmails_ReturnsFalse(string email)
+ {
+ Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void IsPlaceholderUserEmail_NullOrEmpty_ReturnsFalse(string email)
+ {
+ Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs
new file mode 100644
index 00000000..cb8c880f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
+using AutopilotMonitor.Agent.Core.Tests.Helpers;
+using Microsoft.Win32;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Collectors
+{
+ ///
+ /// Integration-style tests for .
+ /// Uses a temporary HKCU sub-key because the production path is under HKLM\SYSTEM which
+ /// requires admin privileges. The watcher implementation reads HKLM, so these tests
+ /// instead assert the observable semantics of
+ /// and end-to-end placeholder / real-user classification behaviour of the watcher's
+ /// decision code path that is testable without HKLM writes.
+ ///
+ /// The full RegNotifyChangeKeyValue flow is validated by the manual syntetic test in
+ /// the verification section of the plan — it requires a test VM with elevated rights.
+ ///
+ public class AadJoinWatcherTests
+ {
+ [Fact]
+ public void Constructor_NullLogger_Throws()
+ {
+ Assert.Throws(() => new AadJoinWatcher(null));
+ }
+
+ [Fact]
+ public void Start_MissingRootKey_ArmsRetryTimer_DoesNotThrow()
+ {
+ // Running tests under a normal user cannot write HKLM — but Start() must
+ // gracefully fall back to the retry timer without throwing.
+ var logger = TestLogger.Instance;
+ using (var watcher = new AadJoinWatcher(logger))
+ {
+ watcher.Start();
+
+ // Give the retry timer one tick — we expect no crash, no events.
+ Thread.Sleep(250);
+
+ bool placeholderFired = false;
+ bool realUserFired = false;
+ watcher.PlaceholderUserDetected += (s, e) => placeholderFired = true;
+ watcher.AadUserJoined += (s, e) => realUserFired = true;
+
+ Thread.Sleep(250);
+
+ Assert.False(placeholderFired);
+ Assert.False(realUserFired);
+ }
+ }
+
+ [Fact]
+ public void Stop_IsIdempotent_MultipleCallsDoNotThrow()
+ {
+ var logger = TestLogger.Instance;
+ var watcher = new AadJoinWatcher(logger);
+ watcher.Start();
+ watcher.Stop();
+ watcher.Stop(); // idempotent
+ watcher.Dispose();
+ watcher.Dispose(); // idempotent
+ }
+
+ [Fact]
+ public void Dispose_AfterStart_StopsCleanly()
+ {
+ var logger = TestLogger.Instance;
+ var watcher = new AadJoinWatcher(logger);
+ watcher.Start();
+ watcher.Dispose();
+
+ // After Dispose a further Start must throw
+ Assert.Throws(() => watcher.Start());
+ }
+
+ // Delegate to AadJoinInfo-level tests for placeholder-pattern recognition — same
+ // code path as the watcher uses internally.
+ [Theory]
+ [InlineData("foouser@tenant.onmicrosoft.com", true)]
+ [InlineData("FOOUSER@foo.com", true)]
+ [InlineData("autopilot@tenant.com", true)]
+ [InlineData("AutoPilot@tenant.com", true)]
+ [InlineData("alice@contoso.com", false)]
+ [InlineData("myfoouser@contoso.com", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void PlaceholderEmailClassification_MatchesAadJoinInfoRule(string email, bool expected)
+ {
+ Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs
index ab5a72a8..665e713d 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs
@@ -1159,32 +1159,86 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking
}
[Fact]
- public void DeviceOnly_ProvisioningComplete_WithSaveWhiteGloveSuccessResult_TransitionsToWhiteGloveCompleted()
+ public void DeviceOnly_ProvisioningComplete_WithOnlySaveWhiteGloveSuccessResult_DoesNotTriggerWhiteGlove()
{
+ // Post-classifier behaviour: HasSaveWhiteGloveSuccessResult alone scores 10 (very
+ // weak — it also fires on genuinely non-WG devices). With DeviceOnly that reaches
+ // 25 → Confidence.None → NOT routed to WhiteGlove. This is the core bugfix.
var sm = new CompletionStateMachine();
var ctx = DeviceOnlyContext();
ctx.HasSaveWhiteGloveSuccessResult = true;
var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx);
+ Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
+ Assert.False(result.ShouldEmitWhiteGloveComplete);
+ Assert.True(result.ShouldEmitEnrollmentComplete);
+ }
+
+ [Fact]
+ public void DeviceOnly_ProvisioningComplete_BothWgSignals_IsWeak_DoesNotTriggerWhiteGlove()
+ {
+ // SaveWg(+10) + Event509(+15) + DeviceOnly(+15) = 40 → Confidence.Weak → NOT WG
+ // (asymmetric-conservative routing: unsicher → kein WG).
+ var sm = new CompletionStateMachine();
+ var ctx = DeviceOnlyContext();
+ ctx.WhiteGloveStartDetected = true;
+ ctx.HasSaveWhiteGloveSuccessResult = true;
+
+ var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx);
+
+ Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
+ Assert.False(result.ShouldEmitWhiteGloveComplete);
+ }
+
+ [Fact]
+ public void DeviceOnly_ProvisioningComplete_ShellCoreWgSuccess_TransitionsToWhiteGloveCompleted()
+ {
+ // ShellCoreWhiteGloveSuccess (Event 62407) alone scores 80 → Confidence.Strong → WG.
+ var sm = new CompletionStateMachine();
+ var ctx = DeviceOnlyContext();
+ ctx.ShellCoreWhiteGloveSuccess = true;
+
+ var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx);
+
Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
Assert.True(result.ShouldEmitWhiteGloveComplete);
Assert.False(result.ShouldEmitEnrollmentComplete);
}
[Fact]
- public void DeviceOnly_ProvisioningComplete_BothWhiteGloveSignals_TransitionsToWhiteGloveCompleted()
+ public void DeviceOnly_ProvisioningComplete_WgSignalVerbund_ReachingThreshold_TransitionsToWhiteGlove()
{
+ // Signalverbund ohne ShellCore: SaveWg(+10) + Event509(+15) + FooUser(+20) +
+ // DeviceOnly(+15) + AgentRestartAfterEsp(+10) = 70 → Strong → WG.
var sm = new CompletionStateMachine();
var ctx = DeviceOnlyContext();
ctx.WhiteGloveStartDetected = true;
ctx.HasSaveWhiteGloveSuccessResult = true;
+ ctx.IsFooUserDetected = true;
+ ctx.EspFinalExitUtc = DateTime.UtcNow.AddHours(-1);
+ ctx.AgentStartTimeUtc = DateTime.UtcNow.AddMinutes(-30);
var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx);
Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
Assert.True(result.ShouldEmitWhiteGloveComplete);
- Assert.False(result.ShouldEmitEnrollmentComplete);
+ }
+
+ [Fact]
+ public void DeviceOnly_ProvisioningComplete_LateAadJoin_DoesNotTriggerWhiteGlove()
+ {
+ // Selbst mit starkem WG-Signal: realer AAD-Join (späte Erkennung via AadJoinWatcher)
+ // muss Classifier-Hard-Excluder auslösen → NICHT WG.
+ var sm = new CompletionStateMachine();
+ var ctx = DeviceOnlyContext();
+ ctx.ShellCoreWhiteGloveSuccess = true;
+ ctx.AadJoinedWithUser = true; // late AAD join flipped this
+
+ var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx);
+
+ Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
+ Assert.False(result.ShouldEmitWhiteGloveComplete);
}
[Fact]
@@ -1219,9 +1273,11 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking
[Fact]
public void WhiteGloveCompleted_IsTerminal()
{
+ // Use ShellCoreWhiteGloveSuccess (score 80) to drive into WG — post-classifier
+ // it's the only single signal that hits Strong on its own.
var sm = new CompletionStateMachine();
var ctx = DeviceOnlyContext();
- ctx.HasSaveWhiteGloveSuccessResult = true;
+ ctx.ShellCoreWhiteGloveSuccess = true;
sm.ProcessTrigger("device_setup_provisioning_complete", ctx);
Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
@@ -1251,8 +1307,9 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking
}
[Fact]
- public void EspExiting_SkipUser_WithSaveWhiteGloveSuccessResult_TransitionsToWhiteGloveCompleted()
+ public void EspExiting_SkipUser_WithOnlySaveWhiteGloveSuccessResult_DoesNotTriggerWhiteGlove()
{
+ // Post-classifier behaviour: weak single signal (score 25 with device-only) → NOT WG.
var sm = new CompletionStateMachine();
var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false,
@@ -1262,6 +1319,24 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking
ctx.LastEspPhase = "DeviceSetup";
var result = sm.ProcessTrigger("esp_exiting", ctx);
+ Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
+ Assert.False(result.ShouldEmitWhiteGloveComplete);
+ }
+
+ [Fact]
+ public void EspExiting_SkipUser_WithShellCoreWgSuccess_TransitionsToWhiteGloveCompleted()
+ {
+ // ShellCoreWhiteGloveSuccess alone reaches Strong → WG.
+ var sm = new CompletionStateMachine();
+
+ var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false,
+ isHelloPolicyConfigured: false);
+ ctx.ShellCoreWhiteGloveSuccess = true;
+ sm.ProcessTrigger("esp_phase_changed", ctx);
+
+ ctx.LastEspPhase = "DeviceSetup";
+ var result = sm.ProcessTrigger("esp_exiting", ctx);
+
Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState);
Assert.True(result.ShouldEmitWhiteGloveComplete);
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs
new file mode 100644
index 00000000..a5ca440a
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs
@@ -0,0 +1,243 @@
+using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Tracking
+{
+ public class WhiteGloveClassifierTests
+ {
+ // =====================================================================
+ // Zero / None baseline
+ // =====================================================================
+
+ [Fact]
+ public void Classify_AllSignalsOff_ReturnsNone()
+ {
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals());
+
+ Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
+ Assert.Equal(0, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ Assert.Empty(result.ContributingFactors);
+ }
+
+ // =====================================================================
+ // Single-signal behaviour (the core bugfix)
+ // =====================================================================
+
+ [Fact]
+ public void Classify_ShellCoreAlone_IsStrong()
+ {
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ });
+
+ Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
+ Assert.Equal(80, result.Score);
+ Assert.True(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ [Fact]
+ public void Classify_OnlySaveWgSuccessResult_IsNoneNotWg()
+ {
+ // The core bug-avoidance test: SaveWhiteGloveSuccessResult=true alone has been
+ // observed on genuinely non-WG devices and MUST NOT classify as WhiteGlove.
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ HasSaveWhiteGloveSuccessResult = true,
+ });
+
+ Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
+ Assert.Equal(10, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ [Fact]
+ public void Classify_SaveWgPlusDeviceOnly_IsStillNone()
+ {
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ HasSaveWhiteGloveSuccessResult = true,
+ IsDeviceOnlyDeployment = true,
+ });
+
+ Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
+ Assert.Equal(25, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ [Fact]
+ public void Classify_WeakSignalVerbundJustBelowThreshold_IsWeakNotWg()
+ {
+ // Mehrere weiche Signale alleine reichen nicht fuer Strong.
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ IsFooUserDetected = true, // +20
+ IsWhiteGloveStartDetected = true, // +15
+ IsDeviceOnlyDeployment = true, // +15
+ });
+
+ Assert.Equal(WhiteGloveConfidence.Weak, result.Confidence);
+ Assert.Equal(50, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ [Fact]
+ public void Classify_WeakSignalVerbundAtThreshold_IsStrongAndRoutes()
+ {
+ // Signalverbund erreicht gerade die 70 — laut Plan: WG.
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ HasSaveWhiteGloveSuccessResult = true, // +10
+ IsFooUserDetected = true, // +20
+ IsWhiteGloveStartDetected = true, // +15
+ IsDeviceOnlyDeployment = true, // +15
+ AgentRestartedAfterEspExit = true, // +10
+ });
+
+ Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
+ Assert.Equal(70, result.Score);
+ Assert.True(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ // =====================================================================
+ // Hard excluders
+ // =====================================================================
+
+ [Fact]
+ public void Classify_AadJoinedWithUser_OverridesStrongPositive()
+ {
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ AadJoinedWithUser = true, // real user — definitively not WG Part 1
+ });
+
+ Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
+ Assert.Equal(0, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ [Fact]
+ public void Classify_DesktopArrived_OverridesStrongPositive()
+ {
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ DesktopArrived = true,
+ });
+
+ Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
+ Assert.Equal(0, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ [Fact]
+ public void Classify_AccountSetupActivity_DampensButDoesNotHardExclude()
+ {
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true, // +80
+ HasAccountSetupActivity = true, // -40
+ });
+
+ // 80 - 40 = 40 → Weak (NOT routed to WG)
+ Assert.Equal(WhiteGloveConfidence.Weak, result.Confidence);
+ Assert.Equal(40, result.Score);
+ Assert.False(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ // =====================================================================
+ // Placeholder user (foouser@ / autopilot@) — NOT aadJoinedWithUser
+ // =====================================================================
+
+ [Fact]
+ public void Classify_FooUserWithoutRealAadJoin_StaysStrong()
+ {
+ // Placeholder-User ist KEIN realer AAD-Join; Hard-Excluder greift nicht.
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true, // +80
+ IsFooUserDetected = true, // +20
+ AadJoinedWithUser = false, // Placeholder zählt NICHT als real
+ });
+
+ Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
+ Assert.Equal(100, result.Score); // capped
+ Assert.True(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ // =====================================================================
+ // Hybrid join is NOT a dampener (removed per plan review)
+ // =====================================================================
+
+ [Fact]
+ public void Classify_HybridWithStrongSignal_StaysStrong()
+ {
+ // Hybrid-Join darf die WG-Klassifikation nicht dämpfen (WG funktioniert auch hybrid).
+ var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ IsDeviceOnlyDeployment = true,
+ });
+
+ Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
+ Assert.Equal(95, result.Score);
+ Assert.True(result.ShouldRouteToWhiteGlovePart1);
+ }
+
+ // =====================================================================
+ // Late AAD-Join Transition
+ // =====================================================================
+
+ [Fact]
+ public void Classify_LateAadJoinFlipsStrongToNone()
+ {
+ // Vor dem Late-Update: Strong.
+ var before = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ AadJoinedWithUser = false,
+ });
+ Assert.Equal(WhiteGloveConfidence.Strong, before.Confidence);
+ Assert.True(before.ShouldRouteToWhiteGlovePart1);
+
+ // Nach dem Late-AAD-Update: None (harter Ausschluss).
+ var after = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ AadJoinedWithUser = true,
+ });
+ Assert.Equal(WhiteGloveConfidence.None, after.Confidence);
+ Assert.False(after.ShouldRouteToWhiteGlovePart1);
+ }
+
+ // =====================================================================
+ // ShouldRouteToWhiteGlovePart1 only fires on Strong
+ // =====================================================================
+
+ [Fact]
+ public void Classify_ShouldRoute_OnlyOnStrong()
+ {
+ var none = WhiteGloveClassifier.Classify(new WhiteGloveSignals());
+ var weak = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ IsFooUserDetected = true,
+ IsDeviceOnlyDeployment = true,
+ });
+ var strong = WhiteGloveClassifier.Classify(new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = true,
+ });
+
+ Assert.Equal(WhiteGloveConfidence.None, none.Confidence);
+ Assert.False(none.ShouldRouteToWhiteGlovePart1);
+
+ Assert.Equal(WhiteGloveConfidence.Weak, weak.Confidence);
+ Assert.False(weak.ShouldRouteToWhiteGlovePart1);
+
+ Assert.Equal(WhiteGloveConfidence.Strong, strong.Confidence);
+ Assert.True(strong.ShouldRouteToWhiteGlovePart1);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
index a7896e69..80e2ddb1 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs
@@ -38,10 +38,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
// WhiteGlove detection signals
public bool WhiteGloveStartDetected { get; set; }
public bool HasSaveWhiteGloveSuccessResult { get; set; }
- // Only SaveWhiteGloveSuccessResult=succeeded from ESP registry is a reliable WG confirmation.
- // WhiteGloveStartDetected (EventID 509) is a soft signal — fires on hybrid-join too.
+ public bool ShellCoreWhiteGloveSuccess { get; set; }
+ public bool IsFooUserDetected { get; set; }
+
+ // Legacy helper — kept for backwards-compat with places that only need a binary
+ // "there is some WG evidence" read. Actual classification now goes through
+ // .
public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult;
+ // Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1
+ // boundary marker (the restart between DeviceSetup-complete and user sign-in).
+ public bool AgentRestartedAfterEspExit
+ => EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value;
+
// Timing
public DateTime AgentStartTimeUtc { get; set; }
public DateTime? EspFinalExitUtc { get; set; }
@@ -319,13 +328,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
// Path B: Registry definitively says device-only
else if (ctx.SkipUserStatusPage == true)
{
- // WhiteGlove guard: SkipUserStatusPage=true + WG signals → WhiteGloveCompleted.
- // Mirrors the guard in EnrollmentTracker.CompletionLogic.OnFinalizingSetupPhaseTriggered.
- if (ctx.IsWhiteGloveActive)
+ // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
+ // HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths).
+ // Weak confidence falls through to the regular device-only path by design.
+ var wgSignals = WhiteGloveSignals.FromContext(ctx);
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
{
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
result.ShouldEmitWhiteGloveComplete = true;
result.SignalsToRecord.Add("whiteglove_guard_esp_exiting");
+ result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
+ result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
result.Transitioned = true;
result.NewState = _currentState;
return result;
@@ -532,13 +546,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
SignalsToRecord = new List { "device_setup_provisioning_complete" }
};
- // WhiteGlove guard: device-only + WG signals → WhiteGloveCompleted, not self-deploying.
- // This mirrors the guard in EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete.
- if (ctx.IsDeviceOnly && ctx.IsWhiteGloveActive)
+ // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
+ // EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and
+ // TryEmitEnrollmentComplete). Weak confidence falls through to the regular
+ // self-deploying path by design (asymmetric-conservative routing).
+ var wgSignals = WhiteGloveSignals.FromContext(ctx);
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
{
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
result.ShouldEmitWhiteGloveComplete = true;
result.SignalsToRecord.Add("whiteglove_guard_activated");
+ result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
+ result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
result.Transitioned = true;
result.NewState = _currentState;
return result;
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs
new file mode 100644
index 00000000..871a5575
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Confidence level for a WhiteGlove Part 1 classification.
+ ///
+ internal enum WhiteGloveConfidence
+ {
+ /// Score below — definitely not WhiteGlove.
+ None,
+
+ /// Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).
+ Weak,
+
+ /// Score at or above — classify as WhiteGlove Part 1.
+ Strong,
+ }
+
+ ///
+ /// Result of a call. Contains the
+ /// final routing decision, the numeric score and the contributing factors for
+ /// observability / tuning.
+ ///
+ internal sealed class WhiteGloveClassification
+ {
+ public WhiteGloveConfidence Confidence { get; set; }
+ public int Score { get; set; }
+ public List ContributingFactors { get; set; } = new List();
+ public string Reason { get; set; }
+
+ ///
+ /// Single Boolean the caller should read to decide whether to route to
+ /// whiteglove_complete (Pending) instead of the normal completion path.
+ /// Only true when is .
+ ///
+ public bool ShouldRouteToWhiteGlovePart1 { get; set; }
+ }
+
+ ///
+ /// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
+ /// AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold pattern.
+ ///
+ /// Asymmetric-conservative decision rule: only a
+ /// score routes to WhiteGlove. A score is emitted
+ /// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG
+ /// classification keeps the agent pending when it should exit; a false-negative emits
+ /// enrollment_complete early and the later user sign-in triggers a new session —
+ /// the user-visible cost of false-negative is much lower than false-positive.
+ ///
+ internal static class WhiteGloveClassifier
+ {
+ // Thresholds (tunable after production data — see project memory).
+ internal const int HighThreshold = 70; // >= 70 → Strong → route to WG
+ internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only)
+ internal const int BaseConfidence = 0;
+
+ // Positive-signal weights
+ internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407
+ internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices
+ internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid
+ internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker
+ internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary
+ internal const int WeightDeviceOnlyDeployment = 15; // context boost
+
+ // Negative-signal weights (hard excluders)
+ internal const int WeightAadJoinedWithUser = -100; // hard exclude
+ internal const int WeightDesktopArrived = -100; // hard exclude
+ internal const int WeightAccountSetupActive = -40; // soft exclude
+
+ public static WhiteGloveClassification Classify(WhiteGloveSignals s)
+ {
+ if (s == null) throw new ArgumentNullException(nameof(s));
+
+ var factors = new List();
+ int score = BaseConfidence;
+
+ // Hard excluders
+ if (s.AadJoinedWithUser)
+ {
+ score += WeightAadJoinedWithUser;
+ factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}");
+ }
+ if (s.DesktopArrived)
+ {
+ score += WeightDesktopArrived;
+ factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}");
+ }
+ if (s.HasAccountSetupActivity)
+ {
+ score += WeightAccountSetupActive;
+ factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
+ }
+
+ // Positive signals — weighted by empirical reliability
+ if (s.ShellCoreWhiteGloveSuccess)
+ {
+ score += WeightShellCoreWhiteGloveSuccess;
+ factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
+ }
+ if (s.HasSaveWhiteGloveSuccessResult)
+ {
+ score += WeightHasSaveWhiteGloveSuccess;
+ factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}");
+ }
+ if (s.IsWhiteGloveStartDetected)
+ {
+ score += WeightWhiteGloveStartDetected;
+ factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}");
+ }
+ if (s.IsFooUserDetected)
+ {
+ score += WeightFooUserDetected;
+ factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}");
+ }
+ if (s.AgentRestartedAfterEspExit)
+ {
+ score += WeightAgentRestartedAfterEspExit;
+ factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}");
+ }
+ if (s.IsDeviceOnlyDeployment)
+ {
+ score += WeightDeviceOnlyDeployment;
+ factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
+ }
+
+ // NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid.
+ // NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons.
+
+ // Cap to [0, 100]
+ if (score < 0) score = 0;
+ if (score > 100) score = 100;
+
+ WhiteGloveConfidence confidence;
+ if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong;
+ else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak;
+ else confidence = WhiteGloveConfidence.None;
+
+ return new WhiteGloveClassification
+ {
+ Confidence = confidence,
+ Score = score,
+ ContributingFactors = factors,
+ Reason = $"confidence={confidence} score={score}",
+ ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong),
+ };
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs
new file mode 100644
index 00000000..6c7c193c
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs
@@ -0,0 +1,63 @@
+using System;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
+{
+ ///
+ /// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
+ /// Populated under _stateLock in the caller and consumed outside the lock by
+ /// — mirrors the CompletionContext
+ /// pattern to preserve the existing thread-safety guarantees.
+ ///
+ /// Source-of-truth is the EnrollmentTracker state. When the tracker uses
+ /// CompletionStateMachine (shadow), use to keep both
+ /// decision paths in sync.
+ ///
+ internal sealed class WhiteGloveSignals
+ {
+ // "Hard" positive signals (registry / Shell-Core)
+ public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal
+ public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG
+ public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too
+ public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator
+ public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary
+
+ // Hard excluders (drive score negative)
+ public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore
+ public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore
+ public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active
+
+ // Deployment context
+ public bool IsDeviceOnlyDeployment { get; set; }
+
+ // Optional metadata (populated for observability, not scored)
+ public DateTime? EspFinalExitUtc { get; set; }
+ public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
+
+ ///
+ /// Builds a signal snapshot from a . Used by the
+ /// CompletionStateMachine so that state-machine decisions use the exact same
+ /// inputs as the EnrollmentTracker's direct decisions.
+ ///
+ public static WhiteGloveSignals FromContext(CompletionContext ctx)
+ {
+ if (ctx == null) throw new ArgumentNullException(nameof(ctx));
+
+ return new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess,
+ HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult,
+ IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected,
+ IsFooUserDetected = ctx.IsFooUserDetected,
+ AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit,
+
+ AadJoinedWithUser = ctx.AadJoinedWithUser,
+ DesktopArrived = ctx.DesktopArrived,
+ HasAccountSetupActivity = ctx.HasAccountSetupActivity,
+
+ IsDeviceOnlyDeployment = ctx.IsDeviceOnly,
+
+ EspFinalExitUtc = ctx.EspFinalExitUtc,
+ };
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
index 2e773c58..b94c4317 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs
@@ -116,8 +116,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
public int? AutopilotMode { get; set; }
// True when AAD join status shows a joined device with a real user email
+ // (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo)
public bool AadJoinedWithUser { get; set; }
+ // True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been
+ // observed at any point in the session — survives crash/restart so the classifier
+ // keeps its positive WG signal after agent recovery.
+ public bool FooUserDetected { get; set; }
+
// True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1)
public bool IsHybridJoin { get; set; }
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
index 2ff78a7e..3a8b839e 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
@@ -112,27 +112,32 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
return;
}
- // WhiteGlove guard: Only SaveWhiteGloveSuccessResult=succeeded from the ESP registry
- // is a reliable WhiteGlove confirmation. EventID 509 is a soft signal only — it fires
- // on hybrid-join devices too and must NOT trigger WG completion on its own.
- bool whiteGloveStarted = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false;
- bool whiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false;
+ // WhiteGlove guard — canonical decision via WhiteGloveClassifier.
+ // See plan: all three completion paths (this one, TryEmitEnrollmentComplete,
+ // and CompletionStateMachine.HandleDeviceSetupProvisioningComplete) share the
+ // same classifier output. HasSaveWhiteGloveSuccessResult alone is no longer
+ // sufficient — it also fires on non-WG devices (observed in production).
+ var wgSignals = SnapshotWhiteGloveSignals();
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ EmitWhiteGloveClassification(wgClassification, "DeviceSetupProvisioningComplete", wgSignals);
- if (whiteGloveSuccessResult)
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
{
- _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove confirmed " +
- $"(whiteGloveStarted={whiteGloveStarted}, saveWhiteGloveSuccessResult={whiteGloveSuccessResult}) — " +
+ _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove (classifier: {wgClassification.Reason}) — " +
"routing to WhiteGlove completion instead of self_deploying_provisioning_complete");
RecordSignal("whiteglove_guard_activated");
EmitTraceEvent("whiteglove_guard_activated",
- "DeviceSetup provisioning complete with SaveWhiteGloveSuccessResult confirmed — routing to WhiteGlove path",
+ "DeviceSetup provisioning complete with WG classifier Strong — routing to WhiteGlove path",
new Dictionary
{
- { "whiteGloveStartDetected", whiteGloveStarted },
- { "saveWhiteGloveSuccessResult", whiteGloveSuccessResult },
- { "fooUserDetected", _fooUserDetected },
+ { "wgScore", wgClassification.Score },
+ { "wgConfidence", wgClassification.Confidence.ToString() },
+ { "wgFactors", wgClassification.ContributingFactors },
+ { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected },
+ { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult },
+ { "fooUserDetected", wgSignals.IsFooUserDetected },
{ "autopilotMode", _autopilotMode },
{ "skipUserStatusPage", _skipUserStatusPage }
});
@@ -211,6 +216,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
///
private void OnWhiteGloveCompleted(object sender, EventArgs e)
{
+ // Shell-Core Event 62407 "WhiteGlove_Success" has fired — record it so the
+ // WhiteGloveClassifier can see this hard-positive signal (weight +80) on any
+ // subsequent classification call in the same session.
+ lock (_stateLock) { _shellCoreWhiteGloveFired = true; }
+
var wgSource = sender?.GetType().Name ?? "unknown";
CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false);
}
@@ -861,6 +871,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
_skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
_autopilotMode = loaded.AutopilotMode;
_aadJoinedWithUser = loaded.AadJoinedWithUser;
+ _fooUserDetected = loaded.FooUserDetected;
// Restore hybrid join flag from persisted state; re-detect as fallback
_isHybridJoin = loaded.IsHybridJoin || DetectHybridJoinStatic();
_stateData = loaded;
@@ -1094,6 +1105,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
_enrollmentCompleteEmitted = true;
_stateData.EnrollmentCompleteEmitted = true;
signalsSeen = new List(_stateData.SignalsSeen);
+
+ // Stop recurring/watchdog timers under the SAME lock that claims
+ // the emission flag. Concurrent timer callbacks already past their
+ // own _enrollmentCompleteEmitted check will no-op further work;
+ // callbacks not yet past it will read flag=true and return.
+ // Change(Inf,Inf) is idempotent and deadlock-safe inside the lock;
+ // Dispose would wait on in-flight callbacks and is risky here.
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+ _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
else
{
@@ -1137,38 +1160,37 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
return;
}
- // GUARD 4: WhiteGlove Part 1 redirect.
- // If SaveWhiteGloveSuccessResult=succeeded (hard WG signal from ESP registry) but no real
- // user has joined yet, this is WG Part 1 — the device is pre-provisioned and waiting for
- // a user. Redirect to whiteglove_complete (Pending) instead of enrollment_complete (Succeeded).
- // This catches WG Part 1 with SkipUser=False where ShellCore WhiteGlove_Success doesn't fire.
- bool wgSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false;
- if (wgSuccessResult && !_aadJoinedWithUser && !desktopArrived)
- {
- _logger.Info($"EnrollmentTracker: all guards passed but SaveWhiteGloveSuccessResult=succeeded " +
- $"with no user joined and no desktop arrived — redirecting to WhiteGlove Part 1 " +
- $"(source={source}, aadJoinedWithUser={_aadJoinedWithUser}, desktopArrived={desktopArrived})");
+ // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision.
+ // All three WG decision sites go through WhiteGloveClassifier, so this guard
+ // uses the same rule as OnDeviceSetupProvisioningComplete and the StateMachine.
+ // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is
+ // observable telemetry but falls through to the regular completion path.
+ var wgSignals = SnapshotWhiteGloveSignals();
+ var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
+ EmitWhiteGloveClassification(wgClassification, "TryEmitEnrollmentComplete", wgSignals);
+
+ if (wgClassification.ShouldRouteToWhiteGlovePart1)
+ {
+ _logger.Info($"EnrollmentTracker: all guards passed and WG classifier Strong — " +
+ $"redirecting to WhiteGlove Part 1 (source={source}, {wgClassification.Reason})");
- // Consolidated decision record: includes the upstream source AND the WG-context
- // signals that used to live in a second (now-suppressed) decision record.
EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List
{
$"original_source={source}",
- "save_whiteglove_succeeded=true",
- (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen",
- "aad_joined_with_user=false",
- "desktop_arrived=false",
- _fooUserDetected ? "foouser_detected" : "no_foouser",
- "redirect: enrollment_complete -> whiteglove_complete"
- });
+ $"wg_score={wgClassification.Score}",
+ $"wg_confidence={wgClassification.Confidence}",
+ }.Concat(wgClassification.ContributingFactors ?? new List())
+ .Concat(new[] { "redirect: enrollment_complete -> whiteglove_complete" })
+ .ToList(),
+ classification: wgClassification);
// Decision is already recorded above — CompleteWhiteGlove must not emit a second one.
- // ShadowProcessTrigger("whiteglove_complete") is called inside CompleteWhiteGlove, so no extra call here.
CompleteWhiteGlove(source, decisionAlreadyEmitted: true);
return;
}
- _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete");
+ _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete " +
+ $"(wg_classifier: {wgClassification.Reason})");
// Emit decision process before the terminal event
EmitDecisionProcess("enrollment_complete", source, "succeeded", new List
@@ -1179,15 +1201,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
desktopArrived ? "desktop_arrived" : "desktop_not_arrived",
isDeviceOnly ? "device_only" : "user_driven",
_isHybridJoin ? "hybrid_join" : "aad_join",
- wgSuccessResult ? "wg_registry_succeeded" : "no_wg_registry"
- });
+ wgSignals.HasSaveWhiteGloveSuccessResult ? "wg_registry_succeeded" : "no_wg_registry",
+ $"wg_score={wgClassification.Score}",
+ $"wg_confidence={wgClassification.Confidence}"
+ }, classification: wgClassification);
- // Stop timers
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
- _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ // Timers were already stopped inside the lock when _enrollmentCompleteEmitted was claimed.
var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown";
@@ -1320,7 +1339,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
/// that led to the outcome — enabling post-hoc analysis without reconstructing from individual events.
///
private void EmitDecisionProcess(string terminalEvent, string source, string outcome,
- List decisionChain = null)
+ List decisionChain = null,
+ WhiteGloveClassification classification = null)
{
Dictionary data;
lock (_stateLock)
@@ -1384,6 +1404,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
ts["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
data["signalTimestamps"] = ts;
+
+ // WhiteGlove classifier output — same fields as the whiteglove_classification
+ // event so aggregate MCP queries can correlate terminal decisions with the
+ // scoring history across a session.
+ if (classification != null)
+ {
+ data["wgScore"] = classification.Score;
+ data["wgConfidence"] = classification.Confidence.ToString();
+ data["wgFactors"] = classification.ContributingFactors ?? new List();
+ }
}
_emitEvent(new EnrollmentEvent
@@ -1402,6 +1432,192 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
_logger.Info($"EnrollmentTracker: decision_process_completion emitted — {terminalEvent} via {source} ({outcome})");
}
+ // =====================================================================
+ // WhiteGlove classification helpers — see WhiteGloveClassifier for the
+ // scoring rules and thresholds. The three completion call-sites
+ // (OnDeviceSetupProvisioningComplete, TryEmitEnrollmentComplete,
+ // CompletionStateMachine.HandleDeviceSetupProvisioningComplete) all
+ // classify through the same classifier, so decisions converge.
+ // =====================================================================
+
+ ///
+ /// Snapshot current WhiteGlove-relevant state under _stateLock and return a
+ /// consumable for .
+ ///
+ private WhiteGloveSignals SnapshotWhiteGloveSignals()
+ {
+ lock (_stateLock)
+ {
+ var hasAccountSetup = _espAndHelloTracker?.HasAccountSetupActivity ?? false;
+ var hasSaveWg = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false;
+ var wgStart = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false;
+
+ bool agentRestartedAfterEsp = _stateData.EspFinalExitUtc.HasValue
+ && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value;
+
+ return new WhiteGloveSignals
+ {
+ ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
+ HasSaveWhiteGloveSuccessResult = hasSaveWg,
+ IsWhiteGloveStartDetected = wgStart,
+ IsFooUserDetected = _fooUserDetected,
+ AgentRestartedAfterEspExit = agentRestartedAfterEsp,
+
+ AadJoinedWithUser = _aadJoinedWithUser,
+ DesktopArrived = _desktopArrived,
+ HasAccountSetupActivity = hasAccountSetup,
+
+ IsDeviceOnlyDeployment = IsDeviceOnlyDeployment,
+
+ EspFinalExitUtc = _stateData.EspFinalExitUtc,
+ DeviceSetupProvisioningCompleteUtc = _stateData.DeviceSetupProvisioningCompleteUtc,
+ };
+ }
+ }
+
+ ///
+ /// Emits the observability-only whiteglove_classification event with the
+ /// full scoring breakdown. Enables MCP query_table queries like
+ /// "distribution of wgConfidence over last 24h" or "drill-down into Weak sessions"
+ /// without any backend schema change — all fields live inside the existing data bag.
+ ///
+ private void EmitWhiteGloveClassification(
+ WhiteGloveClassification c,
+ string callSite,
+ WhiteGloveSignals signals)
+ {
+ if (c == null) return;
+
+ var data = new Dictionary
+ {
+ { "wgScore", c.Score },
+ { "wgConfidence", c.Confidence.ToString() },
+ { "callSite", callSite },
+ { "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 },
+ { "factors", c.ContributingFactors ?? new List() },
+ { "signals", new Dictionary
+ {
+ { "shellCoreWg", signals.ShellCoreWhiteGloveSuccess },
+ { "hasSaveWg", signals.HasSaveWhiteGloveSuccessResult },
+ { "event509", signals.IsWhiteGloveStartDetected },
+ { "fooUser", signals.IsFooUserDetected },
+ { "agentRestartAfterEsp", signals.AgentRestartedAfterEspExit },
+ { "aadJoined", signals.AadJoinedWithUser },
+ { "desktopArrived", signals.DesktopArrived },
+ { "accountSetupActive", signals.HasAccountSetupActivity },
+ { "deviceOnly", signals.IsDeviceOnlyDeployment },
+ }
+ },
+ };
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "whiteglove_classification",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"WG classification at {callSite}: {c.Confidence} (score={c.Score})",
+ ImmediateUpload = false,
+ Data = data,
+ });
+ }
+
+ ///
+ /// Called by CollectorCoordinator when the
+ /// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates
+ /// _fooUserDetected so the WhiteGloveClassifier sees the positive indicator
+ /// on the next classification call. Does NOT re-trigger completion — placeholder
+ /// alone is not a terminal signal.
+ ///
+ public void NotifyAadPlaceholderUserDetected(string userEmail)
+ {
+ bool alreadyDetected;
+ lock (_stateLock)
+ {
+ if (_enrollmentCompleteEmitted) return;
+
+ alreadyDetected = _fooUserDetected;
+ if (!alreadyDetected)
+ {
+ _fooUserDetected = true;
+ _stateData.FooUserDetected = true;
+ }
+ }
+
+ if (alreadyDetected)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyAadPlaceholderUserDetected — already detected, ignoring");
+ return;
+ }
+
+ _statePersistence.Save(_stateData);
+ RecordSignal("aad_placeholder_user_detected_late");
+ _logger.Info("EnrollmentTracker: late placeholder user detected — recording for classifier");
+
+ var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds;
+ EmitTraceEvent("aad_placeholder_user_detected_late",
+ "Transient AAD provisioning placeholder user appeared in JoinInfo registry",
+ new Dictionary
+ {
+ { "secondsSinceAgentStart", secondsSinceStart },
+ { "emailPattern", userEmail?.Split('@')[0] ?? "unknown" },
+ });
+ }
+
+ ///
+ /// Called by CollectorCoordinator when the
+ /// detects a real AAD user join (non-placeholder) during the session. Updates
+ /// _aadJoinedWithUser atomically so subsequent WhiteGloveClassifier calls
+ /// correctly see the real user (driving IsDeviceOnlyDeployment to false and
+ /// triggering the classifier's hard-exclude). Re-runs the completion guards.
+ ///
+ public void NotifyAadUserJoinedLate(string userEmail)
+ {
+ bool alreadyJoined;
+ bool alreadyEmitted;
+ lock (_stateLock)
+ {
+ alreadyEmitted = _enrollmentCompleteEmitted;
+ alreadyJoined = _aadJoinedWithUser;
+ if (!alreadyJoined && !alreadyEmitted)
+ {
+ _aadJoinedWithUser = true;
+ _stateData.AadJoinedWithUser = true;
+ }
+ }
+
+ if (alreadyEmitted)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — enrollment already emitted, ignoring");
+ return;
+ }
+ if (alreadyJoined)
+ {
+ _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — already joined, ignoring");
+ return;
+ }
+
+ _statePersistence.Save(_stateData);
+ RecordSignal("aad_user_joined_late");
+ _logger.Info($"EnrollmentTracker: late AAD user joined ({userEmail}) — re-evaluating completion");
+
+ var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds;
+ EmitTraceEvent("aad_user_joined_late",
+ "Real AAD user appeared in JoinInfo registry after session started",
+ new Dictionary
+ {
+ { "secondsSinceAgentStart", secondsSinceStart },
+ { "userEmailDomain", userEmail?.Contains("@") == true ? userEmail.Substring(userEmail.IndexOf('@')) : "unknown" },
+ });
+
+ // Re-evaluate guards with fresh AAD-joined state. The classifier will now hard-exclude
+ // a WhiteGlove classification for this session; the standard enrollment_complete path
+ // can now fire once Hello/desktop resolve.
+ TryEmitEnrollmentComplete("aad_user_joined_late");
+ }
+
///
/// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user).
/// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion.
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs
index 82fb122b..b21efe82 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs
@@ -104,9 +104,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
// True when AAD join status shows a joined device with a real user email.
// Detected via CollectAadJoinStatus() in DeviceInfoCollector.
private bool _aadJoinedWithUser;
- // Soft indicator: AAD userEmail starts with "foouser@" (pre-provisioning synthetic account).
+ // Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern
+ // (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning
+ // (WhiteGlove) and MUST NOT be treated as a real AAD-joined user.
private bool _fooUserDetected;
+ // Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires.
+ // Distinct from (which is the
+ // fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds
+ // the WhiteGloveClassifier as the strongest positive signal.
+ private bool _shellCoreWhiteGloveFired;
+
// Signal-correlated WhiteGlove detection (Ebene 2.6):
// Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector
// does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done,
@@ -224,6 +232,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false,
WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false,
HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false,
+ ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
+ IsFooUserDetected = _fooUserDetected,
AgentStartTimeUtc = _agentStartTimeUtc,
EspFinalExitUtc = _stateData.EspFinalExitUtc,
ImePatternSeenUtc = _stateData.ImePatternSeenUtc
@@ -628,6 +638,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
_stateData.SkipDeviceStatusPage = _skipDeviceStatusPage;
_stateData.AutopilotMode = _autopilotMode;
_stateData.AadJoinedWithUser = _aadJoinedWithUser;
+ _stateData.FooUserDetected = _fooUserDetected;
_stateData.IsHybridJoin = _isHybridJoin;
_stateDirty = true;
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs
new file mode 100644
index 00000000..d9ddfee1
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs
@@ -0,0 +1,84 @@
+using System;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals
+{
+ ///
+ /// Reads AAD join information from the Windows registry and classifies the
+ /// signed-in user e-mail into "real user" vs. "provisioning placeholder".
+ ///
+ /// Registry path: HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\<thumbprint>\UserEmail
+ ///
+ /// Placeholder users () appear transiently during
+ /// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join.
+ ///
+ internal static class AadJoinInfo
+ {
+ internal const string JoinInfoRegistryPath =
+ @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
+
+ ///
+ /// Reads the first JoinInfo sub-key and returns the UserEmail + thumbprint.
+ /// Returns true when ANY sub-key was found (even without UserEmail); the caller
+ /// inspects the out-parameters to decide semantics.
+ ///
+ /// Raw UserEmail value (may be null/empty).
+ /// The certificate thumbprint sub-key name (may be null).
+ ///
+ /// True when matches a known provisioning-placeholder
+ /// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join.
+ ///
+ internal static bool TryReadAadJoinedUser(
+ out string userEmail,
+ out string thumbprint,
+ out bool isPlaceholderUser)
+ {
+ userEmail = null;
+ thumbprint = null;
+ isPlaceholderUser = false;
+
+ try
+ {
+ using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(JoinInfoRegistryPath))
+ {
+ if (joinInfoKey == null)
+ return false;
+
+ var subKeyNames = joinInfoKey.GetSubKeyNames();
+ if (subKeyNames == null || subKeyNames.Length == 0)
+ return false;
+
+ thumbprint = subKeyNames[0];
+ using (var subKey = joinInfoKey.OpenSubKey(thumbprint))
+ {
+ if (subKey == null)
+ return true;
+
+ userEmail = subKey.GetValue("UserEmail")?.ToString();
+ isPlaceholderUser = IsPlaceholderUserEmail(userEmail);
+ }
+ }
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Returns true when the given email matches a known transient provisioning-account
+ /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot device
+ /// preparation and must not be treated as a real signed-in user.
+ ///
+ internal static bool IsPlaceholderUserEmail(string email)
+ {
+ if (string.IsNullOrWhiteSpace(email))
+ return false;
+
+ return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
+ || email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs
new file mode 100644
index 00000000..73f3dcb9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Interop;
+using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals
+{
+ public sealed class AadPlaceholderUserDetectedEventArgs : EventArgs
+ {
+ public string UserEmail { get; }
+ public string Thumbprint { get; }
+ public AadPlaceholderUserDetectedEventArgs(string userEmail, string thumbprint)
+ {
+ UserEmail = userEmail;
+ Thumbprint = thumbprint;
+ }
+ }
+
+ public sealed class AadUserJoinedEventArgs : EventArgs
+ {
+ public string UserEmail { get; }
+ public string Thumbprint { get; }
+ public AadUserJoinedEventArgs(string userEmail, string thumbprint)
+ {
+ UserEmail = userEmail;
+ Thumbprint = thumbprint;
+ }
+ }
+
+ ///
+ /// Watches HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo for a
+ /// late AAD user join. Uses (RegNotifyChangeKeyValue) for
+ /// instant, low-overhead change detection — no polling.
+ ///
+ /// Two distinct events are raised:
+ ///
+ /// - — first time a transient
+ /// provisioning account (foouser@*, autopilot@*) appears. Fire-once.
+ /// The watcher keeps running afterwards, as the placeholder is expected to be
+ /// replaced by a real user.
+ /// - — first time a REAL user e-mail
+ /// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards.
+ ///
+ ///
+ /// Retry semantics: the JoinInfo root key may not exist yet when the agent
+ /// starts — it is created during AAD provisioning. A retry timer polls for its
+ /// appearance every 2 seconds until it exists, then attaches the registry watcher.
+ ///
+ internal sealed class AadJoinWatcher : IDisposable
+ {
+ internal const int RetryIntervalSeconds = 2;
+ internal const int DebounceMilliseconds = 500;
+
+ private readonly AgentLogger _logger;
+ private readonly object _stateLock = new object();
+
+ private RegistryWatcher _watcher;
+ private Timer _retryTimer;
+ private Timer _debounceTimer;
+
+ private bool _placeholderFired;
+ private bool _realUserFired;
+ private bool _disposed;
+
+ public event EventHandler PlaceholderUserDetected;
+ public event EventHandler AadUserJoined;
+
+ public AadJoinWatcher(AgentLogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public void Start()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(AadJoinWatcher));
+
+ // Initial synchronous read. If a real AAD user is already present (typical after
+ // DeviceInfoCollector ran at agent startup), fire AadUserJoined once and do NOT
+ // attach the registry watcher — there is nothing left to watch for.
+ CheckJoinInfo();
+ bool terminalFromStartup;
+ lock (_stateLock) { terminalFromStartup = _realUserFired; }
+ if (terminalFromStartup)
+ {
+ _logger.Info("AadJoinWatcher: real AAD user already present at startup — skipping RegistryWatcher attach");
+ return;
+ }
+
+ if (!TryStartWatcher())
+ {
+ _logger.Info("AadJoinWatcher: JoinInfo key not yet present — retrying every 2s");
+ _retryTimer = new Timer(
+ _ =>
+ {
+ if (TryStartWatcher())
+ {
+ _retryTimer?.Dispose();
+ _retryTimer = null;
+ }
+ },
+ null,
+ TimeSpan.FromSeconds(RetryIntervalSeconds),
+ TimeSpan.FromSeconds(RetryIntervalSeconds));
+ }
+ }
+
+ public void Stop(string reason = "watcher_stopped")
+ {
+ try
+ {
+ lock (_stateLock)
+ {
+ _retryTimer?.Dispose();
+ _retryTimer = null;
+
+ _debounceTimer?.Dispose();
+ _debounceTimer = null;
+
+ if (_watcher != null)
+ {
+ _watcher.Dispose();
+ _watcher = null;
+ _logger.Info($"AadJoinWatcher: stopped ({reason})");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("AadJoinWatcher: error during Stop", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ Stop("disposed");
+ }
+
+ private bool TryStartWatcher()
+ {
+ try
+ {
+ using (var key = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
+ {
+ if (key == null) return false;
+ }
+
+ lock (_stateLock)
+ {
+ if (_watcher != null) return true; // Already running
+
+ _debounceTimer = new Timer(
+ _ => CheckJoinInfo(),
+ null,
+ Timeout.Infinite,
+ Timeout.Infinite);
+
+ _watcher = new RegistryWatcher(
+ RegistryHive.LocalMachine,
+ AadJoinInfo.JoinInfoRegistryPath,
+ watchSubtree: true,
+ filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
+ | RegistryNativeMethods.RegChangeNotifyFilter.Name,
+ trace: msg => _logger.Trace($"AadJoinWatcher(reg): {msg}"));
+
+ _watcher.Changed += (s, e) =>
+ {
+ _logger.Trace("AadJoinWatcher: registry changed — debouncing");
+ _debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite);
+ };
+ _watcher.Error += (s, ex) => _logger.Warning($"AadJoinWatcher: watcher error: {ex.Message}");
+
+ _watcher.Start();
+ _logger.Info("AadJoinWatcher: RegistryWatcher attached to JoinInfo");
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"AadJoinWatcher: failed to start watcher: {ex.Message}");
+ return false;
+ }
+ }
+
+ private void CheckJoinInfo()
+ {
+ try
+ {
+ // Already reached terminal state? Stop any further processing.
+ lock (_stateLock)
+ {
+ if (_realUserFired) return;
+ }
+
+ if (!AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
+ {
+ // Key gone or unreadable — keep watching, may come back
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(userEmail))
+ {
+ // Sub-key exists but no UserEmail value yet — nothing to act on
+ return;
+ }
+
+ bool firePlaceholder = false;
+ bool fireRealUser = false;
+ lock (_stateLock)
+ {
+ if (isPlaceholderUser && !_placeholderFired)
+ {
+ _placeholderFired = true;
+ firePlaceholder = true;
+ }
+ else if (!isPlaceholderUser && !_realUserFired)
+ {
+ _realUserFired = true;
+ fireRealUser = true;
+ }
+ }
+
+ if (firePlaceholder)
+ {
+ _logger.Info($"AadJoinWatcher: placeholder user detected ({userEmail}) — pre-provisioning indicator");
+ try { PlaceholderUserDetected?.Invoke(this, new AadPlaceholderUserDetectedEventArgs(userEmail, thumbprint)); }
+ catch (Exception ex) { _logger.Error("AadJoinWatcher: PlaceholderUserDetected handler threw", ex); }
+ }
+
+ if (fireRealUser)
+ {
+ _logger.Info($"AadJoinWatcher: real AAD user joined ({userEmail}) — firing AadUserJoined and stopping watcher");
+ try { AadUserJoined?.Invoke(this, new AadUserJoinedEventArgs(userEmail, thumbprint)); }
+ catch (Exception ex) { _logger.Error("AadJoinWatcher: AadUserJoined handler threw", ex); }
+
+ // Terminal — stop the watcher. Run on a thread-pool thread to avoid
+ // callback-in-callback lock edge cases with the RegistryWatcher worker.
+ ThreadPool.QueueUserWorkItem(_ => Stop("real_user_joined"));
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"AadJoinWatcher: CheckJoinInfo threw: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs
index ab0148b8..a08e7b37 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
// Desktop arrival / IME watcher / Network change
private DesktopArrivalDetector _desktopArrivalDetector;
+ private AadJoinWatcher _aadJoinWatcher;
private ImeProcessWatcher _imeProcessWatcher;
private NetworkChangeDetector _networkChangeDetector;
@@ -356,6 +357,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
_desktopArrivalDetector.Start();
_logger.Info("DesktopArrivalDetector started — monitoring for real user desktop");
+ _aadJoinWatcher = new AadJoinWatcher(_logger);
+ _aadJoinWatcher.PlaceholderUserDetected += OnAadPlaceholderUserDetected;
+ _aadJoinWatcher.AadUserJoined += OnAadUserJoined;
+ _aadJoinWatcher.Start();
+ _logger.Info("AadJoinWatcher started — monitoring JoinInfo registry for late AAD user");
+
_imeProcessWatcher = new ImeProcessWatcher(
_configuration.SessionId,
_configuration.TenantId,
@@ -410,6 +417,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
_desktopArrivalDetector = null;
}
+ if (_aadJoinWatcher != null)
+ {
+ _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
+ _aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
+ _aadJoinWatcher.Dispose();
+ _aadJoinWatcher = null;
+ }
+
_imeProcessWatcher?.Dispose();
_imeProcessWatcher = null;
@@ -570,7 +585,37 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
_desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
_desktopArrivalDetector.Dispose();
}
+ if (_aadJoinWatcher != null)
+ {
+ _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
+ _aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
+ _aadJoinWatcher.Dispose();
+ }
_imeProcessWatcher?.Dispose();
}
+
+ private void OnAadPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e)
+ {
+ try
+ {
+ _enrollmentTracker?.NotifyAadPlaceholderUserDetected(e.UserEmail);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("CollectorCoordinator: OnAadPlaceholderUserDetected failed", ex);
+ }
+ }
+
+ private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e)
+ {
+ try
+ {
+ _enrollmentTracker?.NotifyAadUserJoinedLate(e.UserEmail);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("CollectorCoordinator: OnAadUserJoined failed", ex);
+ }
+ }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs
index 03270fcb..59aba960 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs
@@ -7,6 +7,7 @@ using System.Net.NetworkInformation;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.Core.Security;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -20,16 +21,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo
public partial class DeviceInfoCollector
{
///
- /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail.
+ /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail
+ /// that is NOT a provisioning placeholder (see ).
/// Used by EnrollmentTracker to distinguish user-driven from device-only deployments
/// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too).
///
public bool HasAadJoinedUser { get; private set; }
///
- /// True when the AAD join userEmail starts with "foouser@" — a synthetic account used
- /// by Windows during Autopilot pre-provisioning (WhiteGlove) for AAD discovery.
- /// Soft indicator for pre-provisioning; does not affect completion logic.
+ /// True when the AAD join userEmail matches a known transient provisioning-account
+ /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot
+ /// pre-provisioning (WhiteGlove) and are NOT a real AAD join — they are a soft
+ /// positive WhiteGlove indicator and feed into .
///
public bool IsFooUserDetected { get; private set; }
@@ -447,40 +450,36 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo
{
var data = new Dictionary();
- using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"))
+ // Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
+ // are transient provisioning accounts and must NOT be treated as a real AAD join;
+ // HasAadJoinedUser is only set for real user emails.
+ if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
{
- if (joinInfoKey != null)
+ // Still need TenantId — open the subkey directly when we have a thumbprint.
+ if (!string.IsNullOrEmpty(thumbprint))
{
- var subKeyNames = joinInfoKey.GetSubKeyNames();
- if (subKeyNames.Length > 0)
+ using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
+ using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
- using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0]))
- {
- if (subKey != null)
- {
- var userEmail = subKey.GetValue("UserEmail")?.ToString();
- data["tenantId"] = subKey.GetValue("TenantId")?.ToString();
- data["userEmail"] = userEmail;
- data["joinType"] = "Azure AD Joined";
- data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint
-
- if (!string.IsNullOrWhiteSpace(userEmail))
- {
- HasAadJoinedUser = true;
-
- if (userEmail.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase))
- {
- IsFooUserDetected = true;
- data["isFooUser"] = true;
- _logger.Info($"AAD join: foouser@ detected ({userEmail}) — pre-provisioning indicator");
- }
- }
- }
- }
+ data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
}
- else
+
+ data["userEmail"] = userEmail;
+ data["joinType"] = "Azure AD Joined";
+ data["thumbprint"] = thumbprint;
+
+ if (!string.IsNullOrWhiteSpace(userEmail))
{
- data["joinType"] = "Not Joined";
+ if (isPlaceholderUser)
+ {
+ IsFooUserDetected = true;
+ data["isFooUser"] = true;
+ _logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
+ }
+ else
+ {
+ HasAadJoinedUser = true;
+ }
}
}
else
@@ -488,6 +487,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo
data["joinType"] = "Not Joined";
}
}
+ else
+ {
+ data["joinType"] = "Not Joined";
+ }
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 4ac943d1..0ff3e5b1 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1032
+1033
diff --git a/tasks/todo.md b/tasks/todo.md
index bd4ecab5..97fd47d5 100644
--- a/tasks/todo.md
+++ b/tasks/todo.md
@@ -1,3 +1,14 @@
# Tasks
-_Keine offenen Tasks._
+## Post-Deploy: WhiteGlove Scoring (Deploy-Datum: _YYYY-MM-DD eintragen bei Rollout_)
+
+Plan: `C:\Users\OliverKieselbach\.claude\plans\1-whiteglove-klassifizierung-inkonsiste-piped-chipmunk.md`
+Pflicht-Checks nach Deploy. Siehe [feedback_post_deploy_in_todo.md](C:/Users/OliverKieselbach/.claude/projects/c--Code-GitHubRepos-Autopilot-Monitor/memory/feedback_post_deploy_in_todo.md).
+
+- [ ] **+24h** — Scoring-Verteilung Baseline: `query_table(table="Events", filter="EventType eq 'whiteglove_classification' and Timestamp ge <24h-ago>", select="data, SessionId, Timestamp")` → nach `data.wgConfidence` gruppieren (Strong/Weak/None); Ergebnis protokollieren
+- [ ] **+24h** — Late-AAD-Rate: `query_table(... filter="EventType eq 'aad_user_joined_late'")` + gleiche Query für `'aad_placeholder_user_detected_late'` → niedrig aber nicht null erwartet
+- [ ] **+24h** — Eigene Test-VM(s) prüfen: müssen `wgConfidence=None` liefern, **nie** `Weak` oder `Strong` (konkrete DeviceId/SessionId hier eintragen)
+- [ ] **+72h** — Weak-Band drill-down: `query_table(... filter="EventType eq 'whiteglove_classification'")` → Client-Filter `wgConfidence=="Weak"` → `get_session_events(sessionId)` für ≥5 Sessions; sind das echte WG-false-negatives oder legitime Grenzfälle?
+- [ ] **+7d** — Gewichts-Review: brauchen `ShellCoreWhiteGloveSuccess (+80)` oder `HasSaveWhiteGloveSuccessResult (+10)` Anpassung? Falls ja Follow-up-Ticket
+- [ ] **+7d** — `shadow_discrepancy`-Rate vs. Baseline vor Deploy (siehe [project_shadow_sm_cutover.md](C:/Users/OliverKieselbach/.claude/projects/c--Code-GitHubRepos-Autopilot-Monitor/memory/project_shadow_sm_cutover.md)): sollte fallen, nicht steigen
+- [ ] Ergebnisse inline dokumentieren (z.B. `✓ 2026-04-20: 142 Strong / 18 Weak / 3400 None`)