diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index f599e28b..48082f72 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-10
+12
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index bcbb007a..628625b6 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1052
+1054
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs
index 94b21a3d..66f733b3 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs
@@ -75,20 +75,15 @@ namespace AutopilotMonitor.DecisionCore.Tests.Harness
?? throw new InvalidOperationException(
$"RunClassifier effect for '{effect.ClassifierId}' missing snapshot.");
- string snapshotHash;
- if (snapshot is WhiteGloveSealingSnapshot wgSnap)
+ var hashable = snapshot as IClassifierSnapshot;
+ var snapshotHash = hashable?.ComputeInputHash() ?? string.Empty;
+ var lastVerdictId = LookupLastVerdictId(state, effect.ClassifierId!);
+ if (!string.IsNullOrEmpty(snapshotHash) &&
+ !string.IsNullOrEmpty(lastVerdictId) &&
+ snapshotHash == lastVerdictId)
{
- snapshotHash = wgSnap.ComputeInputHash();
- if (state.WhiteGloveSealing.LastClassifierVerdictId == snapshotHash)
- {
- Increment(effect.ClassifierId!, "skipped_by_antiloop");
- continue;
- }
- }
- else
- {
- // No per-classifier state tracking yet; run unconditionally.
- snapshotHash = string.Empty;
+ Increment(effect.ClassifierId!, "skipped_by_antiloop");
+ continue;
}
var verdict = classifier.Classify(snapshot);
@@ -130,6 +125,16 @@ namespace AutopilotMonitor.DecisionCore.Tests.Harness
/// Run counts per classifier (keys: "run", "skipped_by_antiloop"). For test assertions.
public IDictionary ClassifierRunStats { get; private set; } = new Dictionary();
+ private static string? LookupLastVerdictId(DecisionState state, string classifierId)
+ {
+ return classifierId switch
+ {
+ WhiteGloveSealingClassifier.ClassifierId => state.WhiteGloveSealing.LastClassifierVerdictId,
+ WhiteGlovePart2CompletionClassifier.ClassifierId => state.WhiteGlovePart2Completion.LastClassifierVerdictId,
+ _ => null,
+ };
+ }
+
private void Increment(string classifierId, string bucket)
{
var key = $"{classifierId}:{bucket}";
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs
new file mode 100644
index 00000000..fda424e5
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs
@@ -0,0 +1,91 @@
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.DecisionCore.Tests.Harness;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
+{
+ ///
+ /// Plan §4 M3.4 — WhiteGlove Part 2 scenarios.
+ ///
+ public sealed class WhiteGlovePart2ScenarioTests : ScenarioTestBase
+ {
+ private static ClassifierAwareReplayHarness NewHarness() =>
+ new ClassifierAwareReplayHarness(
+ new DecisionEngine(),
+ new Dictionary
+ {
+ [WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(),
+ [WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(),
+ });
+
+ [Fact]
+ public void WhiteGlovePart2Happy_fourUserSignals_sealPart2()
+ {
+ var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl");
+ var result = NewHarness().Replay("session-anon-0020", "tenant-anon-0020", signals);
+
+ Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage);
+ Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome);
+ Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGlovePart2Completion.Level);
+ Assert.Equal(100, result.FinalState.WhiteGlovePart2Completion.Score);
+
+ // All four Part-2 facts should be present.
+ Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc);
+ Assert.NotNull(result.FinalState.HelloResolvedPart2Utc);
+ Assert.NotNull(result.FinalState.DesktopArrivedPart2Utc);
+ Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc);
+
+ // Part-1 hypothesis still Confirmed (not reset by the Part-2 transition).
+ Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
+
+ // SystemReboot fact recorded from SessionRecovered.
+ Assert.NotNull(result.FinalState.SystemRebootUtc);
+
+ Assert.Empty(result.FinalState.Deadlines);
+ }
+
+ [Fact]
+ public void WhiteGlovePart2Stuck_safetyDeadlineFires_failsSession()
+ {
+ var signals = LoadFixture("whiteglove-part2-stuck-v1.jsonl");
+ var result = NewHarness().Replay("session-anon-0021", "tenant-anon-0021", signals);
+
+ Assert.Equal(SessionStage.Failed, result.FinalState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome);
+ Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGlovePart2Completion.Level);
+ Assert.Equal("part2_user_absent", result.FinalState.WhiteGlovePart2Completion.Reason);
+
+ // No user-signal facts captured.
+ Assert.Null(result.FinalState.UserAadSignInCompleteUtc);
+ Assert.Null(result.FinalState.HelloResolvedPart2Utc);
+ Assert.Null(result.FinalState.DesktopArrivedPart2Utc);
+ Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc);
+
+ // Part 1 verdict remains Confirmed even as Part 2 failed.
+ Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
+ Assert.Empty(result.FinalState.Deadlines);
+ }
+
+ [Fact]
+ public void WhiteGlovePart2_bridge_armsSafetyDeadline_onRecoverFromSealed()
+ {
+ // Intermediate-state check by running a shortened fixture: after Part-1 seals and
+ // SessionRecovered fires, the safety deadline should be active. The happy path
+ // then cancels it when the classifier reaches Confirmed; here we just verify the
+ // bridge behaviour by loading only the first 4 signals of the happy fixture.
+ var full = LoadFixture("whiteglove-part2-happy-v1.jsonl");
+ var prefix = new List();
+ for (int i = 0; i < 4 && i < full.Count; i++) prefix.Add(full[i]);
+
+ var result = NewHarness().Replay("session-anon-0022", "tenant-anon-0022", prefix);
+
+ Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, result.FinalState.Stage);
+ Assert.Contains(
+ result.FinalState.Deadlines,
+ d => d.Name == DeadlineNames.WhiteGlovePart2Safety);
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifierSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifierSnapshot.cs
new file mode 100644
index 00000000..396ceee3
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifierSnapshot.cs
@@ -0,0 +1,16 @@
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// A classifier-input snapshot that exposes a deterministic hash. Plan §2.4 anti-loop.
+ ///
+ /// Implementations must produce the same hash byte-for-byte for equivalent snapshots,
+ /// across process restarts, regardless of hashtable ordering or culture. The hash is
+ /// used by the effect runner / replay harness to skip classifier invocations whose
+ /// input matches the last verdict's .
+ ///
+ ///
+ public interface IClassifierSnapshot
+ {
+ string ComputeInputHash();
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs
new file mode 100644
index 00000000..e3e67942
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// Scoring classifier for WhiteGlove Part 2 completion. Plan §2.4 / §M3.4.
+ ///
+ /// Scoring rules (new — no Legacy equivalent): each of the four Part-2 post-reboot user
+ /// signals contributes +25; all four together cross the Confirmed threshold of 100.
+ /// Missing one signal gives 75 -> Weak. This matches the plan's requirement that Part-2
+ /// completion needs the full UserSignIn + Hello + Desktop + AccountSetup set.
+ ///
+ ///
+ public sealed class WhiteGlovePart2CompletionClassifier : IClassifier
+ {
+ public const string ClassifierId = "whiteglove-part2-completion";
+
+ internal const int HighThreshold = 100; // exactly-all-four -> Confirmed
+ internal const int LowThreshold = 25; // any single signal -> Weak
+
+ internal const int WeightUserAadSignIn = 25;
+ internal const int WeightHelloResolved = 25;
+ internal const int WeightDesktopArrived = 25;
+ internal const int WeightAccountSetup = 25;
+
+ public string Id => ClassifierId;
+ public Type SnapshotType => typeof(WhiteGlovePart2CompletionSnapshot);
+
+ public ClassifierVerdict Classify(object snapshot)
+ {
+ if (snapshot == null) throw new ArgumentNullException(nameof(snapshot));
+ if (!(snapshot is WhiteGlovePart2CompletionSnapshot s))
+ {
+ throw new ArgumentException(
+ $"Expected {nameof(WhiteGlovePart2CompletionSnapshot)}, got {snapshot.GetType().Name}.",
+ nameof(snapshot));
+ }
+
+ var factors = new List();
+ int score = 0;
+
+ if (s.UserAadSignInComplete) { score += WeightUserAadSignIn; factors.Add($"user_aad_signin:{WeightUserAadSignIn:+#;-#;0}"); }
+ if (s.HelloResolvedPart2) { score += WeightHelloResolved; factors.Add($"hello_part2:{WeightHelloResolved:+#;-#;0}"); }
+ if (s.DesktopArrivedPart2) { score += WeightDesktopArrived; factors.Add($"desktop_part2:{WeightDesktopArrived:+#;-#;0}"); }
+ if (s.AccountSetupCompletedPart2) { score += WeightAccountSetup; factors.Add($"account_setup_part2:{WeightAccountSetup:+#;-#;0}"); }
+
+ if (score < 0) score = 0;
+ if (score > 100) score = 100;
+
+ HypothesisLevel level;
+ if (score >= HighThreshold) level = HypothesisLevel.Confirmed;
+ else if (score >= LowThreshold) level = HypothesisLevel.Weak;
+ else level = HypothesisLevel.Unknown;
+
+ return new ClassifierVerdict(
+ classifierId: ClassifierId,
+ level: level,
+ score: score,
+ contributingFactors: factors,
+ reason: $"confidence={level} score={score}",
+ inputHash: s.ComputeInputHash());
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs
new file mode 100644
index 00000000..c402c92b
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// Input snapshot for .
+ /// Plan §2.4 / §M3.4 — the four Part-2 post-reboot user signals drive the score.
+ ///
+ public sealed class WhiteGlovePart2CompletionSnapshot : IClassifierSnapshot
+ {
+ public WhiteGlovePart2CompletionSnapshot(
+ bool userAadSignInComplete,
+ bool helloResolvedPart2,
+ bool desktopArrivedPart2,
+ bool accountSetupCompletedPart2,
+ EnrollmentPhase? currentEnrollmentPhase,
+ DateTime? systemRebootUtc)
+ {
+ UserAadSignInComplete = userAadSignInComplete;
+ HelloResolvedPart2 = helloResolvedPart2;
+ DesktopArrivedPart2 = desktopArrivedPart2;
+ AccountSetupCompletedPart2 = accountSetupCompletedPart2;
+ CurrentEnrollmentPhase = currentEnrollmentPhase;
+ SystemRebootUtc = systemRebootUtc;
+ }
+
+ public bool UserAadSignInComplete { get; }
+ public bool HelloResolvedPart2 { get; }
+ public bool DesktopArrivedPart2 { get; }
+ public bool AccountSetupCompletedPart2 { get; }
+ public EnrollmentPhase? CurrentEnrollmentPhase { get; }
+ public DateTime? SystemRebootUtc { get; }
+
+ public string ComputeInputHash()
+ {
+ var canonical = string.Join(
+ "|",
+ "whiteglove-part2-completion-v1",
+ UserAadSignInComplete ? "1" : "0",
+ HelloResolvedPart2 ? "1" : "0",
+ DesktopArrivedPart2 ? "1" : "0",
+ AccountSetupCompletedPart2 ? "1" : "0",
+ CurrentEnrollmentPhase?.ToString() ?? "null",
+ SystemRebootUtc?.ToString("O") ?? "null");
+
+ using (var sha = SHA256.Create())
+ {
+ var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical));
+ var sb = new StringBuilder(16);
+ for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
+ return sb.ToString();
+ }
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs
index c83f8ade..928fb38d 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs
@@ -13,7 +13,7 @@ namespace AutopilotMonitor.DecisionCore.Classifiers
/// would cause unnecessary re-runs on the anti-loop guard.
///
///
- public sealed class WhiteGloveSealingSnapshot
+ public sealed class WhiteGloveSealingSnapshot : IClassifierSnapshot
{
public WhiteGloveSealingSnapshot(
bool shellCoreWhiteGloveSuccessSeen,
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index c618ce46..f74e9b4e 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -68,8 +68,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
///
private DecisionStep HandleSessionRecoveredV1(DecisionState state, DecisionSignal signal)
{
- // M3.4 will add WhiteGlove-Part-2 transition here. For M3.0 the handler is a
- // neutral "observed a restart" step — stage unchanged, StepIndex advanced.
+ // Plan §2.7 sonder-case 1: WhiteGlove Part 1 -> Reboot -> Part 2.
+ // If the recovered session was sealed, transition into the Part 2 awaiting-user
+ // stage and arm the 24h safety deadline. See DecisionEngine.WhiteGlovePart2.cs.
+ if (state.Stage == SessionStage.WhiteGloveSealed)
+ {
+ return HandleWhiteGlovePart1To2Bridge(state, signal);
+ }
+
+ // Otherwise the recovered state is already mid-flight elsewhere; the handler is
+ // a neutral "observed a restart" step — stage unchanged, bookkeeping advanced.
var newState = BumpStepBookkeeping(state, signal);
var transition = BuildTakenTransition(
before: state,
@@ -156,6 +164,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleDeviceOnlyEspDetectionDeadlineFired(state, signal);
case DeadlineNames.ClassifierTick:
return HandleClassifierTickDeadlineFired(state, signal);
+ case DeadlineNames.WhiteGlovePart2Safety:
+ return HandleWhiteGlovePart2SafetyDeadlineFired(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.WhiteGlove.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
index 25c695ec..ca7c01c5 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
@@ -97,7 +97,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
- // Only the sealing classifier is wired in M3.3 — M3.4 adds Part 2.
+ // M3.4 Part-2 classifier runs through its own applier.
+ if (classifier == WhiteGlovePart2CompletionClassifier.ClassifierId)
+ {
+ return ApplyWhiteGlovePart2Verdict(state, signal, level, score, reason, inputHash);
+ }
+
if (classifier == WhiteGloveSealingClassifier.ClassifierId)
{
builder.WhiteGloveSealing = state.WhiteGloveSealing.With(
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
new file mode 100644
index 00000000..585b8043
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
@@ -0,0 +1,257 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ // WhiteGlove Part 2 (post-reboot user sign-in) handlers. Plan §2.5 / §M3.4.
+ public sealed partial class DecisionEngine
+ {
+ // Plan §2.3: "Part-2-Safety-Timeout (z.B. 24h nach Reboot)" watches for the user
+ // never signing in. Firing yields Outcome=EnrollmentFailed reason=part2_user_absent.
+ internal static readonly TimeSpan s_whiteGlovePart2SafetyWindow = TimeSpan.FromHours(24);
+
+ ///
+ /// WhiteGlove Part 1 → Part 2 bridge. Called from
+ /// when the recovered state is . Transitions
+ /// into and arms the Part-2
+ /// safety deadline.
+ ///
+ private DecisionStep HandleWhiteGlovePart1To2Bridge(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+ var safety = new ActiveDeadline(
+ name: DeadlineNames.WhiteGlovePart2Safety,
+ dueAtUtc: signal.OccurredAtUtc.Add(s_whiteGlovePart2SafetyWindow),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety,
+ });
+
+ var builder = state.ToBuilder()
+ .WithStage(SessionStage.WhiteGloveAwaitingUserSignIn)
+ .WithOutcome(null) // leaving the Part-1 Sealed outcome aside until Part-2 concludes.
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .AddDeadline(safety);
+ builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
+
+ var newState = builder.Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.WhiteGloveAwaitingUserSignIn,
+ nextStepIndex: nextStep,
+ trigger: "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn");
+
+ var effects = new[]
+ {
+ new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
+ };
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
+ private DecisionStep HandleUserAadSignInCompleteV1(DecisionState state, DecisionSignal signal)
+ {
+ return UpdatePart2Fact(
+ state,
+ signal,
+ applyFact: b => { b.UserAadSignInCompleteUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
+ trigger: nameof(DecisionSignalKind.UserAadSignInComplete));
+ }
+
+ private DecisionStep HandleHelloResolvedPart2V1(DecisionState state, DecisionSignal signal)
+ {
+ return UpdatePart2Fact(
+ state,
+ signal,
+ applyFact: b => { b.HelloResolvedPart2Utc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
+ trigger: nameof(DecisionSignalKind.HelloResolvedPart2));
+ }
+
+ private DecisionStep HandleDesktopArrivedPart2V1(DecisionState state, DecisionSignal signal)
+ {
+ return UpdatePart2Fact(
+ state,
+ signal,
+ applyFact: b => { b.DesktopArrivedPart2Utc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
+ trigger: nameof(DecisionSignalKind.DesktopArrivedPart2));
+ }
+
+ private DecisionStep HandleAccountSetupCompletedPart2V1(DecisionState state, DecisionSignal signal)
+ {
+ return UpdatePart2Fact(
+ state,
+ signal,
+ applyFact: b => { b.AccountSetupCompletedPart2Utc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
+ trigger: nameof(DecisionSignalKind.AccountSetupCompletedPart2));
+ }
+
+ ///
+ /// Part-2 safety deadline fired — user never signed in within 24 h. Terminates the
+ /// session as with reason
+ /// part2_user_absent.
+ ///
+ private DecisionStep HandleWhiteGlovePart2SafetyDeadlineFired(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+
+ var builder = state.ToBuilder()
+ .WithStage(SessionStage.Failed)
+ .WithOutcome(SessionOutcome.EnrollmentFailed)
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .ClearDeadlines();
+
+ builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With(
+ level: HypothesisLevel.Rejected,
+ reason: "part2_user_absent",
+ score: 0,
+ lastUpdatedUtc: signal.OccurredAtUtc);
+
+ var newState = builder.Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.Failed,
+ nextStepIndex: nextStep,
+ trigger: $"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}");
+
+ var effects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary
+ {
+ ["eventType"] = "enrollment_failed",
+ ["reason"] = "part2_user_absent",
+ }),
+ };
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
+ // ============================================================ internal helpers
+
+ private DecisionStep UpdatePart2Fact(
+ DecisionState state,
+ DecisionSignal signal,
+ Action applyFact,
+ string trigger)
+ {
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+
+ applyFact(builder);
+
+ // Part-2 only matters once we're in the WhiteGloveAwaitingUserSignIn stage;
+ // if signals arrive on other stages, still record the fact but do not emit
+ // a Part-2 classifier run.
+ var emitClassifier =
+ state.Stage == SessionStage.WhiteGloveAwaitingUserSignIn
+ || state.Stage == SessionStage.WhiteGloveCompletedPart2;
+
+ var stateAfterFact = builder.Build();
+
+ var effects = emitClassifier
+ ? new[] { BuildRunPart2ClassifierEffect(stateAfterFact) }
+ : Array.Empty();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: stateAfterFact.Stage,
+ nextStepIndex: nextStep,
+ trigger: trigger);
+
+ return new DecisionStep(stateAfterFact, transition, effects);
+ }
+
+ private static DecisionEffect BuildRunPart2ClassifierEffect(DecisionState state) =>
+ new DecisionEffect(
+ kind: DecisionEffectKind.RunClassifier,
+ classifierId: WhiteGlovePart2CompletionClassifier.ClassifierId,
+ classifierSnapshot: BuildWhiteGlovePart2CompletionSnapshot(state));
+
+ internal static WhiteGlovePart2CompletionSnapshot BuildWhiteGlovePart2CompletionSnapshot(DecisionState state) =>
+ new WhiteGlovePart2CompletionSnapshot(
+ userAadSignInComplete: state.UserAadSignInCompleteUtc != null,
+ helloResolvedPart2: state.HelloResolvedPart2Utc != null,
+ desktopArrivedPart2: state.DesktopArrivedPart2Utc != null,
+ accountSetupCompletedPart2: state.AccountSetupCompletedPart2Utc != null,
+ currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value,
+ systemRebootUtc: state.SystemRebootUtc?.Value);
+
+ ///
+ /// Apply a Part-2 classifier verdict to .
+ /// Called from the extended router when
+ /// the verdict's classifier id is the Part-2 completion classifier. On Confirmed,
+ /// transitions to + Outcome
+ /// and emits the
+ /// whiteglove_part2_complete event.
+ ///
+ private DecisionStep ApplyWhiteGlovePart2Verdict(
+ DecisionState state,
+ DecisionSignal signal,
+ HypothesisLevel level,
+ int score,
+ string reason,
+ string inputHash)
+ {
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+
+ builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With(
+ level: level,
+ reason: reason,
+ score: score,
+ lastUpdatedUtc: signal.OccurredAtUtc,
+ lastClassifierVerdictId: inputHash);
+
+ if (level == HypothesisLevel.Confirmed)
+ {
+ builder
+ .WithStage(SessionStage.WhiteGloveCompletedPart2)
+ .WithOutcome(SessionOutcome.WhiteGlovePart2Complete)
+ .ClearDeadlines();
+
+ var sealedState = builder.Build();
+ var sealedTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.WhiteGloveCompletedPart2,
+ nextStepIndex: nextStep,
+ trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed");
+
+ var effects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary { ["eventType"] = "whiteglove_part2_complete" }),
+ };
+
+ return new DecisionStep(sealedState, sealedTransition, effects);
+ }
+
+ var newState = builder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:{level}");
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index 5fafe83b..ab35daae 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -77,7 +77,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
(DecisionSignalKind.WhiteGloveSealingPatternDetected, 1) => HandleWhiteGloveSealingPatternDetectedV1(state, signal),
(DecisionSignalKind.ClassifierVerdictIssued, 1) => HandleClassifierVerdictIssuedV1(state, signal),
- // ----- Not yet wired (WhiteGlove Part 2 in M3.4, edge in M3.5) -----
+ // ----- WhiteGlove Part 2 (DecisionEngine.WhiteGlovePart2.cs) -----
+ (DecisionSignalKind.UserAadSignInComplete, 1) => HandleUserAadSignInCompleteV1(state, signal),
+ (DecisionSignalKind.HelloResolvedPart2, 1) => HandleHelloResolvedPart2V1(state, signal),
+ (DecisionSignalKind.DesktopArrivedPart2, 1) => HandleDesktopArrivedPart2V1(state, signal),
+ (DecisionSignalKind.AccountSetupCompletedPart2, 1) => HandleAccountSetupCompletedPart2V1(state, signal),
+
+ // ----- Not yet wired (edge in M3.5) -----
_ => HandleUnhandledSignal(state, signal),
};
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
index 43a83276..bc6cc2b0 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
@@ -42,6 +42,10 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact? imeMatchedPatternId,
SignalFact? shellCoreWhiteGloveSuccessSeen,
SignalFact? whiteGloveSealingPatternSeen,
+ SignalFact? userAadSignInCompleteUtc,
+ SignalFact? helloResolvedPart2Utc,
+ SignalFact? desktopArrivedPart2Utc,
+ SignalFact? accountSetupCompletedPart2Utc,
IReadOnlyList deadlines,
long lastAppliedSignalOrdinal,
int stepIndex,
@@ -78,6 +82,10 @@ namespace AutopilotMonitor.DecisionCore.State
ImeMatchedPatternId = imeMatchedPatternId;
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
+ UserAadSignInCompleteUtc = userAadSignInCompleteUtc;
+ HelloResolvedPart2Utc = helloResolvedPart2Utc;
+ DesktopArrivedPart2Utc = desktopArrivedPart2Utc;
+ AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc;
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
StepIndex = stepIndex;
@@ -133,6 +141,12 @@ namespace AutopilotMonitor.DecisionCore.State
/// True once has fired. Signal-correlated WG path.
public SignalFact? WhiteGloveSealingPatternSeen { get; }
+ // --- WhiteGlove Part 2 (post-reboot user sign-in) facts ---
+ public SignalFact? UserAadSignInCompleteUtc { get; }
+ public SignalFact? HelloResolvedPart2Utc { get; }
+ public SignalFact? DesktopArrivedPart2Utc { get; }
+ public SignalFact? AccountSetupCompletedPart2Utc { get; }
+
public IReadOnlyList Deadlines { get; }
public long LastAppliedSignalOrdinal { get; }
@@ -175,6 +189,10 @@ namespace AutopilotMonitor.DecisionCore.State
imeMatchedPatternId: null,
shellCoreWhiteGloveSuccessSeen: null,
whiteGloveSealingPatternSeen: null,
+ userAadSignInCompleteUtc: null,
+ helloResolvedPart2Utc: null,
+ desktopArrivedPart2Utc: null,
+ accountSetupCompletedPart2Utc: null,
deadlines: Array.Empty(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0);
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
index b5e18116..b21eff61 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
@@ -40,6 +40,10 @@ namespace AutopilotMonitor.DecisionCore.State
ImeMatchedPatternId = source.ImeMatchedPatternId;
ShellCoreWhiteGloveSuccessSeen = source.ShellCoreWhiteGloveSuccessSeen;
WhiteGloveSealingPatternSeen = source.WhiteGloveSealingPatternSeen;
+ UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc;
+ HelloResolvedPart2Utc = source.HelloResolvedPart2Utc;
+ DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc;
+ AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc;
Deadlines = new List(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
@@ -67,6 +71,10 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact? ImeMatchedPatternId { get; set; }
public SignalFact? ShellCoreWhiteGloveSuccessSeen { get; set; }
public SignalFact? WhiteGloveSealingPatternSeen { get; set; }
+ public SignalFact? UserAadSignInCompleteUtc { get; set; }
+ public SignalFact? HelloResolvedPart2Utc { get; set; }
+ public SignalFact? DesktopArrivedPart2Utc { get; set; }
+ public SignalFact? AccountSetupCompletedPart2Utc { get; set; }
public List Deadlines { get; set; }
public long LastAppliedSignalOrdinal { get; set; }
public int StepIndex { get; set; }
@@ -136,6 +144,10 @@ namespace AutopilotMonitor.DecisionCore.State
imeMatchedPatternId: ImeMatchedPatternId,
shellCoreWhiteGloveSuccessSeen: ShellCoreWhiteGloveSuccessSeen,
whiteGloveSealingPatternSeen: WhiteGloveSealingPatternSeen,
+ userAadSignInCompleteUtc: UserAadSignInCompleteUtc,
+ helloResolvedPart2Utc: HelloResolvedPart2Utc,
+ desktopArrivedPart2Utc: DesktopArrivedPart2Utc,
+ accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc,
deadlines: Deadlines.ToArray(),
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
stepIndex: StepIndex,
diff --git a/tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl
new file mode 100644
index 00000000..4d84cc6c
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl
@@ -0,0 +1,16 @@
+# Scenario: WhiteGlove Part 2 Happy (Post-Reboot user sign-in)
+# Session picks up from a WhiteGloveSealed Part 1 state (simulated here by driving
+# ShellCoreWhiteGloveSuccess first, which confirms Part 1 inline). SessionRecovered
+# after a reboot transitions the session to WhiteGloveAwaitingUserSignIn and arms the
+# 24h Part-2 safety deadline. All four Part-2 user signals arrive within minutes ->
+# Part2 completion classifier returns Confirmed -> Stage=WhiteGloveCompletedPart2 +
+# Outcome=WhiteGlovePart2Complete, whiteglove_part2_complete event emitted.
+# Plan: §4 M3.4.
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-happy:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08: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":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:310","Summary":"Shell Core white-glove success"}}
+{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"SessionRecovered","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:07:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-happy:recovered","Summary":"Agent started after reboot; Part 1 sealed state persisted"}}
+{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"UserAadSignInComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:10:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AAD user sign-in complete","DerivationInputs":{"user":"user-anon-0015"}}}
+{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"HelloResolvedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:11:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:211","Summary":"Hello for Business enrolled (Part 2)"},"Payload":{"outcome":"Success"}}
+{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DesktopArrivedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:12:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user (Part 2)","DerivationInputs":{"user":"user-anon-0015","explorerPid":"4300"}}}
+{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"AccountSetupCompletedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"account-setup-complete-detector-v1","Summary":"Account setup phase marked complete (Part 2)","DerivationInputs":{"registryKey":"HKLM\\...EspStatus\\AccountSetup","rawValue":"Complete"}}}
diff --git a/tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl
new file mode 100644
index 00000000..fac0a843
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl
@@ -0,0 +1,11 @@
+# Scenario: WhiteGlove Part 2 Stuck (user never signs in; 24h safety deadline fires)
+# Session seals Part 1, reboots, and waits for the user — who never signs in. The
+# 24h WhiteGlovePart2Safety deadline fires -> Stage=Failed, Outcome=EnrollmentFailed,
+# Part2Completion hypothesis Rejected with reason "part2_user_absent",
+# enrollment_failed event emitted with reason="part2_user_absent".
+# Plan: §4 M3.4.
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-stuck:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09: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":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:320","Summary":"Shell Core white-glove success"}}
+{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"SessionRecovered","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:07:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-stuck:recovered","Summary":"Agent started after reboot"}}
+{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:07:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:whiteglove_part2_safety:fired","Summary":"Part 2 safety deadline fired — no user sign-in within 24 h"},"Payload":{"deadline":"whiteglove_part2_safety"}}