refactor(decisioncore): WhiteGlove Part 2 + Part2CompletionClassifier + 2 scenarios (M3.4)

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<DateTime>? 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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 14:21:53 +02:00
parent f75a8d4375
commit 8e4faf4251
16 changed files with 591 additions and 20 deletions
@@ -1 +1 @@
10
12
@@ -1 +1 @@
1052
1054
@@ -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
/// <summary>Run counts per classifier (keys: "run", "skipped_by_antiloop"). For test assertions.</summary>
public IDictionary<string, int> ClassifierRunStats { get; private set; } = new Dictionary<string, int>();
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}";
@@ -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
{
/// <summary>
/// Plan §4 M3.4 — WhiteGlove Part 2 scenarios.
/// </summary>
public sealed class WhiteGlovePart2ScenarioTests : ScenarioTestBase
{
private static ClassifierAwareReplayHarness NewHarness() =>
new ClassifierAwareReplayHarness(
new DecisionEngine(),
new Dictionary<string, IClassifier>
{
[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<AutopilotMonitor.DecisionCore.Signals.DecisionSignal>();
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);
}
}
}
@@ -0,0 +1,16 @@
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// A classifier-input snapshot that exposes a deterministic hash. Plan §2.4 anti-loop.
/// <para>
/// 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 <see cref="ClassifierVerdict.InputHash"/>.
/// </para>
/// </summary>
public interface IClassifierSnapshot
{
string ComputeInputHash();
}
}
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Scoring classifier for WhiteGlove Part 2 completion. Plan §2.4 / §M3.4.
/// <para>
/// 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.
/// </para>
/// </summary>
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<string>();
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());
}
}
}
@@ -0,0 +1,58 @@
using System;
using System.Security.Cryptography;
using System.Text;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Input snapshot for <see cref="WhiteGlovePart2CompletionClassifier"/>.
/// Plan §2.4 / §M3.4 — the four Part-2 post-reboot user signals drive the score.
/// </summary>
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();
}
}
}
}
@@ -13,7 +13,7 @@ namespace AutopilotMonitor.DecisionCore.Classifiers
/// would cause unnecessary re-runs on the anti-loop guard.
/// </para>
/// </summary>
public sealed class WhiteGloveSealingSnapshot
public sealed class WhiteGloveSealingSnapshot : IClassifierSnapshot
{
public WhiteGloveSealingSnapshot(
bool shellCoreWhiteGloveSuccessSeen,
@@ -68,8 +68,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
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
@@ -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(
@@ -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);
/// <summary>
/// WhiteGlove Part 1 → Part 2 bridge. Called from <see cref="HandleSessionRecoveredV1"/>
/// when the recovered state is <see cref="SessionStage.WhiteGloveSealed"/>. Transitions
/// into <see cref="SessionStage.WhiteGloveAwaitingUserSignIn"/> and arms the Part-2
/// safety deadline.
/// </summary>
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<string, string>
{
[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<DateTime>(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<DateTime>(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<DateTime>(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<DateTime>(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<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
trigger: nameof(DecisionSignalKind.AccountSetupCompletedPart2));
}
/// <summary>
/// Part-2 safety deadline fired — user never signed in within 24 h. Terminates the
/// session as <see cref="SessionOutcome.EnrollmentFailed"/> with reason
/// <c>part2_user_absent</c>.
/// </summary>
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<string, string>
{
["eventType"] = "enrollment_failed",
["reason"] = "part2_user_absent",
}),
};
return new DecisionStep(newState, transition, effects);
}
// ============================================================ internal helpers
private DecisionStep UpdatePart2Fact(
DecisionState state,
DecisionSignal signal,
Action<DecisionStateBuilder> 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<DecisionEffect>();
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);
/// <summary>
/// Apply a Part-2 classifier verdict to <see cref="DecisionState.WhiteGlovePart2Completion"/>.
/// Called from the extended <see cref="HandleClassifierVerdictIssuedV1"/> router when
/// the verdict's classifier id is the Part-2 completion classifier. On Confirmed,
/// transitions to <see cref="SessionStage.WhiteGloveCompletedPart2"/> + Outcome
/// <see cref="SessionOutcome.WhiteGlovePart2Complete"/> and emits the
/// <c>whiteglove_part2_complete</c> event.
/// </summary>
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<string, string> { ["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<DecisionEffect>());
}
}
}
@@ -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),
};
}
@@ -42,6 +42,10 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<string>? imeMatchedPatternId,
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
SignalFact<bool>? whiteGloveSealingPatternSeen,
SignalFact<DateTime>? userAadSignInCompleteUtc,
SignalFact<DateTime>? helloResolvedPart2Utc,
SignalFact<DateTime>? desktopArrivedPart2Utc,
SignalFact<DateTime>? accountSetupCompletedPart2Utc,
IReadOnlyList<ActiveDeadline> 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
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired. Signal-correlated WG path.</summary>
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
// --- WhiteGlove Part 2 (post-reboot user sign-in) facts ---
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; }
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; }
public IReadOnlyList<ActiveDeadline> 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<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0);
@@ -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<ActiveDeadline>(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
@@ -67,6 +71,10 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<string>? ImeMatchedPatternId { get; set; }
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; set; }
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; set; }
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; set; }
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; }
public List<ActiveDeadline> 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,
@@ -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"}}}
@@ -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"}}