mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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,
|
||||
|
||||
+19
@@ -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"}}
|
||||
Reference in New Issue
Block a user