diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index ec635144..f599e28b 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-9
+10
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index f1f0ca51..bcbb007a 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1051
+1052
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs
new file mode 100644
index 00000000..94b21a3d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Tests.Harness
+{
+ ///
+ /// Replay harness that also executes
+ /// effects, synthesizing the resulting
+ /// signal and feeding it back into the reducer. Emulates the production EffectRunner
+ /// for the pure-synchronous classifier case used in M3.3 / M3.4 (plan §2.4).
+ ///
+ /// Anti-loop (plan §2.4): a classifier is skipped when the snapshot's
+ /// matches the last
+ /// recorded for that classifier — tested
+ /// end-to-end by the Anti-Loop scenario.
+ ///
+ ///
+ public sealed class ClassifierAwareReplayHarness
+ {
+ private readonly IDecisionEngine _engine;
+ private readonly IReadOnlyDictionary _classifiers;
+
+ public ClassifierAwareReplayHarness(
+ IDecisionEngine engine,
+ IReadOnlyDictionary classifiers)
+ {
+ _engine = engine ?? throw new ArgumentNullException(nameof(engine));
+ _classifiers = classifiers ?? throw new ArgumentNullException(nameof(classifiers));
+ }
+
+ public ReplayResult Replay(
+ string sessionId,
+ string tenantId,
+ IReadOnlyList signals)
+ {
+ if (signals == null) throw new ArgumentNullException(nameof(signals));
+
+ var state = DecisionState.CreateInitial(sessionId, tenantId);
+ var allTransitions = new List(signals.Count * 2);
+ ClassifierRunStats = new Dictionary();
+
+ long nextSyntheticOrdinal = long.MinValue; // reassigned once we know the max real ordinal.
+ foreach (var s in signals)
+ {
+ if (s.SessionSignalOrdinal + 1 > nextSyntheticOrdinal)
+ {
+ nextSyntheticOrdinal = s.SessionSignalOrdinal + 1;
+ }
+ }
+ if (nextSyntheticOrdinal == long.MinValue) nextSyntheticOrdinal = 0;
+
+ foreach (var signal in signals)
+ {
+ var step = _engine.Reduce(state, signal);
+ state = step.NewState;
+ allTransitions.Add(step.Transition);
+
+ // Process effects: the only effect kind the harness acts on is RunClassifier;
+ // scheduling / cancellation / event emission are bookkept by state + the
+ // transition record.
+ foreach (var effect in step.Effects)
+ {
+ if (effect.Kind != DecisionEffectKind.RunClassifier) continue;
+ if (string.IsNullOrEmpty(effect.ClassifierId)) continue;
+ if (!_classifiers.TryGetValue(effect.ClassifierId!, out var classifier)) continue;
+
+ // Anti-loop: skip if snapshot hash matches the last verdict hash for this
+ // classifier. For M3.3 we only track WhiteGloveSealing on state, so the
+ // check is tight. Other classifiers will get their own state fields in M3.4+.
+ var snapshot = effect.ClassifierSnapshot
+ ?? throw new InvalidOperationException(
+ $"RunClassifier effect for '{effect.ClassifierId}' missing snapshot.");
+
+ string snapshotHash;
+ if (snapshot is WhiteGloveSealingSnapshot wgSnap)
+ {
+ 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;
+ }
+
+ var verdict = classifier.Classify(snapshot);
+ Increment(effect.ClassifierId!, "run");
+
+ var verdictSignal = new DecisionSignal(
+ sessionSignalOrdinal: nextSyntheticOrdinal,
+ sessionTraceOrdinal: nextSyntheticOrdinal,
+ kind: DecisionSignalKind.ClassifierVerdictIssued,
+ kindSchemaVersion: 1,
+ occurredAtUtc: signal.OccurredAtUtc,
+ sourceOrigin: $"harness:classifier:{effect.ClassifierId}",
+ evidence: new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: $"classifier:{effect.ClassifierId}:{verdict.InputHash}",
+ summary: $"{effect.ClassifierId}={verdict.Level}/{verdict.Score}"),
+ payload: new Dictionary
+ {
+ ["classifier"] = verdict.ClassifierId,
+ ["level"] = verdict.Level.ToString(),
+ ["score"] = verdict.Score.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["reason"] = verdict.Reason,
+ ["inputHash"] = verdict.InputHash,
+ });
+ nextSyntheticOrdinal++;
+
+ var verdictStep = _engine.Reduce(state, verdictSignal);
+ state = verdictStep.NewState;
+ allTransitions.Add(verdictStep.Transition);
+ }
+ }
+
+ return new ReplayResult(
+ finalState: state,
+ transitions: allTransitions,
+ finalStepHash: ComputeStepHash(state));
+ }
+
+ /// Run counts per classifier (keys: "run", "skipped_by_antiloop"). For test assertions.
+ public IDictionary ClassifierRunStats { get; private set; } = new Dictionary();
+
+ private void Increment(string classifierId, string bucket)
+ {
+ var key = $"{classifierId}:{bucket}";
+ ClassifierRunStats[key] = ClassifierRunStats.TryGetValue(key, out var v) ? v + 1 : 1;
+ }
+
+ private static string ComputeStepHash(DecisionState state)
+ {
+ var canonical = string.Join(
+ "|",
+ state.SessionId,
+ state.TenantId,
+ state.Stage.ToString(),
+ state.Outcome?.ToString() ?? "null",
+ state.StepIndex.ToString(),
+ state.LastAppliedSignalOrdinal.ToString(),
+ state.WhiteGloveSealing.Level.ToString(),
+ state.WhiteGloveSealing.Score.ToString());
+
+ using (var sha = System.Security.Cryptography.SHA256.Create())
+ {
+ var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical));
+ var sb = new System.Text.StringBuilder(16);
+ for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
+ return sb.ToString();
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs
index 7f17e1a3..70d50436 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs
@@ -88,16 +88,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
{
// An unhandled signal advances bookkeeping but leaves stage/outcome alone
// and records a DeadEndReason — plan §2.5 fail-safe shape for non-handlers.
- // WhiteGloveShellCoreSuccess is unhandled until M3.3.
+ // AppInstallCompleted is scheduled for v11.1; still unhandled in M3.3.
var harness = new ReplayHarness(new DecisionEngine());
var signal = new DecisionSignal(
sessionSignalOrdinal: 0,
sessionTraceOrdinal: 0,
- kind: DecisionSignalKind.WhiteGloveShellCoreSuccess,
+ kind: DecisionSignalKind.AppInstallCompleted,
kindSchemaVersion: 1,
occurredAtUtc: DateTime.UtcNow,
sourceOrigin: "harness",
- evidence: new Evidence(EvidenceKind.Raw, "WG:test", "test"));
+ evidence: new Evidence(EvidenceKind.Raw, "App:test", "test"));
var result = harness.Replay("s", "t", new List { signal });
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs
index ab2141b6..5f82ce21 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs
@@ -57,16 +57,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
[Fact]
public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason()
{
- // Pick a kind that no sub-milestone has wired yet. WhiteGloveShellCoreSuccess
- // comes online in M3.3 — update this test then.
+ // Pick a kind that no sub-milestone has wired yet. AppInstallCompleted is
+ // scheduled for v11.1 app-install observability; update this test when wired.
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
- var signal = MakeSignal(DecisionSignalKind.WhiteGloveShellCoreSuccess, schemaVersion: 1);
+ var signal = MakeSignal(DecisionSignalKind.AppInstallCompleted, schemaVersion: 1);
var step = engine.Reduce(state, signal);
Assert.False(step.Transition.Taken);
- Assert.Equal("unhandled_signal_kind:WhiteGloveShellCoreSuccess:v1", step.Transition.DeadEndReason);
+ Assert.Equal("unhandled_signal_kind:AppInstallCompleted:v1", step.Transition.DeadEndReason);
Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage);
Assert.Equal(1, step.NewState.StepIndex);
}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs
new file mode 100644
index 00000000..a83a7f2e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs
@@ -0,0 +1,106 @@
+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.3 — WhiteGlove Part 1 scenarios. Uses the classifier-aware harness so
+ /// RunClassifier effects are executed synchronously and the resulting verdicts feed
+ /// back into the reducer.
+ ///
+ public sealed class WhiteGlovePart1ScenarioTests : ScenarioTestBase
+ {
+ private static ClassifierAwareReplayHarness NewHarness() =>
+ new ClassifierAwareReplayHarness(
+ new DecisionEngine(),
+ new Dictionary
+ {
+ [WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(),
+ });
+
+ private static ReplayResult RunWgFixture(string fixture, string sessionId, string tenantId)
+ {
+ var signals = LoadFixture(fixture);
+ return NewHarness().Replay(sessionId, tenantId, signals);
+ }
+
+ [Fact]
+ public void WhiteGloveInline_shellCoreAlone_sealsWhiteGlove()
+ {
+ var result = RunWgFixture("whiteglove-inline-v1.jsonl", "session-anon-0010", "tenant-anon-0010");
+
+ Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
+ Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
+ Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
+ Assert.Equal(80, result.FinalState.WhiteGloveSealing.Score);
+ Assert.NotNull(result.FinalState.ShellCoreWhiteGloveSuccessSeen);
+ Assert.True(result.FinalState.ShellCoreWhiteGloveSuccessSeen!.Value);
+ Assert.Empty(result.FinalState.Deadlines);
+ }
+
+ [Fact]
+ public void WhiteGloveSignalCorrelated_patternThenShellCore_sealsAfterSecondSignal()
+ {
+ var harness = NewHarness();
+ var signals = LoadFixture("whiteglove-signal-correlated-v1.jsonl");
+ var result = harness.Replay("session-anon-0011", "tenant-anon-0011", signals);
+
+ Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
+ Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
+ Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
+ Assert.True(result.FinalState.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold);
+
+ // The tick fired between the two WG signals — snapshot unchanged at that point,
+ // so it was anti-loop-skipped. The SealingPattern run and the ShellCore run both
+ // produced fresh verdicts.
+ var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0;
+ var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0;
+ Assert.Equal(2, runs);
+ Assert.Equal(1, skipped);
+ }
+
+ [Fact]
+ public void WhiteGloveFalsePositive_LateAadjAfterSealingPattern_rejectsWg_completesClassic()
+ {
+ var result = RunWgFixture("whiteglove-false-positive-v1.jsonl", "session-anon-0012", "tenant-anon-0012");
+
+ // The session must NOT end in WhiteGloveSealed.
+ Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
+ Assert.Equal(SessionStage.Completed, result.FinalState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
+
+ // WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser).
+ Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGloveSealing.Level);
+ Assert.Equal(0, result.FinalState.WhiteGloveSealing.Score);
+
+ // The late-AADJ fact + Hello + Desktop are all recorded.
+ Assert.NotNull(result.FinalState.AadJoinedWithUser);
+ Assert.True(result.FinalState.AadJoinedWithUser!.Value);
+ Assert.NotNull(result.FinalState.HelloResolvedUtc);
+ Assert.NotNull(result.FinalState.DesktopArrivedUtc);
+ Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
+ }
+
+ [Fact]
+ public void ClassifierAntiLoop_sameSnapshot_runsOnceThenSkips()
+ {
+ var harness = NewHarness();
+ var signals = LoadFixture("whiteglove-antiloop-v1.jsonl");
+ var result = harness.Replay("session-anon-0013", "tenant-anon-0013", signals);
+
+ // SealingPattern was seen but insufficient alone for Confirmed.
+ Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
+ Assert.Equal(HypothesisLevel.Weak, result.FinalState.WhiteGloveSealing.Level);
+ Assert.Equal(40, result.FinalState.WhiteGloveSealing.Score);
+
+ var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0;
+ var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0;
+ Assert.Equal(1, runs);
+ Assert.Equal(2, skipped);
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs
new file mode 100644
index 00000000..8fe1ee5d
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// Scoring-based WhiteGlove Part 1 classifier. Plan §2.4 / §M3.3.
+ ///
+ /// Scoring rules ported 1:1 from the Legacy WhiteGloveClassifier
+ /// (src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs)
+ /// — same weights, same thresholds, same asymmetric-conservative decision rule. The
+ /// refactor change is the contract, the snapshot input type,
+ /// and the deterministic anti-loop.
+ ///
+ ///
+ /// Asymmetric-conservative: only drives the
+ /// WhiteGloveSealed stage. verdicts are recorded for
+ /// observability but treated as NOT WhiteGlove — false-positive WG keeps the agent
+ /// pending when it should exit; false-negative emits completion early and the later
+ /// user sign-in triggers a new session (much lower cost).
+ ///
+ ///
+ public sealed class WhiteGloveSealingClassifier : IClassifier
+ {
+ public const string ClassifierId = "whiteglove-sealing";
+
+ // Thresholds (tunable after production data — preserve Legacy baseline)
+ internal const int HighThreshold = 70; // >= 70 -> Confirmed
+ internal const int LowThreshold = 30; // 30..69 -> Weak (observability only)
+
+ // Positive-signal weights (port from Legacy)
+ internal const int WeightShellCoreWhiteGloveSuccess = 80;
+ internal const int WeightWhiteGloveSealingPattern = 40;
+ internal const int WeightDeviceOnlyDeployment = 15;
+ internal const int WeightSystemRebootObserved = 15;
+
+ // Negative-signal weights (hard excluders)
+ internal const int WeightAadJoinedWithUser = -100;
+ internal const int WeightDesktopArrived = -100;
+ internal const int WeightHelloResolved = -100;
+ internal const int WeightAccountSetupActive = -40;
+
+ public string Id => ClassifierId;
+ public Type SnapshotType => typeof(WhiteGloveSealingSnapshot);
+
+ public ClassifierVerdict Classify(object snapshot)
+ {
+ if (snapshot == null) throw new ArgumentNullException(nameof(snapshot));
+ if (!(snapshot is WhiteGloveSealingSnapshot s))
+ {
+ throw new ArgumentException(
+ $"Expected {nameof(WhiteGloveSealingSnapshot)}, got {snapshot.GetType().Name}.",
+ nameof(snapshot));
+ }
+
+ var factors = new List();
+ int score = 0;
+
+ // Hard excluders — any one flips the verdict to definitive NOT WhiteGlove.
+ if (s.AadJoinedWithUser) { score += WeightAadJoinedWithUser; factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}"); }
+ if (s.DesktopArrived) { score += WeightDesktopArrived; factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}"); }
+ if (s.HelloResolved) { score += WeightHelloResolved; factors.Add($"hello_resolved:{WeightHelloResolved:+#;-#;0}"); }
+ if (s.HasAccountSetupActivity)
+ {
+ score += WeightAccountSetupActive;
+ factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
+ }
+
+ // Positive signals.
+ if (s.ShellCoreWhiteGloveSuccessSeen)
+ {
+ score += WeightShellCoreWhiteGloveSuccess;
+ factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
+ }
+ if (s.WhiteGloveSealingPatternSeen)
+ {
+ score += WeightWhiteGloveSealingPattern;
+ factors.Add($"sealing_pattern:{WeightWhiteGloveSealingPattern:+#;-#;0}");
+ }
+ if (s.IsDeviceOnlyDeploymentHypothesis)
+ {
+ score += WeightDeviceOnlyDeployment;
+ factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
+ }
+ if (s.SystemRebootUtc.HasValue)
+ {
+ score += WeightSystemRebootObserved;
+ factors.Add($"system_reboot:{WeightSystemRebootObserved:+#;-#;0}");
+ }
+
+ // Cap to [0, 100].
+ 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.Rejected;
+
+ 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/WhiteGloveSealingSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs
new file mode 100644
index 00000000..c83f8ade
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.Classifiers
+{
+ ///
+ /// Input snapshot for . Plan §2.4 input-hash.
+ ///
+ /// Mirrors the WG-relevant fields from DecisionState that participate in scoring.
+ /// Extra fields (unrelated facts, deadlines, etc.) intentionally excluded — including them
+ /// would cause unnecessary re-runs on the anti-loop guard.
+ ///
+ ///
+ public sealed class WhiteGloveSealingSnapshot
+ {
+ public WhiteGloveSealingSnapshot(
+ bool shellCoreWhiteGloveSuccessSeen,
+ bool whiteGloveSealingPatternSeen,
+ bool aadJoinedWithUser,
+ bool desktopArrived,
+ bool helloResolved,
+ bool hasAccountSetupActivity,
+ bool isDeviceOnlyDeploymentHypothesis,
+ DateTime? systemRebootUtc,
+ EnrollmentPhase? currentEnrollmentPhase)
+ {
+ ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
+ WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
+ AadJoinedWithUser = aadJoinedWithUser;
+ DesktopArrived = desktopArrived;
+ HelloResolved = helloResolved;
+ HasAccountSetupActivity = hasAccountSetupActivity;
+ IsDeviceOnlyDeploymentHypothesis = isDeviceOnlyDeploymentHypothesis;
+ SystemRebootUtc = systemRebootUtc;
+ CurrentEnrollmentPhase = currentEnrollmentPhase;
+ }
+
+ public bool ShellCoreWhiteGloveSuccessSeen { get; }
+ public bool WhiteGloveSealingPatternSeen { get; }
+ public bool AadJoinedWithUser { get; }
+ public bool DesktopArrived { get; }
+ public bool HelloResolved { get; }
+ public bool HasAccountSetupActivity { get; }
+ public bool IsDeviceOnlyDeploymentHypothesis { get; }
+ public DateTime? SystemRebootUtc { get; }
+ public EnrollmentPhase? CurrentEnrollmentPhase { get; }
+
+ ///
+ /// Deterministic 16-hex-char SHA256 prefix over the snapshot. Anti-loop guard:
+ /// the classifier is skipped if this hash equals the last recorded
+ /// . Plan §2.4.
+ ///
+ public string ComputeInputHash()
+ {
+ var canonical = string.Join(
+ "|",
+ "whiteglove-sealing-v1",
+ ShellCoreWhiteGloveSuccessSeen ? "1" : "0",
+ WhiteGloveSealingPatternSeen ? "1" : "0",
+ AadJoinedWithUser ? "1" : "0",
+ DesktopArrived ? "1" : "0",
+ HelloResolved ? "1" : "0",
+ HasAccountSetupActivity ? "1" : "0",
+ IsDeviceOnlyDeploymentHypothesis ? "1" : "0",
+ SystemRebootUtc?.ToString("O") ?? "null",
+ CurrentEnrollmentPhase?.ToString() ?? "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/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index 449e7555..c618ce46 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -154,6 +154,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleHelloSafetyDeadlineFired(state, signal);
case DeadlineNames.DeviceOnlyEspDetection:
return HandleDeviceOnlyEspDetectionDeadlineFired(state, signal);
+ case DeadlineNames.ClassifierTick:
+ return HandleClassifierTickDeadlineFired(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
new file mode 100644
index 00000000..25c695ec
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ // WhiteGlove Part 1 handlers + classifier-verdict routing. Plan §2.5 / §2.4.
+ public sealed partial class DecisionEngine
+ {
+ // Plan §2.6 — ClassifierTick recurrence. 30 s is the legacy polling cadence.
+ internal static readonly TimeSpan s_classifierTickInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Handle . Records the
+ /// fact and emits a
+ /// effect for the WhiteGlove-sealing
+ /// classifier. Also arms the deadline
+ /// if not already armed.
+ ///
+ private DecisionStep HandleWhiteGloveShellCoreSuccessV1(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ builder.ShellCoreWhiteGloveSuccessSeen = new SignalFact(true, signal.SessionSignalOrdinal);
+
+ var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: newState.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.WhiteGloveShellCoreSuccess));
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
+ ///
+ /// Handle . Signal-
+ /// correlated WhiteGlove path (IME-pattern based). Records the fact and emits a
+ /// effect.
+ ///
+ private DecisionStep HandleWhiteGloveSealingPatternDetectedV1(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+ builder.WhiteGloveSealingPatternSeen = new SignalFact(true, signal.SessionSignalOrdinal);
+
+ var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: newState.Stage,
+ nextStepIndex: nextStep,
+ trigger: nameof(DecisionSignalKind.WhiteGloveSealingPatternDetected));
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
+ ///
+ /// Handle . Produced by the
+ /// effect runner after it executed a
+ /// effect. The payload carries the verdict; the handler updates
+ /// (plan §2.3 hypothesis) and, on
+ /// , transitions to
+ /// +
+ /// and emits the whiteglove_complete event.
+ ///
+ private DecisionStep HandleClassifierVerdictIssuedV1(DecisionState state, DecisionSignal signal)
+ {
+ var p = signal.Payload ?? new Dictionary();
+ var classifier = GetPayload(p, "classifier", "unknown");
+ var levelRaw = GetPayload(p, "level", HypothesisLevel.Unknown.ToString());
+ var scoreRaw = GetPayload(p, "score", "0");
+ var reason = GetPayload(p, "reason", classifier);
+ var inputHash = GetPayload(p, "inputHash", string.Empty);
+
+ if (!Enum.TryParse(levelRaw, ignoreCase: true, out var level))
+ {
+ level = HypothesisLevel.Unknown;
+ }
+ if (!int.TryParse(scoreRaw, out var score))
+ {
+ score = 0;
+ }
+
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
+
+ // Only the sealing classifier is wired in M3.3 — M3.4 adds Part 2.
+ if (classifier == WhiteGloveSealingClassifier.ClassifierId)
+ {
+ builder.WhiteGloveSealing = state.WhiteGloveSealing.With(
+ level: level,
+ reason: reason,
+ score: score,
+ lastUpdatedUtc: signal.OccurredAtUtc,
+ lastClassifierVerdictId: inputHash);
+
+ if (level == HypothesisLevel.Confirmed)
+ {
+ builder
+ .WithStage(SessionStage.WhiteGloveSealed)
+ .WithOutcome(SessionOutcome.WhiteGlovePart1Sealed)
+ .ClearDeadlines();
+
+ var sealedState = builder.Build();
+ var sealedTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: SessionStage.WhiteGloveSealed,
+ nextStepIndex: nextStep,
+ trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed");
+
+ var sealedEffects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary { ["eventType"] = "whiteglove_complete" }),
+ };
+
+ return new DecisionStep(sealedState, sealedTransition, sealedEffects);
+ }
+ }
+
+ var newState = builder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: $"ClassifierVerdictIssued:{classifier}:{level}");
+
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ // ============================================================ classifier-tick support
+
+ ///
+ /// Classifier-tick deadline fired. Re-evaluates the WhiteGlove classifier with the
+ /// current snapshot (picks up late-arriving facts the original handlers did not see)
+ /// and re-arms itself unless a terminal stage has been reached.
+ ///
+ private DecisionStep HandleClassifierTickDeadlineFired(DecisionState state, DecisionSignal signal)
+ {
+ var nextStep = state.StepIndex + 1;
+ var builder = state.ToBuilder()
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .CancelDeadline(DeadlineNames.ClassifierTick);
+
+ var effects = new List
+ {
+ BuildRunClassifierEffect(state),
+ };
+
+ var reachedTerminal = state.Stage == SessionStage.Completed
+ || state.Stage == SessionStage.Failed
+ || state.Stage == SessionStage.WhiteGloveSealed
+ || state.Stage == SessionStage.WhiteGloveCompletedPart2;
+
+ if (!reachedTerminal)
+ {
+ var rearm = BuildClassifierTickDeadline(signal.OccurredAtUtc);
+ builder.AddDeadline(rearm);
+ effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rearm));
+ }
+
+ var newState = builder.Build();
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: $"DeadlineFired:{DeadlineNames.ClassifierTick}");
+
+ return new DecisionStep(newState, transition, effects.ToArray());
+ }
+
+ // ============================================================ internal helpers
+
+ ///
+ /// Attach WhiteGlove-classifier effects (RunClassifier + optional ClassifierTick arm)
+ /// to a just-built state. Shared between
+ /// and .
+ ///
+ private (DecisionState NewState, DecisionEffect[] Effects) AttachWhiteGloveClassifierEffects(
+ DecisionState state,
+ DecisionSignal triggerSignal)
+ {
+ var effects = new List { BuildRunClassifierEffect(state) };
+
+ // Arm ClassifierTick on first WG-relevant signal if not already scheduled.
+ var hasTick = false;
+ foreach (var d in state.Deadlines)
+ {
+ if (d.Name == DeadlineNames.ClassifierTick) { hasTick = true; break; }
+ }
+
+ if (!hasTick)
+ {
+ var tick = BuildClassifierTickDeadline(triggerSignal.OccurredAtUtc);
+ state = state.ToBuilder().AddDeadline(tick).Build();
+ effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: tick));
+ }
+
+ return (state, effects.ToArray());
+ }
+
+ private static DecisionEffect BuildRunClassifierEffect(DecisionState state) =>
+ new DecisionEffect(
+ kind: DecisionEffectKind.RunClassifier,
+ classifierId: WhiteGloveSealingClassifier.ClassifierId,
+ classifierSnapshot: BuildWhiteGloveSealingSnapshot(state));
+
+ private static ActiveDeadline BuildClassifierTickDeadline(DateTime fromUtc) =>
+ new ActiveDeadline(
+ name: DeadlineNames.ClassifierTick,
+ dueAtUtc: fromUtc.Add(s_classifierTickInterval),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
+ });
+
+ ///
+ /// Build a from the current .
+ /// Exposed to the effect runner so the verdict snapshot carried with the
+ /// effect is deterministic from state.
+ ///
+ internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) =>
+ new WhiteGloveSealingSnapshot(
+ shellCoreWhiteGloveSuccessSeen: state.ShellCoreWhiteGloveSuccessSeen?.Value == true,
+ whiteGloveSealingPatternSeen: state.WhiteGloveSealingPatternSeen?.Value == true,
+ aadJoinedWithUser: state.AadJoinedWithUser?.Value == true,
+ desktopArrived: state.DesktopArrivedUtc != null,
+ helloResolved: state.HelloResolvedUtc != null,
+ hasAccountSetupActivity: state.AccountSetupEnteredUtc != null,
+ isDeviceOnlyDeploymentHypothesis:
+ state.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong &&
+ state.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly,
+ systemRebootUtc: state.SystemRebootUtc?.Value,
+ currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value);
+
+ private static string GetPayload(IReadOnlyDictionary p, string key, string fallback) =>
+ p.TryGetValue(key, out var v) ? v : fallback;
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index 436cd4c5..5fafe83b 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -72,7 +72,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
// ----- SelfDeploying + Device-Only (DecisionEngine.SelfDeploying.cs) -----
(DecisionSignalKind.DeviceSetupProvisioningComplete, 1) => HandleDeviceSetupProvisioningCompleteV1(state, signal),
- // ----- Not yet wired (WhiteGlove in M3.3/M3.4, …) -----
+ // ----- WhiteGlove Part 1 (DecisionEngine.WhiteGlove.cs) -----
+ (DecisionSignalKind.WhiteGloveShellCoreSuccess, 1) => HandleWhiteGloveShellCoreSuccessV1(state, signal),
+ (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) -----
_ => HandleUnhandledSignal(state, signal),
};
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
index 4bd5ea8f..43a83276 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
@@ -40,6 +40,8 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact? helloOutcome,
SignalFact? aadJoinedWithUser,
SignalFact? imeMatchedPatternId,
+ SignalFact? shellCoreWhiteGloveSuccessSeen,
+ SignalFact? whiteGloveSealingPatternSeen,
IReadOnlyList deadlines,
long lastAppliedSignalOrdinal,
int stepIndex,
@@ -74,6 +76,8 @@ namespace AutopilotMonitor.DecisionCore.State
HelloOutcome = helloOutcome;
AadJoinedWithUser = aadJoinedWithUser;
ImeMatchedPatternId = imeMatchedPatternId;
+ ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
+ WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
StepIndex = stepIndex;
@@ -123,6 +127,12 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact? ImeMatchedPatternId { get; }
+ /// True once has fired. WG Part-1 indicator.
+ public SignalFact? ShellCoreWhiteGloveSuccessSeen { get; }
+
+ /// True once has fired. Signal-correlated WG path.
+ public SignalFact? WhiteGloveSealingPatternSeen { get; }
+
public IReadOnlyList Deadlines { get; }
public long LastAppliedSignalOrdinal { get; }
@@ -163,6 +173,8 @@ namespace AutopilotMonitor.DecisionCore.State
helloOutcome: null,
aadJoinedWithUser: null,
imeMatchedPatternId: null,
+ shellCoreWhiteGloveSuccessSeen: null,
+ whiteGloveSealingPatternSeen: 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 daf0e8f5..b5e18116 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
@@ -38,6 +38,8 @@ namespace AutopilotMonitor.DecisionCore.State
HelloOutcome = source.HelloOutcome;
AadJoinedWithUser = source.AadJoinedWithUser;
ImeMatchedPatternId = source.ImeMatchedPatternId;
+ ShellCoreWhiteGloveSuccessSeen = source.ShellCoreWhiteGloveSuccessSeen;
+ WhiteGloveSealingPatternSeen = source.WhiteGloveSealingPatternSeen;
Deadlines = new List(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
@@ -63,6 +65,8 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact? HelloOutcome { get; set; }
public SignalFact? AadJoinedWithUser { get; set; }
public SignalFact? ImeMatchedPatternId { get; set; }
+ public SignalFact? ShellCoreWhiteGloveSuccessSeen { get; set; }
+ public SignalFact? WhiteGloveSealingPatternSeen { get; set; }
public List Deadlines { get; set; }
public long LastAppliedSignalOrdinal { get; set; }
public int StepIndex { get; set; }
@@ -130,6 +134,8 @@ namespace AutopilotMonitor.DecisionCore.State
helloOutcome: HelloOutcome,
aadJoinedWithUser: AadJoinedWithUser,
imeMatchedPatternId: ImeMatchedPatternId,
+ shellCoreWhiteGloveSuccessSeen: ShellCoreWhiteGloveSuccessSeen,
+ whiteGloveSealingPatternSeen: WhiteGloveSealingPatternSeen,
deadlines: Deadlines.ToArray(),
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
stepIndex: StepIndex,
diff --git a/tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl
new file mode 100644
index 00000000..5c4bb636
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl
@@ -0,0 +1,12 @@
+# Scenario: Classifier Anti-Loop — same snapshot produces a single verdict
+# SealingPattern fires once (snapshot A -> classifier runs -> Weak). Two subsequent
+# ClassifierTick deadlines fire without any intervening fact change — the harness must
+# skip both (anti-loop via InputHash match). End state WhiteGloveSealing=Weak.
+# Expected: ClassifierRunStats["whiteglove-sealing:run"] == 1,
+# ClassifierRunStats["whiteglove-sealing:skipped_by_antiloop"] == 2.
+# Plan: §4 M3.3 — Classifier-Anti-Loop / §2.4 InputHash dedup.
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-antiloop:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12: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":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1"}}}
+{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:02:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick 1 — same snapshot"},"Payload":{"deadline":"classifier_tick"}}
+{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:03:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:2","Summary":"Classifier tick 2 — same snapshot"},"Payload":{"deadline":"classifier_tick"}}
diff --git a/tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl
new file mode 100644
index 00000000..cb0f26d7
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl
@@ -0,0 +1,18 @@
+# Scenario: WhiteGlove False-Positive defense — Late-AADJ rejects WG
+# SealingPattern gives +40 (Weak), then AadUserJoinedLate fires (hard excluder -100).
+# A ClassifierTick re-runs the classifier with the updated snapshot -> score capped to 0
+# -> Rejected. The session continues through the Classic UserDriven-v1 completion path,
+# ending in EnrollmentComplete (NOT WhiteGloveSealed).
+# Expected: Final Stage=Completed, Outcome=EnrollmentComplete,
+# WhiteGloveSealing.Level=Rejected.
+# Plan: §4 M3.3 — WG-False-Positive-Abwehr; project memory
+# feedback_aad_joined_late_not_completion (Late-AADJ never completes prematurely).
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-false-positive:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11: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":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1"}}}
+{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"AadUserJoinedLate","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:03:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AADJ observed with user present","DerivationInputs":{"user":"user-anon-0006"}},"Payload":{"aadJoinedWithUser":"true"}}
+{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:03:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick re-runs after AadUserJoinedLate"},"Payload":{"deadline":"classifier_tick"}}
+{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}}
+{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}}
+{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:55","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}}
+{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0006","explorerPid":"4245"}}}
diff --git a/tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl
new file mode 100644
index 00000000..5c1a305c
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl
@@ -0,0 +1,9 @@
+# Scenario: WhiteGlove Part 1 — Inline Classifier (ShellCore success alone is decisive)
+# ShellCoreWhiteGloveSuccess (Event 62407) fires; classifier sees one strong positive
+# signal, no hard-excluders -> score 80 -> Confirmed -> WhiteGloveSealed.
+# Expected: Stage=WhiteGloveSealed, Outcome=WhiteGlovePart1Sealed,
+# WhiteGloveSealing.Level=Confirmed (score >= 70).
+# Plan: §4 M3.3 — WG-Inline-Classifier.
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-inline:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09: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-22T09:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:104","Summary":"Shell Core white-glove success (Event 62407)","RawPointer":"blob://events/shell-core/104"}}
diff --git a/tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl
new file mode 100644
index 00000000..5357b442
--- /dev/null
+++ b/tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl
@@ -0,0 +1,13 @@
+# Scenario: WhiteGlove Part 1 — Signal-correlated (sealing-pattern + ShellCore together)
+# SealingPattern alone scores 40 (Weak). A subsequent ShellCoreWhiteGloveSuccess changes the
+# snapshot so the classifier re-runs (not anti-loop-skipped) and hits the confirmed threshold.
+# Between the two WG signals a ClassifierTick fires with no new facts — proves the tick is
+# safely idempotent under anti-loop.
+# Expected: Stage=WhiteGloveSealed after ShellCore step. Classifier runs twice (SealingPattern
+# then ShellCore); one ClassifierTick is anti-loop-skipped.
+# Plan: §4 M3.3 — WG-Signal-Correlated.
+{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-signal-correlated:started","Summary":"Session start anchor"}}
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10: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":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1","imeLogLineHash":"0xabcd"}}}
+{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:02:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick fired (no snapshot change)"},"Payload":{"deadline":"classifier_tick"}}
+{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:05:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:201","Summary":"Shell Core white-glove success (Event 62407)","RawPointer":"blob://events/shell-core/201"}}