refactor(decisioncore): SelfDeploying + Device-Only handlers + 2 scenarios (M3.2)

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 14:04:52 +02:00
parent 0210d42c7f
commit 0ebfa2fc3c
10 changed files with 302 additions and 4 deletions
@@ -1 +1 @@
8
9
@@ -1 +1 @@
1050
1051
@@ -0,0 +1,104 @@
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
{
/// <summary>
/// Plan §4 M3.2 — SelfDeploying-v1 and Device-Only scenarios.
/// </summary>
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);
}
}
}
@@ -18,6 +18,14 @@
<ProjectReference Include="..\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Internal helpers (DeviceOnlyReasons etc.) are visible to the test assembly for
precise assertions; never exposed to consumers. -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>AutopilotMonitor.DecisionCore.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<!-- Plan §2.8 — Newtonsoft.Json on both sides (Agent net48 + Backend net8.0) for DecisionCore DTOs. -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
@@ -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<DecisionEffect>();
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<DecisionEffect>());
return new DecisionStep(newState, transition, effectsList.ToArray());
}
/// <summary>
@@ -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);
/// <summary>
/// Hypothesis-reason tokens for <see cref="DecisionState.DeviceOnlyDeployment"/>.
/// Plan §2.3 values UserPresent / DeviceOnly / Ambiguous — expressed through the
/// <see cref="Hypothesis.Reason"/> field so we do not need a new enum.
/// </summary>
internal static class DeviceOnlyReasons
{
public const string UserPresent = "user_present";
public const string DeviceOnly = "device_only";
public const string Ambiguous = "ambiguous";
}
/// <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"/>.
/// </para>
/// </summary>
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);
}
/// <summary>
/// Called by the shared <c>DeadlineFired</c> router when
/// <see cref="DeadlineNames.DeviceOnlyEspDetection"/> elapses without an AccountSetup
/// phase having arrived. Confirms the <see cref="DecisionState.DeviceOnlyDeployment"/>
/// hypothesis as DeviceOnly but does NOT complete the session — the terminal still
/// needs an explicit <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/>.
/// </summary>
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<DecisionEffect>());
}
// ============================================================== internal helpers
/// <summary>
/// Called from the Classic <see cref="HandleEspPhaseChangedV1"/> handler when
/// <c>DeviceSetup</c> is observed for the first time — arms the
/// <see cref="DeadlineNames.DeviceOnlyEspDetection"/> deadline. The Classic handler
/// adds the deadline effect to its own effect list.
/// </summary>
internal ActiveDeadline BuildDeviceOnlyEspDetectionDeadline(DateTime fromUtc) =>
new ActiveDeadline(
name: DeadlineNames.DeviceOnlyEspDetection,
dueAtUtc: fromUtc.Add(s_deviceOnlyEspDetectionWindow),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection,
});
}
}
@@ -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
@@ -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),
};
}
@@ -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"}}}
@@ -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"}}}