v2 single-rail 6.1: guard DeviceSetupProvisioningComplete terminal on Classic path

HandleDeviceSetupProvisioningCompleteV1 completed unconditionally — in session
e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22, Classic UserDriven-v1) a late
DeviceSetupProvisioningComplete with payload deviceSetupResolved="unknown" fired
AFTER AccountSetup had started and DesktopArrived had been observed, but BEFORE
HelloResolved — terminating the session at Stage=Completed before Hello even ran.

Guard: when state.AccountSetupEnteredUtc != null, treat the signal as informational
only (step index + ordinal advance, stage unchanged, no completion effect). On the
Classic path, terminal completion still requires the Hello+Desktop AND-gate
(HandleHelloResolvedV1 / HandleDesktopArrivedV1). The original SelfDeploying /
Device-Only terminal semantics are preserved for AccountSetupEnteredUtc == null.

The redundant AccountSetupEnteredUtc == null check around the EnrollmentType
upgrade is now implicit and removed.

Regression coverage:
- New fixture tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl
  reproduces the 6-signal sequence from the reference session.
- New test ClassicScenarioTests.LateProvisioningComplete_afterAccountSetup_doesNotCompletePrematurely
  asserts terminal trigger == HelloResolved, DeviceSetupProvisioningComplete transition
  is taken but stage-unchanged, and DeviceOnlyDeployment hypothesis stays Unknown.

Tests: 109/109 DecisionCore + 559/559 V2.Core green; solution builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-23 17:09:14 +02:00
parent 877523594f
commit 7c29b607dc
3 changed files with 92 additions and 10 deletions
@@ -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);
}
}
}
@@ -27,18 +27,43 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Handle <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/>.
/// <para>
/// The canonical SelfDeploying / Device-Only terminal signal. Cancels the
/// <see cref="DeadlineNames.DeviceOnlyEspDetection"/> deadline if still armed,
/// finalizes the <see cref="DecisionState.DeviceOnlyDeployment"/> hypothesis based
/// on whether any user-presence fact was observed, and transitions the session to
/// <see cref="SessionStage.Completed"/> with <see cref="SessionOutcome.EnrollmentComplete"/>.
/// Terminal only on the SelfDeploying / Device-Only path (no AccountSetup phase ever
/// entered). On the Classic UserDriven-v1 path — where <see cref="DecisionState.AccountSetupEnteredUtc"/>
/// is already set — this is just the ProvisioningStatusTracker marking DeviceSetupCategory
/// resolved; completion on that path requires <see cref="DecisionSignalKind.HelloResolved"/>
/// + <see cref="DecisionSignalKind.DesktopArrived"/> (handled by Classic.cs).
/// </para>
/// <para>
/// Guarding on <see cref="DecisionState.AccountSetupEnteredUtc"/> prevents the regression
/// observed in session e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22), where a late
/// DeviceSetupProvisioningComplete (payload <c>deviceSetupResolved=unknown</c>) fired while
/// Stage was <see cref="SessionStage.EspAccountSetup"/> and <see cref="DecisionState.HelloResolvedUtc"/>
/// was still null — terminating the session before Hello even ran.
/// </para>
/// </summary>
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<DecisionEffect>());
}
// 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,
@@ -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"}}