WDP hardening: sticky-v2 EnrollmentType + DevicePreparation downgrade guards

Two low-risk fixes ahead of the first live Device Preparation enrollment:

- StoreSessionAsync: EnrollmentType is now sticky-v2 across re-registration
  (same contract as the sticky-true booleans) so a degraded AutopilotSettings
  read after a reboot can no longer flip an observed v2 session back to v1.
- EnrollmentScenarioProfileUpdater: ImeUserSessionCompleted,
  SelfDeployingDeadlineConfirmed and WhiteGloveSealingConfirmed no longer
  overwrite an established DevicePreparation profile (seeded at Medium from
  the registry-deterministic v2 fact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-17 11:55:19 +02:00
parent 2de8f5c4e6
commit d0ab3eee40
4 changed files with 156 additions and 2 deletions
@@ -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);
}
/// <summary>
/// Profile exactly as seeded by ApplyEnrollmentFactsObserved for enrollmentType "v2" —
/// the state the WDP downgrade-guards protect.
/// </summary>
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,
@@ -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()
{
@@ -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,
@@ -292,7 +292,11 @@ namespace AutopilotMonitor.DecisionCore.State
/// <see cref="DecisionSignalKind.ImeUserSessionCompleted"/> is a strong UserDriven-v1
/// indicator. Upgrades Mode to <see cref="EnrollmentMode.Classic"/> at
/// <see cref="ProfileConfidence.High"/>, 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
/// <see cref="EnrollmentMode.DevicePreparation"/> — 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 <see cref="ProfileConfidence.Medium"/>.
/// </summary>
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 <see cref="ProfileConfidence.High"/>, PreProvisioningSide=<see cref="PreProvisioningSide.Technician"/>
/// (Part 1 is by definition the technician side). Non-Confirmed verdicts leave the profile
/// alone — the verdict itself is stored in <see cref="ClassifierOutcomes.WhiteGloveSealing"/>.
/// A profile already classified <see cref="EnrollmentMode.DevicePreparation"/> 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.
/// </summary>
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,