mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+53
@@ -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,
|
||||
|
||||
+66
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user