feat(wdp): scenario gates for Device Preparation + completion backstop

afee7ae0 audit: WDP sessions ran the full Classic/ESP apparatus. Gates key on
the deterministic BootstrapperAgent ExecutionContext marker only (CloudAssigned*
fallbacks keep Classic behavior).

Paket A (event hygiene): no autopilot_profile/-missing + ZTD query/probe on WDP;
esp_config_detected null-gate (no more unknown/unknown); starved sweep skipped
on WDP (data stays in app_tracking_summary/final-status).

Paket B (resources): EspPolicyProviderStallHost + ProvisioningStatusTracker not
started on WDP; ModernDeployment noise IDs 100/417/418/1005 fully suppressed
(Critical always passes) + Autopilot error backfill skipped; StallProbe skips
ESP category scan and treats noise IDs as harmless.

Paket C (DecisionCore): enrollmentTypeDeterministic payload key seeds
DevicePreparation at High confidence (fallbacks stay Medium); completion_waiting
drops the unsatisfiable account_setup_provisioning_complete gate on WDP; arm D
promotes on real-user desktop; new device_prep_completion 30-min backstop closes
the park-until-watchdog hole; parked tripwire gets a WDP branch.

17 new tests; suites green (532/2307/4287). OKF doc agent/wdp-scenario-gates.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-18 13:12:09 +02:00
parent 7a131af74d
commit a369ff99cd
24 changed files with 931 additions and 52 deletions
+108
View File
@@ -0,0 +1,108 @@
---
type: concept
title: WDP Scenario Gates & Completion Backstop
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
resource: agent
tags: [wdp, device-preparation, decision-engine, collectors, completion]
timestamp: 2026-08-18
---
# Schema
Windows Device Preparation (Autopilot v2, "WDP") has **no deployment profile and no ESP**.
Before the afee7ae0 audit (2026-08-18) the agent ran the full Classic apparatus anyway:
misleading `autopilot_profile_missing` warnings, a ZTD event-log query + HTTP probe,
`esp_config_detected` "unknown/unknown" noise, an Autopilot-channel noise flood
(EventIDs 100/417/418/1005, 900+ occurrences per session), a subtree registry watcher
woken by every WDP progress write, and — worst — no completion path when Hello never
resolved (park until the 360-min watchdog).
## Detection: deterministic vs. fallback
`EnrollmentRegistryDetector` classifies v2 through three rules. Only the first is
**deterministic WDP**:
1. `AutopilotSettings\DevicePreparation\BootstrapperAgent` subkey with non-empty
`ExecutionContext` → the WDP policy orchestration tree, present before the agent
starts. Exposed as `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`
and the `DevicePreparationExecutionContextRule` constant.
2./3. `CloudAssignedDeviceRegistration == 2` / `CloudAssignedEspEnabled == 0` —
profile-derived legacy signals. They imply a REAL Autopilot profile (just ESP-less),
so every gate below deliberately ignores them: **gates require the deterministic
marker and fail toward Classic behavior.**
## Agent-side gates (all keyed to the deterministic marker)
* `DeviceInfoCollector.CollectAutopilotProfile`: no `autopilot_profile` event, no
`autopilot_profile_missing` warning, no ZTD event-log query, no ZTD HTTP probe.
`enrollment_type_detected` still fires. (WDP devices DO carry an
`AutopilotPolicyCache` with `ProfileAvailable=0` / error 807 `ZtdDeviceIsNotRegistered`
— Windows runs the ZTD download regardless — so the probe fired for real before.)
* `CollectEspConfigurationLocked`: `esp_config_detected` is emitted only when at least
one FirstSync value or ESP tracking list exists (generic null-gate, mirrors the
orchestrator bootstrap's signal gate) — fixes "unknown/unknown" for WDP AND plain
Entra joins.
* `DefaultComponentFactory`: `EspPolicyProviderStallHost` (60-s poll of the ESP-only
`EnrollmentStatusTracking` key) is not created on WDP.
* `EspAndHelloTracker.Start`: `ProvisioningStatusTracker` is not started on WDP — its
subtree watcher sits on `AutopilotSettings`, exactly where WDP's BootstrapperAgent
writes progress, so it woke on every write only to read three ESP category values
that never exist there.
* `ModernDeploymentTracker`: `DevicePreparationNoiseEventIds` {100, 417, 418, 1005}
are suppressed entirely on WDP (before `FormatDescription()`); Critical (level 1)
always passes; genuine WDP diagnostics (e.g. 408) keep flowing. The Autopilot error
backfill (807/809/815/908 replay) is skipped — 807 is the EXPECTED state on WDP.
The watcher itself stays on: WDP's provisioning stack logs into the same channels.
* `StallProbeCollector`: skips the ESP category registry scan on WDP and unions the
noise IDs into its harmless set; the channel scans remain (they carry genuine WDP
diagnostics during a stall).
* `EnrollmentTerminationHandler`: the `app_install_starved` terminal sweep is skipped
on WDP — post-desktop installs are the DESIGNED order there (only DPP-policy apps
run during provisioning); per-app data still travels via `app_tracking_summary` and
final-status.json.
## DecisionCore: WDP completion without ESP facts
* **Seeding**: `EnrollmentFactsObserved` carries `enrollmentTypeDeterministic`;
the deterministic marker seeds `DevicePreparation` at **High** confidence (same
registry-determinism standard as the self-deploying OobeConfig bits), the fallback
rules stay Medium. The three explicit downgrade guards from d0ab3eee remain as
defence in depth.
* **Arm D** (`ShouldTransitionToAwaitingHello`): on WDP the DAD-validated real-user
desktop is a legitimate promotion basis — arms A–C are unsatisfiable by construction
(no provisioning categories, no Shell-Core 62407 final exit). With a disabled Hello
policy the fast-path now completes at desktop arrival.
* **Prerequisites**: `completion_waiting` never lists
`account_setup_provisioning_complete` on WDP (unsatisfiable; mirrors the
`skipUserEsp` exemption).
* **Backstop**: `DeadlineNames.DevicePrepCompletion` (30 min, replay-safe base) is
armed by `HandleDesktopArrivedV1` when a WDP session parks desktop-first with Hello
pending. On fire it mirrors the HelloSafety timeout: synthetic
`HelloOutcome=Timeout` + completion through Finalizing. This closes the
"no Hello → park until watchdog" hole; a timer surviving into a terminal stage is
swallowed by the post-terminal dispatch guard.
* **Tripwire**: `DecisionStepProcessor.IsParkedWithoutDeadline` has a WDP branch
(dead-end zone = desktop arrived) so a regression in the backstop becomes visible
as `session_parked_without_deadline`.
# Examples
Session afee7ae0 (the audit trigger): Succeeded in 9:32 min, but its timeline carried
`autopilot_profile` (807), an `autopilot_profile_missing` WARN, `esp_config_detected`
unknown/unknown, 30 `modern_deployment_log` rollup events and 10 `app_install_starved`
entries — all structurally meaningless on WDP. After the gates, the same session shape
emits none of these; completion rode Hello-skipped + desktop as before.
Deliberately NOT changed (open follow-ups, see the WDP audit topic memory): the ESP
phase vocabulary (`esp_phase_changed`/stage names ride the Classic rails on WDP),
subscription to the `BootstrapperAgentServiceLogProvider` event channel, and
`registry_app_state` as the primary WDP app source.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs` — deterministic rule + helper
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — profile/ESP emission gates
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs` — `DevicePreparationNoiseEventIds`
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs` — backstop deadline
* `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs` — High/Medium seeding
* Tests: `DevicePreparationEngineTests`, `ModernDeploymentTrackerDevicePrepTests`, `EnrollmentTerminationHandlerTests.StarvedApps_skipped_on_device_preparation`, `DeviceInfoCollectorEspRefreshTests.NoEspEvidenceAtAll_SuppressesTheEvent`
+1
View File
@@ -26,6 +26,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
+2
View File
@@ -2,6 +2,8 @@
## 2026-08-18
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
@@ -61,6 +61,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
}
[Fact]
public void NoEspEvidenceAtAll_SuppressesTheEvent()
{
// afee7ae0 (WDP / plain Entra join): FirstSync has no values and the ESP tracking
// key does not exist — an event saying only "SkipUser=unknown, SkipDevice=unknown"
// is noise and must not be emitted (mirrors the orchestrator bootstrap's null-gate).
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var ingress = new FakeSignalIngressSink();
var clock = new VirtualClock(T0);
var collector = new DeviceInfoCollector(
"session-nullgate", "tenant-1",
new InformationalEventPost(ingress, clock), logger,
signalIngress: null, clock: clock,
startupGate: new StartupEventGate(tmp.Path, logger));
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
_ => new EspFirstSyncSnapshot(
skipUser: null, skipDevice: null,
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
using (new EspTrackingInfoProbe.ScopedOverride(_ => default))
{
collector.CollectEspConfiguration();
}
Assert.Empty(EspConfigEvents(ingress));
// A later refresh where real evidence appeared must still emit normally.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
{
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsDevice);
}
Assert.Single(EspConfigEvents(ingress));
}
[Fact]
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
{
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Session afee7ae0 (2026-08-18) — Device Preparation structural-noise suppression.
/// A nine-minute WDP session shipped 900+ occurrences of WIL error 1005 plus 400+
/// duplicate-workload 417/418 records and "Autopilot policy not found" 100s — all
/// structural on WDP (no deployment profile exists; the BootstrapperAgent's SLDM
/// progress poll produces the 417/418 chatter). On WDP those IDs are suppressed
/// entirely (<see cref="ModernDeploymentTracker.DevicePreparationNoiseEventIds"/>);
/// genuine diagnostics (e.g. 408 provisioning warnings) and Critical records keep
/// flowing, and Classic sessions keep the harmless-downgrade + rollup path.
/// </summary>
public sealed class ModernDeploymentTrackerDevicePrepTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 8, 18, 9, 10, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink;
private readonly AgentLogger _logger;
public ModernDeploymentTrackerDevicePrepTests()
{
_sink = new FakeSignalIngressSink();
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose() => _tmp.Dispose();
private ModernDeploymentTracker BuildTracker(bool isDevicePreparation) =>
new ModernDeploymentTracker(
sessionId: "sess-wdp-noise",
tenantId: "tenant-wdp-noise",
post: new InformationalEventPost(_sink, new VirtualClock(At)),
logger: _logger,
backfillEnabled: false,
isDevicePreparation: isDevicePreparation);
private void Process(ModernDeploymentTracker tracker, int eventId, int level, string shortName, string channel)
{
tracker.ProcessEvent(
eventId: eventId,
level: level,
levelDisplayName: level == 1 ? "Critical" : level == 2 ? "Error" : "Warning",
providerName: "ModernDeployment-Diagnostics-Provider",
timeCreatedUtc: At,
formattedDescription: $"EventID {eventId} record.",
shortName: shortName,
channelName: channel,
isBackfill: false);
}
private List<FakeSignalIngressSink.PostedSignal> PostedModernDeployment() =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et.StartsWith("modern_deployment", StringComparison.Ordinal)).ToList();
[Theory]
[InlineData(100, 3, "Autopilot")] // policy not found — no profile exists on WDP
[InlineData(417, 3, "Autopilot")] // duplicate workload — SLDM poll chatter
[InlineData(418, 3, "Autopilot")] // duplicate batch — SLDM poll chatter
[InlineData(1005, 2, "ManagementService")] // WIL error storm
public void WdpNoiseIds_AreFullySuppressed_OnDevicePreparation(int eventId, int level, string shortName)
{
var tracker = BuildTracker(isDevicePreparation: true);
var channel = shortName == "Autopilot"
? ModernDeploymentTracker.AutopilotChannel
: ModernDeploymentTracker.ManagementChannel;
for (var i = 0; i < 10; i++)
{
Process(tracker, eventId, level, shortName, channel);
}
Assert.Empty(PostedModernDeployment());
}
[Fact]
public void GenuineWdpWarning_408_KeepsFlowing_OnDevicePreparation()
{
// 408 "Autopilot Provisioning reported a warning" carries the real WDP session
// context — it is NOT in the noise set and must keep flowing.
var tracker = BuildTracker(isDevicePreparation: true);
Process(tracker, 408, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
var posted = Assert.Single(PostedModernDeployment());
Assert.Equal("modern_deployment_warning", posted.Payload![SignalPayloadKeys.EventType]);
}
[Fact]
public void CriticalRecords_AreNeverSuppressed_EvenForNoiseIds()
{
var tracker = BuildTracker(isDevicePreparation: true);
Process(tracker, 1005, level: 1, shortName: "ManagementService", channel: ModernDeploymentTracker.ManagementChannel);
var posted = Assert.Single(PostedModernDeployment());
Assert.Equal("modern_deployment_error", posted.Payload![SignalPayloadKeys.EventType]);
}
[Fact]
public void ClassicSessions_KeepTheHarmlessDowngradePath_ForTheSameIds()
{
// Classic regression guard: without the WDP flag EventID 100 still flows through
// the harmless-downgrade path (individual Debug emissions up to the rollup limit).
var tracker = BuildTracker(isDevicePreparation: false);
for (var i = 0; i < 2; i++)
{
Process(tracker, 100, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
}
Assert.Equal(2, PostedModernDeployment().Count);
}
}
}
@@ -1497,6 +1497,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
}
[Fact]
public void StarvedApps_skipped_on_device_preparation()
{
// afee7ae0: WDP has no ESP apps gate — post-desktop installs are the designed
// order, so the sweep would report every pending app as noise (10+ events on a
// normal session). The per-app data still travels via app_tracking_summary.
using var rig = new Rig();
rig.State = DevicePrepState(SessionStage.Completed);
rig.StarvedAppsOverride = new[]
{
BuildStarvedApp("app-pending-1", "Contoso Backgrounds"),
BuildStarvedApp("app-pending-2", "Contoso Tools"),
};
rig.Build().Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
}
[Fact]
public void PromoteActiveInstalls_skipped_on_effect_infrastructure_failure()
{
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly int _modernDeploymentBackfillLookbackMinutes;
private readonly string _stateDirectory;
private readonly int[] _modernDeploymentHarmlessEventIds;
private readonly bool _isDevicePreparation;
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
private readonly Func<bool> _accountSetupActivityProbe;
private readonly Func<bool> _userEspAppsSettledProbe;
@@ -211,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
int modernDeploymentBackfillLookbackMinutes = 30,
string stateDirectory = null,
int[] modernDeploymentHarmlessEventIds = null,
bool isDevicePreparation = false,
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
Func<bool> accountSetupActivityProbe = null,
Func<bool> userEspAppsSettledProbe = null,
@@ -228,6 +230,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
_isDevicePreparation = isDevicePreparation;
// Test seam: allow injection of a fake skip-config reader; defaults to the real
// registry probe so production never has to think about this parameter.
_skipConfigProbe = skipConfigProbe ?? (() => EspSkipConfigurationProbe.Read(_logger));
@@ -348,16 +351,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_shellCoreTracker.HelloWizardStarted += OnHelloWizardStarted;
_shellCoreTracker.Start();
_provisioningTracker = new ProvisioningStatusTracker(
_sessionId,
_tenantId,
_post,
_logger,
packageStatesProbe: _packageStatesProbe);
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
_provisioningTracker.Start();
// WDP gate (afee7ae0, 2026-08-18): Device Preparation writes its BootstrapperAgent
// progress under the very AutopilotSettings key this tracker subtree-watches, so on
// WDP the watcher woke up on every provisioning write only to read three ESP
// category values that never exist there. The ESP categories are the tracker's
// ONLY subject — skip it entirely. All consumers already null-guard the tracker.
if (!_isDevicePreparation)
{
_provisioningTracker = new ProvisioningStatusTracker(
_sessionId,
_tenantId,
_post,
_logger,
packageStatesProbe: _packageStatesProbe);
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
_provisioningTracker.Start();
}
else
{
_logger.Info("EspAndHelloTracker: Device Preparation session — ProvisioningStatusTracker (ESP categories) not started");
}
if (_modernDeploymentWatcherEnabled)
{
@@ -370,7 +385,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_modernDeploymentBackfillEnabled,
_modernDeploymentBackfillLookbackMinutes,
_stateDirectory,
_modernDeploymentHarmlessEventIds);
_modernDeploymentHarmlessEventIds,
_isDevicePreparation);
_modernDeploymentTracker.Start();
}
}
@@ -66,6 +66,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal const string AutopilotErrorBackfillStateFileName = "autopilot-error-backfill.json";
/// <summary>
/// EventIDs that are STRUCTURAL noise on a Device Preparation session and are
/// suppressed there entirely (afee7ae0: 900+ occurrences of 1005 plus 400+ of
/// 417/418 in a nine-minute session, all expected): 100 "Autopilot policy not
/// found" (no deployment profile exists on WDP by design), 417/418 "duplicate
/// deployment workload/batch" (BootstrapperAgent SLDM progress-poll chatter),
/// 1005 ManagementService WIL error storm. Genuine WDP diagnostics (e.g. 408
/// provisioning warnings) keep flowing; Critical (level 1) records are never
/// suppressed. Classic sessions are unaffected — they keep the configurable
/// harmless-downgrade + rollup path instead.
/// </summary>
internal static readonly HashSet<int> DevicePreparationNoiseEventIds = new HashSet<int> { 100, 417, 418, 1005 };
/// <summary>
/// Per-EventId emission caps for harmless-downgraded events (session 8bc1180f,
/// 2026-06-12). Windows can dump hundreds of identical EventID-100 "Autopilot policy
@@ -93,6 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly int _backfillLookbackMinutes;
private readonly string _stateDirectory;
private readonly HashSet<int> _harmlessEventIds;
private readonly bool _isDevicePreparation;
private EventLogWatcher _autopilotWatcher;
private EventLogWatcher _managementWatcher;
@@ -131,7 +145,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
bool backfillEnabled = true,
int backfillLookbackMinutes = 30,
string stateDirectory = null,
int[] harmlessEventIds = null)
int[] harmlessEventIds = null,
bool isDevicePreparation = false)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -140,6 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_logLevelMax = logLevelMax;
_backfillEnabled = backfillEnabled;
_backfillLookbackMinutes = backfillLookbackMinutes;
_isDevicePreparation = isDevicePreparation;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
? new HashSet<int>(harmlessEventIds)
@@ -166,7 +182,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (_backfillEnabled)
{
BackfillTargetedEvents();
BackfillAutopilotErrors();
// WDP gate (afee7ae0): the error backfill replays ZTD registration /
// attestation errors (807/809/815/908) — on Device Preparation the 807
// ZtdDeviceIsNotRegistered answer is the EXPECTED state, not a failure.
if (!_isDevicePreparation)
{
BackfillAutopilotErrors();
}
else
{
_logger.Info("ModernDeployment: Device Preparation session — Autopilot error backfill skipped (ZTD errors are structural there)");
}
}
else
{
@@ -393,6 +419,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
var effectiveLevel = level ?? 4;
// WDP structural-noise gate — see DevicePreparationNoiseEventIds. Sits before
// the severity mapping so suppressed records also skip FormatDescription().
// Critical (1) and LogAlways (0) records always pass.
if (_isDevicePreparation
&& effectiveLevel >= 2
&& DevicePreparationNoiseEventIds.Contains(eventId))
{
return EventVerdict.Suppress;
}
switch (effectiveLevel)
{
case 1:
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly HashSet<string> _sources;
private readonly int _sessionStalledAfterProbeIndex;
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
private readonly bool _isDevicePreparation;
private readonly object _stateLock = new object();
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
@@ -151,7 +152,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
int[] traceIndices,
string[] sources,
int sessionStalledAfterProbeIndex,
int[] harmlessModernDeploymentEventIds = null)
int[] harmlessModernDeploymentEventIds = null,
bool isDevicePreparation = false)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -161,10 +163,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
_isDevicePreparation = isDevicePreparation;
_harmlessModernDeploymentEventIds = new HashSet<int>(
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
? harmlessModernDeploymentEventIds
: DefaultHarmlessModernDeploymentEventIds);
// WDP gate (afee7ae0): the structural WDP noise IDs (417/418 duplicate-workload
// chatter, 1005 WIL storm, ...) must never count as stall anomalies on Device
// Preparation, independent of what the tenant-configured harmless list contains.
// The channels themselves stay scanned — WDP's provisioning stack logs genuine
// diagnostics (e.g. 408) into the very same Autopilot channel.
if (_isDevicePreparation)
{
_harmlessModernDeploymentEventIds.UnionWith(ModernDeploymentTracker.DevicePreparationNoiseEventIds);
}
}
/// <summary>
@@ -368,6 +381,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private void ScanProvisioningRegistry(ProbeResult result)
{
// Device Preparation writes no ESP category values — the three Status JSONs
// below are ESP-only constructs, so the scan can never find anything there.
if (_isDevicePreparation)
return;
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
{
if (key == null)
@@ -275,14 +275,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// Include enrollment type in autopilot_profile event data
data["enrollmentType"] = detectedType;
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
var diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
if (diagnosticsRegistry != null)
data["diagnosticsRegistry"] = diagnosticsRegistry;
// Device Preparation (WDP) has no Autopilot deployment profile by design:
// Windows still runs the ZTD policy download during OOBE and caches the 807
// ZtdDeviceIsNotRegistered answer, so without this gate every WDP session
// emits an autopilot_profile dump plus a misleading "no deployment profile
// was assigned" warning and burns a ZTD event-log query + HTTP probe on
// evidence that answers a question nobody asked (afee7ae0). Gated on the
// deterministic WDP rule only — the CloudAssigned* fallback rules imply a
// real profile (just ESP-less), where the dump stays meaningful. The
// enrollment_type_detected event below still carries the rail decision.
var isDevicePreparation = detectionRule ==
AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
Dictionary<string, object> diagnosticsRegistry = null;
if (!isDevicePreparation)
{
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
if (diagnosticsRegistry != null)
data["diagnosticsRegistry"] = diagnosticsRegistry;
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
}
else
{
_logger.Info("EnrollmentTracker: Device Preparation session — skipping autopilot_profile/ZTD evidence (no deployment profile exists on WDP)");
}
// ProfileAvailable=0 means the ZTD service returned no profile for this device
// during OOBE. Sessions only exist after the backend's device-registration
@@ -293,7 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// Autopilot session — surface the edge case explicitly, with a ZTD verdict
// backed by the ModernDeployment Autopilot event log plus an endpoint probe
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
if (IsAutopilotProfileMissing(data))
if (!isDevicePreparation && IsAutopilotProfileMissing(data))
{
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
// are provisioned by the Windows 365 service, not by Autopilot — so the event
@@ -787,10 +807,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
data["espTrackedModernCount"] = tracking.ModernCount;
}
var summary = BuildSummary(snapshot, tracking);
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
PostEspConfigDetectedSignal(snapshot);
// No FirstSync value and no ESP tracking list means there is no ESP evidence at
// all (Device Preparation, plain Entra join, pre-sync timing) — an event that
// says nothing but "unknown/unknown" is noise, so skip it. Mirrors the null-gate
// the orchestrator bootstrap already applies to the decision signal
// (EnrollmentOrchestrator.PostEspConfigDetectedBootstrap).
var hasAnyEspEvidence = snapshot.SkipUser.HasValue
|| snapshot.SkipDevice.HasValue
|| snapshot.BlockInStatusPage.HasValue
|| snapshot.SyncFailureTimeoutMinutes.HasValue
|| tracking.HasData;
if (hasAnyEspEvidence)
{
var summary = BuildSummary(snapshot, tracking);
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
PostEspConfigDetectedSignal(snapshot);
}
else
{
_logger.Debug("EnrollmentTracker: no ESP configuration evidence (FirstSync empty, no tracking lists) — esp_config_detected suppressed");
}
}
catch (Exception ex)
{
@@ -404,12 +404,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
if (state.Stage.IsTerminal()) return false;
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
if (state.AccountSetupEnteredUtc == null) return false;
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
// anchor nor an ESP final exit is guaranteed to exist — its dead-end zone
// begins at the real-user desktop instead (from there only Hello or a deadline
// can move the session). Normally the DevicePrepCompletion backstop armed at
// desktop arrival satisfies the invariant; this branch makes a regression visible.
var isDevicePreparation = state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation;
if (state.AccountSetupEnteredUtc == null && !isDevicePreparation) return false;
var deadEndZoneEntered =
state.EspAdvisoryFailureRecordedUtc != null
|| (state.EspFinalExitUtc != null
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value);
|| (state.AccountSetupEnteredUtc != null
&& state.EspFinalExitUtc != null
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value)
|| (isDevicePreparation && state.DesktopArrivedUtc != null);
if (!deadEndZoneEntered) return false;
foreach (var deadline in state.Deadlines)
@@ -431,8 +440,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
["stage"] = state.Stage.ToString(),
["stepIndex"] = state.StepIndex.ToString(CultureInfo.InvariantCulture),
["signalOrdinal"] = signal.SessionSignalOrdinal.ToString(CultureInfo.InvariantCulture),
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc!.Value
.ToString("o", CultureInfo.InvariantCulture),
// Null on the WDP branch of IsParkedWithoutDeadline (no ESP anchor there).
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc?.Value
.ToString("o", CultureInfo.InvariantCulture) ?? "",
["signalsSeen"] = string.Join(",", census.SignalsSeen),
["armedDeadlines"] = string.Join(",", state.Deadlines.Select(d => d.Name)),
["dwellSeconds"] = _parkedTripwireDwell.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture),
@@ -109,6 +109,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var hosts = new List<ICollectorHost>();
var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault();
// WDP scenario gate (afee7ae0, 2026-08-18): Device Preparation has no ESP, so the
// ESP-only machinery below (provisioning-status registry watcher, ESP policy-provider
// stall tripwire, Autopilot-channel noise) burns cycles on registry keys that never
// exist there — or worse, wakes up on every WDP progress write. Gated on the
// DETERMINISTIC WDP marker only (BootstrapperAgent ExecutionContext, present before
// the agent starts); the CloudAssigned* fallback rules keep full Classic behavior.
var isDevicePreparation = Security.EnrollmentRegistryDetector.IsDeterministicDevicePreparation();
if (isDevicePreparation)
{
logger.Info("DefaultComponentFactory: Device Preparation session (deterministic marker) — ESP-only hosts and Autopilot-channel noise are gated");
}
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
// Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had
@@ -151,6 +163,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
stateDirectory: _stateDirectory,
isDevicePreparation: isDevicePreparation,
userEspAppsSettledProbe: () => imeLogHostRef?.AreUserEspAppsSettled() == true,
// Liveness plan PR3: starved-apps probe over the same lazily-assigned IME host.
starvedUserEspAppsProbe: () =>
@@ -308,7 +321,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
traceIndices: collectors.StallProbeTraceIndices,
sources: collectors.StallProbeSources,
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
isDevicePreparation: isDevicePreparation));
}
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
@@ -317,14 +331,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// TrackingPoliciesCreated=1 the ESP ignores foreign provider names), parks the user
// ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock dwell, deliberately
// outside the StallProbe gate: the idle clock there resets on any session activity,
// and the stall must accrue regardless of it.
hosts.Add(new EspPolicyProviderStallHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
startupGate: _startupEventGate));
// and the stall must accrue regardless of it. Not created on Device Preparation —
// the EnrollmentStatusTracking key it polls every 60s is an ESP-only construct.
if (!isDevicePreparation)
{
hosts.Add(new EspPolicyProviderStallHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
startupGate: _startupEventGate));
}
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int modernDeploymentBackfillLookbackMinutes,
int[]? modernDeploymentHarmlessEventIds,
string stateDirectory,
bool isDevicePreparation = false,
Func<bool>? userEspAppsSettledProbe = null,
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? starvedUserEspAppsProbe = null,
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
@@ -89,6 +90,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
stateDirectory: stateDirectory,
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds,
isDevicePreparation: isDevicePreparation,
userEspAppsSettledProbe: userEspAppsSettledProbe,
starvedUserEspAppsProbe: starvedUserEspAppsProbe,
packageStatesProbe: packageStatesProbe);
@@ -57,7 +57,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int[]? traceIndices,
string[]? sources,
int sessionStalledAfterProbeIndex,
int[]? harmlessModernDeploymentEventIds)
int[]? harmlessModernDeploymentEventIds,
bool isDevicePreparation = false)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -72,7 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
traceIndices: traceIndices ?? new[] { 2 },
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds);
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
isDevicePreparation: isDevicePreparation);
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
_observableIngress = ingress as SignalIngress;
@@ -188,6 +188,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// </summary>
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
/// <summary>
/// Rule string returned when the deterministic Device Preparation marker fired
/// (the WDP policy orchestration tree — see <see cref="DetectEnrollmentTypeWithRule"/>).
/// </summary>
public const string DevicePreparationExecutionContextRule =
@"DevicePreparation\BootstrapperAgent\ExecutionContext present";
/// <summary>
/// True only when the deterministic Device Preparation marker (BootstrapperAgent
/// ExecutionContext) is present. Scenario gates that DISABLE Autopilot/ESP-only
/// machinery must use this instead of the "v2" string: the CloudAssigned* fallback
/// rules describe a real Autopilot profile (just without ESP) where that machinery
/// still has data to observe — gates fail toward Classic behavior by design.
/// </summary>
public static bool IsDeterministicDevicePreparation()
{
var (enrollmentType, detectionRule) = DetectEnrollmentTypeWithRule();
return enrollmentType == "v2" && detectionRule == DevicePreparationExecutionContextRule;
}
/// <summary>
/// Same as <see cref="DetectEnrollmentType"/> but also returns WHICH rule fired, so the
/// enrollment_type_detected event can name its evidence instead of re-implementing the
@@ -203,7 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString();
if (!string.IsNullOrEmpty(executionContext))
return ("v2", @"DevicePreparation\BootstrapperAgent\ExecutionContext present");
return ("v2", DevicePreparationExecutionContextRule);
}
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
@@ -228,7 +228,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// Runs on every outcome; the live (esp_exited) path's dedupe set is
// consulted so no app is reported twice. Promoted likely-stuck apps are
// Error by now and excluded by the probe itself.
EmitStarvedUserEspApps(args);
EmitStarvedUserEspApps(state, args);
// Session 6b4993e5 / fc48c71a — on an ESP terminal Apps-subcategory failure,
// name the in-flight device app(s) (e.g. stuck Downloading at 0%) as the likely
@@ -557,7 +557,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// wording; Failed/TimedOut keep the Warning + "starved" verdict.
/// </para>
/// </summary>
private void EmitStarvedUserEspApps(EnrollmentTerminatedEventArgs args)
private void EmitStarvedUserEspApps(DecisionState state, EnrollmentTerminatedEventArgs args)
{
if (_post == null) return;
@@ -566,6 +566,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
var starved = _appTracking.GetStarvedUserEspApps();
if (starved == null || starved.Count == 0) return;
// Device Preparation has no ESP apps gate — installing after the desktop is
// the DESIGNED order there (only DPP-policy apps run during provisioning, the
// rest follow via IME). Reporting every pending app as "starved" turned a
// normal WDP session into 10+ noise events (afee7ae0); the same per-app data
// still reaches the backend via app_tracking_summary and final-status.json.
if (state?.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation)
{
_logger.Info(
$"EnrollmentTerminationHandler: {starved.Count} app(s) pending at termination on a " +
"DevicePreparation session — expected post-desktop install order, app_install_starved sweep skipped.");
return;
}
var succeeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded;
foreach (var app in starved)
@@ -306,7 +306,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{
try
{
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
var (enrollmentType, detectionRule) = EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
// High-confidence WDP seed only for the deterministic marker — the
// CloudAssigned* fallback rules keep the Medium seed (see the payload
// key's doc on SignalPayloadKeys.EnrollmentTypeDeterministic).
var deterministicWdp = enrollmentType == "v2"
&& detectionRule == EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
@@ -314,6 +319,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["enrollmentType"] = enrollmentType,
["enrollmentTypeDeterministic"] = deterministicWdp ? "true" : "false",
["isHybridJoin"] = isHybridJoin ? "true" : "false",
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
["isCloudPc"] = isCloudPc ? "true" : "false",
@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Device Preparation (WDP) engine semantics — afee7ae0 audit (2026-08-18).
/// WDP has no ESP, so the ESP-derived arms/gates/deadlines can never fire there.
/// Coverage:
/// <list type="bullet">
/// <item>Profile seeding: the deterministic WDP marker seeds High confidence; the
/// CloudAssigned* fallback rules stay Medium.</item>
/// <item><c>completion_waiting</c> never lists <c>account_setup_provisioning_complete</c>
/// on WDP (unsatisfiable by construction — no ESP categories).</item>
/// <item>Desktop-first arming of the <see cref="DeadlineNames.DevicePrepCompletion"/>
/// backstop, its fire semantics (synthetic Hello timeout + Finalizing), and the
/// no-rearm/no-classic-leak guards.</item>
/// <item>Arm D: the Hello-disabled fast-path completes at desktop arrival on WDP.</item>
/// </list>
/// </summary>
public sealed class DevicePreparationEngineTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 9, 0, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
payload: payload);
}
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
private static List<DecisionEffect> CompletionWaitingEffects(DecisionStep step) =>
step.Effects.Where(e =>
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
&& e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == "completion_waiting").ToList();
/// <summary>Session seeded as WDP via the EnrollmentFactsObserved signal rail.</summary>
private static DecisionState SeedWdpSession(DecisionEngine engine, bool deterministic = true)
{
var state = DecisionState.CreateInitial("sess-wdp", "tenant-wdp", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = engine.Reduce(state, MakeSignal(
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v2",
[SignalPayloadKeys.EnrollmentTypeDeterministic] = deterministic ? "true" : "false",
})).NewState;
return state;
}
// ===================================================== profile seeding
[Fact]
public void DeterministicV2Seed_SetsDevicePreparationAtHighConfidence()
{
var engine = new DecisionEngine();
var state = SeedWdpSession(engine, deterministic: true);
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
Assert.Equal(ProfileConfidence.High, state.ScenarioProfile.Confidence);
}
[Fact]
public void FallbackV2Seed_StaysAtMediumConfidence()
{
// CloudAssigned* fallback rules (no deterministic marker) — Medium, as before
// d0ab3eee: an ESP-less classic profile could in principle trip them.
var engine = new DecisionEngine();
var state = SeedWdpSession(engine, deterministic: false);
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
}
[Fact]
public void LegacyV2Payload_WithoutDeterministicKey_StaysAtMediumConfidence()
{
// Older agents post enrollmentType=v2 without the new key — must behave like
// the fallback (Medium), not throw or over-promote.
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("sess-legacy", "tenant-wdp", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = engine.Reduce(state, MakeSignal(
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
new Dictionary<string, string> { [SignalPayloadKeys.EnrollmentType] = "v2" })).NewState;
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
}
// ===================================================== desktop-first backstop
[Fact]
public void DesktopFirst_OnWdp_ArmsDevicePrepCompletionBackstop()
{
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
// Hello policy unknown → no fast-path; stage parked, backstop armed.
var backstop = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
Assert.Equal(T0.AddMinutes(9).AddMinutes(30), backstop.DueAtUtc);
Assert.Contains(step.Effects,
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
}
[Fact]
public void DesktopFirst_OnWdp_WaitingListsOnlyHello_NeverTheEspProvisioningGate()
{
// account_setup_provisioning_complete is unsatisfiable on WDP (no ESP categories)
// and must not appear as a missing prerequisite (afee7ae0 listed it).
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
var waiting = Assert.Single(CompletionWaitingEffects(step));
Assert.Equal("hello_resolution", waiting.Parameters!["missingPrerequisites"]);
}
[Fact]
public void DuplicateDesktopSignal_DoesNotRearmTheBackstop()
{
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
var second = engine.Reduce(state, MakeSignal(11, DecisionSignalKind.DesktopArrived, T0.AddMinutes(10)));
Assert.Single(second.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
Assert.DoesNotContain(second.Effects,
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
}
[Fact]
public void ClassicDesktopFirst_DoesNotArmTheBackstop()
{
// Classic regression guard: a non-WDP session parked desktop-first keeps the
// pre-change deadline picture (nothing armed by this handler).
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("sess-classic", "tenant-classic", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
}
// ===================================================== backstop fire semantics
[Fact]
public void BackstopFired_WithoutHello_SynthesizesTimeoutAndCompletesThroughFinalizing()
{
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
Assert.Equal(SessionStage.Finalizing, fired.NewState.Stage);
Assert.NotNull(fired.NewState.HelloResolvedUtc);
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
Assert.Contains(fired.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
// FinalizingGrace completes the session — the full WDP no-Hello resolution chain.
var completed = engine.Reduce(fired.NewState,
DeadlineFired(21, T0.AddMinutes(39).AddSeconds(5), DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
}
[Fact]
public void HelloResolved_BeforeBackstopFires_CompletesNormally()
{
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
var resolved = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.HelloResolved, T0.AddMinutes(10),
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" }));
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
Assert.Equal("completed", resolved.NewState.HelloOutcome!.Value);
}
// ===================================================== arm D fast-path
[Fact]
public void HelloDisabledFastPath_OnWdp_CompletesAtDesktopArrival()
{
// Arm D: no ESP facts exist on WDP, yet a disabled Hello policy + real-user
// desktop is a legitimate completion — previously arms A–C all failed and the
// session parked despite Hello being disabled.
var engine = new DecisionEngine();
var state = SeedWdpSession(engine);
state = engine.Reduce(state, MakeSignal(
5, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
}
}
}
@@ -64,6 +64,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// of failing.
/// </summary>
public const string AdvisoryCompletion = "advisory_completion";
/// <summary>
/// Device Preparation completion backstop (afee7ae0, 2026-08-18). WDP has no ESP,
/// so none of the ESP-derived resolution deadlines (HelloSafety, AdvisoryCompletion)
/// can ever arm there — a WDP session whose Hello never resolves would park until
/// the agent max-lifetime watchdog with no verdict. Armed by
/// <c>HandleDesktopArrivedV1</c> when the desktop arrives on a DevicePreparation
/// session without completing in the same step; on fire the session resolves like
/// the HelloSafety timeout (synthetic <c>HelloOutcome=Timeout</c> + completion —
/// the desktop is present by construction of the arming site).
/// </summary>
public const string DevicePrepCompletion = "device_prep_completion";
}
/// <summary>
@@ -130,6 +142,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>On <c>SessionStarted</c>: literal "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType()</c>.</summary>
public const string EnrollmentType = "enrollmentType";
/// <summary>
/// On <c>EnrollmentFactsObserved</c>: "true" when the v2 verdict came from the
/// deterministic Device Preparation marker (BootstrapperAgent ExecutionContext)
/// rather than the CloudAssigned* fallback rules. Lifts the DevicePreparation
/// profile seed to High confidence — the fallback rules stay Medium because they
/// can in principle fire on ESP-less classic profiles.
/// </summary>
public const string EnrollmentTypeDeterministic = "enrollmentTypeDeterministic";
/// <summary>On <c>SessionStarted</c>: "true" / "false" from <c>EnrollmentRegistryDetector.DetectHybridJoin()</c>.</summary>
public const string IsHybridJoin = "isHybridJoin";
@@ -657,12 +657,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
// HelloResolved arrives.
builder.WithStage(state.Stage);
// WDP completion backstop (afee7ae0): no ESP → HelloSafety / AdvisoryCompletion can
// never arm on Device Preparation, so a Hello that never resolves would park the
// session until the max-lifetime watchdog. Desktop is here, Hello is not — bound
// the wait. Armed once (name-keyed dedupe is belt; the explicit check keeps the
// audit trail free of re-arm noise on duplicate desktop signals).
var effects = new List<DecisionEffect>(2);
if (state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation
&& !HasDevicePrepCompletionDeadline(state))
{
var backstop = BuildDevicePrepCompletionDeadline(state, signal);
builder.AddDeadline(backstop);
effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: backstop));
}
// Liveness plan PR2: Desktop is in but completion is still blocked (Hello pending,
// or the Hello-disabled fast-path's strong gate is not satisfied) — surface the
// missing prerequisites. Covers both the gate-false fast-path and the plain
// desktop-first ordering; the fingerprint dedupes against earlier emissions.
var waitingEffect = BuildCompletionWaitingEffect(
state, builder, signal, trigger: nameof(DecisionSignalKind.DesktopArrived));
if (waitingEffect != null) effects.Add(waitingEffect);
var newState = builder.Build();
@@ -676,7 +691,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
effects.Count > 0 ? effects.ToArray() : Array.Empty<DecisionEffect>());
}
/// <summary>
@@ -226,7 +226,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
state.AccountSetupEnteredUtc,
state.EspFinalExitUtc,
state.ImeUserSessionCompletedUtc,
state.DesktopArrivedUtc));
state.DesktopArrivedUtc),
devicePreparation: state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation);
/// <summary>
/// Arm C of <c>ShouldTransitionToAwaitingHello</c> (session a4537c36) restated over raw
@@ -257,10 +258,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
bool helloPolicyDisabled,
bool desktopArrived,
bool realmJoinGateOpen,
bool postEspUserSessionEvidence = false)
bool postEspUserSessionEvidence = false,
bool devicePreparation = false)
{
var missing = new List<string>(4);
if (!accountSetupProvisioned && !skipUserEsp && !postEspUserSessionEvidence)
// Device Preparation has no ESP: AccountSetupCategory.Status is never written
// there, so the provisioning gate is unsatisfiable by construction and must not
// be reported as a missing prerequisite (afee7ae0 — mirrors the skipUserEsp
// exemption; completion on WDP rides on Hello + Desktop, see arm D of
// ShouldTransitionToAwaitingHello and the DevicePrepCompletion backstop).
if (!accountSetupProvisioned && !skipUserEsp && !postEspUserSessionEvidence && !devicePreparation)
missing.Add(CompletionPrerequisites.AccountSetupProvisioningComplete);
if (!helloResolved && !helloPolicyDisabled)
missing.Add(CompletionPrerequisites.HelloResolution);
@@ -302,7 +309,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.AccountSetupEnteredUtc,
builder.EspFinalExitUtc,
builder.ImeUserSessionCompletedUtc,
builder.DesktopArrivedUtc));
builder.DesktopArrivedUtc),
devicePreparation: builder.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation);
if (missing.Count == 0) return null;
var fingerprint = string.Join(",", missing);
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
// Device Preparation (WDP) — completion backstop (afee7ae0, 2026-08-18).
//
// WDP has no ESP, so every ESP-derived resolution deadline is structurally unarmable
// there: HelloSafety arms only on EspExiting / AccountSetupProvisioningComplete / arm C,
// AdvisoryCompletion only on an EspTerminalFailure or a guard-blocked post-AccountSetup
// esp_exiting — none of which exist on Device Preparation. A WDP session whose Hello
// never resolves therefore used to park until the agent max-lifetime watchdog with no
// verdict. The engine otherwise deliberately rides the Classic rails for WDP (the
// Hello + Desktop conjunction and arm D of ShouldTransitionToAwaitingHello); this
// partial adds only the WDP-specific bounded wait.
public sealed partial class DecisionEngine
{
/// <summary>
/// Resolution window from real-user desktop arrival on a Device Preparation session.
/// Deliberately the same order of magnitude as <c>AdvisoryCompletion</c> (30 min),
/// not <c>HelloSafety</c> (300 s): the user may legitimately still be walking
/// through a configured Hello wizard, and the tracker's own Hello completion
/// timers normally resolve long before this fires — it is the last net, not the
/// expected path.
/// </summary>
private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30);
/// <summary>True when <see cref="DeadlineNames.DevicePrepCompletion"/> is armed in <paramref name="state"/>.</summary>
private static bool HasDevicePrepCompletionDeadline(DecisionState state)
{
foreach (var d in state.Deadlines)
{
if (d.Name == DeadlineNames.DevicePrepCompletion) return true;
}
return false;
}
/// <summary>
/// Build the <see cref="DeadlineNames.DevicePrepCompletion"/> backstop deadline.
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc).
/// </summary>
internal static ActiveDeadline BuildDevicePrepCompletionDeadline(DecisionState state, DecisionSignal signal) =>
new ActiveDeadline(
name: DeadlineNames.DevicePrepCompletion,
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_devicePrepCompletionWindow),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.DevicePrepCompletion,
});
/// <summary>
/// <see cref="DeadlineNames.DevicePrepCompletion"/> fired: the WDP session reached
/// the real-user desktop but Hello never resolved within the window. Mirror of
/// <see cref="HandleHelloSafetyDeadlineFired"/>: record the synthetic
/// <see cref="SyntheticHelloOutcomeTimeout"/> (unless a real resolution raced the
/// timer) and complete through Finalizing. Desktop is present by construction of
/// the arming site (<c>HandleDesktopArrivedV1</c>); the AwaitingDesktop fallback
/// only keeps the handler total. A timer surviving into a terminal stage is
/// swallowed by the post-terminal guard in <see cref="Dispatch"/> — no explicit
/// cancel wiring at the completion sites is needed for correctness.
/// </summary>
private DecisionStep HandleDevicePrepCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.CancelDeadline(DeadlineNames.DevicePrepCompletion);
if (state.HelloResolvedUtc == null)
{
builder.HelloResolvedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
builder.HelloOutcome = new SignalFact<string>(SyntheticHelloOutcomeTimeout, signal.SessionSignalOrdinal);
}
if (state.DesktopArrivedUtc != null)
{
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
}
builder.WithStage(SessionStage.AwaitingDesktop);
var waitingEffect = BuildCompletionWaitingEffect(
state, builder, signal, trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.AwaitingDesktop,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
}
}
}
@@ -362,6 +362,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleRealmJoinTimeoutDeadlineFired(state, signal);
case DeadlineNames.AdvisoryCompletion:
return HandleAdvisoryCompletionDeadlineFired(state, signal);
case DeadlineNames.DevicePrepCompletion:
return HandleDevicePrepCompletionDeadlineFired(state, signal);
default:
// Deadline name not recognized in this sub-milestone. Cancel it from state
// and record a neutral taken transition — M3.3+ adds handlers for
@@ -784,6 +786,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
return true;
}
// Arm D (afee7ae0, 2026-08-18) — Device Preparation: there is no ESP, so arms A–C
// are unsatisfiable by construction (no provisioning categories, no Shell-Core
// final exit, IME anchors are incidental pattern hits). The DAD-validated
// real-user desktop IS the end of WDP provisioning — the DPP page has closed and
// the user is signed in; only the Hello gate remains after this promotion.
if (state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation
&& (desktopArrivedInFlight || state.DesktopArrivedUtc != null))
{
return true;
}
return false;
}
@@ -99,8 +99,19 @@ namespace AutopilotMonitor.DecisionCore.State
if (string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase))
{
// Deterministic WDP marker (BootstrapperAgent ExecutionContext) seeds
// High — the marker is as registry-deterministic as the self-deploying
// OobeConfig bits below, and a High seed lets the generic monotonic
// guard protect the mode instead of relying solely on the explicit
// DevicePreparation checks in the downstream promotion handlers.
// The CloudAssigned* fallback rules stay Medium (ESP-less classic
// profiles could in principle trip them).
var deterministic = signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentTypeDeterministic, out var rawDeterministic)
&& bool.TryParse(rawDeterministic, out var isDeterministic)
&& isDeterministic;
mode = EnrollmentMode.DevicePreparation;
confidence = Max(confidence, ProfileConfidence.Medium);
confidence = Max(confidence, deterministic ? ProfileConfidence.High : ProfileConfidence.Medium);
reason = newReason;
changed = true;
}