refactor(decisioncore): WhiteGlove Part 1 handlers + sealing classifier + 4 scenarios (M3.3)

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<bool> 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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 14:15:06 +02:00
parent 0ebfa2fc3c
commit f75a8d4375
17 changed files with 801 additions and 10 deletions
@@ -1 +1 @@
9
10
@@ -1 +1 @@
1051
1052
@@ -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
{
/// <summary>
/// Replay harness that also executes <see cref="DecisionEffectKind.RunClassifier"/>
/// effects, synthesizing the resulting <see cref="DecisionSignalKind.ClassifierVerdictIssued"/>
/// 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).
/// <para>
/// Anti-loop (plan §2.4): a classifier is skipped when the snapshot's
/// <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> matches the last
/// <see cref="Hypothesis.LastClassifierVerdictId"/> recorded for that classifier — tested
/// end-to-end by the Anti-Loop scenario.
/// </para>
/// </summary>
public sealed class ClassifierAwareReplayHarness
{
private readonly IDecisionEngine _engine;
private readonly IReadOnlyDictionary<string, IClassifier> _classifiers;
public ClassifierAwareReplayHarness(
IDecisionEngine engine,
IReadOnlyDictionary<string, IClassifier> classifiers)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_classifiers = classifiers ?? throw new ArgumentNullException(nameof(classifiers));
}
public ReplayResult Replay(
string sessionId,
string tenantId,
IReadOnlyList<DecisionSignal> signals)
{
if (signals == null) throw new ArgumentNullException(nameof(signals));
var state = DecisionState.CreateInitial(sessionId, tenantId);
var allTransitions = new List<DecisionTransition>(signals.Count * 2);
ClassifierRunStats = new Dictionary<string, int>();
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<string, string>
{
["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));
}
/// <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 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();
}
}
}
}
@@ -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<DecisionSignal> { signal });
@@ -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);
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class WhiteGlovePart1ScenarioTests : ScenarioTestBase
{
private static ClassifierAwareReplayHarness NewHarness() =>
new ClassifierAwareReplayHarness(
new DecisionEngine(),
new Dictionary<string, IClassifier>
{
[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);
}
}
}
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Scoring-based WhiteGlove Part 1 classifier. Plan §2.4 / §M3.3.
/// <para>
/// Scoring rules ported 1:1 from the Legacy <c>WhiteGloveClassifier</c>
/// (<c>src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs</c>)
/// — same weights, same thresholds, same asymmetric-conservative decision rule. The
/// refactor change is the <see cref="IClassifier"/> contract, the snapshot input type,
/// and the deterministic <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> anti-loop.
/// </para>
/// <para>
/// Asymmetric-conservative: only <see cref="HypothesisLevel.Confirmed"/> drives the
/// WhiteGloveSealed stage. <see cref="HypothesisLevel.Weak"/> 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).
/// </para>
/// </summary>
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<string>();
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());
}
}
}
@@ -0,0 +1,79 @@
using System;
using System.Security.Cryptography;
using System.Text;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Input snapshot for <see cref="WhiteGloveSealingClassifier"/>. Plan §2.4 input-hash.
/// <para>
/// Mirrors the WG-relevant fields from <c>DecisionState</c> that participate in scoring.
/// Extra fields (unrelated facts, deadlines, etc.) intentionally excluded — including them
/// would cause unnecessary re-runs on the anti-loop guard.
/// </para>
/// </summary>
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; }
/// <summary>
/// Deterministic 16-hex-char SHA256 prefix over the snapshot. Anti-loop guard:
/// the classifier is skipped if this hash equals the last recorded
/// <see cref="State.Hypothesis.LastClassifierVerdictId"/>. Plan §2.4.
/// </summary>
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();
}
}
}
}
@@ -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
@@ -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);
/// <summary>
/// Handle <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/>. Records the
/// <see cref="DecisionState.ShellCoreWhiteGloveSuccessSeen"/> fact and emits a
/// <see cref="DecisionEffectKind.RunClassifier"/> effect for the WhiteGlove-sealing
/// classifier. Also arms the <see cref="DeadlineNames.ClassifierTick"/> deadline
/// if not already armed.
/// </summary>
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<bool>(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);
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.WhiteGloveSealingPatternDetected"/>. Signal-
/// correlated WhiteGlove path (IME-pattern based). Records the fact and emits a
/// <see cref="DecisionEffectKind.RunClassifier"/> effect.
/// </summary>
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<bool>(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);
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.ClassifierVerdictIssued"/>. Produced by the
/// effect runner after it executed a <see cref="DecisionEffectKind.RunClassifier"/>
/// effect. The payload carries the verdict; the handler updates
/// <see cref="DecisionState.WhiteGloveSealing"/> (plan §2.3 hypothesis) and, on
/// <see cref="HypothesisLevel.Confirmed"/>, transitions to
/// <see cref="SessionStage.WhiteGloveSealed"/> + <see cref="SessionOutcome.WhiteGlovePart1Sealed"/>
/// and emits the <c>whiteglove_complete</c> event.
/// </summary>
private DecisionStep HandleClassifierVerdictIssuedV1(DecisionState state, DecisionSignal signal)
{
var p = signal.Payload ?? new Dictionary<string, string>();
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<HypothesisLevel>(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<string, string> { ["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<DecisionEffect>());
}
// ============================================================ classifier-tick support
/// <summary>
/// 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.
/// </summary>
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<DecisionEffect>
{
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
/// <summary>
/// Attach WhiteGlove-classifier effects (RunClassifier + optional ClassifierTick arm)
/// to a just-built state. Shared between <see cref="HandleWhiteGloveShellCoreSuccessV1"/>
/// and <see cref="HandleWhiteGloveSealingPatternDetectedV1"/>.
/// </summary>
private (DecisionState NewState, DecisionEffect[] Effects) AttachWhiteGloveClassifierEffects(
DecisionState state,
DecisionSignal triggerSignal)
{
var effects = new List<DecisionEffect> { 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<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
});
/// <summary>
/// Build a <see cref="WhiteGloveSealingSnapshot"/> from the current <see cref="DecisionState"/>.
/// Exposed to the effect runner so the verdict snapshot carried with the
/// <see cref="DecisionEffectKind.RunClassifier"/> effect is deterministic from state.
/// </summary>
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<string, string> p, string key, string fallback) =>
p.TryGetValue(key, out var v) ? v : fallback;
}
}
@@ -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),
};
}
@@ -40,6 +40,8 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<string>? helloOutcome,
SignalFact<bool>? aadJoinedWithUser,
SignalFact<string>? imeMatchedPatternId,
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
SignalFact<bool>? whiteGloveSealingPatternSeen,
IReadOnlyList<ActiveDeadline> 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<string>? ImeMatchedPatternId { get; }
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> has fired. WG Part-1 indicator.</summary>
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; }
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired. Signal-correlated WG path.</summary>
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
public IReadOnlyList<ActiveDeadline> 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<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0);
@@ -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<ActiveDeadline>(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
@@ -63,6 +65,8 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<string>? HelloOutcome { get; set; }
public SignalFact<bool>? AadJoinedWithUser { get; set; }
public SignalFact<string>? ImeMatchedPatternId { get; set; }
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; set; }
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; set; }
public List<ActiveDeadline> 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,
@@ -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"}}
@@ -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"}}}
@@ -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"}}
@@ -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"}}