diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs
index 17c543d8..9a90ba59 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs
@@ -106,5 +106,44 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(r1.FinalStepHash, r2.FinalStepHash);
Assert.Equal(16, r1.FinalStepHash.Length);
}
+
+ [Fact]
+ public void LateProvisioningComplete_afterAccountSetup_doesNotCompletePrematurely()
+ {
+ // Plan §6.1 regression guard — reproduces session e259c121-dc13-46d6-8e96-118f1da9845e.
+ // DeviceSetupProvisioningComplete arrives AFTER AccountSetup has started and
+ // DesktopArrived was observed, but BEFORE HelloResolved. The SelfDeploying terminal
+ // handler must NOT complete the session on the Classic path.
+ var result = RunFixture(
+ fixtureFilename: "userdriven-late-provisioning-complete-v1.jsonl",
+ sessionId: "session-anon-0006",
+ tenantId: "tenant-anon-0006");
+
+ Assert.Equal(SessionStage.Completed, result.FinalState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
+ Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
+ Assert.NotNull(result.FinalState.HelloResolvedUtc);
+ Assert.NotNull(result.FinalState.DesktopArrivedUtc);
+ Assert.NotNull(result.FinalState.AccountSetupEnteredUtc);
+ Assert.Empty(result.FinalState.Deadlines);
+
+ // Completion must come from HelloResolved (AND-gate with prior DesktopArrived),
+ // NOT from DeviceSetupProvisioningComplete.
+ var last = result.Transitions[^1];
+ Assert.Equal("HelloResolved", last.Trigger);
+ Assert.True(last.Taken);
+
+ // The DeviceSetupProvisioningComplete signal is observed as a taken but stage-unchanged
+ // step (informational on Classic path — not a terminal trigger).
+ var provTransition = Assert.Single(
+ result.Transitions,
+ t => t.Trigger == nameof(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind.DeviceSetupProvisioningComplete));
+ Assert.True(provTransition.Taken);
+ Assert.Equal(SessionStage.EspAccountSetup, provTransition.FromStage);
+ Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage);
+
+ // DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path.
+ Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level);
+ }
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs
index 26d17e67..7fe1827a 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs
@@ -27,18 +27,43 @@ namespace AutopilotMonitor.DecisionCore.Engine
///
/// 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 .
+ /// Terminal only on the SelfDeploying / Device-Only path (no AccountSetup phase ever
+ /// entered). On the Classic UserDriven-v1 path — where
+ /// is already set — this is just the ProvisioningStatusTracker marking DeviceSetupCategory
+ /// resolved; completion on that path requires
+ /// + (handled by Classic.cs).
+ ///
+ ///
+ /// Guarding on prevents the regression
+ /// observed in session e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22), where a late
+ /// DeviceSetupProvisioningComplete (payload deviceSetupResolved=unknown) fired while
+ /// Stage was and
+ /// was still null — terminating the session before Hello even ran.
///
///
private DecisionStep HandleDeviceSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
- // Determine DeviceOnlyDeployment verdict.
+ // Classic-path guard: AccountSetup already started → this signal is informational only.
+ if (state.AccountSetupEnteredUtc != null)
+ {
+ var passthroughState = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .Build();
+
+ var passthroughTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete));
+
+ return new DecisionStep(passthroughState, passthroughTransition, Array.Empty());
+ }
+
+ // SelfDeploying / Device-Only terminal path.
var hasUserPresence =
(state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) ||
state.HelloResolvedUtc != null ||
@@ -62,10 +87,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
.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)
+ // Strengthen EnrollmentType: observing DeviceSetupProvisioningComplete without
+ // preceding AccountSetup strongly implies SelfDeploying-v1.
+ if (state.EnrollmentType.Level < HypothesisLevel.Strong)
{
builder.EnrollmentType = state.EnrollmentType.With(
level: HypothesisLevel.Strong,
diff --git a/tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl b/tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl
new file mode 100644
index 00000000..742c344a
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl
@@ -0,0 +1,19 @@
+# Scenario: UserDriven-v1 with late spurious DeviceSetupProvisioningComplete after AccountSetup started.
+# Reproduces regression from session e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22):
+# ProvisioningStatusTracker fired DeviceSetupProvisioningComplete (payload
+# deviceSetupResolved="unknown") AFTER AccountSetup was already in progress and
+# DesktopArrived had been observed — but HelloResolved had NOT yet fired.
+# Before the fix: the reducer completed the session to Stage=Completed unconditionally on
+# DeviceSetupProvisioningComplete, short-circuiting Hello.
+# After the fix: the handler detects AccountSetupEnteredUtc != null and treats the signal as
+# informational only (stage unchanged, no completion effect). Completion happens on the
+# subsequent HelloResolved via the Classic Hello+Desktop AND-gate.
+# Expected terminal: Stage=Completed, Outcome=EnrollmentComplete, HelloOutcome=Success,
+# last trigger = HelloResolved (NOT DeviceSetupProvisioningComplete).
+# Plan: §6.1 regression fixture.
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:48:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:userdriven-late-provisioning-complete:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:48:30Z","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":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:51:38Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}}
+{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:51:58Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0006","explorerPid":"4242"}}}
+{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:51:59Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"DeviceSetupCategory subcategories succeeded but category unresolved","DerivationInputs":{"deviceSetupResolved":"unknown"}},"Payload":{"deviceSetupResolved":"unknown"}}
+{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:53:30Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:77","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}}