From 0ebfa2fc3c2b480c347eabe3e38a6ef088b55ce4 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 14:04:52 +0200 Subject: [PATCH] refactor(decisioncore): SelfDeploying + Device-Only handlers + 2 scenarios (M3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelfDeploying-v1 vertical slice: DeviceSetupProvisioningComplete terminal handler, DeviceOnlyEspDetection deadline wiring, and the DeviceOnlyDeployment hypothesis progression UserPresent / DeviceOnly / Ambiguous expressed via Hypothesis.Reason (plan §2.3). Handlers (DecisionEngine.SelfDeploying.cs): - DeviceSetupProvisioningComplete terminal. Cancels all deadlines, finalizes DeviceOnlyDeployment based on user-presence facts (AadJoinedWithUser, HelloResolvedUtc, DesktopArrivedUtc), raises EnrollmentType to Strong when no AccountSetup was observed before. - DeviceOnlyEspDetectionFired hypothesis-only update to DeviceOnlyDeployment=Strong(DeviceOnly). Stage unchanged; completion still requires explicit provisioning signal. Classic.cs extended: - First EspPhaseChanged(DeviceSetup) arms the DeviceOnlyEspDetection deadline (5 min window, plan §2.7). - EspPhaseChanged(AccountSetup) cancels it — UserDriven-v1 path. Shared DeadlineFired router routes the deadline to the SelfDeploying body. Scenario fixtures (tests/fixtures/enrollment-sessions/): - selfdeploying-happy-v1.jsonl — 3 signals, SkipUserStatusPage path. Terminal Completed with DeviceOnly hypothesis confirmed and no user-presence facts. - selfdeploying-esp-exit-unknown-v1.jsonl — 4 signals, deadline fires at T+5m and sets DeviceOnly before provisioning-complete arrives. Tests (SelfDeployingScenarioTests, 4 new): - SelfDeployingHappy terminal state + hypotheses - SelfDeployingHappy deadline cleanup - Device-Only ESP-exit-unknown deadline -> provisioning flow - AccountSetup on the Classic path cancels DeviceOnlyEspDetection (regression guard — UserDriven should never classify as DeviceOnly) InternalsVisibleTo added to DecisionCore for DecisionCore.Tests so the internal DeviceOnlyReasons tokens stay hidden from consumers while tests assert on them directly. Suite totals: 45 DecisionCore (was 41) / 507 Legacy / 557 Functions. Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Scenarios/SelfDeployingScenarioTests.cs | 104 +++++++++++++ .../AutopilotMonitor.DecisionCore.csproj | 8 + .../Engine/DecisionEngine.Classic.cs | 20 ++- .../Engine/DecisionEngine.SelfDeploying.cs | 142 ++++++++++++++++++ .../Engine/DecisionEngine.Shared.cs | 2 + .../Engine/DecisionEngine.cs | 5 +- .../selfdeploying-esp-exit-unknown-v1.jsonl | 11 ++ .../selfdeploying-happy-v1.jsonl | 10 ++ 10 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs create mode 100644 tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl create mode 100644 tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 45a4fb75..ec635144 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -8 +9 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index bc51cd77..f1f0ca51 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1050 +1051 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs new file mode 100644 index 00000000..84681f78 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs @@ -0,0 +1,104 @@ +using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.2 — SelfDeploying-v1 and Device-Only scenarios. + /// + public sealed class SelfDeployingScenarioTests : ScenarioTestBase + { + [Fact] + public void SelfDeployingHappy_completes_onProvisioningComplete_noUserFacts() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-happy-v1.jsonl", + sessionId: "session-anon-0004", + tenantId: "tenant-anon-0004"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Empty(result.FinalState.Deadlines); + + // No user-presence facts should have been produced. + Assert.Null(result.FinalState.HelloResolvedUtc); + Assert.Null(result.FinalState.DesktopArrivedUtc); + Assert.Null(result.FinalState.AadJoinedWithUser); + Assert.Null(result.FinalState.AccountSetupEnteredUtc); + + // DeviceOnly hypothesis confirmed as DeviceOnly (no user presence seen). + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level); + Assert.Equal( + DecisionEngine.DeviceOnlyReasons.DeviceOnly, + result.FinalState.DeviceOnlyDeployment.Reason); + + // EnrollmentType upgraded to Strong by the SelfDeploying handler. + Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); + Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.EnrollmentType.Reason); + + Assert.Equal(3, result.Transitions.Count); + Assert.All(result.Transitions, t => Assert.True(t.Taken)); + } + + [Fact] + public void SelfDeployingHappy_schedulesAndCancelsDeviceOnlyEspDetection() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-happy-v1.jsonl", + sessionId: "session-anon-0004", + tenantId: "tenant-anon-0004"); + + // Replay intermediate check via harness is not exposed, but we can assert the + // final state has no active deadlines (the provisioning-complete handler clears them). + Assert.Empty(result.FinalState.Deadlines); + } + + [Fact] + public void DeviceOnlyEspExitUnknown_deadlineFires_then_provisioningCompletes() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-esp-exit-unknown-v1.jsonl", + sessionId: "session-anon-0005", + tenantId: "tenant-anon-0005"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + + // DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning). + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level); + Assert.Equal( + DecisionEngine.DeviceOnlyReasons.DeviceOnly, + result.FinalState.DeviceOnlyDeployment.Reason); + + // The deadline transition is present and taken (hypothesis update without stage change). + var deadlineTransition = Assert.Single( + result.Transitions, + t => t.Trigger == $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); + Assert.True(deadlineTransition.Taken); + Assert.Equal(deadlineTransition.FromStage, deadlineTransition.ToStage); + Assert.Equal(SessionStage.EspDeviceSetup, deadlineTransition.ToStage); + + Assert.Equal(4, result.Transitions.Count); + } + + [Fact] + public void Classic_AccountSetupPhase_CancelsDeviceOnlyEspDetection() + { + // Re-use the UserDriven-Happy fixture (already in M3.1): it goes + // DeviceSetup -> AccountSetup, so the DeviceOnlyEspDetection deadline is + // scheduled and then cancelled before it can fire. The final state must + // carry no DeviceOnlyEspDetection deadline, and the DeviceOnly hypothesis + // stays Unknown — the UserDriven path does not classify itself as DeviceOnly. + var result = RunFixture( + fixtureFilename: "userdriven-happy-v1.jsonl", + sessionId: "s", tenantId: "t"); + + Assert.DoesNotContain( + result.FinalState.Deadlines, + d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj index bc6827a3..71b5018a 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj +++ b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj @@ -18,6 +18,14 @@ + + + + <_Parameter1>AutopilotMonitor.DecisionCore.Tests + + + diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index da23dac7..c9f01fb8 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -68,6 +68,24 @@ namespace AutopilotMonitor.DecisionCore.Engine lastUpdatedUtc: signal.OccurredAtUtc); } + // Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup. + // See DecisionEngine.SelfDeploying.cs for the deadline-fired handler. + var effectsList = new List(); + if (enrollmentPhase == EnrollmentPhase.DeviceSetup && + state.DeviceSetupEnteredUtc == null) + { + var devOnlyDl = BuildDeviceOnlyEspDetectionDeadline(signal.OccurredAtUtc); + builder.AddDeadline(devOnlyDl); + effectsList.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: devOnlyDl)); + } + else if (enrollmentPhase == EnrollmentPhase.AccountSetup) + { + builder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + effectsList.Add(new DecisionEffect( + DecisionEffectKind.CancelDeadline, + cancelDeadlineName: DeadlineNames.DeviceOnlyEspDetection)); + } + var newState = builder.Build(); var transition = BuildTakenTransition( @@ -77,7 +95,7 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.EspPhaseChanged)); - return new DecisionStep(newState, transition, Array.Empty()); + return new DecisionStep(newState, transition, effectsList.ToArray()); } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs new file mode 100644 index 00000000..26d17e67 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // SelfDeploying-v1 + Device-Only handlers. Plan §2.5 partial-class layout. + public sealed partial class DecisionEngine + { + // Plan §2.7: "5min timer after DeviceSetup; if no AccountSetup -> classified as device-only". + internal static readonly TimeSpan s_deviceOnlyEspDetectionWindow = TimeSpan.FromMinutes(5); + + /// + /// Hypothesis-reason tokens for . + /// Plan §2.3 values UserPresent / DeviceOnly / Ambiguous — expressed through the + /// field so we do not need a new enum. + /// + internal static class DeviceOnlyReasons + { + public const string UserPresent = "user_present"; + public const string DeviceOnly = "device_only"; + public const string Ambiguous = "ambiguous"; + } + + /// + /// Handle . + /// + /// The canonical SelfDeploying / Device-Only terminal signal. Cancels the + /// deadline if still armed, + /// finalizes the hypothesis based + /// on whether any user-presence fact was observed, and transitions the session to + /// with . + /// + /// + private DecisionStep HandleDeviceSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + + // Determine DeviceOnlyDeployment verdict. + var hasUserPresence = + (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) || + state.HelloResolvedUtc != null || + state.DesktopArrivedUtc != null; + + var deviceOnlyReason = hasUserPresence + ? DeviceOnlyReasons.UserPresent + : DeviceOnlyReasons.DeviceOnly; + + var updatedHypothesis = state.DeviceOnlyDeployment.With( + level: HypothesisLevel.Confirmed, + reason: deviceOnlyReason, + score: 100, + lastUpdatedUtc: signal.OccurredAtUtc); + + var builder = state.ToBuilder() + .WithStage(SessionStage.Completed) + .WithOutcome(SessionOutcome.EnrollmentComplete) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines(); + builder.DeviceOnlyDeployment = updatedHypothesis; + + // Also strengthen EnrollmentType: observing DeviceSetupProvisioningComplete + // without preceding AccountSetup strongly implies SelfDeploying-v1. + if (state.AccountSetupEnteredUtc == null && + state.EnrollmentType.Level < HypothesisLevel.Strong) + { + builder.EnrollmentType = state.EnrollmentType.With( + level: HypothesisLevel.Strong, + reason: "selfdeploying_provisioning_complete", + score: 80, + lastUpdatedUtc: signal.OccurredAtUtc); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Completed, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)); + + var effects = new[] { BuildEnrollmentCompleteEffect() }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Called by the shared DeadlineFired router when + /// elapses without an AccountSetup + /// phase having arrived. Confirms the + /// hypothesis as DeviceOnly but does NOT complete the session — the terminal still + /// needs an explicit . + /// + private DecisionStep HandleDeviceOnlyEspDetectionDeadlineFired(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + + builder.DeviceOnlyDeployment = state.DeviceOnlyDeployment.With( + level: HypothesisLevel.Strong, + reason: DeviceOnlyReasons.DeviceOnly, + score: 70, + lastUpdatedUtc: signal.OccurredAtUtc); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================== internal helpers + + /// + /// Called from the Classic handler when + /// DeviceSetup is observed for the first time — arms the + /// deadline. The Classic handler + /// adds the deadline effect to its own effect list. + /// + internal ActiveDeadline BuildDeviceOnlyEspDetectionDeadline(DateTime fromUtc) => + new ActiveDeadline( + name: DeadlineNames.DeviceOnlyEspDetection, + dueAtUtc: fromUtc.Add(s_deviceOnlyEspDetectionWindow), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection, + }); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 42007246..449e7555 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -152,6 +152,8 @@ namespace AutopilotMonitor.DecisionCore.Engine { case DeadlineNames.HelloSafety: return HandleHelloSafetyDeadlineFired(state, signal); + case DeadlineNames.DeviceOnlyEspDetection: + return HandleDeviceOnlyEspDetectionDeadlineFired(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 diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index f0ab637f..436cd4c5 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -69,7 +69,10 @@ namespace AutopilotMonitor.DecisionCore.Engine (DecisionSignalKind.ImeUserSessionCompleted, 1) => HandleImeUserSessionCompletedV1(state, signal), (DecisionSignalKind.AadUserJoinedLate, 1) => HandleAadUserJoinedLateV1(state, signal), - // ----- Not yet wired (SelfDeploying in M3.2, WhiteGlove in M3.3/M3.4, …) ----- + // ----- SelfDeploying + Device-Only (DecisionEngine.SelfDeploying.cs) ----- + (DecisionSignalKind.DeviceSetupProvisioningComplete, 1) => HandleDeviceSetupProvisioningCompleteV1(state, signal), + + // ----- Not yet wired (WhiteGlove in M3.3/M3.4, …) ----- _ => HandleUnhandledSignal(state, signal), }; } diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl new file mode 100644 index 00000000..b76a531f --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl @@ -0,0 +1,11 @@ +# Scenario: SelfDeploying Device-Only with ESP-Exit-Unknown +# DeviceSetup phase arrives, but no AccountSetup follows within the 5-minute +# DeviceOnlyEspDetection window — the deadline fires and classifies the session +# as DeviceOnly. Terminal comes via DeviceSetupProvisioningComplete afterwards. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, +# DeviceOnlyDeployment=Confirmed/Strong progression, no user-presence facts. +# Plan: §4 M3.2 — "SelfDeploying-v1 mit ESP-Exit-Unknown -> Device-Only-ESP-Timer -> Complete". +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:device-only:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:06:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"Device-only ESP detection window elapsed without AccountSetup"},"Payload":{"deadline":"device_only_esp_detection"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:08:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl new file mode 100644 index 00000000..ae0dbcc8 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl @@ -0,0 +1,10 @@ +# Scenario: SelfDeploying-v1 Happy (SkipUserStatusPage) +# Device-level ESP only, no AccountSetup phase, no Hello, no Desktop. +# Terminal signal is DeviceSetupProvisioningComplete. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, +# DeviceOnlyDeployment=Confirmed(reason=device_only) (no user-presence facts), +# EnrollmentType=Strong(selfdeploying_provisioning_complete). +# Plan: §4 M3.2 canonical SelfDeploying scenario. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:selfdeploying-happy:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup (SkipUserStatusPage)","DerivationInputs":{"rawValue":"1","skipUserStatusPage":"true"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}}