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