From f75a8d437588138faf6f4c9b7427f45fdf3aee8e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 14:15:06 +0200 Subject: [PATCH] refactor(decisioncore): WhiteGlove Part 1 handlers + sealing classifier + 4 scenarios (M3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §4 M3.3: the first kernel-service classifier goes live (plan §2.4 L.8), the effect-based classifier-run contract is wired end-to-end, and the anti-loop via InputHash is proven by a dedicated fixture. Classifier (WhiteGloveSealingClassifier : IClassifier): - Scoring ported 1:1 from Legacy WhiteGloveClassifier (same weights, same thresholds, same asymmetric-conservative rule: only Confirmed drives the WhiteGloveSealed stage). - Positive weights: ShellCoreWhiteGloveSuccess=80, SealingPattern=40, DeviceOnlyDeployment=15, SystemReboot=15. - Hard excluders (-100 each): AadJoinedWithUser, DesktopArrived, HelloResolved. Soft exclude: AccountSetupActivity=-40. - WhiteGloveSealingSnapshot is the classifier input type and computes a deterministic SHA256-short InputHash over WG-relevant fields only — so the anti-loop triggers on meaningful fact changes, not on unrelated state. Handlers (DecisionEngine.WhiteGlove.cs): - WhiteGloveShellCoreSuccess records fact; emits RunClassifier effect; arms ClassifierTick if not already. - WhiteGloveSealingPatternDetected mirror handler for the IME-pattern path. - ClassifierVerdictIssued applies the verdict to WhiteGloveSealing hypothesis; on Confirmed transitions to WhiteGloveSealed stage + Outcome=WhiteGlovePart1Sealed and emits the whiteglove_complete event. - HandleClassifierTickDeadlineFired (shared router) re-emits RunClassifier and re-arms itself unless terminal. ClassifierAwareReplayHarness (tests-only): - Loops per-step effects: any RunClassifier effect is resolved synchronously via the registered IClassifier; the verdict is wrapped in a ClassifierVerdictIssued signal and fed back into Reduce. Anti-loop skips the call when snapshot hash == state.WhiteGloveSealing.LastClassifierVerdictId. - Exposes ClassifierRunStats for regression tests. DecisionState extension: two new SignalFact fields capture the WG sightings (ShellCoreWhiteGloveSuccessSeen, WhiteGloveSealingPatternSeen). The classifier reads them via WhiteGloveSealingSnapshot. Scenario fixtures (4, committed-anonymized): - whiteglove-inline-v1.jsonl Confirmed on ShellCore alone (80). - whiteglove-signal-correlated-v1.jsonl Weak after pattern, ClassifierTick anti-loop-skipped, Confirmed after ShellCore escalates the score. - whiteglove-false-positive-v1.jsonl SealingPattern + late-AADJ hard- excludes -> Rejected; session still completes via Classic Hello+Desktop. - whiteglove-antiloop-v1.jsonl Single pattern + two ticks; runs=1, skipped_by_antiloop=2. Tests: 4 scenario tests in WhiteGlovePart1ScenarioTests, all passing. DecisionCore.Tests: 49 (was 45). Legacy 507 + Functions 557 unchanged. ReducerFailSafeTests + HarnessTests updated to use AppInstallCompleted as the still-unhandled sentinel (WhiteGloveShellCoreSuccess is now wired). Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.3 + §2.4 classifier kernel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Harness/ClassifierAwareReplayHarness.cs | 161 +++++++++++ .../HarnessTests.cs | 6 +- .../ReducerFailSafeTests.cs | 8 +- .../Scenarios/WhiteGlovePart1ScenarioTests.cs | 106 +++++++ .../WhiteGloveSealingClassifier.cs | 110 ++++++++ .../Classifiers/WhiteGloveSealingSnapshot.cs | 79 ++++++ .../Engine/DecisionEngine.Shared.cs | 2 + .../Engine/DecisionEngine.WhiteGlove.cs | 258 ++++++++++++++++++ .../Engine/DecisionEngine.cs | 7 +- .../State/DecisionState.cs | 12 + .../State/DecisionStateBuilder.cs | 6 + .../whiteglove-antiloop-v1.jsonl | 12 + .../whiteglove-false-positive-v1.jsonl | 18 ++ .../whiteglove-inline-v1.jsonl | 9 + .../whiteglove-signal-correlated-v1.jsonl | 13 + 17 files changed, 801 insertions(+), 10 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs create mode 100644 tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl create mode 100644 tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl create mode 100644 tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl create mode 100644 tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl 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"}}