From a369ff99cd6f23729b395cb1af63a24a0532d2a2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 18 Aug 2026 13:12:09 +0200 Subject: [PATCH] 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 --- docs/agent/wdp-scenario-gates.md | 108 ++++++++ docs/index.md | 1 + docs/log.md | 2 + .../DeviceInfoCollectorEspRefreshTests.cs | 35 +++ .../ModernDeploymentTrackerDevicePrepTests.cs | 130 ++++++++++ .../EnrollmentTerminationHandlerTests.cs | 20 ++ .../SystemSignals/EspAndHelloTracker.cs | 38 ++- .../SystemSignals/ModernDeploymentTracker.cs | 40 ++- .../SystemSignals/StallProbeCollector.cs | 20 +- .../DeviceInfoCollector.NetworkAndSecurity.cs | 61 ++++- .../Orchestration/DecisionStepProcessor.cs | 20 +- .../Orchestration/DefaultComponentFactory.cs | 36 ++- .../Orchestration/Hosts/EspAndHelloHost.cs | 2 + .../Orchestration/Hosts/StallProbeHost.cs | 6 +- .../Security/EnrollmentRegistryDetector.cs | 22 +- .../EnrollmentTerminationHandler.cs | 17 +- .../Runtime/LifecycleEmitters.cs | 8 +- .../DevicePreparationEngineTests.cs | 230 ++++++++++++++++++ .../Engine/DeadlineNames.cs | 21 ++ .../Engine/DecisionEngine.Classic.cs | 17 +- .../Engine/DecisionEngine.Completion.cs | 16 +- .../DecisionEngine.DevicePreparation.cs | 108 ++++++++ .../Engine/DecisionEngine.Shared.cs | 12 + .../State/EnrollmentScenarioProfileUpdater.cs | 13 +- 24 files changed, 931 insertions(+), 52 deletions(-) create mode 100644 docs/agent/wdp-scenario-gates.md create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ModernDeploymentTrackerDevicePrepTests.cs create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/DevicePreparationEngineTests.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs diff --git a/docs/agent/wdp-scenario-gates.md b/docs/agent/wdp-scenario-gates.md new file mode 100644 index 00000000..fc3a5b2c --- /dev/null +++ b/docs/agent/wdp-scenario-gates.md @@ -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` diff --git a/docs/index.md b/docs/index.md index 781c84b2..8cd0ec19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/log.md b/docs/log.md index 610a5470..fb81983b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -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. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/DeviceInfo/DeviceInfoCollectorEspRefreshTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/DeviceInfo/DeviceInfoCollectorEspRefreshTests.cs index 2d09300c..30369061 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/DeviceInfo/DeviceInfoCollectorEspRefreshTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/DeviceInfo/DeviceInfoCollectorEspRefreshTests.cs @@ -61,6 +61,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo return Assert.IsAssignableFrom>(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()))) + { + collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsDevice); + } + Assert.Single(EspConfigEvents(ingress)); + } + [Fact] public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ModernDeploymentTrackerDevicePrepTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ModernDeploymentTrackerDevicePrepTests.cs new file mode 100644 index 00000000..9d974e6e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ModernDeploymentTrackerDevicePrepTests.cs @@ -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 +{ + /// + /// 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 (); + /// genuine diagnostics (e.g. 408 provisioning warnings) and Critical records keep + /// flowing, and Classic sessions keep the harmless-downgrade + rollup path. + /// + 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 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs index f51eca39..7821bea9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -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() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs index 9dc19d2a..98260c83 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs @@ -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 _accountSetupActivityProbe; private readonly Func _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 accountSetupActivityProbe = null, Func 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(); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs index 09c88a52..7246fd42 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs @@ -66,6 +66,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals internal const string AutopilotErrorBackfillStateFileName = "autopilot-error-backfill.json"; + /// + /// 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. + /// + internal static readonly HashSet DevicePreparationNoiseEventIds = new HashSet { 100, 417, 418, 1005 }; + /// /// 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 _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(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: diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs index da446563..3dccf78b 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs @@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private readonly HashSet _sources; private readonly int _sessionStalledAfterProbeIndex; private readonly HashSet _harmlessModernDeploymentEventIds; + private readonly bool _isDevicePreparation; private readonly object _stateLock = new object(); private readonly HashSet _firedProbeIndices = new HashSet(); @@ -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(traceIndices ?? new int[0]); _sources = new HashSet(sources ?? new string[0], StringComparer.OrdinalIgnoreCase); _sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex; + _isDevicePreparation = isDevicePreparation; _harmlessModernDeploymentEventIds = new HashSet( 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); + } } /// @@ -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) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs index 95d394ff..0e1bd94e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -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 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) { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs index 7a78662b..2e73a279 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs @@ -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), diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index fad73db1..c1f1e2e0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -109,6 +109,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration var hosts = new List(); 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, diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs index 77433e6a..f907d67a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs @@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration int modernDeploymentBackfillLookbackMinutes, int[]? modernDeploymentHarmlessEventIds, string stateDirectory, + bool isDevicePreparation = false, Func? userEspAppsSettledProbe = null, Func>? starvedUserEspAppsProbe = null, Func>? 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); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/StallProbeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/StallProbeHost.cs index d321521b..2e499310 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/StallProbeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/StallProbeHost.cs @@ -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; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs index 73b173bc..5de7ee00 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs @@ -188,6 +188,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType; + /// + /// Rule string returned when the deterministic Device Preparation marker fired + /// (the WDP policy orchestration tree — see ). + /// + public const string DevicePreparationExecutionContextRule = + @"DevicePreparation\BootstrapperAgent\ExecutionContext present"; + + /// + /// 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. + /// + public static bool IsDeterministicDevicePreparation() + { + var (enrollmentType, detectionRule) = DetectEnrollmentTypeWithRule(); + return enrollmentType == "v2" && detectionRule == DevicePreparationExecutionContextRule; + } + /// /// Same as 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)) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs index faa62e3e..f1385984 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -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. /// /// - 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) diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index 15ac75cf..021235d5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -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(StringComparer.Ordinal) { ["enrollmentType"] = enrollmentType, + ["enrollmentTypeDeterministic"] = deterministicWdp ? "true" : "false", ["isHybridJoin"] = isHybridJoin ? "true" : "false", ["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false", ["isCloudPc"] = isCloudPc ? "true" : "false", diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DevicePreparationEngineTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DevicePreparationEngineTests.cs new file mode 100644 index 00000000..4036948e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DevicePreparationEngineTests.cs @@ -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 +{ + /// + /// 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: + /// + /// Profile seeding: the deterministic WDP marker seeds High confidence; the + /// CloudAssigned* fallback rules stay Medium. + /// completion_waiting never lists account_setup_provisioning_complete + /// on WDP (unsatisfiable by construction — no ESP categories). + /// Desktop-first arming of the + /// backstop, its fire semantics (synthetic Hello timeout + Finalizing), and the + /// no-rearm/no-classic-leak guards. + /// Arm D: the Hello-disabled fast-path completes at desktop arrival on WDP. + /// + /// + 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? 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 { [SignalPayloadKeys.Deadline] = deadlineName }); + + private static List 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(); + + /// Session seeded as WDP via the EnrollmentFactsObserved signal rail. + 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 + { + [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 { [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 { [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 { [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); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs index 317e39d3..60ed8cc4 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs @@ -64,6 +64,18 @@ namespace AutopilotMonitor.DecisionCore.Engine /// of failing. /// public const string AdvisoryCompletion = "advisory_completion"; + + /// + /// 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 + /// HandleDesktopArrivedV1 when the desktop arrives on a DevicePreparation + /// session without completing in the same step; on fire the session resolves like + /// the HelloSafety timeout (synthetic HelloOutcome=Timeout + completion — + /// the desktop is present by construction of the arming site). + /// + public const string DevicePrepCompletion = "device_prep_completion"; } /// @@ -130,6 +142,15 @@ namespace AutopilotMonitor.DecisionCore.Engine /// On SessionStarted: literal "v1" / "v2" from EnrollmentRegistryDetector.DetectEnrollmentType(). public const string EnrollmentType = "enrollmentType"; + /// + /// On EnrollmentFactsObserved: "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. + /// + public const string EnrollmentTypeDeterministic = "enrollmentTypeDeterministic"; + /// On SessionStarted: "true" / "false" from EnrollmentRegistryDetector.DetectHybridJoin(). public const string IsHybridJoin = "isHybridJoin"; diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 127c50a7..21c89a80 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -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(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()); + effects.Count > 0 ? effects.ToArray() : Array.Empty()); } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs index c802daee..25c3f92f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs @@ -226,7 +226,8 @@ namespace AutopilotMonitor.DecisionCore.Engine state.AccountSetupEnteredUtc, state.EspFinalExitUtc, state.ImeUserSessionCompletedUtc, - state.DesktopArrivedUtc)); + state.DesktopArrivedUtc), + devicePreparation: state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation); /// /// Arm C of ShouldTransitionToAwaitingHello (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(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); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs new file mode 100644 index 00000000..1d017e92 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs @@ -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 + { + /// + /// Resolution window from real-user desktop arrival on a Device Preparation session. + /// Deliberately the same order of magnitude as AdvisoryCompletion (30 min), + /// not HelloSafety (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. + /// + private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30); + + /// True when is armed in . + private static bool HasDevicePrepCompletionDeadline(DecisionState state) + { + foreach (var d in state.Deadlines) + { + if (d.Name == DeadlineNames.DevicePrepCompletion) return true; + } + return false; + } + + /// + /// Build the backstop deadline. + /// Replay-safe via (floored at AgentBootUtc). + /// + 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 + { + [SignalPayloadKeys.Deadline] = DeadlineNames.DevicePrepCompletion, + }); + + /// + /// fired: the WDP session reached + /// the real-user desktop but Hello never resolved within the window. Mirror of + /// : record the synthetic + /// (unless a real resolution raced the + /// timer) and complete through Finalizing. Desktop is present by construction of + /// the arming site (HandleDesktopArrivedV1); the AwaitingDesktop fallback + /// only keeps the handler total. A timer surviving into a terminal stage is + /// swallowed by the post-terminal guard in — no explicit + /// cancel wiring at the completion sites is needed for correctness. + /// + 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(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + builder.HelloOutcome = new SignalFact(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()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 982bd72b..42fd24cb 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -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; } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs index 5e68e1ad..aa730b1e 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs @@ -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; }