diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs
index b17d9290..edbfc1a7 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs
@@ -418,6 +418,21 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Same(seed, profile);
}
+ [Fact]
+ public void ImeUserSessionCompleted_devicePreparationMedium_isNoOp()
+ {
+ // WDP guard: the IME user channel runs on Device Preparation too, so this signal
+ // cannot discriminate Classic from WDP. The registry-deterministic v2 seed sits at
+ // only Medium confidence — without the explicit guard the High-confidence-only bail
+ // would let this signal silently reclassify every WDP session as Classic.
+ var seed = DevicePreparationSeed();
+ var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
+
+ var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
+
+ Assert.Same(seed, profile);
+ }
+
// ================================================================== ApplySelfDeployingDeadlineConfirmed
// Plan v9 — ApplyDeviceSetupProvisioningComplete was removed (signal-time Mode promotion
// caused false-positive SelfDeploying terminations on Classic flows, session 88a53223).
@@ -483,6 +498,19 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Same(seed, profile);
}
+ [Fact]
+ public void SelfDeployingDeadlineConfirmed_devicePreparationMedium_isNoOp()
+ {
+ // WDP guard mirror: the DeviceOnlyEspDetection deadline is ESP-derived and should be
+ // unreachable on WDP, but a misfire must not displace the registry v2 fact.
+ var seed = DevicePreparationSeed();
+ var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null);
+
+ var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed(seed, signal);
+
+ Assert.Same(seed, profile);
+ }
+
[Fact]
public void WhiteGloveSealingConfirmed_setsWhiteGloveHigh_andTechnicianSide()
{
@@ -497,6 +525,20 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Equal("classifier_whiteglove_sealing_confirmed", profile.Reason);
}
+ [Fact]
+ public void WhiteGloveSealingConfirmed_devicePreparationMedium_isNoOp()
+ {
+ // WhiteGlove pre-provisioning is an ESP technician flow that cannot occur on WDP —
+ // a Confirmed sealing verdict against a v2-profiled session is a classifier misfire.
+ // This updater previously had NO guard at all, so it could overwrite any mode.
+ var seed = DevicePreparationSeed();
+ var signal = MakeSignal(DecisionSignalKind.ClassifierVerdictIssued, ordinal: 15, payload: null);
+
+ var profile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(seed, signal);
+
+ Assert.Same(seed, profile);
+ }
+
// ================================================================== AadUserJoinedLate
[Fact]
@@ -608,6 +650,17 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
}
+ ///
+ /// Profile exactly as seeded by ApplyEnrollmentFactsObserved for enrollmentType "v2" —
+ /// the state the WDP downgrade-guards protect.
+ ///
+ private static EnrollmentScenarioProfile DevicePreparationSeed() =>
+ EnrollmentScenarioProfile.Empty.With(
+ mode: EnrollmentMode.DevicePreparation,
+ confidence: ProfileConfidence.Medium,
+ reason: "enrollment_facts_observed:v2",
+ evidenceOrdinal: 0);
+
private static DecisionSignal MakeSignal(
DecisionSignalKind kind,
long ordinal,
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs
index 9d885033..b4e51cc4 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs
@@ -201,6 +201,72 @@ public class StoreSessionReregistrationPreserveTests
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
}
+ [Fact]
+ public async Task StoreSessionAsync_enrollmentType_isStickyV2_acrossReregistration()
+ {
+ // EnrollmentRegistryDetector defaults to "v1" whenever the AutopilotSettings key is
+ // momentarily unreadable, so a re-registration after such a degraded read must NOT
+ // downgrade an already-observed Device Preparation session — sticky-v2, same contract
+ // as the sticky-true booleans.
+ var existing = new TableEntity(TenantId, SessionId)
+ {
+ ["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
+ ["Status"] = "InProgress",
+ ["EnrollmentType"] = "v2",
+ };
+ existing.ETag = new ETag("0xEXISTING");
+ var harness = new Harness(existing);
+
+ var registration = new SessionRegistration
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
+ SerialNumber = "SN-1",
+ Manufacturer = "Contoso",
+ Model = "Model-X",
+ DeviceName = "PC-1",
+ EnrollmentType = "v1", // degraded registry read on restart
+ };
+
+ var ok = await harness.Sut.StoreSessionAsync(registration);
+
+ Assert.True(ok);
+ Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
+ }
+
+ [Fact]
+ public async Task StoreSessionAsync_enrollmentType_upgradesV1ToV2_onReregistration()
+ {
+ // Inverse direction: first registration read "v1" (key not yet populated), a later
+ // re-registration reads "v2" — the more informative value wins.
+ var existing = new TableEntity(TenantId, SessionId)
+ {
+ ["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
+ ["Status"] = "InProgress",
+ ["EnrollmentType"] = "v1",
+ };
+ existing.ETag = new ETag("0xEXISTING");
+ var harness = new Harness(existing);
+
+ var registration = new SessionRegistration
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
+ SerialNumber = "SN-1",
+ Manufacturer = "Contoso",
+ Model = "Model-X",
+ DeviceName = "PC-1",
+ EnrollmentType = "v2",
+ };
+
+ var ok = await harness.Sut.StoreSessionAsync(registration);
+
+ Assert.True(ok);
+ Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
+ }
+
[Fact]
public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration()
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
index 56f4395f..50a506e5 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
@@ -463,6 +463,11 @@ namespace AutopilotMonitor.Functions.Services
bool isHybridJoin = registration.IsHybridJoin;
bool isSelfDeployingProfile = registration.IsSelfDeployingProfile;
bool isCloudPc = registration.IsCloudPc;
+ // Sticky-v2 (not plain preserve): EnrollmentRegistryDetector defaults to "v1"
+ // whenever the AutopilotSettings key is momentarily unreadable, so a
+ // re-registration after such a degraded read must not downgrade an
+ // already-observed Device Preparation session back to v1.
+ string enrollmentType = registration.EnrollmentType ?? "v1";
DateTime? lastEventAt = null;
int? durationSeconds = null;
string? diagnosticsBlobName = null;
@@ -514,6 +519,10 @@ namespace AutopilotMonitor.Functions.Services
// registry/service hiccup on a later re-registration must not clear an
// already-observed Cloud PC fact.
isCloudPc = (existingEntity.GetBoolean("IsCloudPc") ?? false) || isCloudPc;
+ // Sticky-v2 (same contract as the booleans above): once observed "v2",
+ // the enrollment rail stays "v2".
+ if (string.Equals(existingEntity.GetString("EnrollmentType"), "v2", StringComparison.OrdinalIgnoreCase))
+ enrollmentType = "v2";
lastEventAt = existingEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
durationSeconds = existingEntity.GetInt32("DurationSeconds");
diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName");
@@ -584,7 +593,7 @@ namespace AutopilotMonitor.Functions.Services
["IsCloudPc"] = isCloudPc,
["StartedAt"] = EnsureUtc(startedAt),
["AgentVersion"] = registration.AgentVersion ?? string.Empty,
- ["EnrollmentType"] = registration.EnrollmentType ?? "v1",
+ ["EnrollmentType"] = enrollmentType,
["CurrentPhase"] = currentPhase,
["Status"] = status,
["EventCount"] = eventCount,
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs
index 00703f61..5e68e1ad 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs
@@ -292,7 +292,11 @@ namespace AutopilotMonitor.DecisionCore.State
/// is a strong UserDriven-v1
/// indicator. Upgrades Mode to at
/// , UNLESS a prior signal has already confirmed a
- /// different mode at High confidence (monotonic rule).
+ /// different mode at High confidence (monotonic rule), or the profile is already
+ /// — the IME user channel runs on WDP
+ /// too (the user is signed in from the start), so this signal cannot discriminate
+ /// Classic from WDP and must not displace the registry-deterministic v2 fact even
+ /// though that fact is seeded at only .
///
public static EnrollmentScenarioProfile ApplyImeUserSessionCompleted(
EnrollmentScenarioProfile current,
@@ -301,6 +305,11 @@ namespace AutopilotMonitor.DecisionCore.State
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
+ if (current.Mode == EnrollmentMode.DevicePreparation)
+ {
+ return current;
+ }
+
if (current.Confidence == ProfileConfidence.High && current.Mode != EnrollmentMode.Classic
&& current.Mode != EnrollmentMode.Unknown)
{
@@ -339,6 +348,14 @@ namespace AutopilotMonitor.DecisionCore.State
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
+ // WDP guard (mirrors ApplyImeUserSessionCompleted): the DeviceOnlyEspDetection
+ // deadline is ESP-derived and should be unreachable on WDP, but if it ever fires
+ // against a v2-profiled session the registry-deterministic fact wins.
+ if (current.Mode == EnrollmentMode.DevicePreparation)
+ {
+ return current;
+ }
+
if (current.Confidence == ProfileConfidence.High && current.Mode != EnrollmentMode.SelfDeploying
&& current.Mode != EnrollmentMode.Unknown)
{
@@ -360,6 +377,10 @@ namespace AutopilotMonitor.DecisionCore.State
/// at , PreProvisioningSide=
/// (Part 1 is by definition the technician side). Non-Confirmed verdicts leave the profile
/// alone — the verdict itself is stored in .
+ /// A profile already classified is never
+ /// overwritten: WhiteGlove pre-provisioning is an ESP technician flow that cannot occur
+ /// on WDP, so a Confirmed verdict against a v2-profiled session is a classifier misfire
+ /// and the registry-deterministic fact wins.
///
public static EnrollmentScenarioProfile ApplyWhiteGloveSealingConfirmed(
EnrollmentScenarioProfile current,
@@ -368,6 +389,11 @@ namespace AutopilotMonitor.DecisionCore.State
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
+ if (current.Mode == EnrollmentMode.DevicePreparation)
+ {
+ return current;
+ }
+
return current.With(
mode: EnrollmentMode.WhiteGlove,
preProvisioningSide: PreProvisioningSide.Technician,