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"}}}