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,