feat(agent): WhiteGlove Detection Classifier and late-AADJ support

This commit is contained in:
Oliver Kieselbach
2026-04-17 12:35:25 +02:00
parent 64240249ac
commit 6efae2c709
17 changed files with 1405 additions and 92 deletions
+1
View File
@@ -117,3 +117,4 @@ scripts/Deployment/build_and_upload_release_agent_build.ps1
.vscode/
TODO.txt
tools/orphan-analysis.py
tasks/todo.md
@@ -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));
}
}
}
@@ -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
{
/// <summary>
/// Integration-style tests for <see cref="AadJoinWatcher"/>.
/// 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 <see cref="AadJoinInfo.IsPlaceholderUserEmail"/>
/// 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.
/// </summary>
public class AadJoinWatcherTests
{
[Fact]
public void Constructor_NullLogger_Throws()
{
Assert.Throws<ArgumentNullException>(() => 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<ObjectDisposedException>(() => 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));
}
}
}
@@ -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);
}
@@ -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);
}
}
}
@@ -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
// <see cref="WhiteGloveClassifier"/>.
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<string> { "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;
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Confidence level for a WhiteGlove Part 1 classification.
/// </summary>
internal enum WhiteGloveConfidence
{
/// <summary>Score below <see cref="WhiteGloveClassifier.LowThreshold"/> — definitely not WhiteGlove.</summary>
None,
/// <summary>Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).</summary>
Weak,
/// <summary>Score at or above <see cref="WhiteGloveClassifier.HighThreshold"/> — classify as WhiteGlove Part 1.</summary>
Strong,
}
/// <summary>
/// Result of a <see cref="WhiteGloveClassifier.Classify"/> call. Contains the
/// final routing decision, the numeric score and the contributing factors for
/// observability / tuning.
/// </summary>
internal sealed class WhiteGloveClassification
{
public WhiteGloveConfidence Confidence { get; set; }
public int Score { get; set; }
public List<string> ContributingFactors { get; set; } = new List<string>();
public string Reason { get; set; }
/// <summary>
/// Single Boolean the caller should read to decide whether to route to
/// <c>whiteglove_complete</c> (Pending) instead of the normal completion path.
/// Only true when <see cref="Confidence"/> is <see cref="WhiteGloveConfidence.Strong"/>.
/// </summary>
public bool ShouldRouteToWhiteGlovePart1 { get; set; }
}
/// <summary>
/// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
/// <c>AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold</c> pattern.
///
/// Asymmetric-conservative decision rule: only a <see cref="WhiteGloveConfidence.Strong"/>
/// score routes to WhiteGlove. A <see cref="WhiteGloveConfidence.Weak"/> 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
/// <c>enrollment_complete</c> early and the later user sign-in triggers a new session —
/// the user-visible cost of false-negative is much lower than false-positive.
/// </summary>
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<string>();
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),
};
}
}
}
@@ -0,0 +1,63 @@
using System;
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
/// Populated under _stateLock in the caller and consumed outside the lock by
/// <see cref="WhiteGloveClassifier.Classify"/> — 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 <see cref="FromContext"/> to keep both
/// decision paths in sync.
/// </summary>
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; }
/// <summary>
/// Builds a signal snapshot from a <see cref="CompletionContext"/>. Used by the
/// CompletionStateMachine so that state-machine decisions use the exact same
/// inputs as the EnrollmentTracker's direct decisions.
/// </summary>
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,
};
}
}
}
@@ -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; }
@@ -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<string, object>
{
{ "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
/// </summary>
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<string>(_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<string>
{
$"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<string>())
.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<string>
@@ -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.
/// </summary>
private void EmitDecisionProcess(string terminalEvent, string source, string outcome,
List<string> decisionChain = null)
List<string> decisionChain = null,
WhiteGloveClassification classification = null)
{
Dictionary<string, object> 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<string>();
}
}
_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.
// =====================================================================
/// <summary>
/// Snapshot current WhiteGlove-relevant state under _stateLock and return a
/// consumable <see cref="WhiteGloveSignals"/> for <see cref="WhiteGloveClassifier.Classify"/>.
/// </summary>
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,
};
}
}
/// <summary>
/// Emits the observability-only <c>whiteglove_classification</c> event with the
/// full scoring breakdown. Enables MCP <c>query_table</c> 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.
/// </summary>
private void EmitWhiteGloveClassification(
WhiteGloveClassification c,
string callSite,
WhiteGloveSignals signals)
{
if (c == null) return;
var data = new Dictionary<string, object>
{
{ "wgScore", c.Score },
{ "wgConfidence", c.Confidence.ToString() },
{ "callSite", callSite },
{ "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 },
{ "factors", c.ContributingFactors ?? new List<string>() },
{ "signals", new Dictionary<string, object>
{
{ "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,
});
}
/// <summary>
/// Called by CollectorCoordinator when the <see cref="SystemSignals.AadJoinWatcher"/>
/// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates
/// <c>_fooUserDetected</c> so the WhiteGloveClassifier sees the positive indicator
/// on the next classification call. Does NOT re-trigger completion — placeholder
/// alone is not a terminal signal.
/// </summary>
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<string, object>
{
{ "secondsSinceAgentStart", secondsSinceStart },
{ "emailPattern", userEmail?.Split('@')[0] ?? "unknown" },
});
}
/// <summary>
/// Called by CollectorCoordinator when the <see cref="SystemSignals.AadJoinWatcher"/>
/// detects a real AAD user join (non-placeholder) during the session. Updates
/// <c>_aadJoinedWithUser</c> atomically so subsequent WhiteGloveClassifier calls
/// correctly see the real user (driving <c>IsDeviceOnlyDeployment</c> to false and
/// triggering the classifier's hard-exclude). Re-runs the completion guards.
/// </summary>
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<string, object>
{
{ "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");
}
/// <summary>
/// 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.
@@ -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 <see cref="_signalCorrelatedWhiteGloveTriggered"/> (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;
}
@@ -0,0 +1,84 @@
using System;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// 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\&lt;thumbprint&gt;\UserEmail
///
/// Placeholder users (<see cref="IsPlaceholderUserEmail"/>) appear transiently during
/// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join.
/// </summary>
internal static class AadJoinInfo
{
internal const string JoinInfoRegistryPath =
@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
/// <summary>
/// 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.
/// </summary>
/// <param name="userEmail">Raw UserEmail value (may be null/empty).</param>
/// <param name="thumbprint">The certificate thumbprint sub-key name (may be null).</param>
/// <param name="isPlaceholderUser">
/// True when <paramref name="userEmail"/> matches a known provisioning-placeholder
/// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join.
/// </param>
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;
}
}
/// <summary>
/// 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.
/// </summary>
internal static bool IsPlaceholderUserEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return false;
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -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;
}
}
/// <summary>
/// Watches <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo</c> for a
/// late AAD user join. Uses <see cref="RegistryWatcher"/> (RegNotifyChangeKeyValue) for
/// instant, low-overhead change detection — no polling.
///
/// Two distinct events are raised:
/// <list type="bullet">
/// <item><description><see cref="PlaceholderUserDetected"/> — first time a transient
/// provisioning account (<c>foouser@*</c>, <c>autopilot@*</c>) appears. Fire-once.
/// The watcher keeps running afterwards, as the placeholder is expected to be
/// replaced by a real user.</description></item>
/// <item><description><see cref="AadUserJoined"/> — first time a REAL user e-mail
/// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards.</description></item>
/// </list>
///
/// Retry semantics: the <c>JoinInfo</c> 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.
/// </summary>
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<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
public event EventHandler<AadUserJoinedEventArgs> 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}");
}
}
}
}
@@ -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);
}
}
}
}
@@ -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
{
/// <summary>
/// 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 <see cref="IsFooUserDetected"/>).
/// Used by EnrollmentTracker to distinguish user-driven from device-only deployments
/// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too).
/// </summary>
public bool HasAadJoinedUser { get; private set; }
/// <summary>
/// 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 <see cref="AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion.WhiteGloveClassifier"/>.
/// </summary>
public bool IsFooUserDetected { get; private set; }
@@ -447,40 +450,36 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo
{
var data = new Dictionary<string, object>();
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";
@@ -1 +1 @@
1032
1033
+12 -1
View File
@@ -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`)